From 888fa923cba1731eb6717f7d6b57270fa64dda1e Mon Sep 17 00:00:00 2001 From: Rodrigo Reis Date: Wed, 15 Jul 2026 11:51:26 -0700 Subject: [PATCH 1/2] SDKS-4613 Create simple journey sample app for flutter using new Ping Orchestration SDKs --- README.md | 6 + flutter/README.md | 37 + flutter/analysis_options.yaml | 1 + flutter/flutter-journey/.gitignore | 45 + flutter/flutter-journey/.metadata | 33 + flutter/flutter-journey/README.md | 90 ++ flutter/flutter-journey/analysis_options.yaml | 29 + flutter/flutter-journey/android/.gitignore | 14 + .../android/app/build.gradle.kts | 45 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 45 + .../flutter/flutter_journey/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + .../flutter-journey/android/build.gradle.kts | 24 + .../gradle/wrapper/gradle-wrapper.properties | 5 + .../android/settings.gradle.kts | 26 + .../assets/images/ping_logo.png | Bin 0 -> 23323 bytes .../integration_test/journey_login_test.dart | 89 ++ .../journey_registration_test.dart | 111 +++ flutter/flutter-journey/ios/.gitignore | 34 + .../ios/Flutter/AppFrameworkInfo.plist | 24 + .../ios/Flutter/Debug.xcconfig | 1 + .../ios/Flutter/Release.xcconfig | 1 + .../ios/Runner.xcodeproj/project.pbxproj | 647 +++++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/swiftpm/Package.resolved | 113 +++ .../xcshareddata/xcschemes/Runner.xcscheme | 119 +++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/swiftpm/Package.resolved | 113 +++ .../ios/Runner/AppDelegate.swift | 16 + .../AppIcon.appiconset/Contents.json | 122 +++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + .../ios/Runner/Base.lproj/Main.storyboard | 26 + flutter/flutter-journey/ios/Runner/Info.plist | 70 ++ .../ios/Runner/Runner-Bridging-Header.h | 1 + .../ios/Runner/SceneDelegate.swift | 6 + .../ios/RunnerTests/RunnerTests.swift | 12 + flutter/flutter-journey/lib/config/env.dart | 43 + .../lib/data/journey_repository.dart | 61 ++ flutter/flutter-journey/lib/main.dart | 54 ++ .../flutter-journey/lib/routing/router.dart | 53 + .../lib/ui/core/theme/app_theme.dart | 125 +++ .../lib/ui/core/widgets/app_card.dart | 38 + .../lib/ui/core/widgets/brand_logo.dart | 46 + .../lib/ui/core/widgets/error_banner.dart | 48 + .../lib/ui/core/widgets/primary_button.dart | 44 + .../view_models/journey_name_view_model.dart | 31 + .../ui/features/config/views/config_view.dart | 81 ++ .../config/views/journey_name_view.dart | 81 ++ .../view_models/journey_view_model.dart | 66 ++ .../features/journey/views/journey_view.dart | 203 ++++ ...boolean_attribute_input_callback_view.dart | 34 + .../callbacks/callback_controllers_state.dart | 23 + .../callbacks/choice_callback_view.dart | 39 + .../callbacks/kba_create_callback_view.dart | 112 +++ .../widgets/callbacks/name_callback_view.dart | 47 + .../number_attribute_input_callback_view.dart | 69 ++ .../callbacks/password_callback_view.dart | 55 ++ .../string_attribute_input_callback_view.dart | 61 ++ .../terms_and_conditions_callback_view.dart | 45 + .../callbacks/text_input_callback_view.dart | 47 + .../callbacks/text_output_callback_view.dart | 38 + .../validated_password_callback_view.dart | 67 ++ .../validated_username_callback_view.dart | 61 ++ .../callbacks/validation_error_text.dart | 41 + .../view_models/success_view_model.dart | 40 + .../features/success/views/success_view.dart | 214 +++++ flutter/flutter-journey/pubspec.yaml | 101 ++ .../config/views/config_view_test.dart | 58 ++ .../journey/views/journey_view_test.dart | 165 ++++ ...an_attribute_input_callback_view_test.dart | 106 ++ ...er_attribute_input_callback_view_test.dart | 138 +++ ...ng_attribute_input_callback_view_test.dart | 126 +++ ...validated_password_callback_view_test.dart | 116 +++ ...validated_username_callback_view_test.dart | 93 ++ .../success/views/success_view_test.dart | 189 ++++ flutter/flutter-journey/test/widget_test.dart | 21 + .../test_driver/integration_test.dart | 10 + flutter/flutter-sdk-bridge/CHANGELOG.md | 7 + flutter/flutter-sdk-bridge/LICENSE | 21 + flutter/flutter-sdk-bridge/README.md | 92 ++ .../flutter-sdk-bridge/ping_core/.gitignore | 33 + .../flutter-sdk-bridge/ping_core/.metadata | 33 + .../flutter-sdk-bridge/ping_core/CHANGELOG.md | 4 + .../flutter-sdk-bridge/ping_core/README.md | 83 ++ .../ping_core/analysis_options.yaml | 10 + .../ping_core/android/.gitignore | 9 + .../ping_core/android/build.gradle.kts | 77 ++ .../ping_core/android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../pingidentity/flutter/core/CoreRuntime.kt | 36 + .../flutter/core/PingCorePlugin.kt | 21 + .../flutter/core/registry/NativeHandle.kt | 15 + .../flutter/core/registry/Registry.kt | 37 + .../flutter/core/registry/SimpleRegistry.kt | 36 + .../ping_core/ios/.gitignore | 38 + .../ping_core/ios/ping_core.podspec | 30 + .../ping_core/ios/ping_core/Package.swift | 36 + .../Sources/ping_core/CoreRuntime.swift | 64 ++ .../Sources/ping_core/NativeHandle.swift | 17 + .../Sources/ping_core/PingCorePlugin.swift | 16 + .../Sources/ping_core/PrivacyInfo.xcprivacy | 14 + .../Sources/ping_core/Registry.swift | 26 + .../Sources/ping_core/SimpleRegistry.swift | 40 + .../ping_core/lib/ping_core.dart | 9 + .../ping_core/lib/src/json_codec.dart | 38 + .../ping_core/lib/src/ping_exception.dart | 28 + .../flutter-sdk-bridge/ping_core/pubspec.yaml | 74 ++ .../ping_core/test/src/json_codec_test.dart | 119 +++ .../test/src/ping_exception_test.dart | 37 + .../ping_journey/.gitignore | 33 + .../flutter-sdk-bridge/ping_journey/.metadata | 33 + .../ping_journey/CHANGELOG.md | 6 + .../flutter-sdk-bridge/ping_journey/README.md | 89 ++ .../ping_journey/analysis_options.yaml | 10 + .../ping_journey/android/.gitignore | 9 + .../ping_journey/android/build.gradle.kts | 81 ++ .../ping_journey/android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 3 + .../flutter/journey/JourneyClientFactory.kt | 35 + .../flutter/journey/JourneyConfigParser.kt | 57 ++ .../flutter/journey/JourneyHostApiImpl.kt | 195 ++++ .../flutter/journey/Messages.g.kt | 907 ++++++++++++++++++ .../flutter/journey/PingJourneyPlugin.kt | 26 + .../callback/JourneyCallbackValueApplier.kt | 95 ++ .../journey/error/JourneyErrorCodes.kt | 18 + .../journey/error/JourneyErrorMapper.kt | 61 ++ .../journey/mapper/JourneyNodeMapper.kt | 141 +++ .../journey/mapper/JsonBridgeMapper.kt | 39 + .../JourneyCallbackValueApplierTest.kt | 220 +++++ .../journey/error/JourneyErrorMapperTest.kt | 187 ++++ .../journey/mapper/JourneyNodeMapperTest.kt | 339 +++++++ .../ping_journey/ios/.gitignore | 38 + .../ping_journey/ios/ping_journey.podspec | 30 + .../ios/ping_journey/Package.swift | 44 + .../JourneyCallbackValueApplier.swift | 114 +++ .../Error/JourneyErrorCodes.swift | 16 + .../Error/JourneyErrorMapper.swift | 54 ++ .../ping_journey/JourneyClientFactory.swift | 36 + .../ping_journey/JourneyConfigParser.swift | 72 ++ .../ping_journey/JourneyHostApiImpl.swift | 185 ++++ .../Mapper/JourneyNodeMapper.swift | 112 +++ .../Sources/ping_journey/Messages.g.swift | 899 +++++++++++++++++ .../ping_journey/PingJourneyPlugin.swift | 17 + .../ping_journey/PrivacyInfo.xcprivacy | 14 + .../JourneyCallbackValueApplierTests.swift | 195 ++++ .../JourneyErrorMapperTests.swift | 123 +++ .../JourneyNodeMapperTests.swift | 253 +++++ .../ping_journey/lib/ping_journey.dart | 16 + .../boolean_attribute_input_callback.dart | 47 + .../lib/src/callback/callback.dart | 43 + .../lib/src/callback/choice_callback.dart | 35 + .../lib/src/callback/kba_create_callback.dart | 50 + .../lib/src/callback/name_callback.dart | 26 + .../number_attribute_input_callback.dart | 47 + .../lib/src/callback/password_callback.dart | 27 + .../string_attribute_input_callback.dart | 47 + .../terms_and_conditions_callback.dart | 37 + .../lib/src/callback/text_input_callback.dart | 31 + .../src/callback/text_output_callback.dart | 65 ++ .../callback/validated_password_callback.dart | 43 + .../callback/validated_username_callback.dart | 39 + .../lib/src/callback_helpers.dart | 125 +++ .../ping_journey/lib/src/callback_type.dart | 49 + .../ping_journey/lib/src/journey_client.dart | 89 ++ .../ping_journey/lib/src/journey_node.dart | 68 ++ .../ping_journey/lib/src/messages.g.dart | 859 +++++++++++++++++ .../ping_journey/lib/src/node_mapper.dart | 172 ++++ .../ping_journey/lib/src/session.dart | 74 ++ .../ping_journey/pigeons/copyright.txt | 4 + .../ping_journey/pigeons/messages.dart | 165 ++++ .../ping_journey/pubspec.yaml | 77 ++ .../test/src/callback_helpers_test.dart | 89 ++ .../test/src/journey_client_test.dart | 307 ++++++ .../test/src/node_mapper_test.dart | 366 +++++++ .../ping_journey/test/src/session_test.dart | 96 ++ flutter/pubspec.yaml | 10 + .../xcshareddata/swiftpm/Package.resolved | 8 +- .../ViewModels/JourneyViewModel.swift | 14 +- 210 files changed, 14176 insertions(+), 11 deletions(-) create mode 100644 flutter/README.md create mode 100644 flutter/analysis_options.yaml create mode 100644 flutter/flutter-journey/.gitignore create mode 100644 flutter/flutter-journey/.metadata create mode 100644 flutter/flutter-journey/README.md create mode 100644 flutter/flutter-journey/analysis_options.yaml create mode 100644 flutter/flutter-journey/android/.gitignore create mode 100644 flutter/flutter-journey/android/app/build.gradle.kts create mode 100644 flutter/flutter-journey/android/app/src/debug/AndroidManifest.xml create mode 100644 flutter/flutter-journey/android/app/src/main/AndroidManifest.xml create mode 100644 flutter/flutter-journey/android/app/src/main/kotlin/com/pingidentity/flutter/flutter_journey/MainActivity.kt create mode 100644 flutter/flutter-journey/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 flutter/flutter-journey/android/app/src/main/res/drawable/launch_background.xml create mode 100644 flutter/flutter-journey/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 flutter/flutter-journey/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 flutter/flutter-journey/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 flutter/flutter-journey/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 flutter/flutter-journey/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 flutter/flutter-journey/android/app/src/main/res/values-night/styles.xml create mode 100644 flutter/flutter-journey/android/app/src/main/res/values/styles.xml create mode 100644 flutter/flutter-journey/android/app/src/profile/AndroidManifest.xml create mode 100644 flutter/flutter-journey/android/build.gradle.kts create mode 100644 flutter/flutter-journey/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 flutter/flutter-journey/android/settings.gradle.kts create mode 100644 flutter/flutter-journey/assets/images/ping_logo.png create mode 100644 flutter/flutter-journey/integration_test/journey_login_test.dart create mode 100644 flutter/flutter-journey/integration_test/journey_registration_test.dart create mode 100644 flutter/flutter-journey/ios/.gitignore create mode 100644 flutter/flutter-journey/ios/Flutter/AppFrameworkInfo.plist create mode 100644 flutter/flutter-journey/ios/Flutter/Debug.xcconfig create mode 100644 flutter/flutter-journey/ios/Flutter/Release.xcconfig create mode 100644 flutter/flutter-journey/ios/Runner.xcodeproj/project.pbxproj create mode 100644 flutter/flutter-journey/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 flutter/flutter-journey/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 flutter/flutter-journey/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 flutter/flutter-journey/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 flutter/flutter-journey/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 flutter/flutter-journey/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 flutter/flutter-journey/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 flutter/flutter-journey/ios/Runner/AppDelegate.swift create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 flutter/flutter-journey/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 flutter/flutter-journey/ios/Runner/Base.lproj/Main.storyboard create mode 100644 flutter/flutter-journey/ios/Runner/Info.plist create mode 100644 flutter/flutter-journey/ios/Runner/Runner-Bridging-Header.h create mode 100644 flutter/flutter-journey/ios/Runner/SceneDelegate.swift create mode 100644 flutter/flutter-journey/ios/RunnerTests/RunnerTests.swift create mode 100644 flutter/flutter-journey/lib/config/env.dart create mode 100644 flutter/flutter-journey/lib/data/journey_repository.dart create mode 100644 flutter/flutter-journey/lib/main.dart create mode 100644 flutter/flutter-journey/lib/routing/router.dart create mode 100644 flutter/flutter-journey/lib/ui/core/theme/app_theme.dart create mode 100644 flutter/flutter-journey/lib/ui/core/widgets/app_card.dart create mode 100644 flutter/flutter-journey/lib/ui/core/widgets/brand_logo.dart create mode 100644 flutter/flutter-journey/lib/ui/core/widgets/error_banner.dart create mode 100644 flutter/flutter-journey/lib/ui/core/widgets/primary_button.dart create mode 100644 flutter/flutter-journey/lib/ui/features/config/view_models/journey_name_view_model.dart create mode 100644 flutter/flutter-journey/lib/ui/features/config/views/config_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/config/views/journey_name_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/view_models/journey_view_model.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/journey_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/boolean_attribute_input_callback_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/callback_controllers_state.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/choice_callback_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/kba_create_callback_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/name_callback_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/number_attribute_input_callback_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/password_callback_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/string_attribute_input_callback_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/terms_and_conditions_callback_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/text_input_callback_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/text_output_callback_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/validated_password_callback_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/validated_username_callback_view.dart create mode 100644 flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/validation_error_text.dart create mode 100644 flutter/flutter-journey/lib/ui/features/success/view_models/success_view_model.dart create mode 100644 flutter/flutter-journey/lib/ui/features/success/views/success_view.dart create mode 100644 flutter/flutter-journey/pubspec.yaml create mode 100644 flutter/flutter-journey/test/ui/features/config/views/config_view_test.dart create mode 100644 flutter/flutter-journey/test/ui/features/journey/views/journey_view_test.dart create mode 100644 flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/boolean_attribute_input_callback_view_test.dart create mode 100644 flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/number_attribute_input_callback_view_test.dart create mode 100644 flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/string_attribute_input_callback_view_test.dart create mode 100644 flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/validated_password_callback_view_test.dart create mode 100644 flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/validated_username_callback_view_test.dart create mode 100644 flutter/flutter-journey/test/ui/features/success/views/success_view_test.dart create mode 100644 flutter/flutter-journey/test/widget_test.dart create mode 100644 flutter/flutter-journey/test_driver/integration_test.dart create mode 100644 flutter/flutter-sdk-bridge/CHANGELOG.md create mode 100644 flutter/flutter-sdk-bridge/LICENSE create mode 100644 flutter/flutter-sdk-bridge/README.md create mode 100644 flutter/flutter-sdk-bridge/ping_core/.gitignore create mode 100644 flutter/flutter-sdk-bridge/ping_core/.metadata create mode 100644 flutter/flutter-sdk-bridge/ping_core/CHANGELOG.md create mode 100644 flutter/flutter-sdk-bridge/ping_core/README.md create mode 100644 flutter/flutter-sdk-bridge/ping_core/analysis_options.yaml create mode 100644 flutter/flutter-sdk-bridge/ping_core/android/.gitignore create mode 100644 flutter/flutter-sdk-bridge/ping_core/android/build.gradle.kts create mode 100644 flutter/flutter-sdk-bridge/ping_core/android/settings.gradle.kts create mode 100644 flutter/flutter-sdk-bridge/ping_core/android/src/main/AndroidManifest.xml create mode 100644 flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/CoreRuntime.kt create mode 100644 flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/PingCorePlugin.kt create mode 100644 flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/NativeHandle.kt create mode 100644 flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/Registry.kt create mode 100644 flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/SimpleRegistry.kt create mode 100644 flutter/flutter-sdk-bridge/ping_core/ios/.gitignore create mode 100644 flutter/flutter-sdk-bridge/ping_core/ios/ping_core.podspec create mode 100644 flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Package.swift create mode 100644 flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/CoreRuntime.swift create mode 100644 flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/NativeHandle.swift create mode 100644 flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/PingCorePlugin.swift create mode 100644 flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/PrivacyInfo.xcprivacy create mode 100644 flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/Registry.swift create mode 100644 flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/SimpleRegistry.swift create mode 100644 flutter/flutter-sdk-bridge/ping_core/lib/ping_core.dart create mode 100644 flutter/flutter-sdk-bridge/ping_core/lib/src/json_codec.dart create mode 100644 flutter/flutter-sdk-bridge/ping_core/lib/src/ping_exception.dart create mode 100644 flutter/flutter-sdk-bridge/ping_core/pubspec.yaml create mode 100644 flutter/flutter-sdk-bridge/ping_core/test/src/json_codec_test.dart create mode 100644 flutter/flutter-sdk-bridge/ping_core/test/src/ping_exception_test.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/.gitignore create mode 100644 flutter/flutter-sdk-bridge/ping_journey/.metadata create mode 100644 flutter/flutter-sdk-bridge/ping_journey/CHANGELOG.md create mode 100644 flutter/flutter-sdk-bridge/ping_journey/README.md create mode 100644 flutter/flutter-sdk-bridge/ping_journey/analysis_options.yaml create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/.gitignore create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/build.gradle.kts create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/settings.gradle.kts create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/main/AndroidManifest.xml create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyClientFactory.kt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyConfigParser.kt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyHostApiImpl.kt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/Messages.g.kt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/PingJourneyPlugin.kt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplier.kt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/error/JourneyErrorCodes.kt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/error/JourneyErrorMapper.kt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapper.kt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/mapper/JsonBridgeMapper.kt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplierTest.kt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/error/JourneyErrorMapperTest.kt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapperTest.kt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/.gitignore create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey.podspec create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Package.swift create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Callback/JourneyCallbackValueApplier.swift create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Error/JourneyErrorCodes.swift create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Error/JourneyErrorMapper.swift create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyClientFactory.swift create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyConfigParser.swift create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyHostApiImpl.swift create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Mapper/JourneyNodeMapper.swift create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Messages.g.swift create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/PingJourneyPlugin.swift create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/PrivacyInfo.xcprivacy create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyCallbackValueApplierTests.swift create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyErrorMapperTests.swift create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyNodeMapperTests.swift create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/ping_journey.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/boolean_attribute_input_callback.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/callback.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/choice_callback.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/kba_create_callback.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/name_callback.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/number_attribute_input_callback.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/password_callback.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/string_attribute_input_callback.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/terms_and_conditions_callback.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/text_input_callback.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/text_output_callback.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/validated_password_callback.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/validated_username_callback.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback_helpers.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/callback_type.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/journey_client.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/journey_node.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/messages.g.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/node_mapper.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/lib/src/session.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/pigeons/copyright.txt create mode 100644 flutter/flutter-sdk-bridge/ping_journey/pigeons/messages.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/pubspec.yaml create mode 100644 flutter/flutter-sdk-bridge/ping_journey/test/src/callback_helpers_test.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/test/src/journey_client_test.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/test/src/node_mapper_test.dart create mode 100644 flutter/flutter-sdk-bridge/ping_journey/test/src/session_test.dart create mode 100644 flutter/pubspec.yaml diff --git a/README.md b/README.md index f257d0f2..c9f3e48b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Explore the many use cases the Ping Orchestration SDKs have to offer by referrin - [Android](./android/) +- [Flutter](./flutter/) + ## Documentation Detailed [documentation](https://developer.pingidentity.com/orchsdks/index.html) is provided, and includes topics such as: @@ -44,6 +46,10 @@ Detailed [documentation](https://developer.pingidentity.com/orchsdks/index.html) - Gradle 8.6+ - Android API level 29+ +**Flutter** +- Flutter 3.44.x stable / Dart 3.12.x +- Android API level 29+ / iOS 16+ + ## Disclaimer > **This code is provided by Ping Identity Corporation ("Ping") on an "as is" basis, without warranty of any kind, to the fullest extent permitted by law. diff --git a/flutter/README.md b/flutter/README.md new file mode 100644 index 00000000..3c2aab33 --- /dev/null +++ b/flutter/README.md @@ -0,0 +1,37 @@ +[![Ping Identity](https://www.pingidentity.com/content/dam/picr/nav/Ping-Logo-2.svg)](https://github.com/ForgeRock/sdk-sample-apps) + +# Flutter Samples + +Ping provides these Flutter samples to help demonstrate SDK functionality/implementation. They are provided "as is" and are not official products of Ping and are not officially supported. + +Ping ships official React Native, iOS, and Android SDKs but no first-party Flutter SDK. This directory provides one: a native-wrapper **bridge plugin** ([`flutter-sdk-bridge/`](flutter-sdk-bridge/), wrapping the published native Android/iOS Ping SDKs) plus **sample apps** that use it. + +This is a native Dart [pub workspace](https://dart.dev/tools/pub/workspaces) — one `flutter pub get` from this directory resolves every package below. + +## Samples + +- [**Journey — `flutter-journey/`**](flutter-journey/) — authenticates against a PingAM/PingOne Advanced Identity Cloud authentication Journey: dynamic callback rendering, login, self-registration, and post-login token retrieval. + +## SDK Bridge + +- [`flutter-sdk-bridge/`](flutter-sdk-bridge/) — the `ping_core` + `ping_journey` plugin packages + the sample above is built on. See its README for how to add a bridge module for a future + sample (e.g. DaVinci, OIDC). + +## Requirements + +- Flutter 3.44.x stable / Dart 3.12.x +- Android: API level 29+, Java 17 +- iOS: 16.0+, Xcode with Swift Package Manager support + +## Native SDK version + +Both platforms pin the native Ping SDKs to **2.0.0** — Android via Maven +(`com.pingidentity.sdks:*`), iOS via Swift Package Manager +(`github.com/ForgeRock/ping-ios-sdk`, exact `2.0.0`). + +## License + +This software may be modified and distributed under the terms of the MIT license. See the LICENSE file for details. + +© Copyright 2026 Ping Identity Corporation. All rights reserved. \ No newline at end of file diff --git a/flutter/analysis_options.yaml b/flutter/analysis_options.yaml new file mode 100644 index 00000000..f9b30346 --- /dev/null +++ b/flutter/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/flutter/flutter-journey/.gitignore b/flutter/flutter-journey/.gitignore new file mode 100644 index 00000000..3820a95c --- /dev/null +++ b/flutter/flutter-journey/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/flutter/flutter-journey/.metadata b/flutter/flutter-journey/.metadata new file mode 100644 index 00000000..954d6716 --- /dev/null +++ b/flutter/flutter-journey/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: android + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: ios + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/flutter/flutter-journey/README.md b/flutter/flutter-journey/README.md new file mode 100644 index 00000000..fb81177e --- /dev/null +++ b/flutter/flutter-journey/README.md @@ -0,0 +1,90 @@ +[![Ping Identity](https://www.pingidentity.com/content/dam/picr/nav/Ping-Logo-2.svg)](https://github.com/ForgeRock/sdk-sample-apps) + +# Journey app using Flutter + +Ping provides these Flutter samples to help demonstrate SDK functionality/implementation. They are provided "as is" and are not official products of Ping and are not officially supported. + +## Introduction + +This sample is a Flutter application that uses the `ping_journey` bridge plugin (see +[`../flutter-sdk-bridge/`](../flutter-sdk-bridge/)) to authenticate against a PingAM/PingOne +Advanced Identity Cloud authentication Journey. It demonstrates: + +- Starting a named Journey and rendering its callbacks dynamically (Name, Password, Validated + Username/Password, Choice, KBA, Terms and Conditions, Text Input/Output, and String/Number/ + Boolean Attribute Input). +- Advancing the flow (`next()`) and recovering from an `ErrorNode`/`FailureNode` via a "Try Again" + button. +- Retrieving the access/refresh token and userinfo after a successful login (OIDC module). +- Signing off and restarting. + +The bridge wraps the published native Android and iOS Ping SDKs (native-wrapper approach) rather +than a pure-Dart reimplementation. + +## Requirements + +- Flutter 3.44.x stable / Dart 3.12.x +- A PingAM/PingOne Advanced Identity Cloud server with a configured authentication Journey +- Android: API level 29+, Java 17 +- iOS: 16.0+, Xcode with Swift Package Manager support + +## Getting Started + +To try out the Journey Flutter sample, perform these steps: + +1. Configure Ping Services. Ensure you have a PingAM/AIC server configured with an authentication + Journey (e.g. "Login") and, if you want the post-login token exchange, an OAuth 2.0 application + for native mobile apps. 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. From `sdk-sample-apps/flutter/`, run `flutter pub get` to resolve the pub workspace + (`flutter-sdk-bridge/ping_core`, `flutter-sdk-bridge/ping_journey`, `flutter-journey`). +4. Open [`lib/config/env.dart`](lib/config/env.dart) and replace the TODO placeholders: + - `serverUrl`, `realm`, `cookie` — your tenant's Journey server configuration. + - `oidcConfig` — your OAuth 2.0 client's `clientId`/`discoveryEndpoint`/`scopes`/`redirectUri`, + or leave `null` to skip the post-login token exchange (Journey-only, session login). +5. If you set `oidcConfig`, register `redirectUri`'s custom scheme with both native hosts: + - Android: add an `intent-filter` for the scheme in `android/app/src/main/AndroidManifest.xml`. + - iOS (SPM): add a `CFBundleURLTypes` entry for the scheme in `ios/Runner/Info.plist`. +6. Run the app: `flutter run` from this directory (`flutter-journey/`), or open + `ios/Runner.xcworkspace` in Xcode / the Android project in Android Studio. + +## Testing + +Unit and widget tests run with `flutter test` from this directory (or `flutter/` root). The +integration tests under `integration_test/` drive real Journeys against the configured tenant — +run each file standalone against a booted simulator/emulator or device (not combined in one +`flutter test` invocation — the native SDK persists the AM session cookie on-device across +`Journey` instances within one app install, so running both back-to-back in one process changes +each Journey's first-node response): + +``` +flutter test integration_test/journey_login_test.dart \ + --dart-define=E2E_USERNAME= --dart-define=E2E_PASSWORD= +flutter test integration_test/journey_registration_test.dart +``` + +`journey_login_test.dart`'s credentialed assertions (login, Success screen, sign-off) only run +when `E2E_USERNAME`/`E2E_PASSWORD` are supplied, since no test credentials are committed to this +public sample repo. `journey_registration_test.dart` needs no credentials — it signs up a fresh, +uniquely-suffixed user each run. + +## Native SDK version + +Both platforms pin the native Ping SDK to **2.0.0** — Android via Maven +(`com.pingidentity.sdks:*`), iOS via Swift Package Manager +(`github.com/ForgeRock/ping-ios-sdk`, exact `2.0.0`). + +## Additional Resources + +Ping SDK Documentation: https://docs.pingidentity.com/sdks/latest/sdks/index.html + +## License + +This software may be modified and distributed under the terms of the MIT license. See the LICENSE file for details. + +© Copyright 2026 Ping Identity Corporation. All rights reserved. \ No newline at end of file diff --git a/flutter/flutter-journey/analysis_options.yaml b/flutter/flutter-journey/analysis_options.yaml new file mode 100644 index 00000000..21d6b069 --- /dev/null +++ b/flutter/flutter-journey/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + always_use_package_imports: true + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/flutter/flutter-journey/android/.gitignore b/flutter/flutter-journey/android/.gitignore new file mode 100644 index 00000000..be3943c9 --- /dev/null +++ b/flutter/flutter-journey/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/flutter/flutter-journey/android/app/build.gradle.kts b/flutter/flutter-journey/android/app/build.gradle.kts new file mode 100644 index 00000000..7a623a74 --- /dev/null +++ b/flutter/flutter-journey/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.pingidentity.flutter.flutter_journey" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.pingidentity.flutter.flutter_journey" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 29 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/flutter/flutter-journey/android/app/src/debug/AndroidManifest.xml b/flutter/flutter-journey/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/flutter/flutter-journey/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/flutter/flutter-journey/android/app/src/main/AndroidManifest.xml b/flutter/flutter-journey/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..eaa25eca --- /dev/null +++ b/flutter/flutter-journey/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter/flutter-journey/android/app/src/main/kotlin/com/pingidentity/flutter/flutter_journey/MainActivity.kt b/flutter/flutter-journey/android/app/src/main/kotlin/com/pingidentity/flutter/flutter_journey/MainActivity.kt new file mode 100644 index 00000000..a82a5ec3 --- /dev/null +++ b/flutter/flutter-journey/android/app/src/main/kotlin/com/pingidentity/flutter/flutter_journey/MainActivity.kt @@ -0,0 +1,5 @@ +package com.pingidentity.flutter.flutter_journey + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/flutter/flutter-journey/android/app/src/main/res/drawable-v21/launch_background.xml b/flutter/flutter-journey/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/flutter/flutter-journey/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/flutter/flutter-journey/android/app/src/main/res/drawable/launch_background.xml b/flutter/flutter-journey/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/flutter/flutter-journey/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/flutter/flutter-journey/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/flutter/flutter-journey/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/flutter/flutter-journey/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/flutter/flutter-journey/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/flutter/flutter-journey/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/android/app/src/main/res/values-night/styles.xml b/flutter/flutter-journey/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/flutter/flutter-journey/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/flutter/flutter-journey/android/app/src/main/res/values/styles.xml b/flutter/flutter-journey/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/flutter/flutter-journey/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/flutter/flutter-journey/android/app/src/profile/AndroidManifest.xml b/flutter/flutter-journey/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/flutter/flutter-journey/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/flutter/flutter-journey/android/build.gradle.kts b/flutter/flutter-journey/android/build.gradle.kts new file mode 100644 index 00000000..dbee657b --- /dev/null +++ b/flutter/flutter-journey/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/flutter/flutter-journey/android/gradle/wrapper/gradle-wrapper.properties b/flutter/flutter-journey/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..2d428bfb --- /dev/null +++ b/flutter/flutter-journey/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/flutter/flutter-journey/android/settings.gradle.kts b/flutter/flutter-journey/android/settings.gradle.kts new file mode 100644 index 00000000..c21f0c5b --- /dev/null +++ b/flutter/flutter-journey/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/flutter/flutter-journey/assets/images/ping_logo.png b/flutter/flutter-journey/assets/images/ping_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..2218aa29963e4ed5dba6ae2f2c0f0fed5d0705b9 GIT binary patch literal 23323 zcmeHvdpML|^zWV-V=!)sMu;hrB16d~(x3~WRFX=~C?!z|g>rcj(Nz>e(OspQ6e-0J zl~U=BFB#WV?&OleS?@c(zsvcZ^PIoW^W=GY=AC!1z4qE`uf5i1t-Yr>r$RPKGNqy)a7DD`kuUN=oS7z%WHd!1YQ~O~m_T%_JA&f@6 z(Fzs1c%o?vbn|%gae_&j z`DDcYaJV|KX0HrFz7^*=NJ4?Vc?^nL+Si3-_R-b?d0>d08-7SbZSkSEfq6HSeldxQ zAGKjvI{0)ct(C9gv3)H_5Y%`r6rN>uU>?RBKCXc07chV2luR#B5*CQhZ_ezGTj4z+ zvF(g&b@05>3Hn;}&Vw{1IucrHxd=JCo;`%qhHZ>;ilFnYdD_t2Oc1$n5^mg`hKe?u{gTPTx z)!EWeE)_DnB@%5`K63s0G>}8DQ%Fu9TphJG7N&#khV{NPxMxyQlzad8nzhnMxUFkT zLQ{S&_2>twx6j(o^$8xl3Fehwbo-SQdFkaJkL`{AzHc}7Y-Bao%u|pgZ*r1il3Ey< z_gH4G){R4YAQyHB<`){eDrloYY54A^*Fn;sXE@k<%ZJ?kalhunq|gAE!;Uo(AR?wd z_Ghpw{5W~@qbK6#S^O^!Phx;zd43;V{&WE!9{H1AvDg6`O0Kw(A>-a$+T*@i$rsHe zt0Ln9!72m`1H(vPnx+^)t8bm04dUHN9yEEl1ZUnA(h9gK+JhSdtUUG2XAF35pS9B_A=2e7IsJ`i4z8VF()8M6%j}PL6V4o4FrZFf*(Ne znBdEu0R&qJf)vn1rY0feHJC-)YfpR#t_2WGA_$c2hN)b6f*=OgT6wt;m(dV-#VCXq z9SS{YETJwB2D=HY`*IvU!^$+6ix}=YPylQMgCqD(L8Gug95lzks@SuDbbegXtT9=` zQ(y_1b78P@Vz8YnVK7T#uo^ID2`geSST}S@M?E}r)fXEOZl~=ql__t6%b6(&U0sQ; zrqGqCMs$^huCZA=gumBqI7I;>B_PVIC0Nw~tHwFX;dS$WGeM|Sm@<5z9GIEJ8xsw9 zD=f zoE;M2v-bfD;iW9>`8v9m0^6Pkdco-l8#?GVQsxB0xqi{#H=CWKz?<`; zK#4cqMy^U$gN51Ri{QSr@vf01Y@??@=ZXz^b3yjbOVHoAK7?yR&^Z8IaN#gM3GL*JYWWyWbjc_G~IyCH?fz_RxcdJE`n&L zxTm+1x@HB>`PQJ_vuijbpABU|9{x;N!s^%}WgQp@dVjg*r7{CF(FKwz%-P>DXjN@w z3QhGOLwrP`mn%G5f+AnusL8FTo&AWx_Yjbrt-kU4MR>-Cvb`mMwz9r)O)l6I0!}Zl z`ThA60+U<`U*_Ek?ijqnrJN0g7nV3*m-7lg>2g4d&s1yua7~xZ39XyMFpZGGof1r1 zL(|0&LDVn%C9fEzA80VSa?beJ=&9A2x`unChoAL7Ghfqjwc7#M-2tcZvfW-;A+n)= zP4<}n&O+d7GP{U9T_e|){JenIb}SR;>(lHl7clGIiHk8Q89|PIMO1aL82;wkVd@Hh zx^iUwxDqbyz`pOvonpC$rIS7??Ol=ckpB$2nwEfzs`nGNtmpM_Vh+I@qlUMvy11R& zcFw$}za?pZJyi}iR;C$vwYUD`49g<=ESY+VTu^qVZVN-8GRi0{n&f#t)UkOpD0&0# z4z=zZHY?4a+J#$H$dJ1P$_olTm_!E=uyvr4K>2Wg-zZi1)(`G%BwvWrm4dysPsg_* zIt@ES_I%P~5Ki#*&TA&LM#(W(&gchH=@y)Xy>Az58w>}31}_KOb;Ul$0mD>p z{LO;RN1rsZ7C|fbJ}gqe;ytGO3_uqK5k&KTAFsm425&$r_^Uj2<+a``7GLeoC#z6J}b4gNXLJ~q#5ZAg2O;Ua{t?VXeEXbOvp+8-INQhPAZV)-d)EM$|@A%&B; zFHG~Pm@>~I`^32acTe7x`m#$AGUC7f{HiY+H9 z{jopi9~952Yc8tUx>fsG5G*+N#ql2jJ)DNjPU`xI;x(0C9(uF2^L9%<1+7jO4rYL~{A)Rij&9AB*#pw>W9kr2U3V_48;=cM+vr*ZP`JT2E`hp?ec*fRpx7DK~6(vPB`o;b7cDI3qv zS#_vBL?DG*=d4?YkLdn*@ucLB^zK<_PhBkb1E-AC?pWwqTPzr5>|I2E^I*1pQgxV@ zt5B=~hh9nJVcdV-AVayJpk1bqo6a8j_3>`Qj2#C@Bun;zdXVhKsViO7-L5eze@we; zu|wN89TbXPPW4=#?>T3z@hnZ)E&!;5GW#U~hQ*rln?m6g6G!5ijW_sAEmN1^lS{X( z^9*{@h1&a`LDW#}=^Z=pXR*!cCNCAScPjpff3#Q$P%<(_f(V7tiv`2KLTT8cM_{PP z+jo1x&_@%AZ-Hp>y!>N$Y^JB*NN+TtS$Lz@5`jIrc+3decC}paS`LV+z=I>KdC8@# zfh{;f!aMnDJrfr9 zk+UsuXNS&!<8yzvT6SB?(*5d*v#5MqT!4z{l=lY~P8E}r;Lj=b#NU`=?rR#}6IcTw zn@2zGzgBOTw@^f{C*kOetz`N8S0zVIC8SVnTwB!iZl&yct|P)`<5ISjFw|2lDKe3UG~SkV*FaK? zqSQKlnJR&fUWlXYX6&Il&-XOgF>*S#YIMMIiP`c7?%sHk9%l(|d|1=u9ojJrf*zT^ zTF22D=~NZtFI?tI%x9i^(pG2EOWgnIytp1ll1&ID(N%hI$&u`UvI9S786i3Hxq1rm zaFc!8?b$$jxje@x27P(B1{V_LJ5C{G`(*>>MOiP)4*1@DQAvsy!LqL}3sE-i7?IAB zQZa#ExVD}KZudz4!>b4>kNPvfU^y&L`y1@BM}0QVZc|9+lm#Ezq^ij-_yiDteGbbh zqCJgZ=yWn$5^GPpfG;TucX}St?@=#>yXqQ1bWp(!MW(utsYRAJ!>ai`*6AMdws0-y$bk(pQFSwaQQ-%D8W z3GJI|FHc9l-}v7okk2!e?rr-Vj22xdXX?M7%ojhEO^leS-=X?)Nngu+z#71V8=S-9 z;JzENSL+MOW;+YhTi&v;wKT-lAoFi}aP;?q*JwY7GG~;l`i4z6!PSEKd2qO1sjN*P zr>r_~$+qg-Ze9-)@(uG1!+zczwR}(?NSji3zdCOpLo^Umzc@J*f?v6t842-5$fovv zEs&}ppPSJC#bg|WgE~s0IZV@HJW3)@IcV6S5b^^L9Z2wfukH0N*|3j`uEz9zk#_$f zDP0e9=f0*Q?z;o68h5BoZN(q(;M%T9LOxIPy|!wP2=5w4C#B4C*GQ~+jROm`c$KE? z19bRLvEp!Y^N}6w>#M%FrJ6-)oO^384`KU3Xq*+y?w!kz?HWZZ3;ygpl5M|s=j%^3 zV@A=NUBEvB3QD(N)L)uCcZeartFXP`d%^{KOIW)5G~-}|4s4Yfo7~}7W8!3zkG}-i#1I?@@XYhNU+eDMJ6ab`$Us> z_Lz5l-Rqtq;}kGex|CC**yZoX#oyyQ z!l}$J(Cf4Oz@)$@H=oN3z%G2h@g9on5x>W!X1#M_TM!X79#hN?`;h;w6cb0275mK=8YUZSr zr3Xm7nkEMtBpoPEBJVBs)#IvXvth)v)C@%;k2G~gJn`Z(Q{-Zy*2%>;8gDMoOPqI- zNr585h^qp9unf<8Iwd^H@X`Ep+ttu~gxq9G#VcTI{J7#n=n2eGD8hqcXS zLuyo~(IH+uo61kf?e`t_l10AJz4S8z4+*w!4hww^cQE~g2G@i|IQzsP*M5t=5S(|~ z4@YY$t{PftN78daNwceht{HzW|9)gT?6(bS^Dat$Wg>w&WTxq*iu@H+EJKC89pAAI zrccfJCR3M1K`aDJ&bHo&xF^UwkK)ZCCNUwnBUWAxbpft4boFso;*-H{q2G}rjG6+b}f5OodMS~S?LsaA=N96D5`$7=u z+3S%sD}+Nz*C>z#Aso-*e|Y@RLnVVISOLBPCs-eDD#2w}g9J|g7z`|b8d31{AR0VtA1UhCCMHfw-$-%@T(l_k(g)hSLr} zHj-jT2!t=+wbQsjnUg^vGY|+{H}nbXq2XmUg9w7}X5f2V8~7S469p)2?NmtNhXmMZ zS`y$UgOfOwDUYuVqa0X`P;G1P4MW-&s6iNr1k(ft0k~kz0;!wt#iu|^<$FBT(vkE( zLCRtXN63PHD&o87UsCb>&Ts{T$97MK$>NZx@&XXXr~JxVrtAu}JoYwR-|ZmKZP3~S zE#R9sLn$i};ef@+e5aW=34NMArt;b2#ndP^gby@e2Z?7!JN*IZ%J&Q~b4pwzDSr;C zK+}O0*MH5qA#be+yb3s=aVtF=!jRqx_sGj9tO!7SNE6cRWdqcOV3YZ{P4Yc!u3n^& zLBIJ?EDMC}+g+tgf}ogle=FMvhRcH}WR$s0MW3k%I{nA(Hv~~S4rca&ytR0>!`Q&* zy4N=~4I7W!m>~{a@~ogsz??MW#N=h-TxkeZrtelxgVf4-C52o8Vt1dt`-hCo@{GjY z7vApMrY0S|j{Lk2V6;yt0K~Fn4I>VB3XI0VO={td>hJ{|u0A**K2)=@2We_i@++XE z(WmGM zx_oVAdDv}Eo!#5O+emCL)8$99-prh(iC12^p5W>4g~(?6 z2i{-9;^{-iGXD(&VV^_y-W@&gXyIK=1rIQSs!E6l^(wOup7RMw)!4Lkl|~j6<6a)P zX$aN9Zst&9{*vIu(j<1OJhuVEw|sIs{VLbihVNbQC|A0}8QiP*O{CqbRT8SL+{ z`BAV$?3=lM4GV14k}71maYY1tlkRaz8c$dpTYrumGHw8xlpWo9i z57FOZ@uzy}VR`tby=>?~(WgXN#xkB2%ZdV>+ z|1rTm-&ROvY3o5m14+D%w7XZ792GTKd68L~ddrE1fBLn%RZ2Dz4OL6_lUYnX@ZQkl z_Jdj7Ka1yxdPI?_TNdJ*7KRvnf06%7T$EM#nkHG-lM;IpULyhL5&4O#(G_Wh5m_U|#PKp+t~~cRfFbycI^XyeV#USG zng_HP9M2=v>N}pi=6)TM&n1TDJ9Lh(agU)i5!o^7do*tUC2qGocM8}t97RYChT9pR zi-;rU8{49J&W@jLd1T_d#49E4l=BbRVuWCaTcr%V9i5ptwnyj-lQcQpkBs{jC~GTo zBA7KkVz^_10ArVNEKRDScB;gL>Uu*YB4_u>#6lSxl%?gtIruTeeLBH3+p&8)dc(k- zIp{b+jPtxQ#2t?r2Q?(wpSDid(qKgx*djgeG6+lYbBlc^Rz*YHt|0g8;>x_o&a^ zS;&Tj5yq-@?Av^-m98iz7U^Do>330VkyR5CmQ+Arn|eFr(VU6q*t=2#bB25E_*jO2X^q!2WG~s3uVE@gu$! zCWLB2Tb`(Gj5!i>b(rk)9WoPygxVu9Ge^j^Wq3A`qd1sDAf-0 zYcOHLHxmd7@Kg6CB?AFoL561n{6Vr>c3mB1!5JI8S=M)hhJ7O_|K%#xbAyJC{+BZ*VEx{$P`u5N z=s~z`=&9}7!N80B&^qVJGW3(^fSElAnvj8;3SMHAkqIYLK%L?sr0(o|kH-k~F|@S) z>vhk|B%+!Gg{a=)vAa^z6DLh#N`1QW2aR}Q1Z#y5yCgAxy8v-KHG#fgc!mq=qx7ztDxh5u_N!$vrgJou0mZmwo<+?*IFpjpbm zK?H^kBE_UNiBt@VSHn_b(xTA~BF5-A(1SmR%j4Fw3h|-^;(J(a^9A1`qf@wQh8Zy?H|*qkjH7i7p2aI9nA7c({~vRJ2Mj%j<`i5or+09s0y_)*M?S z$_UtgjV!827tOQA*5G9|#5S`WLJQ)G&WI#&iXO5Tt*8>5p=^&UQWR;|>DZ7xRWy&5 z*rF?hGjlp4XUX2z$a*AVsW@{i0S-8TBE8cP6F z0JQsoR<4otgP1aNjJ!CmNCW_I1iHsuVEX`5ZeA4TZ9e0 zo&X08zkh5Pr*h{KllPf1X1yKi_;4T(o$~yK8N{&};0%o(#xB2(P1Ra#WYMbf{C9sKK0*XaK9883+)l(LK}j;B5#rS*_su#-g0 zklV;ixEla3W++6GOhr+F8d9`Ai%P*0acHoA-0xYTsauySE5ii}VBPl1e$tf4wY|l(iCrI~5E&r?CD=JjYo3_25fQ7< z;qGC5p45{W8X?RxqWHp01#c!Z2|p|aMp$Nu1i9*s=(){}v?Y4kDm7y1_$vJzE|dy@ zh&%HSizk{gPkoe;FA5N@XUA0ISB)x&pc=jye{J7r!!!#3XQCZWdEg&kV;hO z5$0T4|3f%)FHw?)ZQFFeNQ5Nf6Nl)cQmZJr1_oaEt`cpGb8$s$hwQ)MC!Nq>yHXe( zDbkFvl#_+mtlF}XboXf%QpTJ|5wJ>_2$Vu+(!V3`w@1cAPb6S1#)uhfam$3eC z)3WeX2%#jc%_P23_~^*s;$`5iuoj&n+M~W-GV>(182RC+Itcq6>q5J+8i@b{YRMXp zCyLMftxz?<(uiTQ%C@hvq#x8|4KYR$L8O4XAU7$mvQ0_BY5TC6RYAB`;F!#vPq|7 z^=M)#(9SM=Y6hnX^*P1TizaAtCBl&s;daovwy_EIw!93#U@ltPe^rc>9*zBoqfGej z^76u_rcJ2;evuB^(hjy2lgx+-@imN=oRYRBpcB)GQ;}|M*U#g5+!LLI zo7${$Yn&SFK-@FI6Th2nKhQ6b2)OWPUVqXi$D@Vlh&4M-$1`m(P1P&Q-jQ8T;tFI3 z+g_s1qU9$O`mS{xpJx0LjSS(HUqp9O z-qzc)fpToYQ6Ay0k?g+lhaI+L5t2YMZACAZ%ZV^1+Sz)UyMiAm!?2(KPaISQVCTx* zUG;8WwY8RDZWN`Gh>9pcixfVUme6xZ^)M7kU<}QWl^IL7o~xnb_a7i9lGX^Z)%2zs zFP?Cx+-qIA0cWRV_cgASTO!I&#;YDhozIIfkh-k&Gwt{2pkDBq8SAX$t+(oJdl%ki zW^P=p^6q%kI~Uj2>zDo(zPi>y79ANlG}1Iu&3m~CwvF&sgd_Oc7t9Qyk^*butRRg- zL{1HRVX=EFp?R$MurhxSC1V0Ie;|gfXkBW-x1@|uluAV}kAL59!I!0&OvsSl{@RVO z7|BmlQ4kSTn1_exRqF(VM?vVJ2M53206z8NC&U+}!ze^xs2~b6m)aI!yT!tYnKiH8 zjZ`H>n+S;}Ne=dwtX1H#}X>OHPYAXBX64ihcd;|V`R4ksx=;25zsoM>WXPR_A8dX`*r(A z>6=MUtTK=G(giNTzh(_x2zY5*IQFQ!H=T+HIMC#SIJs1z8B>kX!SAFNXZFw zx8%$8FlKzswaNs5+j+WhaMfnhx><`Yco*!}^d`!>_qq49BnEmL-Mg3_T#>Bt`QAqx zw@tzQuZr5^isbVy$&g#laJrXcpJ{qi>A1nt~Hn`^Z)cLrAEPotQ61oo~iN@okB zQ*wRe1$R+Lot2-Oi2~IrwfIn#`H1@Cybw87=$ZFonx<+;ce$)*V5RQ{OU&(^84Fk2 z3dL{DEUMg^X4E-zgQNLZ_ARMLA;K5YjI*b7A8*6fN#`+z(bxvyYu8IZup;@1#p|1O ze!kH@Mq`^SLQ6VI#N%9ArknrM>C)xW9j2pb(6);k_$~&`2^y6Jlu=7r9g ztsa4L?kWwFQXcWl%UPf5Sk;0X5m%Jy|t&sKNHJe&b%ox8gPCGow&vdcIBM4Jmi6qRfkdN-fJ+hft^m_tK;Fs8fAG zx#OPW$BRi1SyiW-89v+Gj_vcmnwFbj_v6tjva@M%%f$x<(~$qHFJ~P#>AAD- zSkDiOB?WFHy>D}#Q8Mdf+Ebm>_o%ebTTp9Jtaf;=ZCLTOva>2vqqwnd+6KG)_sBba z!*0-S*@n4KmD^vIqAV44S%LpWW$(j%6o-Y%OM2zeAsE6J$dc30m)kZ zTGheFrynOrJ`&$<7iQIUQpG@T>D`Uz(bPlxqp*cE^>dtS%IVs}(bcKT%@XvYxW3ZS z-gobxnkS(n_oR*?d-=4OiB8tCUi#X&`O@S$aL7nP4@;eUIRS;3}n8zh8LkRCSLA+1b!uoVXMwQ{{Bg0&D7f z`AYT+)78G0KEG|)b)ES4h1Une?SDUBjpQ9(&T|i%+aow2hEFzs*X?iD={LiSvZeKX z(!Pa#63+12-~3U{nG*lBM}5$Ia*v4=eaZ6&y_O}ytF$0kl92mhzLTEzyy(ACb8T>O zu-_tyv83b^=9nrKr46jkoJGYBE&t*2?ws+S$qQ$VB;D3+UUEaPh~jo(jMMv3 zUqkAj3%msZr5>xbd}_=xPmu>TTy1;ll->PqUnehid$;kXw07Xi`Ga#$j()i@d1RfS z_)x5>gzGA+y>jyxsgjjF%BL5eJJ!}5zH+Lf&xLEF1y}Qyr6r9H)skTcNdeTA}Z7zz+A|KIaCi4i=--c$>ys#$7};QPX*Qj#Cvzz0HgL(><{E za@x?sZI%sZt0sL;KdWsixJEkY<@C)dshM#nxqttbDZg{JJ9XC$UK>o0^B_qv4Q zGH-NrPu-Ti+i|7u3uO=En?%4TosCQBpKZe$R`|cw>dZ;q_`5^PYHU^U{Vu^? zDmN8P9ee2`b8l_g{r&v+DKaTDmL*Ci}TH6mnoXImC0l3p(VPEY9qb%dmk5&kip}rmHRGIse9suB@324 zbSt=<b$LN!wMWKRWG|^hgwz*r>fiweSOl<6$=P*%@LbwqYdo z1$UNyx#jh7FmyEUB)?>THCgIZAT!Btx0XD+moC?#t5@ZFC1trolHkPCA9AkD2gzR; zQpQ@^*5 z<8`NG(H|-J89=!Rva6J{YIoPhNgtW9S`xVq{T%d(VW6tWW(I%92UQYEnva+#AX9-q z{Ij!ZwOr;)Qq}eozDZM1n*&^0-TI4;+H?`-qJ@`qUYn*!Bk38#(F$rWcs3N&5pM-) zaXj>7c2Q7eQHOPkEP7)XM&ezCi)5Hl0whtHhhsdQL&GG|am`_Bnwla7Y5h8PXV)Br z%HgO-6S&5MTU%Z}BG~sQIMMG^iTsb|BgWRQd^GRrBB7uL=72;KeV6`0LJ0x(h-vtYh zVEr4%$TajBG(_81C$HxY$}W1Ao4m zxR;D-!F|K&h*4j0fjT@@5R-+ZoS-%qM94h{orz@J_0~oi9{{&ITy$LfaBN%rOy;;a z@=eD_ZF_xL8L9#x<>mBj5&|G0j$DEb#Xw6*B($4?e3d-$h1qRuK|-<#_yljCIK+&? zg`&gi<7`DscRolE1()wn#^W?~!Fm)FzZ@UVcahgT!~=f`b8-0oVp`BAUQ3LvwR?nh6JYnsi}}R)lvwPwdf{6xF4n#&^ra&AhXcF`?IHiI$TjF zEEXyj|K)RC&>tGA{nbljx}GFq%D8!AuY+E)V7dS6*i|VsxcVKkqLf2k2U=~QfRI7Vc5Ky+<)g21NtizbG)}}pIA&}@Y}kAFbns7po$$j<_?^sV z2Amu=Gal3d_vZI9h~G&FcPasP1$cE*yDEtpQC~@h1fZAx%kEG6ToUS6Mf_ZXETg-} z0`YReI$w;AzTg-G^T)YzjhjkU8ExEo=*&zo==?`Hab&DD$%H~{Y99=Dpit1&pc={< zfzG{AWyC&R*#EoeWB6BPGJ6s+`@V#nxAu3Ij?wWQ&|rBoc&nTw3VocrPPbQhp|3aJ z1zKL)g19R zy46>?{a}f1QBTe)wck$%75a^l!HK;=QGSs{B%8~3wGfswTD7TY-zCqH%3DJ#JWv*d zA5%L88mc@j-}h2JRQalET~3b}W(KPl*lIKQdN z>6RMG-f+W_j(E67HEfS`F8gz9sMp$=A&A*4|MrnVZSCM@yP>0b$Lq49DYwFA(~y7``5WIdN&_o`RnF)R5v=VHbd9DGQ{1J zmCSy$EGSY#RjJJk$x{O2HkMX2!xj z44INI=6Cj;^<96`so}m%G400ujlq{~oe~kh2|%B>K9-<-&$G|i>in>djn(OZHkkw2 zGdAm^_7amPR(>Y+zH&bDPnt4Iete|3{hpiZc+_#5@j-*ViXW0!O{bt`@xsVO;y;Un ztXJQC^iA_dZPVh2%|AWH;P(uYND<;esPWHYTkGFa7Lx=k`X2}DCPZ}=NZpHnx~?Sp zE@w4~J^Ml*J#pcoP#$UDaB#u)ly`MeR{I-o6j!ipXVTDR&|SqqO^Zp_@7?>4s|$8S zn_PX_Vh%r3xdFT8dPIW3o=la9FDCK(8tziyM_Ay?ujLILbkpP-RWMykW?Kvf2W{** z?zc@Aj}XP$mAR^uDO6Oq`B3}dfZT&@3SX_TL1s$ZFqPxKQ8=wA^EsnXX|}pSxYwQX z{*K@$egY zThRUbQ@ySvCDNK&RTkB7w~B#&0(@Q3bEB=}*2h1piFs=&N!_o%c6Rvk^ulZPSJoO9 z3OB*;j`2{dYW;QHp4wEo^RKMlU$T`5GykdhEctG8(AXik$cdIE3a!t+HTPMS-|aWg zSbfoa@(q(#y`vs;8_wqlAI$kSHu8Bqrq z%Py{l#GXAm3LE!dm~s$h{dm|_H14IyU7Psu*ujHknOyGR53j9r6qIs&s~p*cpRVFp zUCI($Kxvr#vENzi)#VZ_4Q|+aBye~S@5$Y}oqH-eeYV$yISMVS{U12mAg(1qzs+|} z=(ZclIJ)k?WSy6Ge3_~Uk?<-q1$NJ`*q(V&BDu4Qgw4QX%}mpTcbRKet43*`e+Zzc zvkXpxotdiEHvmao^t6FMXryIBLpek=UUr&AL6ETiCk%mh5gfU;Z1K_J%j#$%g8g@J zfY8@I2=v^!{~5R8Rzq^)BgCGAzv7pPy+V9HJZ%08E&u(*bb|rFI7Q$fVyB}M9W4=o zqR^2+4RAco1om@#F1ZzDO8htOUvp0#^c65r7UV5BTM-yDYAL8g7?%jpbQ~Hv9mP4f zy^P=>W*Yt|SS<^P3Wp2`JD>(PBpZpTt2goi%MduKtqh3&JFu*L*+L6j1kf*w0MY>s zW^#zFL5z}!^LRxFJ=e6Gjvf%uviC%6=rm}U^WQLR15T?#(3V?#W1u#ITd9iT$U^A( ziGZH-;zCK-TYTx*OgXqBo=R}=dR|tD%UuuzSQ4?Nm%|0eE#O$-KSouaM)dZ9>Ip(x z@^E+X$#*XR=KOCK4J0J+#HHk}|2U6B1H9is%zL!L;=3-~k2|1+!{7t+hO1?_au9bd zK}u1F`)|GsT-i=FB^6&m8C(vOu|Rd6IF&as?NgsCBpT>;&fcng$gjcYFsyb<2y&a%L)sZX}hMxYdAaCppU;@P#7<#H0+@|dU zKLJu){YtZxfM04_0!YLS&9={HqA>n{+9*IkY?!~FfA#R!KK^GQ`Tt>G2x(`$M>Pxo Sl%E7?k=@djOYT^6BmWmd$y(?D literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/integration_test/journey_login_test.dart b/flutter/flutter-journey/integration_test/journey_login_test.dart new file mode 100644 index 00000000..6920c06f --- /dev/null +++ b/flutter/flutter-journey/integration_test/journey_login_test.dart @@ -0,0 +1,89 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:flutter_journey/main.dart'; +import 'package:flutter_journey/routing/router.dart'; + +/// Drives the Login Journey end-to-end against the shared Ping SDK demo/test tenant configured +/// in `lib/config/env.dart` (see `AGENT_NOTES.md` § Verification tenant). Requires network access +/// and a reachable tenant — there is no mock, per this project's established verification +/// approach (Phases 2-6 were all verified against the same tenant). +/// +/// Run standalone, not combined with `journey_registration_test.dart` in the same test binary — +/// the native SDK persists the AM session cookie on-device across `Journey` instances within one +/// app install, so running both Journeys back-to-back in one process leaves session-cookie state +/// from one that changes the other's first-node response. +/// +/// The credential-dependent assertions (login, success screen, sign-off) only run when +/// `E2E_USERNAME`/`E2E_PASSWORD` are supplied via `--dart-define`, since no test credentials are +/// committed to this public sample repo: +/// +/// ``` +/// flutter test integration_test/journey_login_test.dart \ +/// --dart-define=E2E_USERNAME= --dart-define=E2E_PASSWORD= +/// ``` +/// +/// Without them, only the tenant-reachable / callback-rendering assertion runs. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const username = String.fromEnvironment('E2E_USERNAME'); + const password = String.fromEnvironment('E2E_PASSWORD'); + final hasCredentials = username.isNotEmpty && password.isNotEmpty; + + testWidgets('starts the Login journey and renders Name + Password fields', ( + tester, + ) async { + router.go('/config'); + await tester.pumpWidget(const FlutterJourneyApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Login'); + await tester.tap(find.text('Start Journey')); + await tester.pumpAndSettle(const Duration(seconds: 10)); + + expect(find.text('User Name'), findsOneWidget); + expect(find.text('Password'), findsOneWidget); + }); + + testWidgets( + 'completes login with valid credentials and reaches Success', + (tester) async { + router.go('/config'); + await tester.pumpWidget(const FlutterJourneyApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Login'); + await tester.tap(find.text('Start Journey')); + await tester.pumpAndSettle(const Duration(seconds: 10)); + + final textFields = find.byType(TextField); + await tester.enterText(textFields.at(0), username); + await tester.enterText(textFields.at(1), password); + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(const Duration(seconds: 10)); + + expect(find.text('Journey completed successfully.'), findsOneWidget); + + await tester.tap(find.text('Sign Off')); + await tester.pumpAndSettle(const Duration(seconds: 10)); + + expect(find.text('Start a Journey'), findsOneWidget); + }, + skip: !hasCredentials, + ); +} diff --git a/flutter/flutter-journey/integration_test/journey_registration_test.dart b/flutter/flutter-journey/integration_test/journey_registration_test.dart new file mode 100644 index 00000000..50f70a58 --- /dev/null +++ b/flutter/flutter-journey/integration_test/journey_registration_test.dart @@ -0,0 +1,111 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:flutter_journey/main.dart'; +import 'package:flutter_journey/routing/router.dart'; + +/// Drives the self-registration Journey end-to-end against the shared Ping SDK demo/test tenant +/// configured in `lib/config/env.dart` (see `AGENT_NOTES.md` § Verification tenant). Requires +/// network access and a reachable tenant — there is no mock, per this project's established +/// verification approach (Phases 2-6 were all verified against the same tenant). +/// +/// Run standalone, not combined with `journey_login_test.dart` in the same test binary — see that +/// file's doc comment for why. +/// +/// Needs no credentials — it signs up a fresh, uniquely-suffixed user each run. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'completes self-registration exercising Validated/Attribute/KBA/Terms callbacks', + (tester) async { + router.go('/config'); + await tester.pumpWidget(const FlutterJourneyApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Continue')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), 'Registration'); + await tester.tap(find.text('Start Journey')); + await tester.pumpAndSettle(const Duration(seconds: 10)); + + final suffix = DateTime.now().millisecondsSinceEpoch.toString(); + final newUsername = 'flutterE2E$suffix'; + + // ValidatedUsername, 2x StringAttributeInput (name), StringAttributeInput (email), + // BooleanAttributeInput x2 (preferences), ValidatedPassword — all on one ContinueNode. + final textFields = find.byType(TextField); + await tester.ensureVisible(textFields.at(0)); + await tester.enterText(textFields.at(0), newUsername); + await tester.enterText(textFields.at(1), 'first$suffix'); + await tester.enterText(textFields.at(2), 'last$suffix'); + await tester.enterText(textFields.at(3), '$newUsername@example.com'); + await tester.enterText(textFields.at(4), 'Password1!'); + + // Each KBA callback defaults to "custom question" mode (selectedQuestion starts empty, + // which isn't in predefinedQuestions) — select a predefined question via its dropdown so + // only the Answer field is rendered. + const questions = [ + "What's your favorite color?", + 'Who was your first employer?', + ]; + final kbaDropdowns = find.byType(DropdownButtonFormField); + for (var i = 0; i < kbaDropdowns.evaluate().length; i++) { + final dropdown = kbaDropdowns.at(i); + await tester.scrollUntilVisible( + dropdown, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(dropdown); + await tester.pumpAndSettle(); + await tester.tap(find.text(questions[i]).last); + await tester.pumpAndSettle(); + } + + final answerFields = find.widgetWithText(TextField, 'Answer'); + for (var i = 0; i < answerFields.evaluate().length; i++) { + final field = answerFields.at(i); + await tester.scrollUntilVisible( + field, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.enterText(field, 'Red'); + } + + final terms = find.text('I accept the terms and conditions'); + await tester.scrollUntilVisible( + terms, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(terms); + await tester.pumpAndSettle(); + + final next = find.text('Next'); + await tester.scrollUntilVisible( + next, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(next); + await tester.pumpAndSettle(const Duration(seconds: 10)); + + expect(find.text('Journey completed successfully.'), findsOneWidget); + }, + ); +} diff --git a/flutter/flutter-journey/ios/.gitignore b/flutter/flutter-journey/ios/.gitignore new file mode 100644 index 00000000..7a7f9873 --- /dev/null +++ b/flutter/flutter-journey/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/flutter/flutter-journey/ios/Flutter/AppFrameworkInfo.plist b/flutter/flutter-journey/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..391a902b --- /dev/null +++ b/flutter/flutter-journey/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/flutter/flutter-journey/ios/Flutter/Debug.xcconfig b/flutter/flutter-journey/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..592ceee8 --- /dev/null +++ b/flutter/flutter-journey/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/flutter/flutter-journey/ios/Flutter/Release.xcconfig b/flutter/flutter-journey/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..592ceee8 --- /dev/null +++ b/flutter/flutter-journey/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/flutter/flutter-journey/ios/Runner.xcodeproj/project.pbxproj b/flutter/flutter-journey/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..fe138309 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,647 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 9QSE66762D; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.flutter.flutterJourney; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.flutter.flutterJourney.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.flutter.flutterJourney.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.flutter.flutterJourney.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 9QSE66762D; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.flutter.flutterJourney; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 9QSE66762D; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.flutter.flutterJourney; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/flutter/flutter-journey/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter/flutter-journey/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flutter/flutter-journey/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter/flutter-journey/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/flutter/flutter-journey/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/flutter/flutter-journey/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..ac690f18 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,113 @@ +{ + "pins" : [ + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", + "version" : "11.3.1" + } + }, + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS.git", + "state" : { + "revision" : "a7caeda164dc5108bf4649472b28a5af65dc6f33", + "version" : "2.1.0" + } + }, + { + "identity" : "facebook-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/facebook/facebook-ios-sdk.git", + "state" : { + "revision" : "3fe31c168903759de1c5752d12856c5c437c6862", + "version" : "16.3.1" + } + }, + { + "identity" : "googlesignin-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleSignIn-iOS.git", + "state" : { + "revision" : "3996d908c7b3ce8a87d39c808f9a6b2a08fbe043", + "version" : "9.0.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", + "version" : "8.1.2" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", + "version" : "3.5.0" + } + }, + { + "identity" : "gtmappauth", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GTMAppAuth.git", + "state" : { + "revision" : "56e0ccf09a6dd29dc7e68bdf729598240ca8aa16", + "version" : "5.0.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "ping-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ForgeRock/ping-ios-sdk", + "state" : { + "revision" : "d025874d598d7337a72f0a8ee446dcddae7b6ef2", + "version" : "2.0.0" + } + }, + { + "identity" : "pingone-signals-sdk-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pingidentity/pingone-signals-sdk-ios.git", + "state" : { + "revision" : "e41f7070fdbb43dd7762274ec610c099256a7c7e", + "version" : "5.4.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + }, + { + "identity" : "recaptcha-enterprise-mobile-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GoogleCloudPlatform/recaptcha-enterprise-mobile-sdk.git", + "state" : { + "revision" : "fb634e89a36fd91725ad654d5576d800c061b37d", + "version" : "18.8.2" + } + } + ], + "version" : 2 +} diff --git a/flutter/flutter-journey/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/flutter/flutter-journey/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..c3fedb29 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter/flutter-journey/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter/flutter-journey/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flutter/flutter-journey/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter/flutter-journey/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/flutter/flutter-journey/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/flutter/flutter-journey/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..ac690f18 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,113 @@ +{ + "pins" : [ + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", + "version" : "11.3.1" + } + }, + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS.git", + "state" : { + "revision" : "a7caeda164dc5108bf4649472b28a5af65dc6f33", + "version" : "2.1.0" + } + }, + { + "identity" : "facebook-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/facebook/facebook-ios-sdk.git", + "state" : { + "revision" : "3fe31c168903759de1c5752d12856c5c437c6862", + "version" : "16.3.1" + } + }, + { + "identity" : "googlesignin-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleSignIn-iOS.git", + "state" : { + "revision" : "3996d908c7b3ce8a87d39c808f9a6b2a08fbe043", + "version" : "9.0.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", + "version" : "8.1.2" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", + "version" : "3.5.0" + } + }, + { + "identity" : "gtmappauth", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GTMAppAuth.git", + "state" : { + "revision" : "56e0ccf09a6dd29dc7e68bdf729598240ca8aa16", + "version" : "5.0.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "ping-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ForgeRock/ping-ios-sdk", + "state" : { + "revision" : "d025874d598d7337a72f0a8ee446dcddae7b6ef2", + "version" : "2.0.0" + } + }, + { + "identity" : "pingone-signals-sdk-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pingidentity/pingone-signals-sdk-ios.git", + "state" : { + "revision" : "e41f7070fdbb43dd7762274ec610c099256a7c7e", + "version" : "5.4.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + }, + { + "identity" : "recaptcha-enterprise-mobile-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GoogleCloudPlatform/recaptcha-enterprise-mobile-sdk.git", + "state" : { + "revision" : "fb634e89a36fd91725ad654d5576d800c061b37d", + "version" : "18.8.2" + } + } + ], + "version" : 2 +} diff --git a/flutter/flutter-journey/ios/Runner/AppDelegate.swift b/flutter/flutter-journey/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..c30b367e --- /dev/null +++ b/flutter/flutter-journey/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 zcmeHN2~<R zh`|8`A_PQ1nSu(UMFx?8j8PC!!VDphaL#`F42fd#7Vlc`zIE4n%Y~eiz4y1j|NDpi z?<@|pSJ-HM`qifhf@m%MamgwK83`XpBA<+azdF#2QsT{X@z0A9Bq>~TVErigKH1~P zRX-!h-f0NJ4Mh++{D}J+K>~~rq}d%o%+4dogzXp7RxX4C>Km5XEI|PAFDmo;DFm6G zzjVoB`@qW98Yl0Kvc-9w09^PrsobmG*Eju^=3f?0o-t$U)TL1B3;sZ^!++3&bGZ!o-*6w?;oOhf z=A+Qb$scV5!RbG+&2S}BQ6YH!FKb0``VVX~T$dzzeSZ$&9=X$3)_7Z{SspSYJ!lGE z7yig_41zpQ)%5dr4ff0rh$@ky3-JLRk&DK)NEIHecf9c*?Z1bUB4%pZjQ7hD!A0r-@NF(^WKdr(LXj|=UE7?gBYGgGQV zidf2`ZT@pzXf7}!NH4q(0IMcxsUGDih(0{kRSez&z?CFA0RVXsVFw3^u=^KMtt95q z43q$b*6#uQDLoiCAF_{RFc{!H^moH_cmll#Fc^KXi{9GDl{>%+3qyfOE5;Zq|6#Hb zp^#1G+z^AXfRKaa9HK;%b3Ux~U@q?xg<2DXP%6k!3E)PA<#4$ui8eDy5|9hA5&{?v z(-;*1%(1~-NTQ`Is1_MGdQ{+i*ccd96ab$R$T3=% zw_KuNF@vI!A>>Y_2pl9L{9h1-C6H8<)J4gKI6{WzGBi<@u3P6hNsXG=bRq5c+z;Gc3VUCe;LIIFDmQAGy+=mRyF++u=drBWV8-^>0yE9N&*05XHZpPlE zxu@?8(ZNy7rm?|<+UNe0Vs6&o?l`Pt>P&WaL~M&#Eh%`rg@Mbb)J&@DA-wheQ>hRV z<(XhigZAT z>=M;URcdCaiO3d^?H<^EiEMDV+7HsTiOhoaMX%P65E<(5xMPJKxf!0u>U~uVqnPN7T!X!o@_gs3Ct1 zlZ_$5QXP4{Aj645wG_SNT&6m|O6~Tsl$q?nK*)(`{J4b=(yb^nOATtF1_aS978$x3 zx>Q@s4i3~IT*+l{@dx~Hst21fR*+5}S1@cf>&8*uLw-0^zK(+OpW?cS-YG1QBZ5q! zgTAgivzoF#`cSz&HL>Ti!!v#?36I1*l^mkrx7Y|K6L#n!-~5=d3;K<;Zqi|gpNUn_ z_^GaQDEQ*jfzh;`j&KXb66fWEk1K7vxQIMQ_#Wu_%3 z4Oeb7FJ`8I>Px;^S?)}2+4D_83gHEq>8qSQY0PVP?o)zAv3K~;R$fnwTmI-=ZLK`= zTm+0h*e+Yfr(IlH3i7gUclNH^!MU>id$Jw>O?2i0Cila#v|twub21@e{S2v}8Z13( zNDrTXZVgris|qYm<0NU(tAPouG!QF4ZNpZPkX~{tVf8xY690JqY1NVdiTtW+NqyRP zZ&;T0ikb8V{wxmFhlLTQ&?OP7 z;(z*<+?J2~z*6asSe7h`$8~Se(@t(#%?BGLVs$p``;CyvcT?7Y!{tIPva$LxCQ&4W z6v#F*);|RXvI%qnoOY&i4S*EL&h%hP3O zLsrFZhv&Hu5tF$Lx!8(hs&?!Kx5&L(fdu}UI5d*wn~A`nPUhG&Rv z2#ixiJdhSF-K2tpVL=)5UkXRuPAFrEW}7mW=uAmtVQ&pGE-&az6@#-(Te^n*lrH^m@X-ftVcwO_#7{WI)5v(?>uC9GG{lcGXYJ~Q8q zbMFl7;t+kV;|;KkBW2!P_o%Czhw&Q(nXlxK9ak&6r5t_KH8#1Mr-*0}2h8R9XNkr zto5-b7P_auqTJb(TJlmJ9xreA=6d=d)CVbYP-r4$hDn5|TIhB>SReMfh&OVLkMk-T zYf%$taLF0OqYF?V{+6Xkn>iX@TuqQ?&cN6UjC9YF&%q{Ut3zv{U2)~$>-3;Dp)*(? zg*$mu8^i=-e#acaj*T$pNowo{xiGEk$%DusaQiS!KjJH96XZ-hXv+jk%ard#fu=@Q z$AM)YWvE^{%tDfK%nD49=PI|wYu}lYVbB#a7wtN^Nml@CE@{Gv7+jo{_V?I*jkdLD zJE|jfdrmVbkfS>rN*+`#l%ZUi5_bMS<>=MBDNlpiSb_tAF|Zy`K7kcp@|d?yaTmB^ zo?(vg;B$vxS|SszusORgDg-*Uitzdi{dUV+glA~R8V(?`3GZIl^egW{a919!j#>f` znL1o_^-b`}xnU0+~KIFLQ)$Q6#ym%)(GYC`^XM*{g zv3AM5$+TtDRs%`2TyR^$(hqE7Y1b&`Jd6dS6B#hDVbJlUXcG3y*439D8MrK!2D~6gn>UD4Imctb z+IvAt0iaW73Iq$K?4}H`7wq6YkTMm`tcktXgK0lKPmh=>h+l}Y+pDtvHnG>uqBA)l zAH6BV4F}v$(o$8Gfo*PB>IuaY1*^*`OTx4|hM8jZ?B6HY;F6p4{`OcZZ(us-RVwDx zUzJrCQlp@mz1ZFiSZ*$yX3c_#h9J;yBE$2g%xjmGF4ca z&yL`nGVs!Zxsh^j6i%$a*I3ZD2SoNT`{D%mU=LKaEwbN(_J5%i-6Va?@*>=3(dQy` zOv%$_9lcy9+(t>qohkuU4r_P=R^6ME+wFu&LA9tw9RA?azGhjrVJKy&8=*qZT5Dr8g--d+S8zAyJ$1HlW3Olryt`yE zFIph~Z6oF&o64rw{>lgZISC6p^CBer9C5G6yq%?8tC+)7*d+ib^?fU!JRFxynRLEZ zj;?PwtS}Ao#9whV@KEmwQgM0TVP{hs>dg(1*DiMUOKHdQGIqa0`yZnHk9mtbPfoLx zo;^V6pKUJ!5#n`w2D&381#5#_t}AlTGEgDz$^;u;-vxDN?^#5!zN9ngytY@oTv!nc zp1Xn8uR$1Z;7vY`-<*?DfPHB;x|GUi_fI9@I9SVRv1)qETbNU_8{5U|(>Du84qP#7 z*l9Y$SgA&wGbj>R1YeT9vYjZuC@|{rajTL0f%N@>3$DFU=`lSPl=Iv;EjuGjBa$Gw zHD-;%YOE@<-!7-Mn`0WuO3oWuL6tB2cpPw~Nvuj|KM@))ixuDK`9;jGMe2d)7gHin zS<>k@!x;!TJEc#HdL#RF(`|4W+H88d4V%zlh(7#{q2d0OQX9*FW^`^_<3r$kabWAB z$9BONo5}*(%kx zOXi-yM_cmB3>inPpI~)duvZykJ@^^aWzQ=eQ&STUa}2uT@lV&WoRzkUoE`rR0)`=l zFT%f|LA9fCw>`enm$p7W^E@U7RNBtsh{_-7vVz3DtB*y#*~(L9+x9*wn8VjWw|Q~q zKFsj1Yl>;}%MG3=PY`$g$_mnyhuV&~O~u~)968$0b2!Jkd;2MtAP#ZDYw9hmK_+M$ zb3pxyYC&|CuAbtiG8HZjj?MZJBFbt`ryf+c1dXFuC z0*ZQhBzNBd*}s6K_G}(|Z_9NDV162#y%WSNe|FTDDhx)K!c(mMJh@h87@8(^YdK$&d*^WQe8Z53 z(|@MRJ$Lk-&ii74MPIs80WsOFZ(NX23oR-?As+*aq6b?~62@fSVmM-_*cb1RzZ)`5$agEiL`-E9s7{GM2?(KNPgK1(+c*|-FKoy}X(D_b#etO|YR z(BGZ)0Ntfv-7R4GHoXp?l5g#*={S1{u-QzxCGng*oWr~@X-5f~RA14b8~B+pLKvr4 zfgL|7I>jlak9>D4=(i(cqYf7#318!OSR=^`xxvI!bBlS??`xxWeg?+|>MxaIdH1U~#1tHu zB{QMR?EGRmQ_l4p6YXJ{o(hh-7Tdm>TAX380TZZZyVkqHNzjUn*_|cb?T? zt;d2s-?B#Mc>T-gvBmQZx(y_cfkXZO~{N zT6rP7SD6g~n9QJ)8F*8uHxTLCAZ{l1Y&?6v)BOJZ)=R-pY=Y=&1}jE7fQ>USS}xP#exo57uND0i*rEk@$;nLvRB@u~s^dwRf?G?_enN@$t* zbL%JO=rV(3Ju8#GqUpeE3l_Wu1lN9Y{D4uaUe`g>zlj$1ER$6S6@{m1!~V|bYkhZA z%CvrDRTkHuajMU8;&RZ&itnC~iYLW4DVkP<$}>#&(`UO>!n)Po;Mt(SY8Yb`AS9lt znbX^i?Oe9r_o=?})IHKHoQGKXsps_SE{hwrg?6dMI|^+$CeC&z@*LuF+P`7LfZ*yr+KN8B4{Nzv<`A(wyR@!|gw{zB6Ha ziwPAYh)oJ(nlqSknu(8g9N&1hu0$vFK$W#mp%>X~AU1ay+EKWcFdif{% z#4!4aoVVJ;ULmkQf!ke2}3hqxLK>eq|-d7Ly7-J9zMpT`?dxo6HdfJA|t)?qPEVBDv z{y_b?4^|YA4%WW0VZd8C(ZgQzRI5(I^)=Ub`Y#MHc@nv0w-DaJAqsbEHDWG8Ia6ju zo-iyr*sq((gEwCC&^TYBWt4_@|81?=B-?#P6NMff(*^re zYqvDuO`K@`mjm_Jd;mW_tP`3$cS?R$jR1ZN09$YO%_iBqh5ftzSpMQQtxKFU=FYmP zeY^jph+g<4>YO;U^O>-NFLn~-RqlHvnZl2yd2A{Yc1G@Ga$d+Q&(f^tnPf+Z7serIU};17+2DU_f4Z z@GaPFut27d?!YiD+QP@)T=77cR9~MK@bd~pY%X(h%L={{OIb8IQmf-!xmZkm8A0Ga zQSWONI17_ru5wpHg3jI@i9D+_Y|pCqVuHJNdHUauTD=R$JcD2K_liQisqG$(sm=k9;L* z!L?*4B~ql7uioSX$zWJ?;q-SWXRFhz2Jt4%fOHA=Bwf|RzhwqdXGr78y$J)LR7&3T zE1WWz*>GPWKZ0%|@%6=fyx)5rzUpI;bCj>3RKzNG_1w$fIFCZ&UR0(7S?g}`&Pg$M zf`SLsz8wK82Vyj7;RyKmY{a8G{2BHG%w!^T|Njr!h9TO2LaP^_f22Q1=l$QiU84ao zHe_#{S6;qrC6w~7{y(hs-?-j?lbOfgH^E=XcSgnwW*eEz{_Z<_xN#0001NP)t-s|Ns9~ z#rXRE|M&d=0au&!`~QyF`q}dRnBDt}*!qXo`c{v z{Djr|@Adh0(D_%#_&mM$D6{kE_x{oE{l@J5@%H*?%=t~i_`ufYOPkAEn!pfkr2$fs z652Tz0001XNklqeeKN4RM4i{jKqmiC$?+xN>3Apn^ z0QfuZLym_5b<*QdmkHjHlj811{If)dl(Z2K0A+ekGtrFJb?g|wt#k#pV-#A~bK=OT ts8>{%cPtyC${m|1#B1A6#u!Q;umknL1chzTM$P~L002ovPDHLkV1lTfnu!1a literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 zcmV;526g#~P){YQnis^a@{&-nmRmq)<&%Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b zE>q9${Lb$D&h7k=+4=!ek^n+`0zq>LL1O?lVyea53S5x`Nqqo2YyeuIrQrJj9XjOp z{;T5qbj3}&1vg1VK~#9!?b~^C5-}JC@Pyrv-6dSEqJqT}#j9#dJ@GzT@B8}x zU&J@bBI>f6w6en+CeI)3^kC*U?}X%OD8$Fd$H&LV$H&LV$H&LV#|K5~mLYf|VqzOc zkc7qL~0sOYuM{tG`rYEDV{DWY`Z8&)kW*hc2VkBuY+^Yx&92j&StN}Wp=LD zxoGxXw6f&8sB^u})h@b@z0RBeD`K7RMR9deyL(ZJu#39Z>rT)^>v}Khq8U-IbIvT> z?4pV9qGj=2)TNH3d)=De<+^w;>S7m_eFKTvzeaBeir45xY!^m!FmxnljbSS_3o=g( z->^wC9%qkR{kbGnW8MfFew_o9h3(r55Is`L$8KI@d+*%{=Nx+FXJ98L0PjFIu;rGnnfY zn1R5Qnp<{Jq0M1vX=X&F8gtLmcWv$1*M@4ZfF^9``()#hGTeKeP`1!iED ztNE(TN}M5}3Bbc*d=FIv`DNv&@|C6yYj{sSqUj5oo$#*0$7pu|Dd2TLI>t5%I zIa4Dvr(iayb+5x=j*Vum9&irk)xV1`t509lnPO0%skL8_1c#Xbamh(2@f?4yUI zhhuT5<#8RJhGz4%b$`PJwKPAudsm|at?u;*hGgnA zU1;9gnxVBC)wA(BsB`AW54N{|qmikJR*%x0c`{LGsSfa|NK61pYH(r-UQ4_JXd!Rsz)=k zL{GMc5{h138)fF5CzHEDM>+FqY)$pdN3}Ml+riTgJOLN0F*Vh?{9ESR{SVVg>*>=# zix;VJHPtvFFCRY$Ks*F;VX~%*r9F)W`PmPE9F!(&s#x07n2<}?S{(ygpXgX-&B&OM zONY&BRQ(#%0%jeQs?oJ4P!p*R98>qCy5p8w>_gpuh39NcOlp)(wOoz0sY-Qz55eB~ z7OC-fKBaD1sE3$l-6QgBJO!n?QOTza`!S_YK z_v-lm^7{VO^8Q@M_^8F)09Ki6%=s?2_5eupee(w1FB%aqSweusQ-T+CH0Xt{` zFjMvW{@C&TB)k25()nh~_yJ9coBRL(0oO@HK~z}7?bm5j;y@69;bvlHb2tf!$ReA~x{22wTq550 z?f?Hnw(;m3ip30;QzdV~7pi!wyMYhDtXW#cO7T>|f=bdFhu+F!zMZ2UFj;GUKX7tI z;hv3{q~!*pMj75WP_c}>6)IWvg5_yyg<9Op()eD1hWC19M@?_9_MHec{Z8n3FaF{8 z;u`Mw0ly(uE>*CgQYv{be6ab2LWhlaH1^iLIM{olnag$78^Fd}%dR7;JECQ+hmk|o z!u2&!3MqPfP5ChDSkFSH8F2WVOEf0(E_M(JL17G}Y+fg0_IuW%WQ zG(mG&u?|->YSdk0;8rc{yw2@2Z&GA}z{Wb91Ooz9VhA{b2DYE7RmG zjL}?eq#iX%3#k;JWMx_{^2nNax`xPhByFiDX+a7uTGU|otOvIAUy|dEKkXOm-`aWS z27pUzD{a)Ct<6p{{3)+lq@i`t@%>-wT4r?*S}k)58e09WZYP0{{R3FC5Sl00039P)t-s|Ns9~ z#rP?<_5oL$Q^olD{r_0T`27C={r>*`|Nj71npVa5OTzc(_WfbW_({R{p56NV{r*M2 z_xt?)2V0#0NsfV0u>{42ctGP(8vQj-Btk1n|O0ZD=YLwd&R{Ko41Gr9H= zY@z@@bOAMB5Ltl$E>bJJ{>JP30ZxkmI%?eW{k`b?Wy<&gOo;dS`~CR$Vwb@XWtR|N zi~t=w02?-0&j0TD{>bb6sNwsK*!p?V`RMQUl(*DVjk-9Cx+-z1KXab|Ka2oXhX5f% z`$|e!000AhNklrxs)5QTeTVRiEmz~MKK1WAjCw(c-JK6eox;2O)?`? zTG`AHia671e^vgmp!llKp|=5sVHk#C7=~epA~VAf-~%aPC=%Qw01h8mnSZ|p?hz91 z7p83F3%LVu9;S$tSI$C^%^yud1dfTM_6p2|+5Ejp$bd`GDvbR|xit>i!ZD&F>@CJrPmu*UjD&?DfZs=$@e3FQA(vNiU+$A*%a} z?`XcG2jDxJ_ZQ#Md`H{4Lpf6QBDp81_KWZ6Tk#yCy1)32zO#3<7>b`eT7UyYH1eGz z;O(rH$=QR*L%%ZcBpc=eGua?N55nD^K(8<#gl2+pN_j~b2MHs4#mcLmv%DkspS-3< zpI1F=^9siI0s-;IN_IrA;5xm~3?3!StX}pUv0vkxMaqm+zxrg7X7(I&*N~&dEd0kD z-FRV|g=|QuUsuh>-xCI}vD2imzYIOIdcCVV=$Bz@*u0+Bs<|L^)32nN*=wu3n%Ynw z@1|eLG>!8ruU1pFXUfb`j>(=Gy~?Rn4QJ-c3%3T|(Frd!bI`9u&zAnyFYTqlG#&J7 zAkD(jpw|oZLNiA>;>hgp1KX7-wxC~31II47gc zHcehD6Uxlf%+M^^uN5Wc*G%^;>D5qT{>=uxUhX%WJu^Z*(_Wq9y}npFO{Hhb>s6<9 zNi0pHXWFaVZnb)1+RS&F)xOv6&aeILcI)`k#0YE+?e)5&#r7J#c`3Z7x!LpTc01dx zrdC3{Z;joZ^KN&))zB_i)I9fWedoN>Zl-6_Iz+^G&*ak2jpF07*qoM6N<$f;w%0(f|Me literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/flutter/flutter-journey/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/flutter/flutter-journey/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/flutter/flutter-journey/ios/Runner/Base.lproj/LaunchScreen.storyboard b/flutter/flutter-journey/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..f2e259c7 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter/flutter-journey/ios/Runner/Base.lproj/Main.storyboard b/flutter/flutter-journey/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter/flutter-journey/ios/Runner/Info.plist b/flutter/flutter-journey/ios/Runner/Info.plist new file mode 100644 index 00000000..4f81e8b1 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Flutter Journey + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + flutter_journey + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/flutter/flutter-journey/ios/Runner/Runner-Bridging-Header.h b/flutter/flutter-journey/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/flutter/flutter-journey/ios/Runner/SceneDelegate.swift b/flutter/flutter-journey/ios/Runner/SceneDelegate.swift new file mode 100644 index 00000000..b9ce8ea2 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/flutter/flutter-journey/ios/RunnerTests/RunnerTests.swift b/flutter/flutter-journey/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..86a7c3b1 --- /dev/null +++ b/flutter/flutter-journey/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/flutter/flutter-journey/lib/config/env.dart b/flutter/flutter-journey/lib/config/env.dart new file mode 100644 index 00000000..596fe0fb --- /dev/null +++ b/flutter/flutter-journey/lib/config/env.dart @@ -0,0 +1,43 @@ +/* + * 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. + */ + +/// OIDC configuration for a Journey environment, applied when a token exchange follows Journey +/// completion. Fields are optional at the wire layer, but a real deployment sets them all. +class OidcConfig { + const OidcConfig({ + required this.clientId, + required this.discoveryEndpoint, + required this.scopes, + required this.redirectUri, + }); + + final String clientId; + final String discoveryEndpoint; + final List scopes; + + /// Must match the native URL scheme registered in both the Android and iOS host apps. + final String redirectUri; +} + +/// Single source of truth for the active Journey environment. Replace the TODO placeholders +/// with values for your tenant before running the app. +class Env { + const Env._(); + + // TODO: replace with your tenant's server URL, e.g. https://openam.example.com/am + static const String serverUrl = ''; + + // TODO: replace with your tenant's realm. + static const String realm = ''; + + // TODO: replace if your tenant uses a non-default session cookie name. + static const String cookie = ''; + + // TODO: replace with your tenant's OIDC client configuration, or set to null to skip + // token exchange after a successful Journey. + static const OidcConfig? oidcConfig = null; +} diff --git a/flutter/flutter-journey/lib/data/journey_repository.dart b/flutter/flutter-journey/lib/data/journey_repository.dart new file mode 100644 index 00000000..e1340b02 --- /dev/null +++ b/flutter/flutter-journey/lib/data/journey_repository.dart @@ -0,0 +1,61 @@ +/* + * 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 'package:ping_journey/ping_journey.dart'; + +import 'package:flutter_journey/config/env.dart'; + +/// Single source of truth for Journey state, wrapping the plugin's [JourneyClient]. ViewModels +/// depend on this rather than the plugin directly, per the app's layered-MVVM architecture. +class JourneyRepository { + JourneyClient? _client; + + /// Builds the native `Journey` for [Env] and starts [journeyName], returning the first node. + /// + /// Disposes any previously configured client first — the app's ViewModels are long-lived + /// (created once at the root, not per-route), so a restart (Try Again / sign-off then log in + /// again) calls this while a stale `_client` is still registered natively; without disposing + /// it first, its native `Journey` + cached `ContinueNode` would leak in the core registry. + Future startJourney(String journeyName) async { + await dispose(); + final oidc = Env.oidcConfig; + _client = await JourneyClient.configure( + JourneyConfigMessage( + serverUrl: Env.serverUrl, + realm: Env.realm, + cookie: Env.cookie, + clientId: oidc?.clientId, + discoveryEndpoint: oidc?.discoveryEndpoint, + redirectUri: oidc?.redirectUri, + scopes: oidc?.scopes, + ), + ); + return _client!.start(journeyName); + } + + Future next(ContinueNode node) => _requireClient().next(node); + + Future user() => _requireClient().user(); + + Future signOff() => _requireClient().signOff(); + + Future dispose() async { + final client = _client; + _client = null; + await client?.dispose(); + } + + JourneyClient _requireClient() { + final client = _client; + if (client == null) { + throw StateError( + 'startJourney() must complete before calling this method', + ); + } + return client; + } +} diff --git a/flutter/flutter-journey/lib/main.dart b/flutter/flutter-journey/lib/main.dart new file mode 100644 index 00000000..9ea268dc --- /dev/null +++ b/flutter/flutter-journey/lib/main.dart @@ -0,0 +1,54 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; +import 'package:provider/provider.dart'; + +import 'package:flutter_journey/data/journey_repository.dart'; +import 'package:flutter_journey/routing/router.dart'; +import 'package:flutter_journey/ui/core/theme/app_theme.dart'; +import 'package:flutter_journey/ui/features/config/view_models/journey_name_view_model.dart'; +import 'package:flutter_journey/ui/features/journey/view_models/journey_view_model.dart'; +import 'package:flutter_journey/ui/features/success/view_models/success_view_model.dart'; + +void main() { + usePathUrlStrategy(); + runApp(const FlutterJourneyApp()); +} + +class FlutterJourneyApp extends StatelessWidget { + const FlutterJourneyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + Provider( + create: (_) => JourneyRepository(), + dispose: (_, repo) => repo.dispose(), + ), + ChangeNotifierProvider( + create: (context) => + JourneyViewModel(repository: context.read()), + ), + ChangeNotifierProvider( + create: (context) => + SuccessViewModel(repository: context.read()), + ), + ChangeNotifierProvider(create: (_) => JourneyNameViewModel()..load()), + ], + child: MaterialApp.router( + title: 'Flutter Journey', + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.system, + routerConfig: router, + ), + ); + } +} diff --git a/flutter/flutter-journey/lib/routing/router.dart b/flutter/flutter-journey/lib/routing/router.dart new file mode 100644 index 00000000..100db8c3 --- /dev/null +++ b/flutter/flutter-journey/lib/routing/router.dart @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import 'package:flutter_journey/ui/features/config/views/config_view.dart'; +import 'package:flutter_journey/ui/features/config/views/journey_name_view.dart'; +import 'package:flutter_journey/ui/features/journey/view_models/journey_view_model.dart'; +import 'package:flutter_journey/ui/features/journey/views/journey_view.dart'; +import 'package:flutter_journey/ui/features/success/views/success_view.dart'; + +/// `/config` -> `/journey-name` -> `/journey` -> `/success`, per DESIGN.md. +final GoRouter router = GoRouter( + initialLocation: '/config', + routes: [ + GoRoute( + path: '/config', + builder: (context, state) => + ConfigView(onContinue: () => context.go('/journey-name')), + ), + GoRoute( + path: '/journey-name', + builder: (context, state) => JourneyNameView( + onSubmit: (name) { + context.read().startJourney(name); + context.go('/journey'); + }, + ), + ), + GoRoute( + path: '/journey', + builder: (context, state) => JourneyView( + onSuccess: () => context.go('/success'), + onRestart: () => context.go('/journey-name'), + ), + ), + GoRoute( + path: '/success', + builder: (context, state) => SuccessView( + onSignOff: () async { + final viewModel = context.read(); + await viewModel.signOff(); + router.go('/journey-name'); + }, + ), + ), + ], +); diff --git a/flutter/flutter-journey/lib/ui/core/theme/app_theme.dart b/flutter/flutter-journey/lib/ui/core/theme/app_theme.dart new file mode 100644 index 00000000..509f0139 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/core/theme/app_theme.dart @@ -0,0 +1,125 @@ +/* + * 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 'package:flutter/material.dart'; + +/// Brand colors, spacing tokens, and [ThemeData] for the Flutter Journey sample app. +abstract final class AppTheme { + static const Color brandRed = Color(0xFFA31300); + static const Color brandRedEnd = Color(0xFF991A1A); + + static const LinearGradient brandGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [brandRed, brandRedEnd], + ); + + static const double cardRadius = 12; + static const double fieldRadius = 8; + static const double buttonRadius = 12; + + static List get cardShadow => [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ]; + + static TextStyle sectionHeader(BuildContext context) { + final theme = Theme.of(context); + return (theme.textTheme.labelMedium ?? const TextStyle()).copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: theme.colorScheme.onSurfaceVariant, + ); + } + + static ThemeData light() => _base( + ColorScheme.fromSeed( + seedColor: brandRed, + brightness: Brightness.light, + ).copyWith(primary: brandRed, onPrimary: Colors.white), + ); + + static ThemeData dark() => _base( + ColorScheme.fromSeed( + seedColor: brandRed, + brightness: Brightness.dark, + ).copyWith(primary: brandRed, onPrimary: Colors.white), + ); + + static ThemeData _base(ColorScheme scheme) { + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: scheme.surface, + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainerHighest.withValues(alpha: 0.4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(fieldRadius), + borderSide: BorderSide(color: scheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(fieldRadius), + borderSide: BorderSide(color: scheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(fieldRadius), + borderSide: BorderSide(color: brandRed, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(fieldRadius), + borderSide: BorderSide(color: scheme.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(fieldRadius), + borderSide: BorderSide(color: scheme.error, width: 2), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: brandRed, + foregroundColor: Colors.white, + disabledBackgroundColor: brandRed.withValues(alpha: 0.4), + disabledForegroundColor: Colors.white.withValues(alpha: 0.8), + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(buttonRadius), + ), + elevation: 0, + ), + ), + cardTheme: CardThemeData( + elevation: 0, + color: scheme.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(cardRadius), + ), + ), + appBarTheme: AppBarTheme( + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 1, + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith( + (states) => + states.contains(WidgetState.selected) ? brandRed : null, + ), + trackColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? brandRed.withValues(alpha: 0.5) + : null, + ), + ), + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/core/widgets/app_card.dart b/flutter/flutter-journey/lib/ui/core/widgets/app_card.dart new file mode 100644 index 00000000..69f0b666 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/core/widgets/app_card.dart @@ -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. + */ + +import 'package:flutter/material.dart'; + +import 'package:flutter_journey/ui/core/theme/app_theme.dart'; + +/// A themed, elevated surface used to group related content (config rows, token/user-info +/// sections). Radius and shadow match [AppTheme.cardRadius]/[AppTheme.cardShadow]. +class AppCard extends StatelessWidget { + const AppCard({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(16), + }); + + final Widget child; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: padding, + decoration: BoxDecoration( + color: scheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(AppTheme.cardRadius), + boxShadow: AppTheme.cardShadow, + ), + child: child, + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/core/widgets/brand_logo.dart b/flutter/flutter-journey/lib/ui/core/widgets/brand_logo.dart new file mode 100644 index 00000000..a5e16052 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/core/widgets/brand_logo.dart @@ -0,0 +1,46 @@ +/* + * 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 'package:flutter/material.dart'; + +import 'package:flutter_journey/ui/core/theme/app_theme.dart'; + +/// The Ping Identity brand mark, rendered at [size]. Falls back to a simple icon + wordmark +/// lockup if the `assets/images/ping_logo.png` asset failed to load. +class BrandLogo extends StatelessWidget { + const BrandLogo({super.key, this.size = 80}); + + final double size; + + @override + Widget build(BuildContext context) { + return Image.asset( + 'assets/images/ping_logo.png', + height: size, + width: size, + errorBuilder: (context, error, stackTrace) => _fallback(context), + ); + } + + Widget _fallback(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.shield, color: AppTheme.brandRed, size: size * 0.6), + const SizedBox(height: 4), + Text( + 'PING IDENTITY', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppTheme.brandRed, + fontWeight: FontWeight.bold, + letterSpacing: 1, + ), + ), + ], + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/core/widgets/error_banner.dart b/flutter/flutter-journey/lib/ui/core/widgets/error_banner.dart new file mode 100644 index 00000000..dfd15cc4 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/core/widgets/error_banner.dart @@ -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. + */ + +import 'package:flutter/material.dart'; + +import 'package:flutter_journey/ui/core/theme/app_theme.dart'; + +/// A themed error card: icon + message, no action button. Screens compose their own +/// "Try Again"/"Sign Off" [PrimaryButton] below it since retry targets differ per screen. +/// +/// Consolidates what was previously duplicated between `journey_view.dart`'s `_ErrorBanner` +/// and `success_view.dart`'s inline error `Card`. +class ErrorBanner extends StatelessWidget { + const ErrorBanner({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(AppTheme.cardRadius), + boxShadow: AppTheme.cardShadow, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error, color: scheme.error), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: TextStyle(color: scheme.onErrorContainer), + ), + ), + ], + ), + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/core/widgets/primary_button.dart b/flutter/flutter-journey/lib/ui/core/widgets/primary_button.dart new file mode 100644 index 00000000..3667f5c4 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/core/widgets/primary_button.dart @@ -0,0 +1,44 @@ +/* + * 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 'package:flutter/material.dart'; + +/// The app's single primary-action button style (brand-filled, full-width). +/// +/// Built on [ElevatedButton] with a [Text] child carrying [label] — this must stay an +/// [ElevatedButton] (not [FilledButton] or an icon-only button) because +/// `journey_view_test.dart` finds buttons via `find.widgetWithText(ElevatedButton, 'Next')` / +/// `'Try Again'`. +class PrimaryButton extends StatelessWidget { + const PrimaryButton({ + super.key, + required this.label, + required this.onPressed, + this.loading = false, + }); + + final String label; + final VoidCallback? onPressed; + final bool loading; + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: loading ? null : onPressed, + child: loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(label), + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/config/view_models/journey_name_view_model.dart b/flutter/flutter-journey/lib/ui/features/config/view_models/journey_name_view_model.dart new file mode 100644 index 00000000..c6c831af --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/config/view_models/journey_name_view_model.dart @@ -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. + */ + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _lastJourneyNameKey = 'lastJourneyName'; + +/// Persists the last-entered Journey name across launches — mirrors the native samples' +/// `PreferenceViewModel`/`UserDefaults` "journeyName" convention. +class JourneyNameViewModel extends ChangeNotifier { + String _lastJourneyName = ''; + + String get lastJourneyName => _lastJourneyName; + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + _lastJourneyName = prefs.getString(_lastJourneyNameKey) ?? ''; + notifyListeners(); + } + + Future save(String journeyName) async { + _lastJourneyName = journeyName; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_lastJourneyNameKey, journeyName); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/config/views/config_view.dart b/flutter/flutter-journey/lib/ui/features/config/views/config_view.dart new file mode 100644 index 00000000..e08ea168 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/config/views/config_view.dart @@ -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. + */ + +import 'package:flutter/material.dart'; + +import 'package:flutter_journey/config/env.dart'; +import 'package:flutter_journey/ui/core/theme/app_theme.dart'; +import 'package:flutter_journey/ui/core/widgets/app_card.dart'; +import 'package:flutter_journey/ui/core/widgets/brand_logo.dart'; +import 'package:flutter_journey/ui/core/widgets/primary_button.dart'; + +/// Read-only display of the active [Env] — analog of the native samples' server/realm config +/// screen (Android's `Env.kt`), shown before starting a Journey. +class ConfigView extends StatelessWidget { + const ConfigView({super.key, required this.onContinue}); + + final VoidCallback onContinue; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Active Environment')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center(child: BrandLogo(size: 72)), + const SizedBox(height: 24), + AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ConfigRow(label: 'Server URL', value: Env.serverUrl), + _ConfigRow(label: 'Realm', value: Env.realm), + _ConfigRow(label: 'Cookie', value: Env.cookie), + if (Env.oidcConfig case final oidc?) ...[ + _ConfigRow(label: 'Client ID', value: oidc.clientId), + _ConfigRow( + label: 'Discovery Endpoint', + value: oidc.discoveryEndpoint, + ), + _ConfigRow(label: 'Redirect URI', value: oidc.redirectUri), + ], + ], + ), + ), + const Spacer(), + PrimaryButton(label: 'Continue', onPressed: onContinue), + ], + ), + ), + ); + } +} + +class _ConfigRow extends StatelessWidget { + const _ConfigRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label.toUpperCase(), style: AppTheme.sectionHeader(context)), + const SizedBox(height: 2), + Text(value, style: Theme.of(context).textTheme.bodyLarge), + ], + ), + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/config/views/journey_name_view.dart b/flutter/flutter-journey/lib/ui/features/config/views/journey_name_view.dart new file mode 100644 index 00000000..709049b8 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/config/views/journey_name_view.dart @@ -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. + */ + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:flutter_journey/ui/core/widgets/brand_logo.dart'; +import 'package:flutter_journey/ui/core/widgets/primary_button.dart'; +import 'package:flutter_journey/ui/features/config/view_models/journey_name_view_model.dart'; + +/// Text field for the Journey to start, pre-filled with the last-used name — analog of the +/// native samples' journey-name entry screen (Android's `JourneyRoute.kt`, iOS's +/// `JourneyNameInputView`). +class JourneyNameView extends StatefulWidget { + const JourneyNameView({super.key, required this.onSubmit}); + + final ValueChanged onSubmit; + + @override + State createState() => _JourneyNameViewState(); +} + +class _JourneyNameViewState extends State { + final _controller = TextEditingController(); + bool _loaded = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit(JourneyNameViewModel viewModel) { + final name = _controller.text.trim(); + if (name.isEmpty) return; + viewModel.save(name); + widget.onSubmit(name); + } + + @override + Widget build(BuildContext context) { + final viewModel = context.read(); + return ListenableBuilder( + listenable: viewModel, + builder: (context, _) { + if (!_loaded && viewModel.lastJourneyName.isNotEmpty) { + _controller.text = viewModel.lastJourneyName; + _loaded = true; + } + + return Scaffold( + appBar: AppBar(title: const Text('Start a Journey')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center(child: BrandLogo(size: 96)), + const SizedBox(height: 32), + TextField( + controller: _controller, + decoration: const InputDecoration(labelText: 'Journey Name'), + onSubmitted: (_) => _submit(viewModel), + ), + const SizedBox(height: 16), + PrimaryButton( + label: 'Start Journey', + onPressed: () => _submit(viewModel), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/view_models/journey_view_model.dart b/flutter/flutter-journey/lib/ui/features/journey/view_models/journey_view_model.dart new file mode 100644 index 00000000..3cf22921 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/view_models/journey_view_model.dart @@ -0,0 +1,66 @@ +/* + * 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 'package:flutter/foundation.dart'; +import 'package:ping_journey/ping_journey.dart'; + +import 'package:flutter_journey/data/journey_repository.dart'; + +/// Drives a single Journey run: current [node], [loading] state, and any top-level [error]. +/// Analog of the native samples' `JourneyViewModel` (Kotlin `JourneyViewModel.kt` / +/// SwiftUI `JourneyViewModel.swift`) — dumb state holder; node-type dispatch happens in the View. +class JourneyViewModel extends ChangeNotifier { + JourneyViewModel({required this._repository}); + + final JourneyRepository _repository; + + JourneyNode? _node; + bool _loading = false; + String? _error; + + JourneyNode? get node => _node; + bool get loading => _loading; + String? get error => _error; + + Future startJourney(String name) => + _run(() => _repository.startJourney(name)); + + Future next() { + final current = _node; + if (current is! ContinueNode) return Future.value(); + return _run(() => _repository.next(current)); + } + + Future signOff() async { + try { + return await _repository.signOff(); + } on PingException catch (error) { + _error = error.message; + notifyListeners(); + return false; + } + } + + /// Notifies listeners without advancing [node] — a callback widget mutated its own field + /// (e.g. a text field keystroke) and the view needs to rebuild, mirroring the native samples' + /// `onNodeUpdated()`/`refresh()` convention. + void refresh() => notifyListeners(); + + Future _run(Future Function() action) async { + _loading = true; + _error = null; + notifyListeners(); + try { + _node = await action(); + } on PingException catch (error) { + _error = error.message; + } finally { + _loading = false; + notifyListeners(); + } + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/journey_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/journey_view.dart new file mode 100644 index 00000000..90e82bf7 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/journey_view.dart @@ -0,0 +1,203 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:ping_journey/ping_journey.dart'; +import 'package:provider/provider.dart'; + +import 'package:flutter_journey/ui/core/widgets/error_banner.dart'; +import 'package:flutter_journey/ui/core/widgets/primary_button.dart'; +import 'package:flutter_journey/ui/features/journey/view_models/journey_view_model.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/boolean_attribute_input_callback_view.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/choice_callback_view.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/kba_create_callback_view.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/name_callback_view.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/number_attribute_input_callback_view.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/password_callback_view.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/string_attribute_input_callback_view.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/terms_and_conditions_callback_view.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/text_input_callback_view.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/text_output_callback_view.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/validated_password_callback_view.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/validated_username_callback_view.dart'; + +/// Dynamic renderer: dispatches on the sealed [JourneyNode], then on the sealed [Callback] type +/// to build one widget each. Analog of the native samples' `Journey`/`JourneyNodeView`. +class JourneyView extends StatefulWidget { + const JourneyView({ + super.key, + required this.onSuccess, + required this.onRestart, + }); + + final VoidCallback onSuccess; + + /// The Journey ended in an [ErrorNode]/[FailureNode] with no further step to render — invoked + /// by a "Try Again" button so the user isn't stuck on a dead-end screen. + final VoidCallback onRestart; + + @override + State createState() => _JourneyViewState(); +} + +class _JourneyViewState extends State { + bool _successHandled = false; + + @override + Widget build(BuildContext context) { + final viewModel = context.read(); + return ListenableBuilder( + listenable: viewModel, + builder: (context, _) { + final node = viewModel.node; + + if (node is SuccessNode && !_successHandled) { + _successHandled = true; + WidgetsBinding.instance.addPostFrameCallback( + (_) => widget.onSuccess(), + ); + } else if (node is! SuccessNode) { + _successHandled = false; + } + + return Scaffold( + appBar: AppBar(title: const Text('Journey')), + body: Padding( + padding: const EdgeInsets.all(16), + child: switch (node) { + ContinueNode() => _ContinueNodeView( + node: node, + viewModel: viewModel, + ), + ErrorNode() => _ErrorBanner( + message: node.message, + onRestart: widget.onRestart, + ), + FailureNode() => _ErrorBanner( + message: node.cause, + onRestart: widget.onRestart, + ), + SuccessNode() || null => const SizedBox.shrink(), + }, + ), + ); + }, + ); + } +} + +class _ContinueNodeView extends StatelessWidget { + const _ContinueNodeView({required this.node, required this.viewModel}); + + final ContinueNode node; + final JourneyViewModel viewModel; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (node.header?.isNotEmpty ?? false) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + node.header!, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + if (node.description?.isNotEmpty ?? false) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text(node.description!), + ), + for (final callback in node.callbacks) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: switch (callback) { + NameCallback() => NameCallbackView( + callback: callback, + onChanged: viewModel.refresh, + ), + PasswordCallback() => PasswordCallbackView( + callback: callback, + onChanged: viewModel.refresh, + ), + ValidatedUsernameCallback() => ValidatedUsernameCallbackView( + callback: callback, + onChanged: viewModel.refresh, + ), + ValidatedPasswordCallback() => ValidatedPasswordCallbackView( + callback: callback, + onChanged: viewModel.refresh, + ), + ChoiceCallback() => ChoiceCallbackView( + callback: callback, + onChanged: viewModel.refresh, + ), + KbaCreateCallback() => KbaCreateCallbackView( + callback: callback, + onChanged: viewModel.refresh, + ), + TermsAndConditionsCallback() => TermsAndConditionsCallbackView( + callback: callback, + onChanged: viewModel.refresh, + ), + TextInputCallback() => TextInputCallbackView( + callback: callback, + onChanged: viewModel.refresh, + ), + TextOutputCallback() => TextOutputCallbackView( + callback: callback, + ), + StringAttributeInputCallback() => + StringAttributeInputCallbackView( + callback: callback, + onChanged: viewModel.refresh, + ), + NumberAttributeInputCallback() => + NumberAttributeInputCallbackView( + callback: callback, + onChanged: viewModel.refresh, + ), + BooleanAttributeInputCallback() => + BooleanAttributeInputCallbackView( + callback: callback, + onChanged: viewModel.refresh, + ), + }, + ), + const SizedBox(height: 16), + PrimaryButton( + label: 'Next', + onPressed: viewModel.next, + loading: viewModel.loading, + ), + ], + ), + ); + } +} + +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.message, required this.onRestart}); + + final String message; + final VoidCallback onRestart; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ErrorBanner(message: message), + const SizedBox(height: 16), + PrimaryButton(label: 'Try Again', onPressed: onRestart), + ], + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/boolean_attribute_input_callback_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/boolean_attribute_input_callback_view.dart new file mode 100644 index 00000000..81199430 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/boolean_attribute_input_callback_view.dart @@ -0,0 +1,34 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:ping_journey/ping_journey.dart'; + +class BooleanAttributeInputCallbackView extends StatelessWidget { + const BooleanAttributeInputCallbackView({ + super.key, + required this.callback, + required this.onChanged, + }); + + final BooleanAttributeInputCallback callback; + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + final label = callback.prompt ?? callback.name; + return SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(callback.required ? '$label *' : label), + value: callback.value, + onChanged: (value) { + callback.value = value; + onChanged(); + }, + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/callback_controllers_state.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/callback_controllers_state.dart new file mode 100644 index 00000000..e51a3cdf --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/callback_controllers_state.dart @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import 'package:flutter/material.dart'; + +/// Disposes [controllers] automatically, removing the repeated +/// `TextEditingController` + `dispose()` boilerplate shared by the callback views. +mixin CallbackControllersState on State { + /// The [TextEditingController]s this view owns; disposed automatically in [dispose]. + List get controllers; + + @override + void dispose() { + for (final controller in controllers) { + controller.dispose(); + } + super.dispose(); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/choice_callback_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/choice_callback_view.dart new file mode 100644 index 00000000..a7f53584 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/choice_callback_view.dart @@ -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. + */ + +import 'package:flutter/material.dart'; +import 'package:ping_journey/ping_journey.dart'; + +class ChoiceCallbackView extends StatelessWidget { + const ChoiceCallbackView({ + super.key, + required this.callback, + required this.onChanged, + }); + + final ChoiceCallback callback; + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + initialValue: callback.selectedIndex, + decoration: InputDecoration( + labelText: callback.prompt ?? 'Choice', + ), + items: [ + for (final (index, choice) in callback.choices.indexed) + DropdownMenuItem(value: index, child: Text(choice)), + ], + onChanged: (value) { + if (value == null) return; + callback.selectedIndex = value; + onChanged(); + }, + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/kba_create_callback_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/kba_create_callback_view.dart new file mode 100644 index 00000000..15c662f1 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/kba_create_callback_view.dart @@ -0,0 +1,112 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:ping_journey/ping_journey.dart'; + +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/callback_controllers_state.dart'; + +const _customQuestion = '__custom__'; + +class KbaCreateCallbackView extends StatefulWidget { + const KbaCreateCallbackView({ + super.key, + required this.callback, + required this.onChanged, + }); + + final KbaCreateCallback callback; + final VoidCallback onChanged; + + @override + State createState() => _KbaCreateCallbackViewState(); +} + +class _KbaCreateCallbackViewState extends State + with CallbackControllersState { + late final _answerController = TextEditingController( + text: widget.callback.selectedAnswer, + ); + late final _customQuestionController = TextEditingController( + text: + widget.callback.predefinedQuestions.contains( + widget.callback.selectedQuestion, + ) + ? '' + : widget.callback.selectedQuestion, + ); + + bool get _isCustomQuestion => + widget.callback.allowUserDefinedQuestions && + !widget.callback.predefinedQuestions.contains( + widget.callback.selectedQuestion, + ); + + @override + List get controllers => [ + _answerController, + _customQuestionController, + ]; + + @override + Widget build(BuildContext context) { + final callback = widget.callback; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: _isCustomQuestion + ? _customQuestion + : callback.selectedQuestion, + decoration: InputDecoration( + labelText: callback.prompt ?? 'Security Question', + ), + items: [ + for (final question in callback.predefinedQuestions) + DropdownMenuItem(value: question, child: Text(question)), + if (callback.allowUserDefinedQuestions) + const DropdownMenuItem( + value: _customQuestion, + child: Text('Provide your own'), + ), + ], + onChanged: (value) { + if (value == null) return; + setState(() { + callback.selectedQuestion = value == _customQuestion ? '' : value; + }); + widget.onChanged(); + }, + ), + if (_isCustomQuestion) ...[ + const SizedBox(height: 8), + TextField( + controller: _customQuestionController, + decoration: const InputDecoration( + labelText: 'Your Question', + ), + onChanged: (value) { + callback.selectedQuestion = value; + widget.onChanged(); + }, + ), + ], + const SizedBox(height: 8), + TextField( + controller: _answerController, + decoration: const InputDecoration( + labelText: 'Answer', + ), + onChanged: (value) { + callback.selectedAnswer = value; + widget.onChanged(); + }, + ), + ], + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/name_callback_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/name_callback_view.dart new file mode 100644 index 00000000..f1521d69 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/name_callback_view.dart @@ -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. + */ + +import 'package:flutter/material.dart'; +import 'package:ping_journey/ping_journey.dart'; + +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/callback_controllers_state.dart'; + +class NameCallbackView extends StatefulWidget { + const NameCallbackView({ + super.key, + required this.callback, + required this.onChanged, + }); + + final NameCallback callback; + final VoidCallback onChanged; + + @override + State createState() => _NameCallbackViewState(); +} + +class _NameCallbackViewState extends State + with CallbackControllersState { + late final _controller = TextEditingController(text: widget.callback.name); + + @override + List get controllers => [_controller]; + + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + decoration: InputDecoration( + labelText: widget.callback.prompt ?? 'Name', + ), + onChanged: (value) { + widget.callback.name = value; + widget.onChanged(); + }, + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/number_attribute_input_callback_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/number_attribute_input_callback_view.dart new file mode 100644 index 00000000..08268886 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/number_attribute_input_callback_view.dart @@ -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. + */ + +import 'package:flutter/material.dart'; +import 'package:ping_journey/ping_journey.dart'; + +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/callback_controllers_state.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/validation_error_text.dart'; + +class NumberAttributeInputCallbackView extends StatefulWidget { + const NumberAttributeInputCallbackView({ + super.key, + required this.callback, + required this.onChanged, + }); + + final NumberAttributeInputCallback callback; + final VoidCallback onChanged; + + @override + State createState() => + _NumberAttributeInputCallbackViewState(); +} + +class _NumberAttributeInputCallbackViewState + extends State + with CallbackControllersState { + late final _controller = TextEditingController( + text: widget.callback.value.toString(), + ); + bool _hasParseError = false; + + @override + List get controllers => [_controller]; + + @override + Widget build(BuildContext context) { + final callback = widget.callback; + final label = callback.prompt ?? callback.name; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: callback.required ? '$label *' : label, + errorText: ValidationErrorText.errorTextFor( + callback.failedPolicies, + hasLocalError: _hasParseError, + ), + helperText: _hasParseError ? 'Enter a valid number' : null, + ), + onChanged: (value) { + final parsed = double.tryParse(value); + setState(() => _hasParseError = value.isNotEmpty && parsed == null); + if (parsed != null) callback.value = parsed; + widget.onChanged(); + }, + ), + ValidationErrorText(failedPolicies: callback.failedPolicies), + ], + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/password_callback_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/password_callback_view.dart new file mode 100644 index 00000000..5a0abcf3 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/password_callback_view.dart @@ -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. + */ + +import 'package:flutter/material.dart'; +import 'package:ping_journey/ping_journey.dart'; + +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/callback_controllers_state.dart'; + +class PasswordCallbackView extends StatefulWidget { + const PasswordCallbackView({ + super.key, + required this.callback, + required this.onChanged, + }); + + final PasswordCallback callback; + final VoidCallback onChanged; + + @override + State createState() => _PasswordCallbackViewState(); +} + +class _PasswordCallbackViewState extends State + with CallbackControllersState { + late final _controller = TextEditingController( + text: widget.callback.password, + ); + bool _obscure = true; + + @override + List get controllers => [_controller]; + + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + obscureText: _obscure, + decoration: InputDecoration( + labelText: widget.callback.prompt ?? 'Password', + suffixIcon: IconButton( + icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ), + onChanged: (value) { + widget.callback.password = value; + widget.onChanged(); + }, + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/string_attribute_input_callback_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/string_attribute_input_callback_view.dart new file mode 100644 index 00000000..f779217c --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/string_attribute_input_callback_view.dart @@ -0,0 +1,61 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:ping_journey/ping_journey.dart'; + +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/callback_controllers_state.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/validation_error_text.dart'; + +class StringAttributeInputCallbackView extends StatefulWidget { + const StringAttributeInputCallbackView({ + super.key, + required this.callback, + required this.onChanged, + }); + + final StringAttributeInputCallback callback; + final VoidCallback onChanged; + + @override + State createState() => + _StringAttributeInputCallbackViewState(); +} + +class _StringAttributeInputCallbackViewState + extends State + with CallbackControllersState { + late final _controller = TextEditingController(text: widget.callback.value); + + @override + List get controllers => [_controller]; + + @override + Widget build(BuildContext context) { + final callback = widget.callback; + final label = callback.prompt ?? callback.name; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _controller, + decoration: InputDecoration( + labelText: callback.required ? '$label *' : label, + errorText: ValidationErrorText.errorTextFor( + callback.failedPolicies, + ), + ), + onChanged: (value) { + callback.value = value; + widget.onChanged(); + }, + ), + ValidationErrorText(failedPolicies: callback.failedPolicies), + ], + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/terms_and_conditions_callback_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/terms_and_conditions_callback_view.dart new file mode 100644 index 00000000..fcd3ddfb --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/terms_and_conditions_callback_view.dart @@ -0,0 +1,45 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:ping_journey/ping_journey.dart'; + +class TermsAndConditionsCallbackView extends StatelessWidget { + const TermsAndConditionsCallbackView({ + super.key, + required this.callback, + required this.onChanged, + }); + + final TermsAndConditionsCallback callback; + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (callback.version.isNotEmpty) Text('Version: ${callback.version}'), + if (callback.createDate.isNotEmpty) + Text('Date: ${callback.createDate}'), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 160), + child: SingleChildScrollView(child: Text(callback.terms)), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('I accept the terms and conditions'), + value: callback.accepted, + onChanged: (value) { + callback.accepted = value; + onChanged(); + }, + ), + ], + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/text_input_callback_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/text_input_callback_view.dart new file mode 100644 index 00000000..cf4b43a5 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/text_input_callback_view.dart @@ -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. + */ + +import 'package:flutter/material.dart'; +import 'package:ping_journey/ping_journey.dart'; + +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/callback_controllers_state.dart'; + +class TextInputCallbackView extends StatefulWidget { + const TextInputCallbackView({ + super.key, + required this.callback, + required this.onChanged, + }); + + final TextInputCallback callback; + final VoidCallback onChanged; + + @override + State createState() => _TextInputCallbackViewState(); +} + +class _TextInputCallbackViewState extends State + with CallbackControllersState { + late final _controller = TextEditingController(text: widget.callback.text); + + @override + List get controllers => [_controller]; + + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + decoration: InputDecoration( + labelText: widget.callback.prompt ?? 'Text', + ), + onChanged: (value) { + widget.callback.text = value; + widget.onChanged(); + }, + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/text_output_callback_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/text_output_callback_view.dart new file mode 100644 index 00000000..4382caa5 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/text_output_callback_view.dart @@ -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. + */ + +import 'package:flutter/material.dart'; +import 'package:ping_journey/ping_journey.dart'; + +class TextOutputCallbackView extends StatelessWidget { + const TextOutputCallbackView({super.key, required this.callback}); + + final TextOutputCallback callback; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final (icon, color) = switch (callback.messageType) { + TextOutputMessageType.information => (Icons.info, scheme.primary), + TextOutputMessageType.warning => (Icons.warning, Colors.orange), + TextOutputMessageType.error => (Icons.error, scheme.error), + TextOutputMessageType.script || + TextOutputMessageType.unknown => ( + Icons.settings, + scheme.onSurfaceVariant, + ), + }; + + return Row( + children: [ + Icon(icon, color: color), + const SizedBox(width: 8), + Expanded(child: Text(callback.message)), + ], + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/validated_password_callback_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/validated_password_callback_view.dart new file mode 100644 index 00000000..2f9edd51 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/validated_password_callback_view.dart @@ -0,0 +1,67 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:ping_journey/ping_journey.dart'; + +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/callback_controllers_state.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/validation_error_text.dart'; + +class ValidatedPasswordCallbackView extends StatefulWidget { + const ValidatedPasswordCallbackView({ + super.key, + required this.callback, + required this.onChanged, + }); + + final ValidatedPasswordCallback callback; + final VoidCallback onChanged; + + @override + State createState() => + _ValidatedPasswordCallbackViewState(); +} + +class _ValidatedPasswordCallbackViewState + extends State + with CallbackControllersState { + late final _controller = TextEditingController( + text: widget.callback.password, + ); + bool _obscure = true; + + @override + List get controllers => [_controller]; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _controller, + obscureText: _obscure && widget.callback.echoOn == false, + decoration: InputDecoration( + labelText: widget.callback.prompt ?? 'Password', + errorText: ValidationErrorText.errorTextFor( + widget.callback.failedPolicies, + ), + suffixIcon: IconButton( + icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ), + onChanged: (value) { + widget.callback.password = value; + widget.onChanged(); + }, + ), + ValidationErrorText(failedPolicies: widget.callback.failedPolicies), + ], + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/validated_username_callback_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/validated_username_callback_view.dart new file mode 100644 index 00000000..f13e2b86 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/validated_username_callback_view.dart @@ -0,0 +1,61 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:ping_journey/ping_journey.dart'; + +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/callback_controllers_state.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/validation_error_text.dart'; + +class ValidatedUsernameCallbackView extends StatefulWidget { + const ValidatedUsernameCallbackView({ + super.key, + required this.callback, + required this.onChanged, + }); + + final ValidatedUsernameCallback callback; + final VoidCallback onChanged; + + @override + State createState() => + _ValidatedUsernameCallbackViewState(); +} + +class _ValidatedUsernameCallbackViewState + extends State + with CallbackControllersState { + late final _controller = TextEditingController( + text: widget.callback.username, + ); + + @override + List get controllers => [_controller]; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _controller, + decoration: InputDecoration( + labelText: widget.callback.prompt ?? 'Username', + errorText: ValidationErrorText.errorTextFor( + widget.callback.failedPolicies, + ), + ), + onChanged: (value) { + widget.callback.username = value; + widget.onChanged(); + }, + ), + ValidationErrorText(failedPolicies: widget.callback.failedPolicies), + ], + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/validation_error_text.dart b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/validation_error_text.dart new file mode 100644 index 00000000..120b84e2 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/journey/views/widgets/callbacks/validation_error_text.dart @@ -0,0 +1,41 @@ +/* + * 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 'package:flutter/material.dart'; + +/// Shared inline validation-error display for the Validated*/Attribute*Input callback family, +/// driven by [failedPolicies] (`{"policyRequirement": ..., "params": ...}` entries) — +/// analog of the native samples' `ErrorMessageView`/`supportingText` error rendering. +class ValidationErrorText extends StatelessWidget { + const ValidationErrorText({super.key, required this.failedPolicies}); + + final List> failedPolicies; + + /// Sentinel [TextField.decoration.errorText] value that triggers Material's themed + /// `errorBorder` without duplicating the message already shown by [ValidationErrorText] + /// underneath. Returns `null` (no error) when [failedPolicies] and [hasLocalError] are both + /// clear, or `''` (empty-string sentinel) otherwise. + static String? errorTextFor( + List> failedPolicies, { + bool hasLocalError = false, + }) => failedPolicies.isNotEmpty || hasLocalError ? '' : null; + + @override + Widget build(BuildContext context) { + if (failedPolicies.isEmpty) return const SizedBox.shrink(); + final message = failedPolicies + .map((policy) => policy['policyRequirement'] as String? ?? 'INVALID') + .join(', '); + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + message, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ); + } +} diff --git a/flutter/flutter-journey/lib/ui/features/success/view_models/success_view_model.dart b/flutter/flutter-journey/lib/ui/features/success/view_models/success_view_model.dart new file mode 100644 index 00000000..cb471d54 --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/success/view_models/success_view_model.dart @@ -0,0 +1,40 @@ +/* + * 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 'package:flutter/foundation.dart'; +import 'package:ping_journey/ping_journey.dart'; + +import 'package:flutter_journey/data/journey_repository.dart'; + +/// Holds the session state shown on the success screen. +class SuccessViewModel extends ChangeNotifier { + SuccessViewModel({required this._repository}); + + final JourneyRepository _repository; + + Session? _session; + bool _loading = false; + String? _error; + + Session? get session => _session; + bool get loading => _loading; + String? get error => _error; + + Future loadSession() async { + _loading = true; + _error = null; + notifyListeners(); + try { + _session = await _repository.user(); + } on PingException catch (e) { + _error = e.message; + } finally { + _loading = false; + notifyListeners(); + } + } +} diff --git a/flutter/flutter-journey/lib/ui/features/success/views/success_view.dart b/flutter/flutter-journey/lib/ui/features/success/views/success_view.dart new file mode 100644 index 00000000..0a3a422b --- /dev/null +++ b/flutter/flutter-journey/lib/ui/features/success/views/success_view.dart @@ -0,0 +1,214 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import 'package:flutter_journey/ui/core/theme/app_theme.dart'; +import 'package:flutter_journey/ui/core/widgets/app_card.dart'; +import 'package:flutter_journey/ui/core/widgets/error_banner.dart'; +import 'package:flutter_journey/ui/core/widgets/primary_button.dart'; +import 'package:flutter_journey/ui/features/success/view_models/success_view_model.dart'; + +/// Displays the access/refresh tokens and userinfo after a successful Journey, and provides a +/// Sign-off button. Port of the native samples' success/token screens. +class SuccessView extends StatefulWidget { + const SuccessView({super.key, required this.onSignOff}); + + final VoidCallback onSignOff; + + @override + State createState() => _SuccessViewState(); +} + +class _SuccessViewState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadSession(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Success')), + body: ListenableBuilder( + listenable: context.read(), + builder: (context, _) { + final vm = context.read(); + if (vm.loading) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 12), + Text( + 'Loading session…', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + if (vm.error != null) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ErrorBanner(message: vm.error!), + const SizedBox(height: 16), + PrimaryButton( + label: 'Sign Off', + onPressed: widget.onSignOff, + ), + ], + ), + ); + } + final session = vm.session; + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Journey completed successfully.', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + if (session != null) ...[ + _TokenSection( + label: 'Access Token', + value: session.accessToken, + ), + if (session.refreshToken != null) + _TokenSection( + label: 'Refresh Token', + value: session.refreshToken!, + ), + _TokenSection( + label: 'Expires In', + value: '${session.expiresIn}s', + ), + if (session.userInfo.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'User Info', + style: AppTheme.sectionHeader(context), + ), + const SizedBox(height: 8), + AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final entry in session.userInfo.entries) + _InfoRow(key_: entry.key, value: entry.value), + ], + ), + ), + ], + ], + const SizedBox(height: 24), + PrimaryButton(label: 'Sign Off', onPressed: widget.onSignOff), + ], + ), + ); + }, + ), + ); + } +} + +class _TokenSection extends StatelessWidget { + const _TokenSection({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: AppTheme.sectionHeader(context)), + const SizedBox(height: 4), + AppCard( + padding: const EdgeInsets.all(8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + value, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + ), + ), + InkWell( + onTap: () => Clipboard.setData(ClipboardData(text: value)), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.copy, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.key_, required this.value}); + + final String key_; + final Object? value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + key_, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Expanded( + child: Text( + value?.toString() ?? '', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ); + } +} diff --git a/flutter/flutter-journey/pubspec.yaml b/flutter/flutter-journey/pubspec.yaml new file mode 100644 index 00000000..df11d943 --- /dev/null +++ b/flutter/flutter-journey/pubspec.yaml @@ -0,0 +1,101 @@ +name: flutter_journey +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.2 + +resolution: workspace + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + ping_journey: + path: ../flutter-sdk-bridge/ping_journey + + provider: ^6.1.5 + go_router: ^16.2.4 + shared_preferences: ^2.5.3 + flutter_web_plugins: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + +dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - assets/images/ping_logo.png + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/flutter/flutter-journey/test/ui/features/config/views/config_view_test.dart b/flutter/flutter-journey/test/ui/features/config/views/config_view_test.dart new file mode 100644 index 00000000..f63e3988 --- /dev/null +++ b/flutter/flutter-journey/test/ui/features/config/views/config_view_test.dart @@ -0,0 +1,58 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_journey/config/env.dart'; +import 'package:flutter_journey/ui/features/config/views/config_view.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'renders the Server URL, Realm, and Cookie rows for the active Env', + (tester) async { + await tester.pumpWidget( + MaterialApp(home: ConfigView(onContinue: () {})), + ); + + expect(find.text('Active Environment'), findsOneWidget); + expect(find.text('SERVER URL'), findsOneWidget); + expect(find.text(Env.serverUrl), findsOneWidget); + expect(find.text('REALM'), findsOneWidget); + expect(find.text(Env.realm), findsOneWidget); + expect(find.text('COOKIE'), findsOneWidget); + expect(find.text(Env.cookie), findsOneWidget); + }, + ); + + testWidgets( + 'omits the OIDC rows when Env.oidcConfig is null', + (tester) async { + await tester.pumpWidget( + MaterialApp(home: ConfigView(onContinue: () {})), + ); + + // The sample app ships with Env.oidcConfig == null by default. + expect(Env.oidcConfig, isNull); + expect(find.text('CLIENT ID'), findsNothing); + expect(find.text('DISCOVERY ENDPOINT'), findsNothing); + expect(find.text('REDIRECT URI'), findsNothing); + }, + ); + + testWidgets('tapping Continue invokes onContinue', (tester) async { + var continued = false; + + await tester.pumpWidget( + MaterialApp(home: ConfigView(onContinue: () => continued = true)), + ); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Continue')); + await tester.pump(); + + expect(continued, isTrue); + }); +} diff --git a/flutter/flutter-journey/test/ui/features/journey/views/journey_view_test.dart b/flutter/flutter-journey/test/ui/features/journey/views/journey_view_test.dart new file mode 100644 index 00000000..31aa77d5 --- /dev/null +++ b/flutter/flutter-journey/test/ui/features/journey/views/journey_view_test.dart @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_journey/data/journey_repository.dart'; +import 'package:flutter_journey/ui/features/journey/view_models/journey_view_model.dart'; +import 'package:flutter_journey/ui/features/journey/views/journey_view.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ping_journey/ping_journey.dart'; +import 'package:provider/provider.dart'; + +/// Stubs the network-bound [JourneyRepository] so [JourneyViewModel.startJourney] resolves to a +/// fixed [JourneyNode] instead of calling the real plugin. +class _StubJourneyRepository extends JourneyRepository { + _StubJourneyRepository(this.node); + + final JourneyNode node; + + @override + Future startJourney(String journeyName) async => node; +} + +NameCallback _nameCallback() => + NameCallback(type: 'NameCallback', index: 0, prompt: 'User Name'); + +PasswordCallback _passwordCallback() => + PasswordCallback(type: 'PasswordCallback', index: 0, prompt: 'Password'); + +Future _pumpJourneyView( + WidgetTester tester, + JourneyNode node, { + VoidCallback onRestart = _noop, + VoidCallback onSuccess = _noop, +}) async { + final viewModel = JourneyViewModel(repository: _StubJourneyRepository(node)); + await viewModel.startJourney('Login'); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: viewModel, + child: MaterialApp( + home: JourneyView(onSuccess: onSuccess, onRestart: onRestart), + ), + ), + ); + await tester.pumpAndSettle(); + return viewModel; +} + +void _noop() {} + +void main() { + testWidgets( + 'renders Name + Password fields and a Next button for a Name+Password ContinueNode', + (tester) async { + final node = ContinueNode( + journeyId: 'journey-1', + callbacks: [_nameCallback(), _passwordCallback()], + ); + + await _pumpJourneyView(tester, node); + + expect(find.widgetWithText(TextField, 'User Name'), findsOneWidget); + expect(find.widgetWithText(TextField, 'Password'), findsOneWidget); + expect(find.widgetWithText(ElevatedButton, 'Next'), findsOneWidget); + }, + ); + + testWidgets( + 'entering text updates the NameCallback and PasswordCallback values', + (tester) async { + final nameCallback = _nameCallback(); + final passwordCallback = _passwordCallback(); + final node = ContinueNode( + journeyId: 'journey-1', + callbacks: [nameCallback, passwordCallback], + ); + + await _pumpJourneyView(tester, node); + + await tester.enterText( + find.widgetWithText(TextField, 'User Name'), + 'jdoe', + ); + await tester.enterText( + find.widgetWithText(TextField, 'Password'), + 's3cr3t', + ); + await tester.pump(); + + expect(nameCallback.name, 'jdoe'); + expect(passwordCallback.password, 's3cr3t'); + }, + ); + + testWidgets( + 'an ErrorNode renders a Try Again button that invokes onRestart', + (tester) async { + var restarted = false; + + await _pumpJourneyView( + tester, + const ErrorNode(message: 'Invalid username or password'), + onRestart: () => restarted = true, + ); + + expect(find.text('Invalid username or password'), findsOneWidget); + expect(find.widgetWithText(ElevatedButton, 'Try Again'), findsOneWidget); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Try Again')); + await tester.pump(); + + expect(restarted, isTrue); + }, + ); + + testWidgets( + 'a FailureNode renders a Try Again button that invokes onRestart', + (tester) async { + var restarted = false; + + await _pumpJourneyView( + tester, + const FailureNode(cause: 'Network error'), + onRestart: () => restarted = true, + ); + + expect(find.text('Network error'), findsOneWidget); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Try Again')); + await tester.pump(); + + expect(restarted, isTrue); + }, + ); + + testWidgets( + 'onSuccess is invoked exactly once across multiple rebuilds while the node stays a ' + 'SuccessNode', + (tester) async { + var successCount = 0; + + final viewModel = await _pumpJourneyView( + tester, + const SuccessNode(), + onSuccess: () => successCount++, + ); + + expect(successCount, 1); + + // Force additional rebuilds while the node is still a SuccessNode — onSuccess must not + // fire again. + viewModel.refresh(); + await tester.pumpAndSettle(); + viewModel.refresh(); + await tester.pumpAndSettle(); + + expect(successCount, 1); + }, + ); +} diff --git a/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/boolean_attribute_input_callback_view_test.dart b/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/boolean_attribute_input_callback_view_test.dart new file mode 100644 index 00000000..d29a51ef --- /dev/null +++ b/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/boolean_attribute_input_callback_view_test.dart @@ -0,0 +1,106 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/boolean_attribute_input_callback_view.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ping_journey/ping_journey.dart'; + +Future _pump(WidgetTester tester, Widget child) => + tester.pumpWidget(MaterialApp(home: Scaffold(body: child))); + +BooleanAttributeInputCallback _callback({ + String name = 'marketingOptIn', + String? prompt = 'Receive marketing emails', + bool required = false, + bool value = false, +}) => BooleanAttributeInputCallback( + type: 'BooleanAttributeInputCallback', + index: 0, + prompt: prompt, + name: name, + required: required, + value: value, +); + +void main() { + testWidgets('renders a SwitchListTile labelled with the prompt and initial value', ( + tester, + ) async { + final callback = _callback(value: true); + + await _pump( + tester, + BooleanAttributeInputCallbackView(callback: callback, onChanged: () {}), + ); + + expect( + find.widgetWithText(SwitchListTile, 'Receive marketing emails'), + findsOneWidget, + ); + final tile = tester.widget(find.byType(SwitchListTile)); + expect(tile.value, isTrue); + }); + + testWidgets('appends " *" to the label when the attribute is required', ( + tester, + ) async { + final callback = _callback(required: true); + + await _pump( + tester, + BooleanAttributeInputCallbackView(callback: callback, onChanged: () {}), + ); + + expect( + find.widgetWithText(SwitchListTile, 'Receive marketing emails *'), + findsOneWidget, + ); + }); + + testWidgets( + 'falls back to the attribute name when prompt is null', + (tester) async { + final callback = _callback(prompt: null, name: 'marketingOptIn'); + + await _pump( + tester, + BooleanAttributeInputCallbackView( + callback: callback, + onChanged: () {}, + ), + ); + + expect( + find.widgetWithText(SwitchListTile, 'marketingOptIn'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'toggling the switch updates the callback value and invokes onChanged', + (tester) async { + final callback = _callback(value: false); + var changed = false; + + await _pump( + tester, + BooleanAttributeInputCallbackView( + callback: callback, + onChanged: () => changed = true, + ), + ); + + await tester.tap(find.byType(SwitchListTile)); + await tester.pump(); + + expect(callback.value, isTrue); + expect(changed, isTrue); + }, + ); +} diff --git a/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/number_attribute_input_callback_view_test.dart b/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/number_attribute_input_callback_view_test.dart new file mode 100644 index 00000000..e1e25b74 --- /dev/null +++ b/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/number_attribute_input_callback_view_test.dart @@ -0,0 +1,138 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/number_attribute_input_callback_view.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ping_journey/ping_journey.dart'; + +Future _pump(WidgetTester tester, Widget child) => + tester.pumpWidget(MaterialApp(home: Scaffold(body: child))); + +NumberAttributeInputCallback _callback({ + String name = 'age', + String? prompt = 'Age', + bool required = false, + double value = 0, + List> failedPolicies = const [], +}) => NumberAttributeInputCallback( + type: 'NumberAttributeInputCallback', + index: 0, + prompt: prompt, + name: name, + required: required, + value: value, + failedPolicies: failedPolicies, +); + +void main() { + testWidgets('renders a TextField labelled with the prompt and initial value', ( + tester, + ) async { + final callback = _callback(value: 42); + + await _pump( + tester, + NumberAttributeInputCallbackView(callback: callback, onChanged: () {}), + ); + + expect(find.widgetWithText(TextField, 'Age'), findsOneWidget); + expect(find.text('42.0'), findsOneWidget); + }); + + testWidgets('appends " *" to the label when the attribute is required', ( + tester, + ) async { + final callback = _callback(required: true); + + await _pump( + tester, + NumberAttributeInputCallbackView(callback: callback, onChanged: () {}), + ); + + expect(find.widgetWithText(TextField, 'Age *'), findsOneWidget); + }); + + testWidgets( + 'typing non-numeric text sets the parse-error helper and error sentinel without ' + 'updating the value', + (tester) async { + final callback = _callback(value: 10); + var changed = false; + + await _pump( + tester, + NumberAttributeInputCallbackView( + callback: callback, + onChanged: () => changed = true, + ), + ); + + await tester.enterText(find.byType(TextField), 'abc'); + await tester.pump(); + + final field = tester.widget(find.byType(TextField)); + expect(field.decoration?.helperText, 'Enter a valid number'); + // Empty-string sentinel: triggers the themed error border without duplicating the + // helperText message above (Material only renders one of helper/error at a time, so the + // helperText itself isn't shown on screen once errorText is non-null). + expect(field.decoration?.errorText, ''); + expect(callback.value, 10); + expect(changed, isTrue); + }, + ); + + testWidgets( + 'typing valid numeric text updates the callback value and clears the parse error', + (tester) async { + final callback = _callback(value: 0); + var changed = false; + + await _pump( + tester, + NumberAttributeInputCallbackView( + callback: callback, + onChanged: () => changed = true, + ), + ); + + // First introduce a parse error, then correct it, to exercise the clearing path too. + await tester.enterText(find.byType(TextField), 'abc'); + await tester.pump(); + expect( + tester.widget(find.byType(TextField)).decoration?.helperText, + 'Enter a valid number', + ); + + await tester.enterText(find.byType(TextField), '12.5'); + await tester.pump(); + + final field = tester.widget(find.byType(TextField)); + expect(field.decoration?.helperText, isNull); + expect(field.decoration?.errorText, isNull); + expect(callback.value, 12.5); + expect(changed, isTrue); + }, + ); + + testWidgets('renders failedPolicies as error text below the field', ( + tester, + ) async { + final callback = _callback( + failedPolicies: [ + {'policyRequirement': 'MIN_VALUE'}, + ], + ); + + await _pump( + tester, + NumberAttributeInputCallbackView(callback: callback, onChanged: () {}), + ); + + expect(find.text('MIN_VALUE'), findsOneWidget); + }); +} diff --git a/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/string_attribute_input_callback_view_test.dart b/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/string_attribute_input_callback_view_test.dart new file mode 100644 index 00000000..6c51caae --- /dev/null +++ b/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/string_attribute_input_callback_view_test.dart @@ -0,0 +1,126 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/string_attribute_input_callback_view.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ping_journey/ping_journey.dart'; + +Future _pump(WidgetTester tester, Widget child) => + tester.pumpWidget(MaterialApp(home: Scaffold(body: child))); + +StringAttributeInputCallback _callback({ + String name = 'givenName', + String? prompt = 'First Name', + bool required = false, + String value = '', + List> failedPolicies = const [], +}) => StringAttributeInputCallback( + type: 'StringAttributeInputCallback', + index: 0, + prompt: prompt, + name: name, + required: required, + value: value, + failedPolicies: failedPolicies, +); + +void main() { + testWidgets('renders a TextField labelled with the prompt', (tester) async { + final callback = _callback(); + + await _pump( + tester, + StringAttributeInputCallbackView(callback: callback, onChanged: () {}), + ); + + expect(find.widgetWithText(TextField, 'First Name'), findsOneWidget); + }); + + testWidgets('appends " *" to the label when the attribute is required', ( + tester, + ) async { + final callback = _callback(required: true); + + await _pump( + tester, + StringAttributeInputCallbackView(callback: callback, onChanged: () {}), + ); + + expect(find.widgetWithText(TextField, 'First Name *'), findsOneWidget); + }); + + testWidgets( + 'falls back to the attribute name when prompt is null', + (tester) async { + final callback = _callback(prompt: null, name: 'givenName'); + + await _pump( + tester, + StringAttributeInputCallbackView( + callback: callback, + onChanged: () {}, + ), + ); + + expect(find.widgetWithText(TextField, 'givenName'), findsOneWidget); + }, + ); + + testWidgets( + 'entering text updates the callback value and invokes onChanged', + (tester) async { + final callback = _callback(); + var changed = false; + + await _pump( + tester, + StringAttributeInputCallbackView( + callback: callback, + onChanged: () => changed = true, + ), + ); + + await tester.enterText(find.byType(TextField), 'Jane'); + await tester.pump(); + + expect(callback.value, 'Jane'); + expect(changed, isTrue); + }, + ); + + testWidgets('renders failedPolicies as error text below the field', ( + tester, + ) async { + final callback = _callback( + failedPolicies: [ + {'policyRequirement': 'REQUIRED'}, + ], + ); + + await _pump( + tester, + StringAttributeInputCallbackView(callback: callback, onChanged: () {}), + ); + + expect(find.text('REQUIRED'), findsOneWidget); + }); + + testWidgets('shows no error text when there are no failedPolicies', ( + tester, + ) async { + final callback = _callback(); + + await _pump( + tester, + StringAttributeInputCallbackView(callback: callback, onChanged: () {}), + ); + + final field = tester.widget(find.byType(TextField)); + expect(field.decoration?.errorText, isNull); + }); +} diff --git a/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/validated_password_callback_view_test.dart b/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/validated_password_callback_view_test.dart new file mode 100644 index 00000000..a63dbdce --- /dev/null +++ b/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/validated_password_callback_view_test.dart @@ -0,0 +1,116 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/validated_password_callback_view.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ping_journey/ping_journey.dart'; + +Future _pump(WidgetTester tester, Widget child) => + tester.pumpWidget(MaterialApp(home: Scaffold(body: child))); + +ValidatedPasswordCallback _callback({ + String password = '', + bool echoOn = false, + List> failedPolicies = const [], +}) => ValidatedPasswordCallback( + type: 'ValidatedPasswordCallback', + index: 0, + prompt: 'Password', + password: password, + echoOn: echoOn, + failedPolicies: failedPolicies, +); + +void main() { + testWidgets('renders a TextField with the Password label', (tester) async { + final callback = _callback(); + + await _pump( + tester, + ValidatedPasswordCallbackView(callback: callback, onChanged: () {}), + ); + + expect(find.widgetWithText(TextField, 'Password'), findsOneWidget); + }); + + testWidgets( + 'entering text updates the callback password and invokes onChanged', + (tester) async { + final callback = _callback(); + var changed = false; + + await _pump( + tester, + ValidatedPasswordCallbackView( + callback: callback, + onChanged: () => changed = true, + ), + ); + + await tester.enterText(find.byType(TextField), 's3cr3t'); + await tester.pump(); + + expect(callback.password, 's3cr3t'); + expect(changed, isTrue); + }, + ); + + testWidgets('obscures text by default and can be toggled visible', ( + tester, + ) async { + final callback = _callback(); + + await _pump( + tester, + ValidatedPasswordCallbackView(callback: callback, onChanged: () {}), + ); + + expect(tester.widget(find.byType(TextField)).obscureText, isTrue); + expect(find.byIcon(Icons.visibility), findsOneWidget); + + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pump(); + + expect( + tester.widget(find.byType(TextField)).obscureText, + isFalse, + ); + expect(find.byIcon(Icons.visibility_off), findsOneWidget); + }); + + testWidgets('renders failedPolicies as error text below the field', ( + tester, + ) async { + final callback = _callback( + failedPolicies: [ + {'policyRequirement': 'MIN_LENGTH'}, + ], + ); + + await _pump( + tester, + ValidatedPasswordCallbackView(callback: callback, onChanged: () {}), + ); + + expect(find.text('MIN_LENGTH'), findsOneWidget); + }); + + testWidgets('shows no error text when there are no failedPolicies', ( + tester, + ) async { + final callback = _callback(); + + await _pump( + tester, + ValidatedPasswordCallbackView(callback: callback, onChanged: () {}), + ); + + final field = tester.widget(find.byType(TextField)); + expect(field.decoration?.errorText, isNull); + }); +} diff --git a/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/validated_username_callback_view_test.dart b/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/validated_username_callback_view_test.dart new file mode 100644 index 00000000..61c61ae9 --- /dev/null +++ b/flutter/flutter-journey/test/ui/features/journey/views/widgets/callbacks/validated_username_callback_view_test.dart @@ -0,0 +1,93 @@ +/* + * 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 'package:flutter/material.dart'; +import 'package:flutter_journey/ui/features/journey/views/widgets/callbacks/validated_username_callback_view.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ping_journey/ping_journey.dart'; + +Future _pump(WidgetTester tester, Widget child) => + tester.pumpWidget(MaterialApp(home: Scaffold(body: child))); + +ValidatedUsernameCallback _callback({ + String username = '', + List> failedPolicies = const [], +}) => ValidatedUsernameCallback( + type: 'ValidatedUsernameCallback', + index: 0, + prompt: 'Username', + username: username, + failedPolicies: failedPolicies, +); + +void main() { + testWidgets('renders a TextField pre-filled with the callback username', ( + tester, + ) async { + final callback = _callback(username: 'jdoe'); + + await _pump( + tester, + ValidatedUsernameCallbackView(callback: callback, onChanged: () {}), + ); + + expect(find.widgetWithText(TextField, 'jdoe'), findsOneWidget); + }); + + testWidgets( + 'entering text updates the callback username and invokes onChanged', + (tester) async { + final callback = _callback(); + var changed = false; + + await _pump( + tester, + ValidatedUsernameCallbackView( + callback: callback, + onChanged: () => changed = true, + ), + ); + + await tester.enterText(find.byType(TextField), 'new-username'); + await tester.pump(); + + expect(callback.username, 'new-username'); + expect(changed, isTrue); + }, + ); + + testWidgets('renders failedPolicies as error text below the field', ( + tester, + ) async { + final callback = _callback( + failedPolicies: [ + {'policyRequirement': 'UNIQUE'}, + ], + ); + + await _pump( + tester, + ValidatedUsernameCallbackView(callback: callback, onChanged: () {}), + ); + + expect(find.text('UNIQUE'), findsOneWidget); + }); + + testWidgets('shows no error text when there are no failedPolicies', ( + tester, + ) async { + final callback = _callback(); + + await _pump( + tester, + ValidatedUsernameCallbackView(callback: callback, onChanged: () {}), + ); + + final field = tester.widget(find.byType(TextField)); + expect(field.decoration?.errorText, isNull); + }); +} diff --git a/flutter/flutter-journey/test/ui/features/success/views/success_view_test.dart b/flutter/flutter-journey/test/ui/features/success/views/success_view_test.dart new file mode 100644 index 00000000..70a6b7f2 --- /dev/null +++ b/flutter/flutter-journey/test/ui/features/success/views/success_view_test.dart @@ -0,0 +1,189 @@ +/* + * 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 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_journey/data/journey_repository.dart'; +import 'package:flutter_journey/ui/features/success/view_models/success_view_model.dart'; +import 'package:flutter_journey/ui/features/success/views/success_view.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ping_journey/ping_journey.dart'; +import 'package:provider/provider.dart'; + +/// Stubs the network-bound [JourneyRepository] so [SuccessViewModel.loadSession] resolves to a +/// fixed [Session] (or throws a fixed [PingException]) instead of calling the real plugin. +/// +/// [sessionFuture] is awaited directly (rather than returned from an `async` function that +/// itself introduces an implicit microtask hop) so tests can hold it pending via a [Completer] +/// to deterministically observe the view's loading state. +class _StubJourneyRepository extends JourneyRepository { + _StubJourneyRepository({this.sessionFuture, this.error}); + + final Future? sessionFuture; + final PingException? error; + + @override + Future user() { + if (error != null) return Future.error(error!); + return sessionFuture ?? Future.value(null); + } +} + +Future _pumpSuccessView( + WidgetTester tester, { + Future? sessionFuture, + PingException? error, + VoidCallback onSignOff = _noop, +}) async { + final viewModel = SuccessViewModel( + repository: _StubJourneyRepository(sessionFuture: sessionFuture, error: error), + ); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: viewModel, + child: MaterialApp(home: SuccessView(onSignOff: onSignOff)), + ), + ); + return viewModel; +} + +void _noop() {} + +void main() { + testWidgets('shows a loading indicator while the session is being fetched', ( + tester, + ) async { + final completer = Completer(); + + await _pumpSuccessView(tester, sessionFuture: completer.future); + + // One pump lets the post-frame callback invoke loadSession(), which sets loading = true + // and calls notifyListeners() — but the stubbed Future is still pending, so the loading + // state stays visible until we complete it below. + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Loading session…'), findsOneWidget); + + completer.complete(const Session(accessToken: 'token', expiresIn: 3600)); + await tester.pumpAndSettle(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('shows an error banner and Sign Off button when loading fails', ( + tester, + ) async { + var signedOff = false; + + await _pumpSuccessView( + tester, + error: const PingException( + 'JOURNEY_USER_ERROR', + 'state', + 'Unable to load session', + ), + onSignOff: () => signedOff = true, + ); + await tester.pumpAndSettle(); + + expect(find.text('Unable to load session'), findsOneWidget); + expect(find.widgetWithText(ElevatedButton, 'Sign Off'), findsOneWidget); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Sign Off')); + await tester.pump(); + + expect(signedOff, isTrue); + }); + + testWidgets( + 'renders the access token, refresh token, expiry, and user info on success', + (tester) async { + await _pumpSuccessView( + tester, + sessionFuture: Future.value( + const Session( + accessToken: 'access-123', + refreshToken: 'refresh-456', + expiresIn: 3600, + userInfo: {'sub': 'user-1', 'email': 'jdoe@example.com'}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Journey completed successfully.'), findsOneWidget); + expect(find.text('Access Token'), findsOneWidget); + expect(find.text('access-123'), findsOneWidget); + expect(find.text('Refresh Token'), findsOneWidget); + expect(find.text('refresh-456'), findsOneWidget); + expect(find.text('Expires In'), findsOneWidget); + expect(find.text('3600s'), findsOneWidget); + expect(find.text('User Info'), findsOneWidget); + expect(find.text('sub'), findsOneWidget); + expect(find.text('user-1'), findsOneWidget); + expect(find.text('email'), findsOneWidget); + expect(find.text('jdoe@example.com'), findsOneWidget); + expect(find.widgetWithText(ElevatedButton, 'Sign Off'), findsOneWidget); + }, + ); + + testWidgets('omits the Refresh Token and User Info sections when absent', ( + tester, + ) async { + await _pumpSuccessView( + tester, + sessionFuture: Future.value( + const Session(accessToken: 'access-only', expiresIn: 60), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Access Token'), findsOneWidget); + expect(find.text('Refresh Token'), findsNothing); + expect(find.text('User Info'), findsNothing); + }); + + testWidgets('tapping the copy icon copies the token value to the clipboard', ( + tester, + ) async { + final copied = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + final args = (call.arguments as Map)['text'] as String; + copied.add(ClipboardData(text: args)); + } + return null; + }, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + await _pumpSuccessView( + tester, + sessionFuture: Future.value( + const Session(accessToken: 'copy-me', expiresIn: 60), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.copy).first); + await tester.pump(); + + expect(copied, isNotEmpty); + expect(copied.first.text, 'copy-me'); + }); +} diff --git a/flutter/flutter-journey/test/widget_test.dart b/flutter/flutter-journey/test/widget_test.dart new file mode 100644 index 00000000..76b0ff41 --- /dev/null +++ b/flutter/flutter-journey/test/widget_test.dart @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flutter_journey/main.dart'; + +void main() { + testWidgets('boots to the active-environment config screen', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const FlutterJourneyApp()); + await tester.pumpAndSettle(); + + expect(find.text('Active Environment'), findsOneWidget); + }); +} diff --git a/flutter/flutter-journey/test_driver/integration_test.dart b/flutter/flutter-journey/test_driver/integration_test.dart new file mode 100644 index 00000000..c98ff6b2 --- /dev/null +++ b/flutter/flutter-journey/test_driver/integration_test.dart @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/flutter/flutter-sdk-bridge/CHANGELOG.md b/flutter/flutter-sdk-bridge/CHANGELOG.md new file mode 100644 index 00000000..815e91be --- /dev/null +++ b/flutter/flutter-sdk-bridge/CHANGELOG.md @@ -0,0 +1,7 @@ +## 0.0.1 + +#### Added +- Added `ping_core`, a federated native plugin carrying the shared `CoreRuntime` handle registry, `PingException`, and JSON codec helpers used by every bridge module [SDKS-4613] +- Added `ping_journey`, a Pigeon-generated bridge over the native Ping Journey SDK: `configureJourney`, `start`, `next`, `getSession`, `signOff`, and `dispose` [SDKS-4613] +- Added Dart sealed types for `JourneyNode` (`ContinueNode`/`SuccessNode`/`ErrorNode`/`FailureNode`) and the v1 callback set (`NameCallback`, `PasswordCallback`, `ValidatedUsernameCallback`, `ValidatedPasswordCallback`, `ChoiceCallback`, `KbaCreateCallback`, `TermsAndConditionsCallback`, `TextInputCallback`, `TextOutputCallback`, `StringAttributeInputCallback`, `NumberAttributeInputCallback`, `BooleanAttributeInputCallback`) [SDKS-4613] +- Pinned both platforms to native Ping SDK `2.0.0` (Android Maven, iOS SPM) diff --git a/flutter/flutter-sdk-bridge/LICENSE b/flutter/flutter-sdk-bridge/LICENSE new file mode 100644 index 00000000..da5881cb --- /dev/null +++ b/flutter/flutter-sdk-bridge/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 - 2025 Ping Identity Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/flutter/flutter-sdk-bridge/README.md b/flutter/flutter-sdk-bridge/README.md new file mode 100644 index 00000000..cc5e3740 --- /dev/null +++ b/flutter/flutter-sdk-bridge/README.md @@ -0,0 +1,92 @@ +[![Ping Identity](https://www.pingidentity.com/content/dam/picr/nav/Ping-Logo-2.svg)](https://github.com/ForgeRock/sdk-sample-apps) + +# flutter-sdk-bridge + +A native-wrapper bridge exposing the Ping Orchestration SDKs to Flutter, built with [Pigeon](https://pub.dev/packages/pigeon) for type-safe Dart ⇄ Kotlin ⇄ Swift codegen. + +## Packages + +- **`ping_core`** — a federated native plugin carrying the shared, process-wide `CoreRuntime` registry (keyed native handles for live SDK objects that never cross the platform channel) plus small shared helpers (`PingException`, JSON codec). It exposes **no Pigeon `HostApi`** — it exists purely so multiple SDK modules can share one native registry singleton. +- **`ping_journey`** — a federated plugin carrying the Pigeon schema (`pigeons/messages.dart`), the generated Dart/Kotlin/Swift code, and the native Journey orchestration logic (`JourneyClientFactory`, `JourneyConfigParser`, `JourneyHostApiImpl`, node/callback mappers, error mapper). Depends on `ping_core` at both the Dart and native levels, and on the native Ping Journey SDK (Android `com.pingidentity.sdks:journey`, iOS SPM `PingJourney`). + +## Architecture + +The bridge is a **native-wrapper**, not a pure-Dart reimplementation: the published native iOS/Android Ping SDKs do the real work (auth flow orchestration, token handling, secure storage), and the Flutter side is a thin, type-safe layer on top, generated with [Pigeon](https://pub.dev/packages/pigeon). + +Two things make this different from a typical single-package Flutter plugin: + +- **Federated split** — `ping_core` carries a shared native registry with no Pigeon API of its own; `ping_journey` (and future `ping_davinci`, `ping_oidc`, ...) each carry their own Pigeon `HostApi` and depend on `ping_core` for the shared registry. +- **Native handle registry** — live native SDK objects (a `Journey`, a `ContinueNode`) never cross the platform channel. They stay in a process-wide native singleton (`CoreRuntime`), keyed by a UUID. Only that id, plus a flat serialized snapshot of the node, crosses to Dart. + +```mermaid +graph TD + subgraph Dart + App[Sample app] + JC[JourneyClient] + Msg["messages.g.dart
(Pigeon-generated)"] + end + + subgraph "Native (Kotlin / Swift)" + HostApi["PingJourneyHostApi impl"] + Mapper["Node / callback mapper"] + Registry["CoreRuntime registry
(ping_core)"] + SDK["Native Ping SDK
(Journey / Orchestrate)"] + end + + App --> JC --> Msg + Msg <-->|Pigeon platform channel| HostApi + HostApi --> Mapper + HostApi --> Registry + Mapper --> Registry + HostApi --> SDK + Registry -.->|keyed by journeyId| SDK +``` + +Each turn of a journey follows the same round trip: Dart calls `next()` with the values the user entered, +native resolves the cached node from the registry, applies the values to the real SDK callbacks, advances +the flow, and serializes whatever comes back into a flat `NodeMessage`. + +```mermaid +sequenceDiagram + participant UI as Sample app (Dart) + participant Bridge as ping_journey (native) + participant Reg as CoreRuntime registry + participant SDK as Native Ping SDK + + UI->>Bridge: start(journeyId, journeyName) + Bridge->>SDK: start journey + SDK-->>Bridge: ContinueNode (callbacks) + Bridge->>Reg: store node, keyed by journeyId + Bridge-->>UI: NodeMessage { type: continue, callbacks } + + UI->>Bridge: next(journeyId, values) + Bridge->>Reg: look up cached node + Bridge->>SDK: apply values, advance + SDK-->>Bridge: SuccessNode | ContinueNode | ErrorNode + Bridge-->>UI: NodeMessage +``` + +Because nodes and callbacks cross the channel as flat, tagged messages (`{ type, index, ... }`), the Dart +side re-inflates them into a `sealed class` hierarchy and dispatches over it with exhaustive switch +expressions — the UI never has to know how a callback was represented on the wire. + +## Native SDK version + +Both platforms pin the native Ping SDKs to **2.0.0** — Android via Maven +(`com.pingidentity.sdks:*`), iOS via Swift Package Manager +(`github.com/ForgeRock/ping-ios-sdk`, exact `2.0.0`). + +## Adding new modules (e.g. `ping_davinci`, `ping_oidc`) + +1. Scaffold a new federated plugin (`flutter create --template=plugin ...`) alongside `ping_journey`, named `ping_`. +2. Depend on `ping_core` at both levels — Dart (`pubspec.yaml` path dependency), Android (`implementation project(':ping_core')`), iOS SPM (a local `Package.swift` path dependency). +3. Register a module-specific `NativeHandle` in `CoreRuntime` if the module needs to share live native handles with another module (add a new registry slot to `CoreRuntime`, mirroring `journeyRegistry`). +4. Author a Pigeon schema under `ping_/pigeons/messages.dart`. Message type names must not use the literal `Pigeon` prefix — Pigeon reserves it for its own generated helpers; this repo's convention is a `*Message` suffix instead (see `ping_journey`'s schema). +5. Regenerate with `dart run pigeon --input pigeons/messages.dart` from the new package's directory, and check in the generated `.g.*` files. +6. Add the new package to the workspace root `pubspec.yaml`'s `workspace:` list. + +## License + +This software may be modified and distributed under the terms of the MIT license. See the LICENSE file for details. + +© Copyright 2026 Ping Identity Corporation. All rights reserved. diff --git a/flutter/flutter-sdk-bridge/ping_core/.gitignore b/flutter/flutter-sdk-bridge/ping_core/.gitignore new file mode 100644 index 00000000..b9d7f25b --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/flutter/flutter-sdk-bridge/ping_core/.metadata b/flutter/flutter-sdk-bridge/ping_core/.metadata new file mode 100644 index 00000000..274c4057 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: android + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: ios + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/flutter/flutter-sdk-bridge/ping_core/CHANGELOG.md b/flutter/flutter-sdk-bridge/ping_core/CHANGELOG.md new file mode 100644 index 00000000..704b61dd --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/CHANGELOG.md @@ -0,0 +1,4 @@ +## 0.0.1 + +#### Added +- Added `ping_core`, a federated native plugin carrying the shared `CoreRuntime` handle registry, `PingException`, and JSON codec helpers used by every bridge module [SDKS-4613] diff --git a/flutter/flutter-sdk-bridge/ping_core/README.md b/flutter/flutter-sdk-bridge/ping_core/README.md new file mode 100644 index 00000000..b8269ae6 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/README.md @@ -0,0 +1,83 @@ +[![Ping Identity](https://www.pingidentity.com/content/dam/picr/nav/Ping-Logo-2.svg)](https://github.com/ForgeRock/sdk-sample-apps) + +# ping_core + +Shared native runtime for the Flutter Ping SDK bridge — see [`../README.md`](../README.md) for the +bridge overview and its architecture diagrams. + +This is a federated Flutter plugin with **no Pigeon `HostApi`**. It exposes no callable Dart methods of +its own — instead it exists purely so every SDK module plugin (`ping_journey`, and future +`ping_davinci`/`ping_oidc`) can share one process-wide native registry (`CoreRuntime`) of live SDK +handles, keyed by UUID string. The live objects themselves (a `Journey`, a `ContinueNode`, ...) never +cross the platform channel — only their generated id does. + +## Why a plugin, not a plain Dart package + +`CoreRuntime` has to be a genuine **native** singleton — one instance per process, reachable from +Kotlin/Swift code in every module plugin that links against it. A pure-Dart package can't provide that: +each platform's native SDK objects are created and consumed entirely on the native side, so the registry +holding them has to live there too. Dart never sees a `Journey` or `ContinueNode` instance directly; it +only ever sees the UUID key. + +## What's inside + +- **Dart** (`lib/src/`): `ping_exception.dart` — `PingException`, a typed exception that wraps a native + failure (code + message) so callers don't have to catch platform-specific `PlatformException`; + `json_codec.dart` — small shared JSON encode/decode helpers used by every module's message layer. +- **Android** (`android/.../flutter/core`, namespace `com.pingidentity.flutter.core`): + - `CoreRuntime` — a Kotlin `object` (JVM singleton), the single entry point dependents reach for a + registry instance. + - `Registry` / `SimpleRegistry` / `NativeHandle` — a minimal keyed store: `put`/`get`/`remove` by UUID + string, generic over the handle type each module stores in it. + - `PingCorePlugin` — the `FlutterPlugin` implementation; registers nothing on the Pigeon side, just + makes the package a loadable native plugin so Gradle/CocoaPods/SPM wire it into the app. +- **iOS** (`ios/ping_core`, Swift Package Manager): the same shapes, Swift-idiomatic — + - `CoreRuntime` — a Swift `enum` with `static let` storage (no instances, same "one native singleton" + role as the Kotlin `object`). + - `Registry` — **actor-isolated**, since Swift concurrency doesn't give a JVM-style single-classloader + guarantee for free; the actor serializes access to the underlying dictionary instead. + - `SimpleRegistry`, `NativeHandle`, `PingCorePlugin` — mirror the Android shapes. + +## How a module plugin depends on this one + +A Dart-level dependency (`pubspec.yaml`) does **not** by itself create a native one — each platform needs +an explicit native link so the dependent's native code can actually call into `CoreRuntime` and share the +*same* singleton instance, not a second copy: + +- **Android**: the dependent's `android/build.gradle` adds `implementation project(':ping_core')`. Both + plugins load in one classloader, so `CoreRuntime` resolves to the same JVM object. +- **iOS (SPM)**: the dependent's `Package.swift` adds `.package(name: "ping_core", path: + "../../ping_core/ios/ping_core")` and depends on its product. Both plugin packages link the same + compiled `ping_core` module, so `CoreRuntime`'s `static let` storage is shared. + +```mermaid +graph LR + subgraph Core["ping_core"] + CR["CoreRuntime (singleton)"] + Reg1["journey registry"] + Reg2["davinci registry (future)"] + CR --> Reg1 + CR --> Reg2 + end + + PJ["ping_journey native code"] -->|native dependency| CR + PD["ping_davinci (future)"] -.-> CR +``` + +Each module owns its **own registry slot** inside `CoreRuntime` (e.g. a `journeyRegistry` for +`ping_journey`) rather than sharing one generic store — that keeps one module's handle lifecycle +(creation, lookup, disposal) from leaking into another's. + +## Adding a new registry slot + +When a new module plugin needs to keep a live native handle around between Pigeon calls (the way +`ping_journey` keeps a `Journey`/`ContinueNode` per `journeyId`), add a new named `Registry` instance to +`CoreRuntime` on both platforms — don't reuse an existing module's slot, and don't route unrelated handle +types through the same registry. See `ping_journey`'s `journeyRegistry` usage in `JourneyHostApiImpl` / +`JourneyClientFactory` for the pattern to copy. + +## License + +This software may be modified and distributed under the terms of the MIT license. See the LICENSE file for details. + +© Copyright 2026 Ping Identity Corporation. All rights reserved. \ No newline at end of file diff --git a/flutter/flutter-sdk-bridge/ping_core/analysis_options.yaml b/flutter/flutter-sdk-bridge/ping_core/analysis_options.yaml new file mode 100644 index 00000000..6ca0a3d7 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/analysis_options.yaml @@ -0,0 +1,10 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options + +linter: + rules: + # This package is the redistributable SDK surface (not app code); enforce + # docs on every public class/method so coverage can't silently regress. + public_member_api_docs: true diff --git a/flutter/flutter-sdk-bridge/ping_core/android/.gitignore b/flutter/flutter-sdk-bridge/ping_core/android/.gitignore new file mode 100644 index 00000000..161bdcda --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/flutter/flutter-sdk-bridge/ping_core/android/build.gradle.kts b/flutter/flutter-sdk-bridge/ping_core/android/build.gradle.kts new file mode 100644 index 00000000..e1650792 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/android/build.gradle.kts @@ -0,0 +1,77 @@ +group = "com.pingidentity.flutter.core" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.3.20" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:9.0.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") +} + +android { + namespace = "com.pingidentity.flutter.core" + + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/kotlin") + } + getByName("test") { + java.srcDirs("src/test/kotlin") + } + } + + defaultConfig { + minSdk = 29 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + + it.outputs.upToDateWhen { false } + + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/flutter/flutter-sdk-bridge/ping_core/android/settings.gradle.kts b/flutter/flutter-sdk-bridge/ping_core/android/settings.gradle.kts new file mode 100644 index 00000000..4660df86 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "ping_core" diff --git a/flutter/flutter-sdk-bridge/ping_core/android/src/main/AndroidManifest.xml b/flutter/flutter-sdk-bridge/ping_core/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..83c451b3 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/CoreRuntime.kt b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/CoreRuntime.kt new file mode 100644 index 00000000..16ce34c6 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/CoreRuntime.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.pingidentity.flutter.core + +import com.pingidentity.flutter.core.registry.Registry +import com.pingidentity.flutter.core.registry.SimpleRegistry + +/** Resolves callbacks for a given Journey id, invoked across the Flutter bridge boundary. */ +public typealias JourneyCallbackResolver = suspend (String) -> List? + +/** + * Central place to hold process-wide registries and shared helpers used by the core module. + * + * Keeps native handles alive across calls from the Flutter bridge. Every future native module + * (oidc, davinci, storage, ...) shares this same object, so it must live in `ping_core` rather + * than in any single feature plugin. + */ +object CoreRuntime { + /** Registry for Journey client instances. */ + val journeyRegistry: Registry = SimpleRegistry() + + /** Resolver that exposes Journey callbacks to other packages; set by `ping_journey`. */ + @Volatile + var journeyCallbackResolver: JourneyCallbackResolver? = null + + /** + * Resolves callbacks for the provided Journey id via the registered resolver. + */ + suspend fun resolveJourneyCallbacks(journeyId: String): List? = + journeyCallbackResolver?.invoke(journeyId) +} diff --git a/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/PingCorePlugin.kt b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/PingCorePlugin.kt new file mode 100644 index 00000000..87ff481d --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/PingCorePlugin.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.pingidentity.flutter.core + +import io.flutter.embedding.engine.plugins.FlutterPlugin + +/** + * Trivial [FlutterPlugin] registration so the `ping_core` module loads and links + * [CoreRuntime] into the app's classloader. Carries no Pigeon/method channel of its own — + * see DESIGN.md for why the registry lives here instead of in `ping_journey`. + */ +class PingCorePlugin : FlutterPlugin { + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {} + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {} +} diff --git a/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/NativeHandle.kt b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/NativeHandle.kt new file mode 100644 index 00000000..335e69b0 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/NativeHandle.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.flutter.core.registry + +/** + * Marker interface for native objects that can be tracked in a [Registry]. + * + * Implementations represent platform resources that should be resolvable by id. + */ +interface NativeHandle diff --git a/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/Registry.kt b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/Registry.kt new file mode 100644 index 00000000..55237506 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/Registry.kt @@ -0,0 +1,37 @@ +/* + * 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.flutter.core.registry + +/** + * A lightweight registry for tracking native instances by generated identifiers. + * + * Implementations are responsible for generating stable ids, storing handles, + * and cleaning up when entries are removed. + */ +interface Registry { + /** + * Register a native instance and return the generated id that can be used to + * retrieve it later. + */ + fun register(instance: NativeHandle): String + + /** + * Look up a previously registered instance by id. Returns null if no match is found. + */ + fun resolve(id: String): NativeHandle? + + /** + * Remove a registered instance by id. Safe to call with unknown ids. + */ + fun remove(id: String) + + /** + * Remove all registered instances. + */ + fun removeAll() +} diff --git a/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/SimpleRegistry.kt b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/SimpleRegistry.kt new file mode 100644 index 00000000..ae2c5b1a --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/SimpleRegistry.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.pingidentity.flutter.core.registry + +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * In-memory, thread-safe [Registry] backed by a [ConcurrentHashMap]. + * + * Entries are keyed by generated UUID strings. + */ +class SimpleRegistry : Registry { + private val map = ConcurrentHashMap() + + override fun register(instance: NativeHandle): String { + val id = UUID.randomUUID().toString() + map[id] = instance + return id + } + + override fun resolve(id: String): NativeHandle? = map[id] + + override fun remove(id: String) { + map.remove(id) + } + + override fun removeAll() { + map.clear() + } +} diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/.gitignore b/flutter/flutter-sdk-bridge/ping_core/ios/.gitignore new file mode 100644 index 00000000..034771fc --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core.podspec b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core.podspec new file mode 100644 index 00000000..70f0600a --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core.podspec @@ -0,0 +1,30 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint ping_core.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'ping_core' + s.version = '0.0.1' + s.summary = 'Shared runtime for the Ping Flutter SDK bridge.' + s.description = <<-DESC +Shared runtime for the Ping Flutter SDK bridge: process-wide native handle +registries, JSON codec, and exception types used by feature plugins. + DESC + s.homepage = 'https://github.com/ForgeRock/ping-ios-sdk' + s.license = { :file => '../LICENSE' } + s.author = { 'Ping Identity' => 'oss@pingidentity.com' } + s.source = { :path => '.' } + s.source_files = 'ping_core/Sources/ping_core/**/*' + s.dependency 'Flutter' + s.platform = :ios, '16.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + # If your plugin requires a privacy manifest, for example if it uses any + # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your + # plugin's privacy impact, and then uncomment this line. For more information, + # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + # s.resource_bundles = {'ping_core_privacy' => ['ping_core/Sources/ping_core/PrivacyInfo.xcprivacy']} +end diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Package.swift b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Package.swift new file mode 100644 index 00000000..7f0f3acf --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ping_core", + platforms: [ + .iOS(.v16) + ], + products: [ + .library(name: "ping-core", targets: ["ping_core"]) + ], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework") + ], + targets: [ + .target( + name: "ping_core", + dependencies: [ + .product(name: "FlutterFramework", package: "FlutterFramework") + ], + resources: [ + // If your plugin requires a privacy manifest, for example if it uses any required + // reason APIs, update the PrivacyInfo.xcprivacy file to describe your plugin's + // privacy impact, and then uncomment these lines. For more information, see + // https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + // .process("PrivacyInfo.xcprivacy"), + + // If you have other resources that need to be bundled with your plugin, refer to + // the following instructions to add them: + // https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package + ] + ) + ] +) diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/CoreRuntime.swift b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/CoreRuntime.swift new file mode 100644 index 00000000..b2f0b28e --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/CoreRuntime.swift @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import Foundation + +/// A closure that returns the callbacks for a running Journey instance. +public typealias JourneyCallbackResolver = @Sendable (String) async -> [Any]? + +/// Thread-safe storage for the optional Journey callback resolver. +/// +/// - Note: `@unchecked Sendable` is used because this class stores a mutable +/// closure reference that Swift cannot prove sendable. Access is serialized +/// with `NSLock`, so cross-thread mutation/read is synchronized. +private final class JourneyCallbackResolverStore: @unchecked Sendable { + private let lock = NSLock() + private var resolver: JourneyCallbackResolver? + + func set(_ resolver: JourneyCallbackResolver?) { + lock.lock() + self.resolver = resolver + lock.unlock() + } + + func get() -> JourneyCallbackResolver? { + lock.lock() + let current = resolver + lock.unlock() + return current + } +} + +/// Central place to hold process-wide registries used by the core module. +/// +/// Keeps native handles alive across calls from the Flutter bridge. Every future native module +/// (oidc, davinci, storage, ...) shares this same type, so it must live in `ping_core` rather +/// than in any single feature plugin. +public enum CoreRuntime { + /// Registry for Journey client instances. + public static let journeyRegistry: Registry = SimpleRegistry() + + /// Internal resolver store used to avoid shared mutable global state. + private static let journeyCallbackResolverStore = JourneyCallbackResolverStore() + + /// Registers or clears the resolver that exposes Journey callbacks to other packages. + /// + /// - Parameter resolver: Resolver closure to register, or `nil` to clear. + public static func setJourneyCallbackResolver(_ resolver: JourneyCallbackResolver?) { + journeyCallbackResolverStore.set(resolver) + } + + /// Resolves Journey callbacks for the given journey instance via the registered resolver. + public static func resolveJourneyCallbacks( + _ journeyId: String + ) async -> [Any]? { + guard let resolver = journeyCallbackResolverStore.get() else { + return nil + } + return await resolver(journeyId) + } +} diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/NativeHandle.swift b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/NativeHandle.swift new file mode 100644 index 00000000..92552072 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/NativeHandle.swift @@ -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. + */ + +import Foundation + +/// Marker protocol for native objects that can be tracked in a `Registry`. +/// +/// Implementations represent platform resources that should be resolvable by id. +/// +/// - Important: Conforming reference types that include mutable or non-Sendable +/// state should declare `@unchecked Sendable` and provide their own +/// synchronization guarantees. +public protocol NativeHandle: AnyObject, Sendable {} diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/PingCorePlugin.swift b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/PingCorePlugin.swift new file mode 100644 index 00000000..640ede03 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/PingCorePlugin.swift @@ -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. + */ + +import Flutter +import UIKit + +/// Trivial `FlutterPlugin` registration so the `ping_core` module loads and links +/// `CoreRuntime` into the app binary. Carries no Pigeon/method channel of its own — +/// see DESIGN.md for why the registry lives here instead of in `ping_journey`. +public class PingCorePlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) {} +} diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/PrivacyInfo.xcprivacy b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..a34b7e2e --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/Registry.swift b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/Registry.swift new file mode 100644 index 00000000..4e6ff848 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/Registry.swift @@ -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. + */ + +import Foundation + +/// Lightweight registry for tracking native instances by generated identifiers. +/// +/// Implementations generate stable ids, store handles, +/// and clean up when entries are removed. +public protocol Registry: Sendable { + /// Register a native instance and return the generated id that can be used to retrieve it later. + func register(_ instance: NativeHandle) async -> String + + /// Look up a previously registered instance by id. Returns `nil` if no match is found. + func resolve(_ id: String) async -> NativeHandle? + + /// Remove a registered instance by id. Safe to call with unknown ids. + func remove(_ id: String) async + + /// Remove all registered instances. + func removeAll() async +} diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/SimpleRegistry.swift b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/SimpleRegistry.swift new file mode 100644 index 00000000..3ee5f308 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/SimpleRegistry.swift @@ -0,0 +1,40 @@ +/* + * 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 Foundation + +/// In-memory actor-isolated `Registry` backed by a dictionary. +/// +/// Entries are keyed by generated UUID strings and actor isolation +/// keeps mutations safe across concurrent calls. +public actor SimpleRegistry: Registry { + private var map: [String: NativeHandle] = [:] + + public init() {} + + /// Store the handle and return a fresh UUID key. + public func register(_ instance: NativeHandle) async -> String { + let id = UUID().uuidString + map[id] = instance + return id + } + + /// Retrieve a handle by id, or `nil` when missing. + public func resolve(_ id: String) async -> NativeHandle? { + map[id] + } + + /// Remove a handle by id; no-op when it does not exist. + public func remove(_ id: String) async { + map.removeValue(forKey: id) + } + + /// Clear all stored handles. + public func removeAll() async { + map.removeAll() + } +} diff --git a/flutter/flutter-sdk-bridge/ping_core/lib/ping_core.dart b/flutter/flutter-sdk-bridge/ping_core/lib/ping_core.dart new file mode 100644 index 00000000..85095a15 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/lib/ping_core.dart @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export 'src/json_codec.dart'; +export 'src/ping_exception.dart'; diff --git a/flutter/flutter-sdk-bridge/ping_core/lib/src/json_codec.dart b/flutter/flutter-sdk-bridge/ping_core/lib/src/json_codec.dart new file mode 100644 index 00000000..ad8cfe85 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/lib/src/json_codec.dart @@ -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. + */ + +/// Helpers for converting values received from the platform channel +/// (StandardMessageCodec-decoded `dynamic`) into plain `Map`. +/// +/// Platform channels decode nested structures as `Map` / +/// `List`; these helpers normalize that into JSON-shaped Dart values. +class JsonCodec { + const JsonCodec._(); + + /// Convert a channel-decoded map into a `Map`, recursively + /// normalizing nested maps and lists. + static Map decodeObject(Map value) { + final result = {}; + for (final entry in value.entries) { + result[entry.key as String] = decodeElement(entry.value); + } + return result; + } + + /// Normalize a single channel-decoded value. + static Object? decodeElement(Object? element) { + if (element == null) return null; + if (element is Map) return decodeObject(element); + if (element is List) return decodeArray(element); + return element; + } + + /// Normalize a channel-decoded list, recursively normalizing elements. + static List decodeArray(List value) { + return value.map(decodeElement).toList(); + } +} diff --git a/flutter/flutter-sdk-bridge/ping_core/lib/src/ping_exception.dart b/flutter/flutter-sdk-bridge/ping_core/lib/src/ping_exception.dart new file mode 100644 index 00000000..449f2224 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/lib/src/ping_exception.dart @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +/// Typed exception surfaced to Dart callers for any native-side failure. +/// +/// Carries a stable [code], a [type] classification, and a human-readable +/// [message]. +class PingException implements Exception { + /// Stable, native-side error code (e.g. `JOURNEY_START_ERROR`). + final String code; + + /// Coarse-grained classification of [code] (e.g. `state`, `argument`, `network`). + final String type; + + /// Human-readable description of the failure. + final String message; + + /// Creates a [PingException] with the given [code], [type], and [message]. + const PingException(this.code, this.type, this.message); + + @override + String toString() => + 'PingException(code: $code, type: $type, message: $message)'; +} diff --git a/flutter/flutter-sdk-bridge/ping_core/pubspec.yaml b/flutter/flutter-sdk-bridge/ping_core/pubspec.yaml new file mode 100644 index 00000000..b516e33f --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/pubspec.yaml @@ -0,0 +1,74 @@ +name: ping_core +description: "Shared runtime for the Ping Flutter SDK bridge: process-wide native handle registries, JSON codec, and exception types used by feature plugins (ping_journey, ...)." +version: 0.0.1 +homepage: +publish_to: none + +environment: + sdk: ^3.12.2 + flutter: '>=3.3.0' + +resolution: workspace + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.pingidentity.flutter.core + pluginClass: PingCorePlugin + ios: + pluginClass: PingCorePlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/flutter/flutter-sdk-bridge/ping_core/test/src/json_codec_test.dart b/flutter/flutter-sdk-bridge/ping_core/test/src/json_codec_test.dart new file mode 100644 index 00000000..7fb6d5b1 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/test/src/json_codec_test.dart @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ping_core/ping_core.dart'; + +void main() { + group('JsonCodec.decodeElement', () { + test('passes through primitives unchanged', () { + expect(JsonCodec.decodeElement(null), isNull); + expect(JsonCodec.decodeElement('a string'), 'a string'); + expect(JsonCodec.decodeElement(42), 42); + expect(JsonCodec.decodeElement(true), true); + }); + + test('normalizes a nested Map', () { + final result = JsonCodec.decodeElement({ + 'key': 'value', + }); + expect(result, isA>()); + expect(result, {'key': 'value'}); + }); + + test('normalizes a nested List', () { + final result = JsonCodec.decodeElement([1, 2, 3]); + expect(result, isA>()); + expect(result, [1, 2, 3]); + }); + }); + + group('JsonCodec.decodeObject', () { + test('converts a flat Map to Map', () { + final result = JsonCodec.decodeObject({ + 'name': 'Alice', + 'age': 30, + }); + expect(result, isA>()); + expect(result, {'name': 'Alice', 'age': 30}); + }); + + test('recursively normalizes nested maps', () { + final result = JsonCodec.decodeObject({ + 'user': {'name': 'Alice', 'age': 30}, + }); + expect(result['user'], isA>()); + expect(result['user'], {'name': 'Alice', 'age': 30}); + }); + + test('recursively normalizes lists of maps', () { + final result = JsonCodec.decodeObject({ + 'items': [ + {'id': 1}, + {'id': 2}, + ], + }); + final items = result['items'] as List; + expect(items, hasLength(2)); + expect(items[0], isA>()); + expect(items[0], {'id': 1}); + expect(items[1], {'id': 2}); + }); + + test('recursively normalizes maps nested inside lists nested inside maps', () { + final result = JsonCodec.decodeObject({ + 'groups': [ + { + 'members': [ + {'name': 'Bob'}, + ], + }, + ], + }); + final groups = result['groups'] as List; + final firstGroup = groups[0] as Map; + final members = firstGroup['members'] as List; + expect(members[0], isA>()); + expect(members[0], {'name': 'Bob'}); + }); + + test('preserves null values', () { + final result = JsonCodec.decodeObject({ + 'value': null, + }); + expect(result['value'], isNull); + expect(result.containsKey('value'), isTrue); + }); + + test('handles an empty map', () { + expect(JsonCodec.decodeObject({}), {}); + }); + }); + + group('JsonCodec.decodeArray', () { + test('returns a plain list unchanged for primitives', () { + expect(JsonCodec.decodeArray(['a', 1, true, null]), [ + 'a', + 1, + true, + null, + ]); + }); + + test('recursively normalizes maps within the list', () { + final result = JsonCodec.decodeArray([ + {'key': 'value'}, + ]); + expect(result[0], isA>()); + expect(result[0], {'key': 'value'}); + }); + + test('handles an empty list', () { + expect(JsonCodec.decodeArray([]), []); + }); + }); +} diff --git a/flutter/flutter-sdk-bridge/ping_core/test/src/ping_exception_test.dart b/flutter/flutter-sdk-bridge/ping_core/test/src/ping_exception_test.dart new file mode 100644 index 00000000..c600fc28 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_core/test/src/ping_exception_test.dart @@ -0,0 +1,37 @@ +/* + * 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 'package:flutter_test/flutter_test.dart'; +import 'package:ping_core/ping_core.dart'; + +void main() { + group('PingException', () { + test('exposes code, type, and message from the constructor', () { + const exception = PingException( + 'JOURNEY_START_ERROR', + 'state', + 'No active ContinueNode', + ); + expect(exception.code, 'JOURNEY_START_ERROR'); + expect(exception.type, 'state'); + expect(exception.message, 'No active ContinueNode'); + }); + + test('toString includes code, type, and message', () { + const exception = PingException('CODE', 'type', 'message'); + expect( + exception.toString(), + 'PingException(code: CODE, type: type, message: message)', + ); + }); + + test('implements Exception', () { + const exception = PingException('CODE', 'type', 'message'); + expect(exception, isA()); + }); + }); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/.gitignore b/flutter/flutter-sdk-bridge/ping_journey/.gitignore new file mode 100644 index 00000000..b9d7f25b --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/flutter/flutter-sdk-bridge/ping_journey/.metadata b/flutter/flutter-sdk-bridge/ping_journey/.metadata new file mode 100644 index 00000000..274c4057 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: android + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: ios + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/flutter/flutter-sdk-bridge/ping_journey/CHANGELOG.md b/flutter/flutter-sdk-bridge/ping_journey/CHANGELOG.md new file mode 100644 index 00000000..33fc1fdb --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/CHANGELOG.md @@ -0,0 +1,6 @@ +## 0.0.1 + +#### Added +- Added `ping_journey`, a Pigeon-generated bridge over the native Ping Journey SDK: `configureJourney`, `start`, `next`, `getSession`, `signOff`, and `dispose` [SDKS-4613] +- Added Dart sealed types for `JourneyNode` (`ContinueNode`/`SuccessNode`/`ErrorNode`/`FailureNode`) and the v1 callback set (`NameCallback`, `PasswordCallback`, `ValidatedUsernameCallback`, `ValidatedPasswordCallback`, `ChoiceCallback`, `KbaCreateCallback`, `TermsAndConditionsCallback`, `TextInputCallback`, `TextOutputCallback`, `StringAttributeInputCallback`, `NumberAttributeInputCallback`, `BooleanAttributeInputCallback`) [SDKS-4613] +- Pinned both platforms to native Ping SDK `2.0.0` (Android Maven, iOS SPM) diff --git a/flutter/flutter-sdk-bridge/ping_journey/README.md b/flutter/flutter-sdk-bridge/ping_journey/README.md new file mode 100644 index 00000000..0a910f8b --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/README.md @@ -0,0 +1,89 @@ +[![Ping Identity](https://www.pingidentity.com/content/dam/picr/nav/Ping-Logo-2.svg)](https://github.com/ForgeRock/sdk-sample-apps) + +# ping_journey + +Journey bridge plugin for the Flutter Ping SDK bridge — see [`../README.md`](../README.md) for the bridge overview and its architecture diagrams. + +Wraps the native Ping Journey SDK (Android `com.pingidentity.sdks:journey`, iOS SPM `PingJourney`) behind a Pigeon-generated `HostApi`, exposing: + +- `JourneyClient.configure(config)` / `.start(name)` — build a native `Journey` and start it. +- `.next(node)` — submit callback values and advance the flow. +- `.user()` / `.signOff()` / `.dispose()` — session/token retrieval, sign-off, cleanup. + +Depends on `ping_core` (for the shared `CoreRuntime` registry) at both the Dart and native levels. + +## Why a federated plugin, not part of `ping_core` + +`ping_journey` carries the one thing `ping_core` deliberately does *not*: a Pigeon `@HostApi` and the native Ping Journey SDK dependency itself. Splitting them means a future `ping_davinci` or `ping_oidc` can depend on `ping_core`'s registry without also pulling in the Journey SDK — each module plugin owns its own wire schema and its own native SDK dependency, and only shares the registry mechanism. + +## Wire schema (`pigeons/messages.dart`) + +The Pigeon schema is the source of truth for everything crossing the channel — it's a flat, serializable, `@async` HostApi (Pigeon binds one channel at plugin registration). + +- `JourneyConfigMessage` — server URL, realm, cookie name, timeout, OIDC fields (client id, discovery endpoint, redirect URI, scopes). +- `StartOptionsMessage` — `forceAuth`, `noSession`. +- `CallbackMessage` — a flat union of every v1 callback's fields (`type`, `index`, `prompt`, `value`, `choices`, `terms`, ...) plus a `raw` escape hatch for anything not modeled explicitly. +- `CallbackValueMessage` — the Dart→native direction: `{ type, index, value }`, addressing the specific callback a value belongs to. +- `NodeMessage` — `{ type: continue|success|error|failure, message?, cause?, callbacks?, ... }`. +- `SessionMessage` — `{ accessToken, refreshToken?, expiresIn, userInfo? }`. +- `PingJourneyHostApi` (`@HostApi`, all methods `@async`) — `configureJourney`, `start`, `next`, `getSession`, `signOff`, `dispose`. + +Message type names use a `*Message` suffix (`NodeMessage`, not `PingNode`) — Pigeon reserves the literal `Pigeon` prefix for its own generated helpers and refuses to codegen user types that collide with it. + +After editing the schema, regenerate from this directory and check in the generated files: + +```sh +dart run pigeon --input pigeons/messages.dart +``` + +## Node / callback mapping + +Native and Dart never share live objects — only the flat messages above. `node_mapper` (native side) and its Dart counterpart translate between the real SDK's node/callback types and the wire format: + +```mermaid +graph LR + SDKNode["Native SDK node (ContinueNode, callbacks)"] -->|NodeMapper| Msg["NodeMessage + CallbackMessage list"] + Msg -->|platform channel| DartMsg["NodeMessage (Dart, generated)"] + DartMsg -->|"node_mapper.dart"| Sealed["sealed JourneyNode + sealed Callback"] +``` + +On the way back, Dart addresses each answered callback by `{ type, index }` rather than sending a whole object back; native re-resolves that pair against the `ContinueNode` it already has cached in `CoreRuntime` and applies the value to the real callback (`JourneyCallbackValueApplier`) before calling the native SDK's own `next()`. + +## Dart public API + +The generated Pigeon code (`lib/src/messages.g.dart`) is intentionally low-level — flat messages, no sealed types. `ping_journey.dart` and the rest of `lib/src/` wrap it in the API the sample app actually uses: + +- `journey_node.dart` — `sealed class JourneyNode`: `ContinueNode(journeyId, callbacks, header?, description?, stage?)`, `SuccessNode`, `ErrorNode(message, status?)`, `FailureNode(cause)`. +- `callback/*.dart` — `sealed class Callback` and one subclass per v1 callback type (`NameCallback`, `PasswordCallback`, `ChoiceCallback`, `KbaCreateCallback`, `TermsAndConditionsCallback`, ...), each exposing a mutable field the UI sets and a `toValue()` that produces a `CallbackValueMessage` (`null` for output-only callbacks like `TextOutputCallback`). +- `journey_client.dart` — `JourneyClient`: `.configure(JourneyConfig)`, `.start(name, {forceAuth, noSession})`, `.next(ContinueNode)`, `.user()`, `.signOff()`, `.dispose()`. This is the type the sample app's repository layer depends on. +- `node_mapper.dart` — `NodeMessage` → sealed `JourneyNode` via a switch expression on `NodeType` (exhaustiveness checked by `dart analyze`), re-inflating each `CallbackMessage` into its typed `Callback` subclass. +- `callback_helpers.dart` — pure logic (no platform calls) deciding which widget a callback renders as and whether it's interactive or output-only. +- Native failures surface as `PlatformException` and are caught and rethrown as `ping_core`'s typed `PingException`, so callers never need to know a platform channel is involved. + +## Native implementation shape + +Both platforms follow the same layering, so a change on one side has an obvious counterpart on the other: + +| Concern | Android | iOS | +|---|---|---| +| Pigeon-generated types | `Messages.g.kt` | `Messages.g.swift` | +| Plugin registration | `PingJourneyPlugin.kt` | `PingJourneyPlugin.swift` | +| `HostApi` implementation | `JourneyHostApiImpl.kt` | `JourneyHostApiImpl.swift` | +| Build a native `Journey` from config | `JourneyClientFactory.kt` / `JourneyConfigParser.kt` | `JourneyClientFactory.swift` / `JourneyConfigParser.swift` | +| Node → wire message | `mapper/JourneyNodeMapper.kt` | `Mapper/JourneyNodeMapper.swift` | +| Wire values → callback | `callback/JourneyCallbackValueApplier.kt` | `Callback/JourneyCallbackValueApplier.swift` | +| Native error → wire error | `error/JourneyErrorMapper.kt` | `Error/JourneyErrorMapper.swift` | + +`ErrorNode.status` is present on iOS but has no Android equivalent, hence nullable on the wire — a platform difference in the native SDKs themselves, not a bridge limitation. + +## Build / dependency wiring + +- **Android**: `namespace 'com.pingidentity.flutter.journey'`, `minSdk 29`, `compileSdk 36`, Java 17; `implementation project(':ping_core')` + `implementation 'com.pingidentity.sdks:journey:2.0.0'` (pulls in orchestrate/oidc/storage transitively) + `kotlinx-coroutines-android`. +- **iOS (SPM)**: platform `.iOS(.v16)`; dependencies = local `ping_core` package + `.package(url: "https://github.com/ForgeRock/ping-ios-sdk", exact: "2.0.0")`; the target links the `PingJourney` product, which transitively pulls in `PingOrchestrate`/`PingOidc`. A CocoaPods podspec is kept as an optional fallback for host apps not yet on SPM. +- Native SDK version is pinned to **2.0.0** on both platforms — see [`../README.md`](../README.md). + +## License + +This software may be modified and distributed under the terms of the MIT license. See the LICENSE file for details. + +© Copyright 2026 Ping Identity Corporation. All rights reserved. \ No newline at end of file diff --git a/flutter/flutter-sdk-bridge/ping_journey/analysis_options.yaml b/flutter/flutter-sdk-bridge/ping_journey/analysis_options.yaml new file mode 100644 index 00000000..6ca0a3d7 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/analysis_options.yaml @@ -0,0 +1,10 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options + +linter: + rules: + # This package is the redistributable SDK surface (not app code); enforce + # docs on every public class/method so coverage can't silently regress. + public_member_api_docs: true diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/.gitignore b/flutter/flutter-sdk-bridge/ping_journey/android/.gitignore new file mode 100644 index 00000000..161bdcda --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/build.gradle.kts b/flutter/flutter-sdk-bridge/ping_journey/android/build.gradle.kts new file mode 100644 index 00000000..90c9ece9 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/build.gradle.kts @@ -0,0 +1,81 @@ +group = "com.pingidentity.flutter.journey" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.3.20" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:9.0.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") +} + +android { + namespace = "com.pingidentity.flutter.journey" + + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/kotlin") + } + getByName("test") { + java.srcDirs("src/test/kotlin") + } + } + + defaultConfig { + minSdk = 29 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + + it.outputs.upToDateWhen { false } + + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + implementation(project(":ping_core")) + implementation("com.pingidentity.sdks:journey:2.0.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0") + + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/settings.gradle.kts b/flutter/flutter-sdk-bridge/ping_journey/android/settings.gradle.kts new file mode 100644 index 00000000..9c805e33 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "ping_journey" diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/AndroidManifest.xml b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..fdbd3ea3 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyClientFactory.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyClientFactory.kt new file mode 100644 index 00000000..8510aff9 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyClientFactory.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.pingidentity.flutter.journey + +import com.pingidentity.flutter.core.CoreRuntime +import com.pingidentity.flutter.core.registry.NativeHandle +import com.pingidentity.journey.Journey + +/** + * Wraps a live native [Journey] so it can be stored in [CoreRuntime.journeyRegistry]. + * + * [hasOidc] records whether the [JourneyConfigMessage] this journey was built from actually + * configured an Oidc module — [Journey.user] unconditionally calls `oidcClient()` internally, + * which throws `IllegalStateException` when no Oidc module was registered, so callers must + * check this flag *before* touching `user()`/`token()` on a session-only Journey. + */ +internal class JourneyHandle(val journey: Journey, val hasOidc: Boolean) : NativeHandle + +/** + * Builds a native [Journey] from a [JourneyConfigMessage] and registers it in the shared + * core registry, returning the generated `journeyId`. + */ +internal object JourneyClientFactory { + fun create(config: JourneyConfigMessage): String { + val journey = JourneyConfigParser.parse(config) + return CoreRuntime.journeyRegistry.register( + JourneyHandle(journey, hasOidc = JourneyConfigParser.hasOidcConfig(config)) + ) + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyConfigParser.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyConfigParser.kt new file mode 100644 index 00000000..6378e58c --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyConfigParser.kt @@ -0,0 +1,57 @@ +/* + * 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.flutter.journey + +import com.pingidentity.journey.Journey +import com.pingidentity.journey.module.Oidc + +/** + * Builds a native [Journey] from the flat, wire-serializable [JourneyConfigMessage]. + * + * Trimmed to the fields the Pigeon schema carries (no storage/logger handle registries yet — + * those arrive with a future module). `realm`/`cookie` are left unset when absent so the native + * SDK's own defaults (`realm = "root"`, `cookie = "iPlanetDirectoryPro"`) apply. + */ +internal object JourneyConfigParser { + fun parse(config: JourneyConfigMessage): Journey = + Journey { + serverUrl = config.serverUrl + config.realm?.let { realm = it } + config.cookie?.let { cookie = it } + config.timeoutMillis?.let { timeout = it } + + if (hasOidcConfig(config)) { + module(Oidc) { + config.clientId?.let { clientId = it } + config.discoveryEndpoint?.let { discoveryEndpoint = it } + config.redirectUri?.let { redirectUri = it } + config.scopes?.let { scopes = it.filterNotNull().toMutableSet() } + config.acrValues?.let { acrValues = it } + config.signOutRedirectUri?.let { signOutRedirectUri = it } + config.state?.let { state = it } + config.nonce?.let { nonce = it } + config.uiLocales?.let { uiLocales = it } + config.refreshThreshold?.let { refreshThreshold = it } + config.loginHint?.let { loginHint = it } + config.display?.let { display = it } + config.prompt?.let { prompt = it } + config.additionalParameters?.let { params -> + additionalParameters = + params.entries.filter { it.key != null && it.value != null } + .associate { it.key!! to it.value!! } + } + } + } + } + + fun hasOidcConfig(config: JourneyConfigMessage): Boolean = + config.clientId != null || + config.discoveryEndpoint != null || + config.redirectUri != null || + config.scopes != null +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyHostApiImpl.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyHostApiImpl.kt new file mode 100644 index 00000000..041fb989 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyHostApiImpl.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.pingidentity.flutter.journey + +import com.pingidentity.flutter.core.CoreRuntime +import com.pingidentity.flutter.journey.callback.JourneyCallbackValueApplier +import com.pingidentity.flutter.journey.error.JourneyErrorCodes +import com.pingidentity.flutter.journey.error.JourneyErrorMapper +import com.pingidentity.flutter.journey.error.classifyError +import com.pingidentity.flutter.journey.mapper.JourneyNodeMapper +import com.pingidentity.flutter.journey.mapper.JsonBridgeMapper +import com.pingidentity.journey.start +import com.pingidentity.journey.user +import com.pingidentity.oidc.Token +import android.util.Log +import com.pingidentity.orchestrate.ContinueNode +import com.pingidentity.orchestrate.Node +import com.pingidentity.utils.Result as PingResult +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Implements the generated [PingJourneyHostApi]: `configureJourney`/`start` build the native + * `Journey` and cache the current node per `journeyId`; `next()` applies submitted callback + * values onto the cached [ContinueNode] and advances the flow. + */ +class JourneyHostApiImpl : PingJourneyHostApi { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** Most recent [Node] per journeyId. */ + private val nodeMap = ConcurrentHashMap() + + /** Most recent [ContinueNode] per journeyId, for future callback re-resolution. */ + private val continueNodeMap = ConcurrentHashMap() + + override fun configureJourney( + config: JourneyConfigMessage, + callback: (Result) -> Unit + ) { + callback( + runCatching { JourneyClientFactory.create(config) } + .classifyError(JourneyErrorCodes.CONFIGURE) + ) + } + + override fun start( + journeyId: String, + name: String, + options: StartOptionsMessage, + callback: (Result) -> Unit + ) { + scope.launch { + val result = runCatching { + val journey = resolveJourney(journeyId) + val node = journey.start(name) { + forceAuth = options.forceAuth + noSession = options.noSession + } + setNode(journeyId, node) + JourneyNodeMapper.map(node) + } + callback(result.classifyError(JourneyErrorCodes.START)) + } + } + + override fun next( + journeyId: String, + values: List, + callback: (Result) -> Unit + ) { + scope.launch { + val result = runCatching { + val currentNode = + continueNodeMap[journeyId] + ?: throw IllegalStateException( + "No active ContinueNode found for journeyId=$journeyId" + ) + JourneyCallbackValueApplier.apply(currentNode, values.filterNotNull()) + val nextNode = currentNode.next() + setNode(journeyId, nextNode) + JourneyNodeMapper.map(nextNode) + } + callback(result.classifyError(JourneyErrorCodes.NEXT)) + } + } + + override fun getSession(journeyId: String, callback: (Result) -> Unit) { + scope.launch { + val result = runCatching { + val handle = resolveHandle(journeyId) + if (!handle.hasOidc) { + null + } else { + val user = handle.journey.user() + if (user == null) { + null + } else { + when (val tokenResult = user.token()) { + is PingResult.Success -> { + val token = tokenResult.value as? Token + ?: throw IllegalStateException("Invalid token payload type") + val userInfo = when (val uiResult = user.userinfo(false)) { + is PingResult.Success -> { + @Suppress("UNCHECKED_CAST") + JsonBridgeMapper.encodeJsonElement(uiResult.value) as? Map + } + is PingResult.Failure -> null + } + SessionMessage( + accessToken = token.accessToken, + refreshToken = token.refreshToken, + expiresIn = token.expiresIn, + userInfo = userInfo, + ) + } + is PingResult.Failure -> + throw JourneyErrorMapper.fromOidcError( + JourneyErrorCodes.GET_SESSION, + tokenResult.value + ) + } + } + } + } + callback(result.classifyError(JourneyErrorCodes.GET_SESSION)) + } + } + + override fun signOff(journeyId: String, callback: (Result) -> Unit) { + scope.launch { + val result = runCatching { + val handle = resolveHandle(journeyId) + if (handle.hasOidc) { + handle.journey.user()?.logout() + } + clearNodeState(journeyId) + true + } + callback(result.classifyError(JourneyErrorCodes.SIGN_OFF)) + } + } + + override fun dispose(journeyId: String, callback: (Result) -> Unit) { + callback( + runCatching { removeJourney(journeyId) }.classifyError(JourneyErrorCodes.DISPOSE) + ) + } + + private fun resolveJourney(journeyId: String) = resolveHandle(journeyId).journey + + private fun resolveHandle(journeyId: String): JourneyHandle = + (CoreRuntime.journeyRegistry.resolve(journeyId) as? JourneyHandle) + ?: throw IllegalStateException("Journey instance not found for id=$journeyId") + + private fun setNode(journeyId: String, node: Node) { + nodeMap[journeyId] = node + if (node is ContinueNode) { + continueNodeMap[journeyId] = node + } else { + continueNodeMap.remove(journeyId) + } + } + + private fun clearNodeState(journeyId: String) { + nodeMap.remove(journeyId) + continueNodeMap.remove(journeyId) + } + + private fun removeJourney(journeyId: String) { + continueNodeMap.remove(journeyId)?.let { node -> + runCatching { node.close() } + .onFailure { Log.w(TAG, "Failed to close ContinueNode for journeyId=$journeyId", it) } + } + nodeMap.remove(journeyId) + CoreRuntime.journeyRegistry.remove(journeyId) + } + + private companion object { + const val TAG = "JourneyHostApiImpl" + } + + /** Cancels all in-flight coroutines. Called from [PingJourneyPlugin.onDetachedFromEngine]. */ + fun shutdown() { + scope.cancel() + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/Messages.g.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/Messages.g.kt new file mode 100644 index 00000000..c7af8d35 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/Messages.g.kt @@ -0,0 +1,907 @@ +// 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. +// Autogenerated from Pigeon (v27.1.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package com.pingidentity.flutter.journey + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object MessagesPigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } + fun doubleEquals(a: Double, b: Double): Boolean { + // Normalize -0.0 to 0.0 and handle NaN equality. + return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN()) + } + + fun floatEquals(a: Float, b: Float): Boolean { + // Normalize -0.0 to 0.0 and handle NaN equality. + return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN()) + } + + fun doubleHash(d: Double): Int { + // Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes. + val normalized = if (d == 0.0) 0.0 else d + val bits = java.lang.Double.doubleToLongBits(normalized) + return (bits xor (bits ushr 32)).toInt() + } + + fun floatHash(f: Float): Int { + // Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes. + val normalized = if (f == 0.0f) 0.0f else f + return java.lang.Float.floatToIntBits(normalized) + } + + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a === b) { + return true + } + if (a == null || b == null) { + return false + } + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!doubleEquals(a[i], b[i])) return false + } + return true + } + if (a is FloatArray && b is FloatArray) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!floatEquals(a[i], b[i])) return false + } + return true + } + if (a is Array<*> && b is Array<*>) { + if (a.size != b.size) return false + for (i in a.indices) { + if (!deepEquals(a[i], b[i])) return false + } + return true + } + if (a is List<*> && b is List<*>) { + if (a.size != b.size) return false + val iterA = a.iterator() + val iterB = b.iterator() + while (iterA.hasNext() && iterB.hasNext()) { + if (!deepEquals(iterA.next(), iterB.next())) return false + } + return true + } + if (a is Map<*, *> && b is Map<*, *>) { + if (a.size != b.size) return false + for (entry in a) { + val key = entry.key + var found = false + for (bEntry in b) { + if (deepEquals(key, bEntry.key)) { + if (deepEquals(entry.value, bEntry.value)) { + found = true + break + } else { + return false + } + } + } + if (!found) return false + } + return true + } + if (a is Double && b is Double) { + return doubleEquals(a, b) + } + if (a is Float && b is Float) { + return floatEquals(a, b) + } + return a == b + } + + fun deepHash(value: Any?): Int { + return when (value) { + null -> 0 + is ByteArray -> value.contentHashCode() + is IntArray -> value.contentHashCode() + is LongArray -> value.contentHashCode() + is DoubleArray -> { + var result = 1 + for (item in value) { + result = 31 * result + doubleHash(item) + } + result + } + is FloatArray -> { + var result = 1 + for (item in value) { + result = 31 * result + floatHash(item) + } + result + } + is Array<*> -> { + var result = 1 + for (item in value) { + result = 31 * result + deepHash(item) + } + result + } + is List<*> -> { + var result = 1 + for (item in value) { + result = 31 * result + deepHash(item) + } + result + } + is Map<*, *> -> { + var result = 0 + for (entry in value) { + result += ((deepHash(entry.key) * 31) xor deepHash(entry.value)) + } + result + } + is Double -> doubleHash(value) + is Float -> floatHash(value) + else -> value.hashCode() + } + } + +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : RuntimeException() + +/** + * Node type tag mirroring the native `Node` sealed hierarchy + * (`ContinueNode`/`SuccessNode`/`ErrorNode`/`FailureNode`). + */ +enum class NodeType(val raw: Int) { + CONTINUE_NODE(0), + SUCCESS_NODE(1), + ERROR_NODE(2), + FAILURE_NODE(3); + + companion object { + fun ofRaw(raw: Int): NodeType? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** + * Flat, wire-serializable Journey configuration. OIDC fields are hoisted to + * the top level rather than nested, since Pigeon classes can't express an + * "OIDC configured only if any OIDC field is present" union cleanly. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class JourneyConfigMessage ( + val serverUrl: String, + val realm: String? = null, + val cookie: String? = null, + /** Milliseconds. */ + val timeoutMillis: Long? = null, + val clientId: String? = null, + val discoveryEndpoint: String? = null, + val redirectUri: String? = null, + val scopes: List? = null, + val acrValues: String? = null, + val signOutRedirectUri: String? = null, + val state: String? = null, + val nonce: String? = null, + val uiLocales: String? = null, + /** Seconds. */ + val refreshThreshold: Long? = null, + val loginHint: String? = null, + val display: String? = null, + val prompt: String? = null, + val additionalParameters: Map? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): JourneyConfigMessage { + val serverUrl = pigeonVar_list[0] as String + val realm = pigeonVar_list[1] as String? + val cookie = pigeonVar_list[2] as String? + val timeoutMillis = pigeonVar_list[3] as Long? + val clientId = pigeonVar_list[4] as String? + val discoveryEndpoint = pigeonVar_list[5] as String? + val redirectUri = pigeonVar_list[6] as String? + val scopes = pigeonVar_list[7] as List? + val acrValues = pigeonVar_list[8] as String? + val signOutRedirectUri = pigeonVar_list[9] as String? + val state = pigeonVar_list[10] as String? + val nonce = pigeonVar_list[11] as String? + val uiLocales = pigeonVar_list[12] as String? + val refreshThreshold = pigeonVar_list[13] as Long? + val loginHint = pigeonVar_list[14] as String? + val display = pigeonVar_list[15] as String? + val prompt = pigeonVar_list[16] as String? + val additionalParameters = pigeonVar_list[17] as Map? + return JourneyConfigMessage(serverUrl, realm, cookie, timeoutMillis, clientId, discoveryEndpoint, redirectUri, scopes, acrValues, signOutRedirectUri, state, nonce, uiLocales, refreshThreshold, loginHint, display, prompt, additionalParameters) + } + } + fun toList(): List { + return listOf( + serverUrl, + realm, + cookie, + timeoutMillis, + clientId, + discoveryEndpoint, + redirectUri, + scopes, + acrValues, + signOutRedirectUri, + state, + nonce, + uiLocales, + refreshThreshold, + loginHint, + display, + prompt, + additionalParameters, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as JourneyConfigMessage + return MessagesPigeonUtils.deepEquals(this.serverUrl, other.serverUrl) && MessagesPigeonUtils.deepEquals(this.realm, other.realm) && MessagesPigeonUtils.deepEquals(this.cookie, other.cookie) && MessagesPigeonUtils.deepEquals(this.timeoutMillis, other.timeoutMillis) && MessagesPigeonUtils.deepEquals(this.clientId, other.clientId) && MessagesPigeonUtils.deepEquals(this.discoveryEndpoint, other.discoveryEndpoint) && MessagesPigeonUtils.deepEquals(this.redirectUri, other.redirectUri) && MessagesPigeonUtils.deepEquals(this.scopes, other.scopes) && MessagesPigeonUtils.deepEquals(this.acrValues, other.acrValues) && MessagesPigeonUtils.deepEquals(this.signOutRedirectUri, other.signOutRedirectUri) && MessagesPigeonUtils.deepEquals(this.state, other.state) && MessagesPigeonUtils.deepEquals(this.nonce, other.nonce) && MessagesPigeonUtils.deepEquals(this.uiLocales, other.uiLocales) && MessagesPigeonUtils.deepEquals(this.refreshThreshold, other.refreshThreshold) && MessagesPigeonUtils.deepEquals(this.loginHint, other.loginHint) && MessagesPigeonUtils.deepEquals(this.display, other.display) && MessagesPigeonUtils.deepEquals(this.prompt, other.prompt) && MessagesPigeonUtils.deepEquals(this.additionalParameters, other.additionalParameters) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + MessagesPigeonUtils.deepHash(this.serverUrl) + result = 31 * result + MessagesPigeonUtils.deepHash(this.realm) + result = 31 * result + MessagesPigeonUtils.deepHash(this.cookie) + result = 31 * result + MessagesPigeonUtils.deepHash(this.timeoutMillis) + result = 31 * result + MessagesPigeonUtils.deepHash(this.clientId) + result = 31 * result + MessagesPigeonUtils.deepHash(this.discoveryEndpoint) + result = 31 * result + MessagesPigeonUtils.deepHash(this.redirectUri) + result = 31 * result + MessagesPigeonUtils.deepHash(this.scopes) + result = 31 * result + MessagesPigeonUtils.deepHash(this.acrValues) + result = 31 * result + MessagesPigeonUtils.deepHash(this.signOutRedirectUri) + result = 31 * result + MessagesPigeonUtils.deepHash(this.state) + result = 31 * result + MessagesPigeonUtils.deepHash(this.nonce) + result = 31 * result + MessagesPigeonUtils.deepHash(this.uiLocales) + result = 31 * result + MessagesPigeonUtils.deepHash(this.refreshThreshold) + result = 31 * result + MessagesPigeonUtils.deepHash(this.loginHint) + result = 31 * result + MessagesPigeonUtils.deepHash(this.display) + result = 31 * result + MessagesPigeonUtils.deepHash(this.prompt) + result = 31 * result + MessagesPigeonUtils.deepHash(this.additionalParameters) + return result + } + override fun toString(): String { + return "JourneyConfigMessage(serverUrl=$serverUrl, realm=$realm, cookie=$cookie, timeoutMillis=$timeoutMillis, clientId=$clientId, discoveryEndpoint=$discoveryEndpoint, redirectUri=$redirectUri, scopes=$scopes, acrValues=$acrValues, signOutRedirectUri=$signOutRedirectUri, state=$state, nonce=$nonce, uiLocales=$uiLocales, refreshThreshold=$refreshThreshold, loginHint=$loginHint, display=$display, prompt=$prompt, additionalParameters=$additionalParameters)" + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class StartOptionsMessage ( + val forceAuth: Boolean, + val noSession: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): StartOptionsMessage { + val forceAuth = pigeonVar_list[0] as Boolean + val noSession = pigeonVar_list[1] as Boolean + return StartOptionsMessage(forceAuth, noSession) + } + } + fun toList(): List { + return listOf( + forceAuth, + noSession, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as StartOptionsMessage + return MessagesPigeonUtils.deepEquals(this.forceAuth, other.forceAuth) && MessagesPigeonUtils.deepEquals(this.noSession, other.noSession) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + MessagesPigeonUtils.deepHash(this.forceAuth) + result = 31 * result + MessagesPigeonUtils.deepHash(this.noSession) + return result + } + override fun toString(): String { + return "StartOptionsMessage(forceAuth=$forceAuth, noSession=$noSession)" + } +} + +/** + * Flat union of the v1 callback fields (~10 callback types). `value` is + * `Object?`, StandardMessageCodec-backed (covers String/bool/num/Map/List). + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class CallbackMessage ( + val type: String, + val index: Long, + val prompt: String? = null, + val message: String? = null, + val required: Boolean? = null, + val value: Any? = null, + val choices: List? = null, + val defaultChoice: Long? = null, + val selectedIndex: Long? = null, + /** Full terms-and-conditions text (TermsAndConditionsCallback). */ + val terms: String? = null, + val version: String? = null, + val createDate: String? = null, + val accepted: Boolean? = null, + /** Placeholder/default text (TextInputCallback). */ + val defaultText: String? = null, + val predefinedQuestions: List? = null, + val selectedQuestion: String? = null, + val selectedAnswer: String? = null, + val allowUserDefinedQuestions: Boolean? = null, + /** Attribute name/label (Attribute*InputCallback), distinct from [value]. */ + val name: String? = null, + /** + * Validate-without-advancing flag (ValidatedUsername/ValidatedPassword/ + * Attribute*InputCallback). + */ + val validateOnly: Boolean? = null, + val policies: Map? = null, + val failedPolicies: List?>? = null, + /** Whether to mask input (ValidatedPasswordCallback). */ + val echoOn: Boolean? = null, + /** TextOutputCallback message classification: INFORMATION/WARNING/ERROR/SCRIPT/UNKNOWN. */ + val messageType: String? = null, + val raw: Map? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): CallbackMessage { + val type = pigeonVar_list[0] as String + val index = pigeonVar_list[1] as Long + val prompt = pigeonVar_list[2] as String? + val message = pigeonVar_list[3] as String? + val required = pigeonVar_list[4] as Boolean? + val value = pigeonVar_list[5] + val choices = pigeonVar_list[6] as List? + val defaultChoice = pigeonVar_list[7] as Long? + val selectedIndex = pigeonVar_list[8] as Long? + val terms = pigeonVar_list[9] as String? + val version = pigeonVar_list[10] as String? + val createDate = pigeonVar_list[11] as String? + val accepted = pigeonVar_list[12] as Boolean? + val defaultText = pigeonVar_list[13] as String? + val predefinedQuestions = pigeonVar_list[14] as List? + val selectedQuestion = pigeonVar_list[15] as String? + val selectedAnswer = pigeonVar_list[16] as String? + val allowUserDefinedQuestions = pigeonVar_list[17] as Boolean? + val name = pigeonVar_list[18] as String? + val validateOnly = pigeonVar_list[19] as Boolean? + val policies = pigeonVar_list[20] as Map? + val failedPolicies = pigeonVar_list[21] as List?>? + val echoOn = pigeonVar_list[22] as Boolean? + val messageType = pigeonVar_list[23] as String? + val raw = pigeonVar_list[24] as Map? + return CallbackMessage(type, index, prompt, message, required, value, choices, defaultChoice, selectedIndex, terms, version, createDate, accepted, defaultText, predefinedQuestions, selectedQuestion, selectedAnswer, allowUserDefinedQuestions, name, validateOnly, policies, failedPolicies, echoOn, messageType, raw) + } + } + fun toList(): List { + return listOf( + type, + index, + prompt, + message, + required, + value, + choices, + defaultChoice, + selectedIndex, + terms, + version, + createDate, + accepted, + defaultText, + predefinedQuestions, + selectedQuestion, + selectedAnswer, + allowUserDefinedQuestions, + name, + validateOnly, + policies, + failedPolicies, + echoOn, + messageType, + raw, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as CallbackMessage + return MessagesPigeonUtils.deepEquals(this.type, other.type) && MessagesPigeonUtils.deepEquals(this.index, other.index) && MessagesPigeonUtils.deepEquals(this.prompt, other.prompt) && MessagesPigeonUtils.deepEquals(this.message, other.message) && MessagesPigeonUtils.deepEquals(this.required, other.required) && MessagesPigeonUtils.deepEquals(this.value, other.value) && MessagesPigeonUtils.deepEquals(this.choices, other.choices) && MessagesPigeonUtils.deepEquals(this.defaultChoice, other.defaultChoice) && MessagesPigeonUtils.deepEquals(this.selectedIndex, other.selectedIndex) && MessagesPigeonUtils.deepEquals(this.terms, other.terms) && MessagesPigeonUtils.deepEquals(this.version, other.version) && MessagesPigeonUtils.deepEquals(this.createDate, other.createDate) && MessagesPigeonUtils.deepEquals(this.accepted, other.accepted) && MessagesPigeonUtils.deepEquals(this.defaultText, other.defaultText) && MessagesPigeonUtils.deepEquals(this.predefinedQuestions, other.predefinedQuestions) && MessagesPigeonUtils.deepEquals(this.selectedQuestion, other.selectedQuestion) && MessagesPigeonUtils.deepEquals(this.selectedAnswer, other.selectedAnswer) && MessagesPigeonUtils.deepEquals(this.allowUserDefinedQuestions, other.allowUserDefinedQuestions) && MessagesPigeonUtils.deepEquals(this.name, other.name) && MessagesPigeonUtils.deepEquals(this.validateOnly, other.validateOnly) && MessagesPigeonUtils.deepEquals(this.policies, other.policies) && MessagesPigeonUtils.deepEquals(this.failedPolicies, other.failedPolicies) && MessagesPigeonUtils.deepEquals(this.echoOn, other.echoOn) && MessagesPigeonUtils.deepEquals(this.messageType, other.messageType) && MessagesPigeonUtils.deepEquals(this.raw, other.raw) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + MessagesPigeonUtils.deepHash(this.type) + result = 31 * result + MessagesPigeonUtils.deepHash(this.index) + result = 31 * result + MessagesPigeonUtils.deepHash(this.prompt) + result = 31 * result + MessagesPigeonUtils.deepHash(this.message) + result = 31 * result + MessagesPigeonUtils.deepHash(this.required) + result = 31 * result + MessagesPigeonUtils.deepHash(this.value) + result = 31 * result + MessagesPigeonUtils.deepHash(this.choices) + result = 31 * result + MessagesPigeonUtils.deepHash(this.defaultChoice) + result = 31 * result + MessagesPigeonUtils.deepHash(this.selectedIndex) + result = 31 * result + MessagesPigeonUtils.deepHash(this.terms) + result = 31 * result + MessagesPigeonUtils.deepHash(this.version) + result = 31 * result + MessagesPigeonUtils.deepHash(this.createDate) + result = 31 * result + MessagesPigeonUtils.deepHash(this.accepted) + result = 31 * result + MessagesPigeonUtils.deepHash(this.defaultText) + result = 31 * result + MessagesPigeonUtils.deepHash(this.predefinedQuestions) + result = 31 * result + MessagesPigeonUtils.deepHash(this.selectedQuestion) + result = 31 * result + MessagesPigeonUtils.deepHash(this.selectedAnswer) + result = 31 * result + MessagesPigeonUtils.deepHash(this.allowUserDefinedQuestions) + result = 31 * result + MessagesPigeonUtils.deepHash(this.name) + result = 31 * result + MessagesPigeonUtils.deepHash(this.validateOnly) + result = 31 * result + MessagesPigeonUtils.deepHash(this.policies) + result = 31 * result + MessagesPigeonUtils.deepHash(this.failedPolicies) + result = 31 * result + MessagesPigeonUtils.deepHash(this.echoOn) + result = 31 * result + MessagesPigeonUtils.deepHash(this.messageType) + result = 31 * result + MessagesPigeonUtils.deepHash(this.raw) + return result + } + override fun toString(): String { + return "CallbackMessage(type=$type, index=$index, prompt=$prompt, message=$message, required=$required, value=$value, choices=$choices, defaultChoice=$defaultChoice, selectedIndex=$selectedIndex, terms=$terms, version=$version, createDate=$createDate, accepted=$accepted, defaultText=$defaultText, predefinedQuestions=$predefinedQuestions, selectedQuestion=$selectedQuestion, selectedAnswer=$selectedAnswer, allowUserDefinedQuestions=$allowUserDefinedQuestions, name=$name, validateOnly=$validateOnly, policies=$policies, failedPolicies=$failedPolicies, echoOn=$echoOn, messageType=$messageType, raw=$raw)" + } +} + +/** + * Dart -> native callback value, addressed by `{type, index}` so native can + * re-resolve it against the cached `ContinueNode`. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class CallbackValueMessage ( + val type: String, + val index: Long, + val value: Any? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): CallbackValueMessage { + val type = pigeonVar_list[0] as String + val index = pigeonVar_list[1] as Long + val value = pigeonVar_list[2] + return CallbackValueMessage(type, index, value) + } + } + fun toList(): List { + return listOf( + type, + index, + value, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as CallbackValueMessage + return MessagesPigeonUtils.deepEquals(this.type, other.type) && MessagesPigeonUtils.deepEquals(this.index, other.index) && MessagesPigeonUtils.deepEquals(this.value, other.value) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + MessagesPigeonUtils.deepHash(this.type) + result = 31 * result + MessagesPigeonUtils.deepHash(this.index) + result = 31 * result + MessagesPigeonUtils.deepHash(this.value) + return result + } + override fun toString(): String { + return "CallbackValueMessage(type=$type, index=$index, value=$value)" + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class NodeMessage ( + val type: NodeType, + val message: String? = null, + val cause: String? = null, + val status: Long? = null, + /** + * Page metadata (ContinueNode only) — native extension properties on + * `ContinueNode`, exposed as real fields rather than left opaque. + */ + val header: String? = null, + val pageDescription: String? = null, + val stage: String? = null, + val callbacks: List? = null, + val input: Map? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): NodeMessage { + val type = pigeonVar_list[0] as NodeType + val message = pigeonVar_list[1] as String? + val cause = pigeonVar_list[2] as String? + val status = pigeonVar_list[3] as Long? + val header = pigeonVar_list[4] as String? + val pageDescription = pigeonVar_list[5] as String? + val stage = pigeonVar_list[6] as String? + val callbacks = pigeonVar_list[7] as List? + val input = pigeonVar_list[8] as Map? + return NodeMessage(type, message, cause, status, header, pageDescription, stage, callbacks, input) + } + } + fun toList(): List { + return listOf( + type, + message, + cause, + status, + header, + pageDescription, + stage, + callbacks, + input, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as NodeMessage + return MessagesPigeonUtils.deepEquals(this.type, other.type) && MessagesPigeonUtils.deepEquals(this.message, other.message) && MessagesPigeonUtils.deepEquals(this.cause, other.cause) && MessagesPigeonUtils.deepEquals(this.status, other.status) && MessagesPigeonUtils.deepEquals(this.header, other.header) && MessagesPigeonUtils.deepEquals(this.pageDescription, other.pageDescription) && MessagesPigeonUtils.deepEquals(this.stage, other.stage) && MessagesPigeonUtils.deepEquals(this.callbacks, other.callbacks) && MessagesPigeonUtils.deepEquals(this.input, other.input) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + MessagesPigeonUtils.deepHash(this.type) + result = 31 * result + MessagesPigeonUtils.deepHash(this.message) + result = 31 * result + MessagesPigeonUtils.deepHash(this.cause) + result = 31 * result + MessagesPigeonUtils.deepHash(this.status) + result = 31 * result + MessagesPigeonUtils.deepHash(this.header) + result = 31 * result + MessagesPigeonUtils.deepHash(this.pageDescription) + result = 31 * result + MessagesPigeonUtils.deepHash(this.stage) + result = 31 * result + MessagesPigeonUtils.deepHash(this.callbacks) + result = 31 * result + MessagesPigeonUtils.deepHash(this.input) + return result + } + override fun toString(): String { + return "NodeMessage(type=$type, message=$message, cause=$cause, status=$status, header=$header, pageDescription=$pageDescription, stage=$stage, callbacks=$callbacks, input=$input)" + } +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class SessionMessage ( + val accessToken: String, + val refreshToken: String? = null, + val expiresIn: Long, + val userInfo: Map? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): SessionMessage { + val accessToken = pigeonVar_list[0] as String + val refreshToken = pigeonVar_list[1] as String? + val expiresIn = pigeonVar_list[2] as Long + val userInfo = pigeonVar_list[3] as Map? + return SessionMessage(accessToken, refreshToken, expiresIn, userInfo) + } + } + fun toList(): List { + return listOf( + accessToken, + refreshToken, + expiresIn, + userInfo, + ) + } + override fun equals(other: Any?): Boolean { + if (other == null || other.javaClass != javaClass) { + return false + } + if (this === other) { + return true + } + val other = other as SessionMessage + return MessagesPigeonUtils.deepEquals(this.accessToken, other.accessToken) && MessagesPigeonUtils.deepEquals(this.refreshToken, other.refreshToken) && MessagesPigeonUtils.deepEquals(this.expiresIn, other.expiresIn) && MessagesPigeonUtils.deepEquals(this.userInfo, other.userInfo) + } + + override fun hashCode(): Int { + var result = javaClass.hashCode() + result = 31 * result + MessagesPigeonUtils.deepHash(this.accessToken) + result = 31 * result + MessagesPigeonUtils.deepHash(this.refreshToken) + result = 31 * result + MessagesPigeonUtils.deepHash(this.expiresIn) + result = 31 * result + MessagesPigeonUtils.deepHash(this.userInfo) + return result + } + override fun toString(): String { + return "SessionMessage(accessToken=$accessToken, refreshToken=$refreshToken, expiresIn=$expiresIn, userInfo=$userInfo)" + } +} +private open class MessagesPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { + NodeType.ofRaw(it.toInt()) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + JourneyConfigMessage.fromList(it) + } + } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { + StartOptionsMessage.fromList(it) + } + } + 132.toByte() -> { + return (readValue(buffer) as? List)?.let { + CallbackMessage.fromList(it) + } + } + 133.toByte() -> { + return (readValue(buffer) as? List)?.let { + CallbackValueMessage.fromList(it) + } + } + 134.toByte() -> { + return (readValue(buffer) as? List)?.let { + NodeMessage.fromList(it) + } + } + 135.toByte() -> { + return (readValue(buffer) as? List)?.let { + SessionMessage.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is NodeType -> { + stream.write(129) + writeValue(stream, value.raw.toLong()) + } + is JourneyConfigMessage -> { + stream.write(130) + writeValue(stream, value.toList()) + } + is StartOptionsMessage -> { + stream.write(131) + writeValue(stream, value.toList()) + } + is CallbackMessage -> { + stream.write(132) + writeValue(stream, value.toList()) + } + is CallbackValueMessage -> { + stream.write(133) + writeValue(stream, value.toList()) + } + is NodeMessage -> { + stream.write(134) + writeValue(stream, value.toList()) + } + is SessionMessage -> { + stream.write(135) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface PingJourneyHostApi { + fun configureJourney(config: JourneyConfigMessage, callback: (Result) -> Unit) + fun start(journeyId: String, name: String, options: StartOptionsMessage, callback: (Result) -> Unit) + fun next(journeyId: String, values: List, callback: (Result) -> Unit) + fun getSession(journeyId: String, callback: (Result) -> Unit) + fun signOff(journeyId: String, callback: (Result) -> Unit) + fun dispose(journeyId: String, callback: (Result) -> Unit) + + companion object { + /** The codec used by PingJourneyHostApi. */ + val codec: MessageCodec by lazy { + MessagesPigeonCodec() + } + /** Sets up an instance of `PingJourneyHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: PingJourneyHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.ping_journey.PingJourneyHostApi.configureJourney$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val configArg = args[0] as JourneyConfigMessage + api.configureJourney(configArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(MessagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.ping_journey.PingJourneyHostApi.start$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val journeyIdArg = args[0] as String + val nameArg = args[1] as String + val optionsArg = args[2] as StartOptionsMessage + api.start(journeyIdArg, nameArg, optionsArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(MessagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.ping_journey.PingJourneyHostApi.next$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val journeyIdArg = args[0] as String + val valuesArg = args[1] as List + api.next(journeyIdArg, valuesArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(MessagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.ping_journey.PingJourneyHostApi.getSession$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val journeyIdArg = args[0] as String + api.getSession(journeyIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(MessagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.ping_journey.PingJourneyHostApi.signOff$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val journeyIdArg = args[0] as String + api.signOff(journeyIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(MessagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.ping_journey.PingJourneyHostApi.dispose$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val journeyIdArg = args[0] as String + api.dispose(journeyIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + reply.reply(MessagesPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/PingJourneyPlugin.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/PingJourneyPlugin.kt new file mode 100644 index 00000000..adf4450b --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/PingJourneyPlugin.kt @@ -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. + */ + +package com.pingidentity.flutter.journey + +import io.flutter.embedding.engine.plugins.FlutterPlugin + +/** Registers the generated [PingJourneyHostApi] Pigeon channel. */ +class PingJourneyPlugin : FlutterPlugin { + private var api: JourneyHostApiImpl? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + api = JourneyHostApiImpl() + PingJourneyHostApi.setUp(binding.binaryMessenger, api) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + PingJourneyHostApi.setUp(binding.binaryMessenger, null) + api?.shutdown() + api = null + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplier.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplier.kt new file mode 100644 index 00000000..042d63c1 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplier.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.flutter.journey.callback + +import com.pingidentity.flutter.journey.CallbackValueMessage +import com.pingidentity.journey.callback.BooleanAttributeInputCallback +import com.pingidentity.journey.callback.ChoiceCallback +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.StringAttributeInputCallback +import com.pingidentity.journey.callback.TermsAndConditionsCallback +import com.pingidentity.journey.callback.TextInputCallback +import com.pingidentity.journey.callback.ValidatedPasswordCallback +import com.pingidentity.journey.callback.ValidatedUsernameCallback +import com.pingidentity.journey.plugin.Callback +import com.pingidentity.journey.plugin.callbacks +import com.pingidentity.orchestrate.ContinueNode + +/** + * Applies Dart-submitted [CallbackValueMessage]s back onto a cached native [ContinueNode]'s live + * callbacks, addressed by `{type, index}`. Trimmed to the v1 callback set (no + * integration-gated/output-only mutation attempts to reject — the Dart side never submits a + * value for [com.pingidentity.journey.callback.TextOutputCallback], and no v1 type requires + * additional native integration). + */ +internal object JourneyCallbackValueApplier { + fun apply(node: ContinueNode, values: List) { + val callbacksByType = node.callbacks.groupBy { it::class.java.simpleName } + for (value in values) { + val callback = + callbacksByType[value.type]?.getOrNull(value.index.toInt()) + ?: throw IllegalArgumentException( + "No active callback found for type ${value.type} at index ${value.index}" + ) + applyValue(callback, value) + } + } + + private fun applyValue(callback: Callback, value: CallbackValueMessage) { + when (callback) { + is NameCallback -> callback.name = asString(value) + is PasswordCallback -> callback.password = asString(value) + is ValidatedUsernameCallback -> callback.username = asString(value) + is ValidatedPasswordCallback -> callback.password = asString(value) + is TextInputCallback -> callback.text = asString(value) + is StringAttributeInputCallback -> callback.value = asString(value) + is NumberAttributeInputCallback -> callback.value = asDouble(value) + is BooleanAttributeInputCallback -> callback.value = asBoolean(value) + is ChoiceCallback -> callback.selectedIndex = asInt(value) + is TermsAndConditionsCallback -> callback.accepted = asBoolean(value) + is KbaCreateCallback -> applyKba(callback, value) + else -> + throw UnsupportedOperationException( + "Callback type ${value.type} is not supported for value mutation" + ) + } + } + + private fun applyKba(callback: KbaCreateCallback, value: CallbackValueMessage) { + val map = asMap(value) + (map["selectedQuestion"] as? String)?.let { callback.selectedQuestion = it } + (map["selectedAnswer"] as? String)?.let { callback.selectedAnswer = it } + (map["allowUserDefinedQuestions"] as? Boolean)?.let { + callback.allowUserDefinedQuestions = it + } + } + + private fun asString(value: CallbackValueMessage): String = + value.value as? String + ?: throw IllegalArgumentException("${value.type} expects a String value") + + private fun asBoolean(value: CallbackValueMessage): Boolean = + value.value as? Boolean + ?: throw IllegalArgumentException("${value.type} expects a Boolean value") + + private fun asInt(value: CallbackValueMessage): Int = + (value.value as? Number)?.toInt() + ?: throw IllegalArgumentException("${value.type} expects a numeric value") + + private fun asDouble(value: CallbackValueMessage): Double = + (value.value as? Number)?.toDouble() + ?: throw IllegalArgumentException("${value.type} expects a numeric value") + + @Suppress("UNCHECKED_CAST") + private fun asMap(value: CallbackValueMessage): Map = + value.value as? Map + ?: throw IllegalArgumentException("${value.type} expects an object value") +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/error/JourneyErrorCodes.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/error/JourneyErrorCodes.kt new file mode 100644 index 00000000..bd619b44 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/error/JourneyErrorCodes.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.flutter.journey.error + +/** Stable [com.pingidentity.flutter.journey.FlutterError.code] per bridge operation. */ +internal object JourneyErrorCodes { + const val CONFIGURE = "JOURNEY_CONFIGURE_ERROR" + const val START = "JOURNEY_START_ERROR" + const val NEXT = "JOURNEY_NEXT_ERROR" + const val GET_SESSION = "JOURNEY_GET_SESSION_ERROR" + const val SIGN_OFF = "JOURNEY_SIGN_OFF_ERROR" + const val DISPOSE = "JOURNEY_DISPOSE_ERROR" +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/error/JourneyErrorMapper.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/error/JourneyErrorMapper.kt new file mode 100644 index 00000000..7fa399a9 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/error/JourneyErrorMapper.kt @@ -0,0 +1,61 @@ +/* + * 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.flutter.journey.error + +import com.pingidentity.exception.ApiException +import com.pingidentity.flutter.journey.FlutterError +import com.pingidentity.oidc.OidcError +import com.pingidentity.oidc.exception.AuthorizeException + +/** + * Classifies a native failure into a Pigeon [FlutterError] carrying a stable per-operation + * [FlutterError.code] plus a small `type` classification in [FlutterError.details], surfaced to + * Dart as `PingException.type` (see `JourneyClient._guard`). + * + * Native `Workflow.start()`/`ContinueNode.next()` never throw — failure is always a returned + * `FailureNode`/`ErrorNode`, already handled by + * [com.pingidentity.flutter.journey.mapper.JourneyNodeMapper] — so this mapper only needs to + * classify [OidcError] (from `getSession`'s `Result.Failure`, per + * `com.pingidentity.oidc.OidcError`'s own `catch` classification) and the handful of exception + * types the bridge itself can throw (unknown journeyId/node, malformed callback value). + */ +internal object JourneyErrorMapper { + /** Wraps a caught [Throwable] as a classified [FlutterError], unless it already is one. */ + fun fromThrowable(code: String, throwable: Throwable): FlutterError { + if (throwable is FlutterError) return throwable + val type = + when (throwable) { + is ApiException -> "exchange" + is AuthorizeException -> "auth" + is IllegalArgumentException -> "argument" + is IllegalStateException -> "state" + is UnsupportedOperationException -> "unsupported" + else -> "unknown" + } + return FlutterError(code, throwable.message ?: throwable.toString(), type) + } + + /** Classifies an [OidcError] value (not a [Throwable] — returned via `oidc.Result`). */ + fun fromOidcError(code: String, error: OidcError): FlutterError { + val (type, message) = + when (error) { + is OidcError.AuthorizeError -> + "auth" to (error.cause.message ?: "Authorization error") + is OidcError.NetworkError -> "network" to (error.cause.message ?: "Network error") + is OidcError.ApiError -> "exchange" to "API error ${error.code}: ${error.message}" + is OidcError.Unknown -> "unknown" to (error.cause.message ?: "Unknown OIDC error") + } + return FlutterError(code, message, type) + } +} + +/** Classifies this [Result]'s failure, if any, via [JourneyErrorMapper.fromThrowable]. */ +internal fun Result.classifyError(code: String): Result { + val error = exceptionOrNull() ?: return this + return Result.failure(JourneyErrorMapper.fromThrowable(code, error)) +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapper.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapper.kt new file mode 100644 index 00000000..a6e5a6e3 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapper.kt @@ -0,0 +1,141 @@ +/* + * 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.flutter.journey.mapper + +import com.pingidentity.flutter.journey.CallbackMessage +import com.pingidentity.flutter.journey.NodeMessage +import com.pingidentity.flutter.journey.NodeType +import com.pingidentity.journey.callback.AbstractValidatedCallback +import com.pingidentity.journey.callback.AttributeInputCallback +import com.pingidentity.journey.callback.BooleanAttributeInputCallback +import com.pingidentity.journey.callback.ChoiceCallback +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.StringAttributeInputCallback +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.Callback +import com.pingidentity.journey.plugin.callbacks +import com.pingidentity.journey.plugin.description +import com.pingidentity.journey.plugin.header +import com.pingidentity.journey.plugin.stage +import com.pingidentity.orchestrate.ContinueNode +import com.pingidentity.orchestrate.ErrorNode +import com.pingidentity.orchestrate.FailureNode +import com.pingidentity.orchestrate.Node +import com.pingidentity.orchestrate.SuccessNode + +/** + * Maps a native [Node] to the wire-serializable [NodeMessage], including full per-callback field + * mapping for the v1 callback set. Exposes `header`/`description`/`stage` as real fields (this + * SDK's `journey-plugin` `ContinueNode` extension properties) rather than as an opaque + * passthrough blob. + */ +internal object JourneyNodeMapper { + fun map(node: Node): NodeMessage = + when (node) { + is ContinueNode -> + NodeMessage( + type = NodeType.CONTINUE_NODE, + header = node.header, + pageDescription = node.description, + stage = node.stage, + callbacks = mapCallbacks(node), + ) + is SuccessNode -> NodeMessage(type = NodeType.SUCCESS_NODE) + is ErrorNode -> NodeMessage(type = NodeType.ERROR_NODE, message = node.message) + is FailureNode -> + NodeMessage( + type = NodeType.FAILURE_NODE, + cause = node.cause.message ?: node.cause.toString(), + ) + else -> NodeMessage(type = NodeType.FAILURE_NODE, cause = "Unknown node type: $node") + } + + private fun mapCallbacks(node: ContinueNode): List { + val typeCounts = mutableMapOf() + return node.callbacks.map { callback -> + val type = callback::class.java.simpleName + val index = typeCounts.getOrDefault(type, 0) + typeCounts[type] = index + 1 + mapCallback(callback, type, index.toLong()) + } + } + + private fun mapCallback(callback: Callback, type: String, index: Long): CallbackMessage { + var message = CallbackMessage(type = type, index = index) + + if (callback is AbstractValidatedCallback) { + message = + message.copy( + prompt = callback.prompt, + validateOnly = callback.validateOnly, + policies = JsonBridgeMapper.encodeJsonElement(callback.policies) as? Map, + failedPolicies = + callback.failedPolicies.map { + mapOf( + "params" to JsonBridgeMapper.encodeJsonElement(it.params), + "policyRequirement" to it.policyRequirement, + ) + }, + ) + } + if (callback is AttributeInputCallback) { + message = message.copy(name = callback.name, required = callback.required) + } + + return when (callback) { + is NameCallback -> message.copy(prompt = callback.prompt, value = callback.name) + is PasswordCallback -> message.copy(prompt = callback.prompt, value = "") + is ValidatedUsernameCallback -> message.copy(value = callback.username) + is ValidatedPasswordCallback -> message.copy(value = "", echoOn = callback.echoOn) + is TextInputCallback -> + message.copy( + prompt = callback.prompt, + defaultText = callback.defaultText, + value = callback.text, + ) + is TextOutputCallback -> + message.copy( + message = callback.message, + messageType = callback.messageType.name, + ) + is ChoiceCallback -> + message.copy( + prompt = callback.prompt, + choices = callback.choices, + defaultChoice = callback.defaultChoice.toLong(), + selectedIndex = callback.selectedIndex.toLong(), + ) + is KbaCreateCallback -> + message.copy( + prompt = callback.prompt, + predefinedQuestions = callback.predefinedQuestions, + selectedQuestion = callback.selectedQuestion, + selectedAnswer = callback.selectedAnswer, + allowUserDefinedQuestions = callback.allowUserDefinedQuestions, + ) + is TermsAndConditionsCallback -> + message.copy( + version = callback.version, + terms = callback.terms, + createDate = callback.createDate, + accepted = callback.accepted, + ) + is BooleanAttributeInputCallback -> message.copy(value = callback.value) + is NumberAttributeInputCallback -> message.copy(value = callback.value) + is StringAttributeInputCallback -> message.copy(value = callback.value) + else -> message + } + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/mapper/JsonBridgeMapper.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/mapper/JsonBridgeMapper.kt new file mode 100644 index 00000000..eafb067c --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/mapper/JsonBridgeMapper.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.flutter.journey.mapper + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Converts native `kotlinx.serialization.json` values into plain Kotlin values (`Map`/`List`/ + * scalars) that Pigeon's `StandardMessageCodec` can serialize across the platform channel. + * Trimmed to the encode direction only (this bridge only ever sends native JSON outward, never + * decodes it back). + */ +internal object JsonBridgeMapper { + fun encodeJsonElement(element: JsonElement): Any? = + when (element) { + JsonNull -> null + is JsonPrimitive -> encodePrimitive(element) + is JsonObject -> element.mapValues { (_, value) -> encodeJsonElement(value) } + is JsonArray -> element.map { encodeJsonElement(it) } + } + + private fun encodePrimitive(value: JsonPrimitive): Any? { + if (value.isString) return value.content + val raw = value.content + raw.toBooleanStrictOrNull()?.let { return it } + raw.toLongOrNull()?.let { return it } + raw.toDoubleOrNull()?.let { return it } + return raw + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplierTest.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplierTest.kt new file mode 100644 index 00000000..47cdbcca --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplierTest.kt @@ -0,0 +1,220 @@ +/* + * 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.flutter.journey.callback + +import com.pingidentity.flutter.journey.CallbackValueMessage +import com.pingidentity.journey.callback.BooleanAttributeInputCallback +import com.pingidentity.journey.callback.ChoiceCallback +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.StringAttributeInputCallback +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.Callback +import com.pingidentity.orchestrate.Action +import com.pingidentity.orchestrate.ContinueNode +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * Unit tests for [JourneyCallbackValueApplier.apply]: verifies each callback type's submitted + * value lands on the correct native field, and that lookup/type-mismatch failures are surfaced. + */ +class JourneyCallbackValueApplierTest { + + @Test + fun `applies String value onto NameCallback name`() { + val callback = NameCallback() + apply(callback, "NameCallback", value = "John Doe") + + assertEquals("John Doe", callback.name) + } + + @Test + fun `applies String value onto PasswordCallback password`() { + val callback = PasswordCallback() + apply(callback, "PasswordCallback", value = "s3cr3t") + + assertEquals("s3cr3t", callback.password) + } + + @Test + fun `applies String value onto ValidatedUsernameCallback username`() { + val callback = ValidatedUsernameCallback() + apply(callback, "ValidatedUsernameCallback", value = "jdoe") + + assertEquals("jdoe", callback.username) + } + + @Test + fun `applies String value onto ValidatedPasswordCallback password`() { + val callback = ValidatedPasswordCallback() + apply(callback, "ValidatedPasswordCallback", value = "s3cr3t") + + assertEquals("s3cr3t", callback.password) + } + + @Test + fun `applies String value onto TextInputCallback text`() { + val callback = TextInputCallback() + apply(callback, "TextInputCallback", value = "some text") + + assertEquals("some text", callback.text) + } + + @Test + fun `applies String value onto StringAttributeInputCallback value`() { + val callback = StringAttributeInputCallback() + apply(callback, "StringAttributeInputCallback", value = "user@example.com") + + assertEquals("user@example.com", callback.value) + } + + @Test + fun `applies numeric value onto NumberAttributeInputCallback value as Double`() { + val callback = NumberAttributeInputCallback() + apply(callback, "NumberAttributeInputCallback", value = 42) + + assertEquals(42.0, callback.value) + } + + @Test + fun `applies Boolean value onto BooleanAttributeInputCallback value`() { + val callback = BooleanAttributeInputCallback() + apply(callback, "BooleanAttributeInputCallback", value = true) + + assertEquals(true, callback.value) + } + + @Test + fun `applies numeric value onto ChoiceCallback selectedIndex`() { + val callback = ChoiceCallback() + apply(callback, "ChoiceCallback", value = 2) + + assertEquals(2, callback.selectedIndex) + } + + @Test + fun `applies Boolean value onto TermsAndConditionsCallback accepted`() { + val callback = TermsAndConditionsCallback() + apply(callback, "TermsAndConditionsCallback", value = true) + + assertEquals(true, callback.accepted) + } + + @Test + fun `applies map value onto KbaCreateCallback sub-fields`() { + val callback = KbaCreateCallback() + apply( + callback, + "KbaCreateCallback", + value = mapOf( + "selectedQuestion" to "Pet's name?", + "selectedAnswer" to "Rex", + "allowUserDefinedQuestions" to true, + ), + ) + + assertEquals("Pet's name?", callback.selectedQuestion) + assertEquals("Rex", callback.selectedAnswer) + assertEquals(true, callback.allowUserDefinedQuestions) + } + + @Test + fun `KbaCreateCallback partial map only updates provided sub-fields`() { + val callback = KbaCreateCallback().apply { + selectedQuestion = "existing question" + selectedAnswer = "existing answer" + allowUserDefinedQuestions = true + } + + apply(callback, "KbaCreateCallback", value = mapOf("selectedAnswer" to "new answer")) + + assertEquals("existing question", callback.selectedQuestion) + assertEquals("new answer", callback.selectedAnswer) + assertEquals(true, callback.allowUserDefinedQuestions) + } + + @Test + fun `throws for unsupported callback type such as TextOutputCallback`() { + val callback = TextOutputCallback() + val node = mockNode(listOf(callback)) + + assertFailsWith { + JourneyCallbackValueApplier.apply( + node, + listOf(CallbackValueMessage(type = "TextOutputCallback", index = 0, value = "ignored")), + ) + } + } + + @Test + fun `throws IllegalArgumentException when no callback matches type and index`() { + val node = mockNode(listOf(NameCallback())) + + val exception = assertFailsWith { + JourneyCallbackValueApplier.apply( + node, + listOf(CallbackValueMessage(type = "PasswordCallback", index = 0, value = "x")), + ) + } + assertEquals( + "No active callback found for type PasswordCallback at index 0", + exception.message, + ) + } + + @Test + fun `throws IllegalArgumentException when value type mismatches expected type`() { + val callback = NameCallback() + val node = mockNode(listOf(callback)) + + assertFailsWith { + JourneyCallbackValueApplier.apply( + node, + listOf(CallbackValueMessage(type = "NameCallback", index = 0, value = 123)), + ) + } + } + + @Test + fun `applies values addressed by index within same-type group`() { + val first = NameCallback() + val second = NameCallback() + val node = mockNode(listOf(first, second)) + + JourneyCallbackValueApplier.apply( + node, + listOf(CallbackValueMessage(type = "NameCallback", index = 1, value = "second value")), + ) + + assertEquals("", first.name) + assertEquals("second value", second.name) + } + + // --- Helpers ----------------------------------------------------------------------------- + + private fun mockNode(callbacks: List): ContinueNode { + val node = mock(ContinueNode::class.java) + `when`(node.actions).thenReturn(callbacks.map { it as Action }) + return node + } + + private fun apply(callback: Callback, type: String, value: Any?) { + val node = mockNode(listOf(callback)) + JourneyCallbackValueApplier.apply(node, listOf(CallbackValueMessage(type = type, index = 0, value = value))) + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/error/JourneyErrorMapperTest.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/error/JourneyErrorMapperTest.kt new file mode 100644 index 00000000..c6c506b2 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/error/JourneyErrorMapperTest.kt @@ -0,0 +1,187 @@ +/* + * 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.flutter.journey.error + +import com.pingidentity.exception.ApiException +import com.pingidentity.flutter.journey.FlutterError +import com.pingidentity.oidc.OidcError +import com.pingidentity.oidc.exception.AuthorizeException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +/** + * Unit tests for [JourneyErrorMapper]'s classification logic and the [classifyError] `Result` + * extension. + */ +class JourneyErrorMapperTest { + + private val code = "JOURNEY_TEST_ERROR" + + // --- fromThrowable ----------------------------------------------------------------------- + + @Test + fun `fromThrowable returns the same FlutterError unchanged`() { + val original = FlutterError("SOME_CODE", "already classified", "state") + + val result = JourneyErrorMapper.fromThrowable(code, original) + + assertSame(original, result) + } + + @Test + fun `fromThrowable classifies ApiException as exchange`() { + val error = ApiException(500, "server error") + + val result = JourneyErrorMapper.fromThrowable(code, error) + + assertEquals(code, result.code) + assertEquals("exchange", result.details) + assertEquals("server error", result.message) + } + + @Test + fun `fromThrowable classifies AuthorizeException as auth`() { + val error = AuthorizeException("authorization failed") + + val result = JourneyErrorMapper.fromThrowable(code, error) + + assertEquals("auth", result.details) + assertEquals("authorization failed", result.message) + } + + @Test + fun `fromThrowable classifies IllegalArgumentException as argument`() { + val error = IllegalArgumentException("bad argument") + + val result = JourneyErrorMapper.fromThrowable(code, error) + + assertEquals("argument", result.details) + } + + @Test + fun `fromThrowable classifies IllegalStateException as state`() { + val error = IllegalStateException("bad state") + + val result = JourneyErrorMapper.fromThrowable(code, error) + + assertEquals("state", result.details) + assertEquals("bad state", result.message) + } + + @Test + fun `fromThrowable classifies UnsupportedOperationException as unsupported`() { + val error = UnsupportedOperationException("not supported") + + val result = JourneyErrorMapper.fromThrowable(code, error) + + assertEquals("unsupported", result.details) + } + + @Test + fun `fromThrowable classifies unrecognized exception types as unknown`() { + val error = RuntimeException("mystery failure") + + val result = JourneyErrorMapper.fromThrowable(code, error) + + assertEquals("unknown", result.details) + assertEquals("mystery failure", result.message) + } + + @Test + fun `fromThrowable falls back to toString when message is null`() { + val error = RuntimeException() + + val result = JourneyErrorMapper.fromThrowable(code, error) + + assertEquals(error.toString(), result.message) + } + + @Test + fun `fromThrowable preserves the given code`() { + val error = IllegalStateException("bad state") + + val result = JourneyErrorMapper.fromThrowable("JOURNEY_NEXT_ERROR", error) + + assertEquals("JOURNEY_NEXT_ERROR", result.code) + } + + // --- fromOidcError ------------------------------------------------------------------------- + + @Test + fun `fromOidcError classifies AuthorizeError as auth`() { + val error = OidcError.AuthorizeError(RuntimeException("auth failed")) + + val result = JourneyErrorMapper.fromOidcError(code, error) + + assertEquals("auth", result.details) + assertEquals("auth failed", result.message) + } + + @Test + fun `fromOidcError AuthorizeError falls back to default message when cause has none`() { + val error = OidcError.AuthorizeError(RuntimeException()) + + val result = JourneyErrorMapper.fromOidcError(code, error) + + assertEquals("Authorization error", result.message) + } + + @Test + fun `fromOidcError classifies NetworkError as network`() { + val error = OidcError.NetworkError(RuntimeException("no connection")) + + val result = JourneyErrorMapper.fromOidcError(code, error) + + assertEquals("network", result.details) + assertEquals("no connection", result.message) + } + + @Test + fun `fromOidcError classifies ApiError as exchange with code and message`() { + val error = OidcError.ApiError(code = 403, message = "forbidden") + + val result = JourneyErrorMapper.fromOidcError(code, error) + + assertEquals("exchange", result.details) + assertEquals("API error 403: forbidden", result.message) + } + + @Test + fun `fromOidcError classifies Unknown as unknown`() { + val error = OidcError.Unknown(RuntimeException("mystery")) + + val result = JourneyErrorMapper.fromOidcError(code, error) + + assertEquals("unknown", result.details) + assertEquals("mystery", result.message) + } + + // --- classifyError Result extension -------------------------------------------------------- + + @Test + fun `classifyError returns the original Result on success`() { + val success = Result.success("value") + + val result = success.classifyError(code) + + assertEquals(success, result) + } + + @Test + fun `classifyError wraps a failure's exception as a classified FlutterError`() { + val failure = Result.failure(IllegalStateException("bad state")) + + val result = failure.classifyError(code) + + val error = result.exceptionOrNull() + check(error is FlutterError) + assertEquals(code, error.code) + assertEquals("state", error.details) + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapperTest.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapperTest.kt new file mode 100644 index 00000000..5fe670e1 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapperTest.kt @@ -0,0 +1,339 @@ +/* + * 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.flutter.journey.mapper + +import com.pingidentity.flutter.journey.NodeType +import com.pingidentity.journey.callback.ChoiceCallback +import com.pingidentity.journey.callback.KbaCreateCallback +import com.pingidentity.journey.callback.NameCallback +import com.pingidentity.journey.callback.PasswordCallback +import com.pingidentity.journey.callback.StringAttributeInputCallback +import com.pingidentity.journey.callback.TextOutputCallback +import com.pingidentity.journey.callback.ValidatedUsernameCallback +import com.pingidentity.journey.plugin.Callback +import com.pingidentity.orchestrate.Action +import com.pingidentity.orchestrate.ContinueNode +import com.pingidentity.orchestrate.ErrorNode +import com.pingidentity.orchestrate.FailureNode +import com.pingidentity.orchestrate.FlowContext +import com.pingidentity.orchestrate.Node +import com.pingidentity.orchestrate.SharedContext +import com.pingidentity.orchestrate.SuccessNode +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Unit tests for [JourneyNodeMapper]: one test per [Node] subtype, plus per-callback-type field + * mapping for a representative set of the v1 callback set. + */ +class JourneyNodeMapperTest { + + // --- Node subtype mapping --------------------------------------------------------------- + + @Test + fun `map SuccessNode returns SUCCESS_NODE type`() { + val node = SuccessNode(session = com.pingidentity.orchestrate.EmptySession) + + val result = JourneyNodeMapper.map(node) + + assertEquals(NodeType.SUCCESS_NODE, result.type) + } + + @Test + fun `map ErrorNode returns ERROR_NODE type with message`() { + val flowContext = FlowContext(SharedContext(mutableMapOf())) + val node = ErrorNode(context = flowContext, message = "Something went wrong") + + val result = JourneyNodeMapper.map(node) + + assertEquals(NodeType.ERROR_NODE, result.type) + assertEquals("Something went wrong", result.message) + } + + @Test + fun `map FailureNode returns FAILURE_NODE type with cause message`() { + val node = FailureNode(cause = IllegalStateException("bad state")) + + val result = JourneyNodeMapper.map(node) + + assertEquals(NodeType.FAILURE_NODE, result.type) + assertEquals("bad state", result.cause) + } + + @Test + fun `map FailureNode falls back to toString when cause has no message`() { + val cause = RuntimeException() + val node = FailureNode(cause = cause) + + val result = JourneyNodeMapper.map(node) + + assertEquals(NodeType.FAILURE_NODE, result.type) + assertEquals(cause.toString(), result.cause) + } + + // Note: `JourneyNodeMapper.map`'s `else` branch (unknown node type -> FAILURE_NODE with a + // descriptive cause) is not covered here. `com.pingidentity.orchestrate.Node` is a Kotlin + // sealed interface compiled with a JVM-level `PermittedSubclasses` attribute restricted to + // ContinueNode/SuccessNode/ErrorNode/FailureNode (confirmed by the Kotlin compiler's own + // "'when' is exhaustive so 'else' is redundant here" warning on this file, and by two + // independent failed attempts to reach it from this test: Mockito's inline mock maker refuses + // to mock the interface ("Unsupported settings with this type"), and the Kotlin compiler + // rejects an anonymous object implementing it ("Anonymous object cannot extend a sealed + // interface"). The branch is unreachable dead code from outside the `orchestrate` module. + + @Test + fun `map ContinueNode exposes header, description, stage and callbacks`() { + val nameCallback = NameCallback() + val node = mockContinueNode( + input = buildJsonObject { + put("header", "Welcome") + put("description", "Please sign in") + put("stage", "Login") + }, + actions = listOf(nameCallback), + ) + + val result = JourneyNodeMapper.map(node) + + assertEquals(NodeType.CONTINUE_NODE, result.type) + assertEquals("Welcome", result.header) + assertEquals("Please sign in", result.pageDescription) + assertEquals("Login", result.stage) + assertEquals(1, result.callbacks?.size) + } + + @Test + fun `map ContinueNode with no page metadata defaults to empty strings`() { + val node = mockContinueNode(input = buildJsonObject {}, actions = emptyList()) + + val result = JourneyNodeMapper.map(node) + + assertEquals("", result.header) + assertEquals("", result.pageDescription) + assertEquals("", result.stage) + assertEquals(0, result.callbacks?.size) + } + + // --- Callback field mapping (mapCallback, exercised via map(ContinueNode)) -------------- + + @Test + fun `mapCallback maps NameCallback prompt and value`() { + val callback = NameCallback().apply { init(outputOnly("prompt" to "Enter your name")) } + callback.name = "John Doe" + + val message = mapSingleCallback(callback) + + assertEquals("NameCallback", message.type) + assertEquals(0L, message.index) + assertEquals("Enter your name", message.prompt) + assertEquals("John Doe", message.value) + } + + @Test + fun `mapCallback maps PasswordCallback prompt but never leaks the password value`() { + val callback = PasswordCallback().apply { init(outputOnly("prompt" to "Enter your password")) } + callback.password = "super-secret" + + val message = mapSingleCallback(callback) + + assertEquals("PasswordCallback", message.type) + assertEquals("Enter your password", message.prompt) + assertEquals("", message.value) + } + + @Test + fun `mapCallback maps ValidatedUsernameCallback username as value`() { + val callback = ValidatedUsernameCallback() + callback.username = "jdoe" + + val message = mapSingleCallback(callback) + + assertEquals("ValidatedUsernameCallback", message.type) + assertEquals("jdoe", message.value) + } + + @Test + fun `mapCallback maps TextOutputCallback message and messageType`() { + val callback = TextOutputCallback().apply { + init( + buildJsonObject { + put( + "output", + buildJsonArray { + add(buildJsonObject { put("name", "messageType"); put("value", 0) }) + add(buildJsonObject { put("name", "message"); put("value", "Hello!") }) + }, + ) + }, + ) + } + + val message = mapSingleCallback(callback) + + assertEquals("TextOutputCallback", message.type) + assertEquals("Hello!", message.message) + assertEquals("INFORMATION", message.messageType) + } + + @Test + fun `mapCallback maps ChoiceCallback prompt, choices, defaultChoice and selectedIndex`() { + val callback = ChoiceCallback().apply { + init( + buildJsonObject { + put( + "output", + buildJsonArray { + add(buildJsonObject { put("name", "prompt"); put("value", "Pick one") }) + add(buildJsonObject { put("name", "defaultChoice"); put("value", 1) }) + add( + buildJsonObject { + put("name", "choices") + put( + "value", + buildJsonArray { + add(JsonPrimitive("A")) + add(JsonPrimitive("B")) + }, + ) + }, + ) + }, + ) + }, + ) + } + callback.selectedIndex = 1 + + val message = mapSingleCallback(callback) + + assertEquals("ChoiceCallback", message.type) + assertEquals("Pick one", message.prompt) + assertEquals(listOf("A", "B"), message.choices) + assertEquals(1L, message.defaultChoice) + assertEquals(1L, message.selectedIndex) + } + + @Test + fun `mapCallback maps KbaCreateCallback question and answer fields`() { + val callback = KbaCreateCallback().apply { + init( + buildJsonObject { + put( + "output", + buildJsonArray { + add(buildJsonObject { put("name", "prompt"); put("value", "Security question") }) + add( + buildJsonObject { + put("name", "predefinedQuestions") + put( + "value", + buildJsonArray { + add(JsonPrimitive("Pet's name?")) + add(JsonPrimitive("First school?")) + }, + ) + }, + ) + add(buildJsonObject { put("name", "allowUserDefinedQuestions"); put("value", true) }) + }, + ) + }, + ) + } + callback.selectedQuestion = "Pet's name?" + callback.selectedAnswer = "Rex" + + val message = mapSingleCallback(callback) + + assertEquals("KbaCreateCallback", message.type) + assertEquals("Security question", message.prompt) + assertEquals(listOf("Pet's name?", "First school?"), message.predefinedQuestions) + assertEquals("Pet's name?", message.selectedQuestion) + assertEquals("Rex", message.selectedAnswer) + assertEquals(true, message.allowUserDefinedQuestions) + } + + @Test + fun `mapCallback maps StringAttributeInputCallback name, required and value`() { + val callback = StringAttributeInputCallback().apply { + init( + buildJsonObject { + put( + "output", + buildJsonArray { + add(buildJsonObject { put("name", "name"); put("value", "mail") }) + add(buildJsonObject { put("name", "prompt"); put("value", "Email address") }) + add(buildJsonObject { put("name", "required"); put("value", true) }) + }, + ) + }, + ) + } + callback.value = "user@example.com" + + val message = mapSingleCallback(callback) + + assertEquals("StringAttributeInputCallback", message.type) + assertEquals("mail", message.name) + assertEquals("Email address", message.prompt) + assertEquals(true, message.required) + assertEquals("user@example.com", message.value) + } + + @Test + fun `mapCallbacks assigns per-type indices independently`() { + val name1 = NameCallback() + val name2 = NameCallback() + val password = PasswordCallback() + val node = mockContinueNode(input = buildJsonObject {}, actions = listOf(name1, password, name2)) + + val result = JourneyNodeMapper.map(node) + val callbacks = requireNotNull(result.callbacks) + + assertEquals(3, callbacks.size) + assertEquals("NameCallback", callbacks[0]!!.type) + assertEquals(0L, callbacks[0]!!.index) + assertEquals("PasswordCallback", callbacks[1]!!.type) + assertEquals(0L, callbacks[1]!!.index) + assertEquals("NameCallback", callbacks[2]!!.type) + assertEquals(1L, callbacks[2]!!.index) + } + + // --- Helpers ----------------------------------------------------------------------------- + + private fun mockContinueNode( + input: kotlinx.serialization.json.JsonObject, + actions: List, + ): ContinueNode { + val node = mock(ContinueNode::class.java) + `when`(node.input).thenReturn(input) + `when`(node.actions).thenReturn(actions.map { it as Action }) + return node + } + + private fun mapSingleCallback(callback: Callback) = + JourneyNodeMapper.map(mockContinueNode(buildJsonObject {}, listOf(callback))).callbacks!!.single()!! + + private fun outputOnly(vararg entries: Pair) = + buildJsonObject { + put( + "output", + buildJsonArray { + entries.forEach { (name, value) -> + add(buildJsonObject { put("name", name); put("value", value) }) + } + }, + ) + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/.gitignore b/flutter/flutter-sdk-bridge/ping_journey/ios/.gitignore new file mode 100644 index 00000000..034771fc --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey.podspec b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey.podspec new file mode 100644 index 00000000..e66666aa --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey.podspec @@ -0,0 +1,30 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint ping_journey.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'ping_journey' + s.version = '0.0.1' + s.summary = 'Flutter bridge for the Ping Journey SDK.' + s.description = <<-DESC +Flutter bridge for the Ping Journey SDK: drives native Journey orchestration +(nodes, callbacks, sessions) from Dart via ping_core. + DESC + s.homepage = 'https://github.com/ForgeRock/ping-ios-sdk' + s.license = { :file => '../LICENSE' } + s.author = { 'Ping Identity' => 'oss@pingidentity.com' } + s.source = { :path => '.' } + s.source_files = 'ping_journey/Sources/ping_journey/**/*' + s.dependency 'Flutter' + s.platform = :ios, '16.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + # If your plugin requires a privacy manifest, for example if it uses any + # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your + # plugin's privacy impact, and then uncomment this line. For more information, + # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + # s.resource_bundles = {'ping_journey_privacy' => ['ping_journey/Sources/ping_journey/PrivacyInfo.xcprivacy']} +end diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Package.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Package.swift new file mode 100644 index 00000000..452e0d9d --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Package.swift @@ -0,0 +1,44 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ping_journey", + platforms: [ + .iOS(.v16) + ], + products: [ + .library(name: "ping-journey", targets: ["ping_journey"]) + ], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework"), + .package(name: "ping_core", path: "../../ping_core/ios/ping_core"), + .package(url: "https://github.com/ForgeRock/ping-ios-sdk", exact: "2.0.0") + ], + targets: [ + .target( + name: "ping_journey", + dependencies: [ + .product(name: "FlutterFramework", package: "FlutterFramework"), + .product(name: "ping-core", package: "ping_core"), + .product(name: "PingJourney", package: "ping-ios-sdk") + ], + resources: [ + // If your plugin requires a privacy manifest, for example if it uses any required + // reason APIs, update the PrivacyInfo.xcprivacy file to describe your plugin's + // privacy impact, and then uncomment these lines. For more information, see + // https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + // .process("PrivacyInfo.xcprivacy"), + + // If you have other resources that need to be bundled with your plugin, refer to + // the following instructions to add them: + // https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package + ] + ), + .testTarget( + name: "ping_journeyTests", + dependencies: ["ping_journey"] + ) + ] +) diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Callback/JourneyCallbackValueApplier.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Callback/JourneyCallbackValueApplier.swift new file mode 100644 index 00000000..4764f2d3 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Callback/JourneyCallbackValueApplier.swift @@ -0,0 +1,114 @@ +/* + * 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 Foundation +import PingJourney +import PingJourneyPlugin +import PingOrchestrate + +/// Applies Dart-submitted `CallbackValueMessage`s back onto a cached native `ContinueNode`'s live +/// callbacks, addressed by `{type, index}`. Trimmed to the v1 callback set (no +/// integration-gated/output-only mutation attempts to reject — the Dart side never submits a +/// value for `TextOutputCallback`, and no v1 type requires additional native integration). +enum JourneyCallbackValueApplier { + static func apply(_ node: ContinueNode, values: [CallbackValueMessage]) throws { + var callbacksByType: [String: [any Callback]] = [:] + for callback in node.callbacks { + let type = String(describing: Swift.type(of: callback)) + callbacksByType[type, default: []].append(callback) + } + + for value in values { + let matching = callbacksByType[value.type] ?? [] + guard value.index >= 0, Int(value.index) < matching.count else { + throw JourneyHostApiError.callbackApply( + "No active callback found for type \(value.type) at index \(value.index)" + ) + } + try applyValue(matching[Int(value.index)], value) + } + } + + private static func applyValue(_ callback: any Callback, _ value: CallbackValueMessage) throws { + switch callback { + case let name as NameCallback: + name.name = try asString(value) + case let password as PasswordCallback: + password.password = try asString(value) + case let username as ValidatedUsernameCallback: + username.username = try asString(value) + case let validatedPassword as ValidatedPasswordCallback: + validatedPassword.password = try asString(value) + case let textInput as TextInputCallback: + textInput.text = try asString(value) + case let stringAttribute as StringAttributeInputCallback: + stringAttribute.value = try asString(value) + case let numberAttribute as NumberAttributeInputCallback: + numberAttribute.value = try asDouble(value) + case let boolAttribute as BooleanAttributeInputCallback: + boolAttribute.value = try asBool(value) + case let choice as ChoiceCallback: + choice.selectedIndex = try asInt(value) + case let terms as TermsAndConditionsCallback: + terms.accepted = try asBool(value) + case let kba as KbaCreateCallback: + try applyKba(kba, value) + default: + throw JourneyHostApiError.unsupported( + "Callback type \(value.type) is not supported for value mutation" + ) + } + } + + private static func applyKba(_ callback: KbaCreateCallback, _ value: CallbackValueMessage) throws { + let map = try asMap(value) + if let selectedQuestion = map["selectedQuestion"] as? String { + callback.selectedQuestion = selectedQuestion + } + if let selectedAnswer = map["selectedAnswer"] as? String { + callback.selectedAnswer = selectedAnswer + } + if let allowUserDefinedQuestions = map["allowUserDefinedQuestions"] as? Bool { + callback.allowUserDefinedQuestions = allowUserDefinedQuestions + } + } + + private static func asString(_ value: CallbackValueMessage) throws -> String { + guard let stringValue = value.value as? String else { + throw JourneyHostApiError.callbackApply("\(value.type) expects a String value") + } + return stringValue + } + + private static func asBool(_ value: CallbackValueMessage) throws -> Bool { + guard let boolValue = value.value as? Bool else { + throw JourneyHostApiError.callbackApply("\(value.type) expects a Bool value") + } + return boolValue + } + + private static func asInt(_ value: CallbackValueMessage) throws -> Int { + guard let numberValue = value.value as? NSNumber else { + throw JourneyHostApiError.callbackApply("\(value.type) expects a numeric value") + } + return numberValue.intValue + } + + private static func asDouble(_ value: CallbackValueMessage) throws -> Double { + guard let numberValue = value.value as? NSNumber else { + throw JourneyHostApiError.callbackApply("\(value.type) expects a numeric value") + } + return numberValue.doubleValue + } + + private static func asMap(_ value: CallbackValueMessage) throws -> [String: Any] { + guard let mapValue = value.value as? [String: Any] else { + throw JourneyHostApiError.callbackApply("\(value.type) expects an object value") + } + return mapValue + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Error/JourneyErrorCodes.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Error/JourneyErrorCodes.swift new file mode 100644 index 00000000..9041102e --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Error/JourneyErrorCodes.swift @@ -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. + */ + +/// Stable `FlutterError.code` per bridge operation. +enum JourneyErrorCodes { + static let configure = "JOURNEY_CONFIGURE_ERROR" + static let start = "JOURNEY_START_ERROR" + static let next = "JOURNEY_NEXT_ERROR" + static let getSession = "JOURNEY_GET_SESSION_ERROR" + static let signOff = "JOURNEY_SIGN_OFF_ERROR" + static let dispose = "JOURNEY_DISPOSE_ERROR" +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Error/JourneyErrorMapper.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Error/JourneyErrorMapper.swift new file mode 100644 index 00000000..c4212cb6 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Error/JourneyErrorMapper.swift @@ -0,0 +1,54 @@ +/* + * 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 Foundation +import PingJourney +import PingOidc + +/// Classifies a native failure into a Pigeon `FlutterError` carrying a stable per-operation +/// `code` plus a small `type` classification in `details`, surfaced to Dart as +/// `PingException.type` (see `JourneyClient._guard`). +/// +/// Native `Journey.start()`/`ContinueNode.next()` never throw — failure is always a returned +/// `FailureNode`/`ErrorNode`, already handled by `JourneyNodeMapper` — so this mapper only needs +/// to classify `OidcError` (from `getSession`'s `Result.failure`) and the handful of error types +/// the bridge itself throws (`JourneyHostApiError`). +enum JourneyErrorMapper { + /// Wraps any caught `Error` as a classified `PigeonError`, unless it already is one. + /// + /// `PigeonError` (Pigeon's own `Error`-conforming type, defined in `Messages.g.swift`) is used + /// here rather than Flutter's `FlutterError` — the latter is an Objective-C bridged class that + /// does not conform to Swift's `Error` protocol, so it cannot satisfy `Result`. + /// Pigeon's generated `wrapError` recognizes `PigeonError` and forwards its + /// `code`/`message`/`details` to Dart identically to `FlutterError`. + static func from(_ code: String, _ error: Error) -> PigeonError { + if let pigeonError = error as? PigeonError { return pigeonError } + if let oidcError = error as? OidcError { return from(code, oidcError) } + let type: String + switch error { + case JourneyHostApiError.journeyNotFound, JourneyHostApiError.stateError: + type = "state" + case JourneyHostApiError.unsupported, JourneyHostApiError.callbackApply: + type = "argument" + default: + type = "unknown" + } + return PigeonError(code: code, message: String(describing: error), details: type) + } + + /// Classifies an `OidcError` value (not caught via `throws` — returned via `Result.failure`). + static func from(_ code: String, _ error: OidcError) -> PigeonError { + let type: String + switch error { + case .authorizeError: type = "auth" + case .networkError: type = "network" + case .apiError: type = "exchange" + case .unknown: type = "unknown" + } + return PigeonError(code: code, message: error.errorMessage, details: type) + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyClientFactory.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyClientFactory.swift new file mode 100644 index 00000000..23eb72db --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyClientFactory.swift @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import Foundation +import PingJourney +import ping_core + +/// Wraps a live native `Journey` so it can be stored in `CoreRuntime.journeyRegistry`. +/// +/// `hasOidc` records whether the `JourneyConfigMessage` this journey was built from actually +/// configured an Oidc module — `Journey.journeyUser()` unconditionally registers an Oidc module +/// internally and never returns nil, so callers must check this flag *before* touching +/// `journeyUser()`/`token()` on a session-only Journey. +final class JourneyHandle: NativeHandle, @unchecked Sendable { + let journey: Journey + let hasOidc: Bool + + init(journey: Journey, hasOidc: Bool) { + self.journey = journey + self.hasOidc = hasOidc + } +} + +/// Builds a native `Journey` from a `JourneyConfigMessage` and registers it in the shared +/// core registry, returning the generated `journeyId`. +enum JourneyClientFactory { + static func create(_ config: JourneyConfigMessage) async -> String { + let journey = JourneyConfigParser.parse(config) + let handle = JourneyHandle(journey: journey, hasOidc: JourneyConfigParser.hasOidcFields(config)) + return await CoreRuntime.journeyRegistry.register(handle) + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyConfigParser.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyConfigParser.swift new file mode 100644 index 00000000..f34a4a9c --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyConfigParser.swift @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import Foundation +import PingJourney +import PingOidc + +/// Builds a native `Journey` from the flat, wire-serializable `JourneyConfigMessage`. +/// +/// Trimmed to the fields the Pigeon schema carries (no storage/logger handle registries yet — +/// those arrive with a future module). `realm`/`cookie` are left unset when absent so the native +/// SDK's own defaults (`realm = "root"`, `cookie = "iPlanetDirectoryPro"`) apply. +enum JourneyConfigParser { + static func parse(_ message: JourneyConfigMessage) -> Journey { + Journey.createJourney { journeyConfig in + applyJourneyFields(message, to: journeyConfig) + if hasOidcFields(message) { + journeyConfig.module(PingJourney.OidcModule.config) { oidcConfig in + applyOidcFields(message, to: oidcConfig) + } + } + } + } + + private static func applyJourneyFields(_ message: JourneyConfigMessage, to journeyConfig: JourneyConfig) { + journeyConfig.serverUrl = message.serverUrl + if let realm = message.realm { journeyConfig.realm = realm } + if let cookie = message.cookie { journeyConfig.cookie = cookie } + if let timeoutMillis = message.timeoutMillis { + journeyConfig.timeout = TimeInterval(timeoutMillis) / 1000 + } + } + + private static func applyOidcFields(_ message: JourneyConfigMessage, to oidcConfig: OidcClientConfig) { + if let clientId = message.clientId { oidcConfig.clientId = clientId } + if let discoveryEndpoint = message.discoveryEndpoint { + oidcConfig.discoveryEndpoint = discoveryEndpoint + } + if let redirectUri = message.redirectUri { oidcConfig.redirectUri = redirectUri } + if let scopes = message.scopes { + oidcConfig.scopes = Set(scopes.compactMap { $0 }) + } + if let acrValues = message.acrValues { oidcConfig.acrValues = acrValues } + if let state = message.state { oidcConfig.state = state } + if let nonce = message.nonce { oidcConfig.nonce = nonce } + if let uiLocales = message.uiLocales { oidcConfig.uiLocales = uiLocales } + if let refreshThreshold = message.refreshThreshold { + oidcConfig.refreshThreshold = refreshThreshold + } + if let loginHint = message.loginHint { oidcConfig.loginHint = loginHint } + if let display = message.display { oidcConfig.display = display } + if let prompt = message.prompt { oidcConfig.prompt = prompt } + if let additionalParameters = message.additionalParameters { + oidcConfig.additionalParameters = additionalParameters.reduce(into: [String: String]()) { result, entry in + if let key = entry.key, let value = entry.value { + result[key] = value + } + } + } + } + + static func hasOidcFields(_ message: JourneyConfigMessage) -> Bool { + message.clientId != nil + || message.discoveryEndpoint != nil + || message.redirectUri != nil + || message.scopes != nil + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyHostApiImpl.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyHostApiImpl.swift new file mode 100644 index 00000000..93fc37cd --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyHostApiImpl.swift @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import Foundation +import PingJourney +import PingOrchestrate +import ping_core + +/// Implements the generated `PingJourneyHostApi`: `configureJourney`/`start` build the native +/// `Journey` and cache the current node per `journeyId`; `next()` applies submitted callback +/// values onto the cached `ContinueNode` and advances the flow. +final class JourneyHostApiImpl: PingJourneyHostApi, @unchecked Sendable { + private let lock = NSLock() + + /// Most recent `Node` per journeyId. + private var nodeMap: [String: Node] = [:] + + /// Most recent `ContinueNode` per journeyId, for future callback re-resolution. + private var continueNodeMap: [String: ContinueNode] = [:] + + func configureJourney(config: JourneyConfigMessage, completion: @escaping (Result) -> Void) { + Task { + let journeyId = await JourneyClientFactory.create(config) + completion(.success(journeyId)) + } + } + + func start( + journeyId: String, + name: String, + options: StartOptionsMessage, + completion: @escaping (Result) -> Void + ) { + Task { + guard let journey = await resolveJourney(journeyId) else { + completion(.failure(JourneyErrorMapper.from( + JourneyErrorCodes.start, JourneyHostApiError.journeyNotFound(journeyId) + ))) + return + } + let node = await journey.start(name) { startOptions in + startOptions.forceAuth = options.forceAuth + startOptions.noSession = options.noSession + } + setNode(journeyId: journeyId, node: node) + completion(.success(JourneyNodeMapper.map(node))) + } + } + + func next( + journeyId: String, + values: [CallbackValueMessage?], + completion: @escaping (Result) -> Void + ) { + Task { + guard let currentNode = activeContinueNode(journeyId) else { + completion(.failure(JourneyErrorMapper.from( + JourneyErrorCodes.next, + JourneyHostApiError.stateError( + "No active ContinueNode found for journeyId=\(journeyId)" + ) + ))) + return + } + do { + try JourneyCallbackValueApplier.apply(currentNode, values: values.compactMap { $0 }) + let nextNode = await currentNode.next() + setNode(journeyId: journeyId, node: nextNode) + completion(.success(JourneyNodeMapper.map(nextNode))) + } catch { + completion(.failure(JourneyErrorMapper.from(JourneyErrorCodes.next, error))) + } + } + } + + func getSession(journeyId: String, completion: @escaping (Result) -> Void) { + Task { + guard let handle = await resolveHandle(journeyId) else { + completion(.failure(JourneyErrorMapper.from( + JourneyErrorCodes.getSession, JourneyHostApiError.journeyNotFound(journeyId) + ))) + return + } + guard handle.hasOidc, let user = await handle.journey.journeyUser() else { + completion(.success(nil)) + return + } + let tokenResult = await user.token() + switch tokenResult { + case .success(let token): + var userInfoMap: [String?: Any?]? = nil + let uiResult = await user.userinfo(cache: false) + if case .success(let userInfo) = uiResult { + userInfoMap = userInfo as? [String?: Any?] + } + let session = SessionMessage( + accessToken: token.accessToken, + refreshToken: token.refreshToken, + expiresIn: token.expiresIn, + userInfo: userInfoMap + ) + completion(.success(session)) + case .failure(let error): + completion(.failure(JourneyErrorMapper.from(JourneyErrorCodes.getSession, error))) + } + } + } + + func signOff(journeyId: String, completion: @escaping (Result) -> Void) { + Task { + guard let handle = await resolveHandle(journeyId) else { + completion(.failure(JourneyErrorMapper.from( + JourneyErrorCodes.signOff, JourneyHostApiError.journeyNotFound(journeyId) + ))) + return + } + if handle.hasOidc { + let user = await handle.journey.journeyUser() + await user?.logout() + } + clearNodeState(journeyId: journeyId) + completion(.success(true)) + } + } + + func dispose(journeyId: String, completion: @escaping (Result) -> Void) { + Task { + closeAndClearNodeState(journeyId: journeyId) + await CoreRuntime.journeyRegistry.remove(journeyId) + completion(.success(())) + } + } + + private func resolveJourney(_ journeyId: String) async -> Journey? { + await resolveHandle(journeyId)?.journey + } + + private func resolveHandle(_ journeyId: String) async -> JourneyHandle? { + await CoreRuntime.journeyRegistry.resolve(journeyId) as? JourneyHandle + } + + private func activeContinueNode(_ journeyId: String) -> ContinueNode? { + lock.lock() + defer { lock.unlock() } + return continueNodeMap[journeyId] + } + + private func setNode(journeyId: String, node: Node) { + lock.lock() + defer { lock.unlock() } + nodeMap[journeyId] = node + if let continueNode = node as? ContinueNode { + continueNodeMap[journeyId] = continueNode + } else { + continueNodeMap.removeValue(forKey: journeyId) + } + } + + private func clearNodeState(journeyId: String) { + lock.lock() + defer { lock.unlock() } + nodeMap.removeValue(forKey: journeyId) + continueNodeMap.removeValue(forKey: journeyId) + } + + /// Closes the cached `ContinueNode` (mirrors Android's `removeJourney()`) before dropping it, + /// so any `Closeable` actions on the last node get a chance to release resources. + private func closeAndClearNodeState(journeyId: String) { + lock.lock() + defer { lock.unlock() } + nodeMap.removeValue(forKey: journeyId) + continueNodeMap.removeValue(forKey: journeyId)?.close() + } +} + +enum JourneyHostApiError: Error { + case journeyNotFound(String) + case unsupported(String) + case stateError(String) + case callbackApply(String) +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Mapper/JourneyNodeMapper.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Mapper/JourneyNodeMapper.swift new file mode 100644 index 00000000..cdc49418 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Mapper/JourneyNodeMapper.swift @@ -0,0 +1,112 @@ +/* + * 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 Foundation +import PingJourney +import PingJourneyPlugin +import PingOrchestrate + +/// Maps a native `Node` to the wire-serializable `NodeMessage`, including full per-callback field +/// mapping for the v1 callback set. Exposes `pageHeader`/`pageDescription`/`stage` as real fields +/// (this SDK's `JourneyPlugin` `ContinueNode` extension properties) rather than as an opaque +/// passthrough blob. +enum JourneyNodeMapper { + static func map(_ node: Node) -> NodeMessage { + switch node { + case let continueNode as ContinueNode: + return NodeMessage( + type: .continueNode, + header: continueNode.pageHeader, + pageDescription: continueNode.pageDescription, + stage: continueNode.stage, + callbacks: mapCallbacks(continueNode) + ) + case is SuccessNode: + return NodeMessage(type: .successNode) + case let errorNode as ErrorNode: + return NodeMessage(type: .errorNode, message: errorNode.message, status: errorNode.status.map { Int64($0) }) + case let failureNode as FailureNode: + return NodeMessage(type: .failureNode, cause: String(describing: failureNode.cause)) + default: + return NodeMessage(type: .failureNode, cause: "Unknown node type: \(node)") + } + } + + private static func mapCallbacks(_ node: ContinueNode) -> [CallbackMessage] { + var typeCounts: [String: Int64] = [:] + return node.callbacks.map { callback in + let type = String(describing: Swift.type(of: callback)) + let index = typeCounts[type, default: 0] + typeCounts[type] = index + 1 + return mapCallback(callback, type: type, index: index) + } + } + + private static func mapCallback(_ callback: any Callback, type: String, index: Int64) -> CallbackMessage { + var message = CallbackMessage(type: type, index: index) + + if let validated = callback as? AbstractValidatedCallback { + message.prompt = validated.prompt + message.validateOnly = validated.validateOnly + message.policies = validated.policies + message.failedPolicies = validated.failedPolicies.map { + ["params": $0.params, "policyRequirement": $0.policyRequirement] + } + } + if let attribute = callback as? AttributeInputCallback { + message.name = attribute.name + message.required = attribute.required + } + + switch callback { + case let name as NameCallback: + message.prompt = name.prompt + message.value = name.name + case let password as PasswordCallback: + message.prompt = password.prompt + message.value = "" + case let username as ValidatedUsernameCallback: + message.value = username.username + case let validatedPassword as ValidatedPasswordCallback: + message.value = "" + message.echoOn = validatedPassword.echoOn + case let textInput as TextInputCallback: + message.prompt = textInput.prompt + message.defaultText = textInput.defaultText + message.value = textInput.text + case let textOutput as TextOutputCallback: + message.message = textOutput.message + message.messageType = String(describing: textOutput.messageType) + case let choice as ChoiceCallback: + message.prompt = choice.prompt + message.choices = choice.choices + message.defaultChoice = Int64(choice.defaultChoice) + message.selectedIndex = Int64(choice.selectedIndex) + case let kba as KbaCreateCallback: + message.prompt = kba.prompt + message.predefinedQuestions = kba.predefinedQuestions + message.selectedQuestion = kba.selectedQuestion + message.selectedAnswer = kba.selectedAnswer + message.allowUserDefinedQuestions = kba.allowUserDefinedQuestions + case let terms as TermsAndConditionsCallback: + message.version = terms.version + message.terms = terms.terms + message.createDate = terms.createDate + message.accepted = terms.accepted + case let boolAttribute as BooleanAttributeInputCallback: + message.value = boolAttribute.value + case let numberAttribute as NumberAttributeInputCallback: + message.value = numberAttribute.value + case let stringAttribute as StringAttributeInputCallback: + message.value = stringAttribute.value + default: + break + } + + return message + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Messages.g.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Messages.g.swift new file mode 100644 index 00000000..f1463ba0 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Messages.g.swift @@ -0,0 +1,899 @@ +// 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. +// Autogenerated from Pigeon (v27.1.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(Swift.type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +enum MessagesPigeonInternal { + static func isNullish(_ value: Any?) -> Bool { + guard let innerValue = value else { + return true + } + + if case Optional.some(Optional.none) = value { + return true + } + + return innerValue is NSNull + } + static func doubleEquals(_ lhs: Double, _ rhs: Double) -> Bool { + return (lhs.isNaN && rhs.isNaN) || lhs == rhs + } + + static func doubleHash(_ value: Double, _ hasher: inout Hasher) { + if value.isNaN { + hasher.combine(0x7FF8000000000000) + } else { + // Normalize -0.0 to 0.0 + hasher.combine(value == 0 ? 0 : value) + } + } + + static func deepEquals(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case (let lhs as AnyObject, let rhs as AnyObject) where lhs === rhs: + return true + + case is (Void, Void): + return true + + case (let lhsArray, let rhsArray) as ([Any?], [Any?]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !deepEquals(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsArray, let rhsArray) as ([Double], [Double]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !doubleEquals(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsDictionary, let rhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard lhsDictionary.count == rhsDictionary.count else { return false } + for (lhsKey, lhsValue) in lhsDictionary { + var found = false + for (rhsKey, rhsValue) in rhsDictionary { + if deepEquals(lhsKey, rhsKey) { + if deepEquals(lhsValue, rhsValue) { + found = true + break + } else { + return false + } + } + } + if !found { return false } + } + return true + + case (let lhs as Double, let rhs as Double): + return doubleEquals(lhs, rhs) + + case (let lhsHashable, let rhsHashable) as (AnyHashable, AnyHashable): + return lhsHashable == rhsHashable + + default: + return false + } + } + + static func deepHash(value: Any?, hasher: inout Hasher) { + let cleanValue = nilOrValue(value) as Any? + if let cleanValue = cleanValue { + if let doubleValue = cleanValue as? Double { + doubleHash(doubleValue, &hasher) + } else if let valueList = cleanValue as? [Any?] { + for item in valueList { + deepHash(value: item, hasher: &hasher) + } + } else if let valueList = cleanValue as? [Double] { + for item in valueList { + doubleHash(item, &hasher) + } + } else if let valueDict = cleanValue as? [AnyHashable: Any?] { + var result = 0 + for (key, value) in valueDict { + var entryKeyHasher = Hasher() + deepHash(value: key, hasher: &entryKeyHasher) + var entryValueHasher = Hasher() + deepHash(value: value, hasher: &entryValueHasher) + result = result &+ ((entryKeyHasher.finalize() &* 31) ^ entryValueHasher.finalize()) + } + hasher.combine(result) + } else if let hashableValue = cleanValue as? AnyHashable { + hasher.combine(hashableValue) + } else { + hasher.combine(String(describing: cleanValue)) + } + } else { + hasher.combine(0) + } + } + +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + + +/// Node type tag mirroring the native `Node` sealed hierarchy +/// (`ContinueNode`/`SuccessNode`/`ErrorNode`/`FailureNode`). +enum NodeType: Int, CaseIterable { + case continueNode = 0 + case successNode = 1 + case errorNode = 2 + case failureNode = 3 +} + +/// Flat, wire-serializable Journey configuration. OIDC fields are hoisted to +/// the top level rather than nested, since Pigeon classes can't express an +/// "OIDC configured only if any OIDC field is present" union cleanly. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct JourneyConfigMessage: Hashable, CustomStringConvertible { + var serverUrl: String + var realm: String? = nil + var cookie: String? = nil + /// Milliseconds. + var timeoutMillis: Int64? = nil + var clientId: String? = nil + var discoveryEndpoint: String? = nil + var redirectUri: String? = nil + var scopes: [String?]? = nil + var acrValues: String? = nil + var signOutRedirectUri: String? = nil + var state: String? = nil + var nonce: String? = nil + var uiLocales: String? = nil + /// Seconds. + var refreshThreshold: Int64? = nil + var loginHint: String? = nil + var display: String? = nil + var prompt: String? = nil + var additionalParameters: [String?: String?]? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> JourneyConfigMessage? { + let serverUrl = pigeonVar_list[0] as! String + let realm: String? = nilOrValue(pigeonVar_list[1]) + let cookie: String? = nilOrValue(pigeonVar_list[2]) + let timeoutMillis: Int64? = nilOrValue(pigeonVar_list[3]) + let clientId: String? = nilOrValue(pigeonVar_list[4]) + let discoveryEndpoint: String? = nilOrValue(pigeonVar_list[5]) + let redirectUri: String? = nilOrValue(pigeonVar_list[6]) + let scopes: [String?]? = nilOrValue(pigeonVar_list[7]) + let acrValues: String? = nilOrValue(pigeonVar_list[8]) + let signOutRedirectUri: String? = nilOrValue(pigeonVar_list[9]) + let state: String? = nilOrValue(pigeonVar_list[10]) + let nonce: String? = nilOrValue(pigeonVar_list[11]) + let uiLocales: String? = nilOrValue(pigeonVar_list[12]) + let refreshThreshold: Int64? = nilOrValue(pigeonVar_list[13]) + let loginHint: String? = nilOrValue(pigeonVar_list[14]) + let display: String? = nilOrValue(pigeonVar_list[15]) + let prompt: String? = nilOrValue(pigeonVar_list[16]) + let additionalParameters: [String?: String?]? = nilOrValue(pigeonVar_list[17]) + + return JourneyConfigMessage( + serverUrl: serverUrl, + realm: realm, + cookie: cookie, + timeoutMillis: timeoutMillis, + clientId: clientId, + discoveryEndpoint: discoveryEndpoint, + redirectUri: redirectUri, + scopes: scopes, + acrValues: acrValues, + signOutRedirectUri: signOutRedirectUri, + state: state, + nonce: nonce, + uiLocales: uiLocales, + refreshThreshold: refreshThreshold, + loginHint: loginHint, + display: display, + prompt: prompt, + additionalParameters: additionalParameters + ) + } + func toList() -> [Any?] { + return [ + serverUrl, + realm, + cookie, + timeoutMillis, + clientId, + discoveryEndpoint, + redirectUri, + scopes, + acrValues, + signOutRedirectUri, + state, + nonce, + uiLocales, + refreshThreshold, + loginHint, + display, + prompt, + additionalParameters, + ] + } + static func == (lhs: JourneyConfigMessage, rhs: JourneyConfigMessage) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return MessagesPigeonInternal.deepEquals(lhs.serverUrl, rhs.serverUrl) && MessagesPigeonInternal.deepEquals(lhs.realm, rhs.realm) && MessagesPigeonInternal.deepEquals(lhs.cookie, rhs.cookie) && MessagesPigeonInternal.deepEquals(lhs.timeoutMillis, rhs.timeoutMillis) && MessagesPigeonInternal.deepEquals(lhs.clientId, rhs.clientId) && MessagesPigeonInternal.deepEquals(lhs.discoveryEndpoint, rhs.discoveryEndpoint) && MessagesPigeonInternal.deepEquals(lhs.redirectUri, rhs.redirectUri) && MessagesPigeonInternal.deepEquals(lhs.scopes, rhs.scopes) && MessagesPigeonInternal.deepEquals(lhs.acrValues, rhs.acrValues) && MessagesPigeonInternal.deepEquals(lhs.signOutRedirectUri, rhs.signOutRedirectUri) && MessagesPigeonInternal.deepEquals(lhs.state, rhs.state) && MessagesPigeonInternal.deepEquals(lhs.nonce, rhs.nonce) && MessagesPigeonInternal.deepEquals(lhs.uiLocales, rhs.uiLocales) && MessagesPigeonInternal.deepEquals(lhs.refreshThreshold, rhs.refreshThreshold) && MessagesPigeonInternal.deepEquals(lhs.loginHint, rhs.loginHint) && MessagesPigeonInternal.deepEquals(lhs.display, rhs.display) && MessagesPigeonInternal.deepEquals(lhs.prompt, rhs.prompt) && MessagesPigeonInternal.deepEquals(lhs.additionalParameters, rhs.additionalParameters) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("JourneyConfigMessage") + MessagesPigeonInternal.deepHash(value: serverUrl, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: realm, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: cookie, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: timeoutMillis, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: clientId, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: discoveryEndpoint, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: redirectUri, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: scopes, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: acrValues, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: signOutRedirectUri, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: state, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: nonce, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: uiLocales, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: refreshThreshold, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: loginHint, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: display, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: prompt, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: additionalParameters, hasher: &hasher) + } + + public var description: String { + return "JourneyConfigMessage(serverUrl: \(String(describing: serverUrl)), realm: \(String(describing: realm)), cookie: \(String(describing: cookie)), timeoutMillis: \(String(describing: timeoutMillis)), clientId: \(String(describing: clientId)), discoveryEndpoint: \(String(describing: discoveryEndpoint)), redirectUri: \(String(describing: redirectUri)), scopes: \(String(describing: scopes)), acrValues: \(String(describing: acrValues)), signOutRedirectUri: \(String(describing: signOutRedirectUri)), state: \(String(describing: state)), nonce: \(String(describing: nonce)), uiLocales: \(String(describing: uiLocales)), refreshThreshold: \(String(describing: refreshThreshold)), loginHint: \(String(describing: loginHint)), display: \(String(describing: display)), prompt: \(String(describing: prompt)), additionalParameters: \(String(describing: additionalParameters)))" + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct StartOptionsMessage: Hashable, CustomStringConvertible { + var forceAuth: Bool + var noSession: Bool + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> StartOptionsMessage? { + let forceAuth = pigeonVar_list[0] as! Bool + let noSession = pigeonVar_list[1] as! Bool + + return StartOptionsMessage( + forceAuth: forceAuth, + noSession: noSession + ) + } + func toList() -> [Any?] { + return [ + forceAuth, + noSession, + ] + } + static func == (lhs: StartOptionsMessage, rhs: StartOptionsMessage) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return MessagesPigeonInternal.deepEquals(lhs.forceAuth, rhs.forceAuth) && MessagesPigeonInternal.deepEquals(lhs.noSession, rhs.noSession) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("StartOptionsMessage") + MessagesPigeonInternal.deepHash(value: forceAuth, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: noSession, hasher: &hasher) + } + + public var description: String { + return "StartOptionsMessage(forceAuth: \(String(describing: forceAuth)), noSession: \(String(describing: noSession)))" + } +} + +/// Flat union of the v1 callback fields (~10 callback types). `value` is +/// `Object?`, StandardMessageCodec-backed (covers String/bool/num/Map/List). +/// +/// Generated class from Pigeon that represents data sent in messages. +struct CallbackMessage: Hashable, CustomStringConvertible { + var type: String + var index: Int64 + var prompt: String? = nil + var message: String? = nil + var required: Bool? = nil + var value: Any? = nil + var choices: [String?]? = nil + var defaultChoice: Int64? = nil + var selectedIndex: Int64? = nil + /// Full terms-and-conditions text (TermsAndConditionsCallback). + var terms: String? = nil + var version: String? = nil + var createDate: String? = nil + var accepted: Bool? = nil + /// Placeholder/default text (TextInputCallback). + var defaultText: String? = nil + var predefinedQuestions: [String?]? = nil + var selectedQuestion: String? = nil + var selectedAnswer: String? = nil + var allowUserDefinedQuestions: Bool? = nil + /// Attribute name/label (Attribute*InputCallback), distinct from [value]. + var name: String? = nil + /// Validate-without-advancing flag (ValidatedUsername/ValidatedPassword/ + /// Attribute*InputCallback). + var validateOnly: Bool? = nil + var policies: [String?: Any?]? = nil + var failedPolicies: [[String?: Any?]?]? = nil + /// Whether to mask input (ValidatedPasswordCallback). + var echoOn: Bool? = nil + /// TextOutputCallback message classification: INFORMATION/WARNING/ERROR/SCRIPT/UNKNOWN. + var messageType: String? = nil + var raw: [String?: Any?]? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> CallbackMessage? { + let type = pigeonVar_list[0] as! String + let index = pigeonVar_list[1] as! Int64 + let prompt: String? = nilOrValue(pigeonVar_list[2]) + let message: String? = nilOrValue(pigeonVar_list[3]) + let required: Bool? = nilOrValue(pigeonVar_list[4]) + let value: Any? = pigeonVar_list[5] + let choices: [String?]? = nilOrValue(pigeonVar_list[6]) + let defaultChoice: Int64? = nilOrValue(pigeonVar_list[7]) + let selectedIndex: Int64? = nilOrValue(pigeonVar_list[8]) + let terms: String? = nilOrValue(pigeonVar_list[9]) + let version: String? = nilOrValue(pigeonVar_list[10]) + let createDate: String? = nilOrValue(pigeonVar_list[11]) + let accepted: Bool? = nilOrValue(pigeonVar_list[12]) + let defaultText: String? = nilOrValue(pigeonVar_list[13]) + let predefinedQuestions: [String?]? = nilOrValue(pigeonVar_list[14]) + let selectedQuestion: String? = nilOrValue(pigeonVar_list[15]) + let selectedAnswer: String? = nilOrValue(pigeonVar_list[16]) + let allowUserDefinedQuestions: Bool? = nilOrValue(pigeonVar_list[17]) + let name: String? = nilOrValue(pigeonVar_list[18]) + let validateOnly: Bool? = nilOrValue(pigeonVar_list[19]) + let policies: [String?: Any?]? = nilOrValue(pigeonVar_list[20]) + let failedPolicies: [[String?: Any?]?]? = nilOrValue(pigeonVar_list[21]) + let echoOn: Bool? = nilOrValue(pigeonVar_list[22]) + let messageType: String? = nilOrValue(pigeonVar_list[23]) + let raw: [String?: Any?]? = nilOrValue(pigeonVar_list[24]) + + return CallbackMessage( + type: type, + index: index, + prompt: prompt, + message: message, + required: required, + value: value, + choices: choices, + defaultChoice: defaultChoice, + selectedIndex: selectedIndex, + terms: terms, + version: version, + createDate: createDate, + accepted: accepted, + defaultText: defaultText, + predefinedQuestions: predefinedQuestions, + selectedQuestion: selectedQuestion, + selectedAnswer: selectedAnswer, + allowUserDefinedQuestions: allowUserDefinedQuestions, + name: name, + validateOnly: validateOnly, + policies: policies, + failedPolicies: failedPolicies, + echoOn: echoOn, + messageType: messageType, + raw: raw + ) + } + func toList() -> [Any?] { + return [ + type, + index, + prompt, + message, + required, + value, + choices, + defaultChoice, + selectedIndex, + terms, + version, + createDate, + accepted, + defaultText, + predefinedQuestions, + selectedQuestion, + selectedAnswer, + allowUserDefinedQuestions, + name, + validateOnly, + policies, + failedPolicies, + echoOn, + messageType, + raw, + ] + } + static func == (lhs: CallbackMessage, rhs: CallbackMessage) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return MessagesPigeonInternal.deepEquals(lhs.type, rhs.type) && MessagesPigeonInternal.deepEquals(lhs.index, rhs.index) && MessagesPigeonInternal.deepEquals(lhs.prompt, rhs.prompt) && MessagesPigeonInternal.deepEquals(lhs.message, rhs.message) && MessagesPigeonInternal.deepEquals(lhs.required, rhs.required) && MessagesPigeonInternal.deepEquals(lhs.value, rhs.value) && MessagesPigeonInternal.deepEquals(lhs.choices, rhs.choices) && MessagesPigeonInternal.deepEquals(lhs.defaultChoice, rhs.defaultChoice) && MessagesPigeonInternal.deepEquals(lhs.selectedIndex, rhs.selectedIndex) && MessagesPigeonInternal.deepEquals(lhs.terms, rhs.terms) && MessagesPigeonInternal.deepEquals(lhs.version, rhs.version) && MessagesPigeonInternal.deepEquals(lhs.createDate, rhs.createDate) && MessagesPigeonInternal.deepEquals(lhs.accepted, rhs.accepted) && MessagesPigeonInternal.deepEquals(lhs.defaultText, rhs.defaultText) && MessagesPigeonInternal.deepEquals(lhs.predefinedQuestions, rhs.predefinedQuestions) && MessagesPigeonInternal.deepEquals(lhs.selectedQuestion, rhs.selectedQuestion) && MessagesPigeonInternal.deepEquals(lhs.selectedAnswer, rhs.selectedAnswer) && MessagesPigeonInternal.deepEquals(lhs.allowUserDefinedQuestions, rhs.allowUserDefinedQuestions) && MessagesPigeonInternal.deepEquals(lhs.name, rhs.name) && MessagesPigeonInternal.deepEquals(lhs.validateOnly, rhs.validateOnly) && MessagesPigeonInternal.deepEquals(lhs.policies, rhs.policies) && MessagesPigeonInternal.deepEquals(lhs.failedPolicies, rhs.failedPolicies) && MessagesPigeonInternal.deepEquals(lhs.echoOn, rhs.echoOn) && MessagesPigeonInternal.deepEquals(lhs.messageType, rhs.messageType) && MessagesPigeonInternal.deepEquals(lhs.raw, rhs.raw) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("CallbackMessage") + MessagesPigeonInternal.deepHash(value: type, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: index, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: prompt, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: message, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: required, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: value, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: choices, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: defaultChoice, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: selectedIndex, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: terms, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: version, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: createDate, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: accepted, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: defaultText, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: predefinedQuestions, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: selectedQuestion, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: selectedAnswer, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: allowUserDefinedQuestions, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: name, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: validateOnly, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: policies, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: failedPolicies, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: echoOn, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: messageType, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: raw, hasher: &hasher) + } + + public var description: String { + return "CallbackMessage(type: \(String(describing: type)), index: \(String(describing: index)), prompt: \(String(describing: prompt)), message: \(String(describing: message)), required: \(String(describing: required)), value: \(String(describing: value)), choices: \(String(describing: choices)), defaultChoice: \(String(describing: defaultChoice)), selectedIndex: \(String(describing: selectedIndex)), terms: \(String(describing: terms)), version: \(String(describing: version)), createDate: \(String(describing: createDate)), accepted: \(String(describing: accepted)), defaultText: \(String(describing: defaultText)), predefinedQuestions: \(String(describing: predefinedQuestions)), selectedQuestion: \(String(describing: selectedQuestion)), selectedAnswer: \(String(describing: selectedAnswer)), allowUserDefinedQuestions: \(String(describing: allowUserDefinedQuestions)), name: \(String(describing: name)), validateOnly: \(String(describing: validateOnly)), policies: \(String(describing: policies)), failedPolicies: \(String(describing: failedPolicies)), echoOn: \(String(describing: echoOn)), messageType: \(String(describing: messageType)), raw: \(String(describing: raw)))" + } +} + +/// Dart -> native callback value, addressed by `{type, index}` so native can +/// re-resolve it against the cached `ContinueNode`. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct CallbackValueMessage: Hashable, CustomStringConvertible { + var type: String + var index: Int64 + var value: Any? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> CallbackValueMessage? { + let type = pigeonVar_list[0] as! String + let index = pigeonVar_list[1] as! Int64 + let value: Any? = pigeonVar_list[2] + + return CallbackValueMessage( + type: type, + index: index, + value: value + ) + } + func toList() -> [Any?] { + return [ + type, + index, + value, + ] + } + static func == (lhs: CallbackValueMessage, rhs: CallbackValueMessage) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return MessagesPigeonInternal.deepEquals(lhs.type, rhs.type) && MessagesPigeonInternal.deepEquals(lhs.index, rhs.index) && MessagesPigeonInternal.deepEquals(lhs.value, rhs.value) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("CallbackValueMessage") + MessagesPigeonInternal.deepHash(value: type, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: index, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: value, hasher: &hasher) + } + + public var description: String { + return "CallbackValueMessage(type: \(String(describing: type)), index: \(String(describing: index)), value: \(String(describing: value)))" + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct NodeMessage: Hashable, CustomStringConvertible { + var type: NodeType + var message: String? = nil + var cause: String? = nil + var status: Int64? = nil + /// Page metadata (ContinueNode only) — native extension properties on + /// `ContinueNode`, exposed as real fields rather than left opaque. + var header: String? = nil + var pageDescription: String? = nil + var stage: String? = nil + var callbacks: [CallbackMessage?]? = nil + var input: [String?: Any?]? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NodeMessage? { + let type = pigeonVar_list[0] as! NodeType + let message: String? = nilOrValue(pigeonVar_list[1]) + let cause: String? = nilOrValue(pigeonVar_list[2]) + let status: Int64? = nilOrValue(pigeonVar_list[3]) + let header: String? = nilOrValue(pigeonVar_list[4]) + let pageDescription: String? = nilOrValue(pigeonVar_list[5]) + let stage: String? = nilOrValue(pigeonVar_list[6]) + let callbacks: [CallbackMessage?]? = nilOrValue(pigeonVar_list[7]) + let input: [String?: Any?]? = nilOrValue(pigeonVar_list[8]) + + return NodeMessage( + type: type, + message: message, + cause: cause, + status: status, + header: header, + pageDescription: pageDescription, + stage: stage, + callbacks: callbacks, + input: input + ) + } + func toList() -> [Any?] { + return [ + type, + message, + cause, + status, + header, + pageDescription, + stage, + callbacks, + input, + ] + } + static func == (lhs: NodeMessage, rhs: NodeMessage) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return MessagesPigeonInternal.deepEquals(lhs.type, rhs.type) && MessagesPigeonInternal.deepEquals(lhs.message, rhs.message) && MessagesPigeonInternal.deepEquals(lhs.cause, rhs.cause) && MessagesPigeonInternal.deepEquals(lhs.status, rhs.status) && MessagesPigeonInternal.deepEquals(lhs.header, rhs.header) && MessagesPigeonInternal.deepEquals(lhs.pageDescription, rhs.pageDescription) && MessagesPigeonInternal.deepEquals(lhs.stage, rhs.stage) && MessagesPigeonInternal.deepEquals(lhs.callbacks, rhs.callbacks) && MessagesPigeonInternal.deepEquals(lhs.input, rhs.input) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("NodeMessage") + MessagesPigeonInternal.deepHash(value: type, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: message, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: cause, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: status, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: header, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: pageDescription, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: stage, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: callbacks, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: input, hasher: &hasher) + } + + public var description: String { + return "NodeMessage(type: \(String(describing: type)), message: \(String(describing: message)), cause: \(String(describing: cause)), status: \(String(describing: status)), header: \(String(describing: header)), pageDescription: \(String(describing: pageDescription)), stage: \(String(describing: stage)), callbacks: \(String(describing: callbacks)), input: \(String(describing: input)))" + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct SessionMessage: Hashable, CustomStringConvertible { + var accessToken: String + var refreshToken: String? = nil + var expiresIn: Int64 + var userInfo: [String?: Any?]? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> SessionMessage? { + let accessToken = pigeonVar_list[0] as! String + let refreshToken: String? = nilOrValue(pigeonVar_list[1]) + let expiresIn = pigeonVar_list[2] as! Int64 + let userInfo: [String?: Any?]? = nilOrValue(pigeonVar_list[3]) + + return SessionMessage( + accessToken: accessToken, + refreshToken: refreshToken, + expiresIn: expiresIn, + userInfo: userInfo + ) + } + func toList() -> [Any?] { + return [ + accessToken, + refreshToken, + expiresIn, + userInfo, + ] + } + static func == (lhs: SessionMessage, rhs: SessionMessage) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return MessagesPigeonInternal.deepEquals(lhs.accessToken, rhs.accessToken) && MessagesPigeonInternal.deepEquals(lhs.refreshToken, rhs.refreshToken) && MessagesPigeonInternal.deepEquals(lhs.expiresIn, rhs.expiresIn) && MessagesPigeonInternal.deepEquals(lhs.userInfo, rhs.userInfo) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("SessionMessage") + MessagesPigeonInternal.deepHash(value: accessToken, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: refreshToken, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: expiresIn, hasher: &hasher) + MessagesPigeonInternal.deepHash(value: userInfo, hasher: &hasher) + } + + public var description: String { + return "SessionMessage(accessToken: \(String(describing: accessToken)), refreshToken: \(String(describing: refreshToken)), expiresIn: \(String(describing: expiresIn)), userInfo: \(String(describing: userInfo)))" + } +} + +private class MessagesPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return NodeType(rawValue: enumResultAsInt) + } + return nil + case 130: + return JourneyConfigMessage.fromList(self.readValue() as! [Any?]) + case 131: + return StartOptionsMessage.fromList(self.readValue() as! [Any?]) + case 132: + return CallbackMessage.fromList(self.readValue() as! [Any?]) + case 133: + return CallbackValueMessage.fromList(self.readValue() as! [Any?]) + case 134: + return NodeMessage.fromList(self.readValue() as! [Any?]) + case 135: + return SessionMessage.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class MessagesPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? NodeType { + super.writeByte(129) + super.writeValue(value.rawValue) + } else if let value = value as? JourneyConfigMessage { + super.writeByte(130) + super.writeValue(value.toList()) + } else if let value = value as? StartOptionsMessage { + super.writeByte(131) + super.writeValue(value.toList()) + } else if let value = value as? CallbackMessage { + super.writeByte(132) + super.writeValue(value.toList()) + } else if let value = value as? CallbackValueMessage { + super.writeByte(133) + super.writeValue(value.toList()) + } else if let value = value as? NodeMessage { + super.writeByte(134) + super.writeValue(value.toList()) + } else if let value = value as? SessionMessage { + super.writeByte(135) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class MessagesPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return MessagesPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return MessagesPigeonCodecWriter(data: data) + } +} + +class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = MessagesPigeonCodec(readerWriter: MessagesPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol PingJourneyHostApi { + func configureJourney(config: JourneyConfigMessage, completion: @escaping (Result) -> Void) + func start(journeyId: String, name: String, options: StartOptionsMessage, completion: @escaping (Result) -> Void) + func next(journeyId: String, values: [CallbackValueMessage?], completion: @escaping (Result) -> Void) + func getSession(journeyId: String, completion: @escaping (Result) -> Void) + func signOff(journeyId: String, completion: @escaping (Result) -> Void) + func dispose(journeyId: String, completion: @escaping (Result) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class PingJourneyHostApiSetup { + static var codec: FlutterStandardMessageCodec { MessagesPigeonCodec.shared } + /// Sets up an instance of `PingJourneyHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PingJourneyHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let configureJourneyChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.ping_journey.PingJourneyHostApi.configureJourney\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + configureJourneyChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let configArg = args[0] as! JourneyConfigMessage + api.configureJourney(config: configArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + configureJourneyChannel.setMessageHandler(nil) + } + let startChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.ping_journey.PingJourneyHostApi.start\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + startChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let journeyIdArg = args[0] as! String + let nameArg = args[1] as! String + let optionsArg = args[2] as! StartOptionsMessage + api.start(journeyId: journeyIdArg, name: nameArg, options: optionsArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + startChannel.setMessageHandler(nil) + } + let nextChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.ping_journey.PingJourneyHostApi.next\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + nextChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let journeyIdArg = args[0] as! String + let valuesArg = args[1] as! [CallbackValueMessage?] + api.next(journeyId: journeyIdArg, values: valuesArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + nextChannel.setMessageHandler(nil) + } + let getSessionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.ping_journey.PingJourneyHostApi.getSession\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getSessionChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let journeyIdArg = args[0] as! String + api.getSession(journeyId: journeyIdArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + getSessionChannel.setMessageHandler(nil) + } + let signOffChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.ping_journey.PingJourneyHostApi.signOff\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + signOffChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let journeyIdArg = args[0] as! String + api.signOff(journeyId: journeyIdArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + signOffChannel.setMessageHandler(nil) + } + let disposeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.ping_journey.PingJourneyHostApi.dispose\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + disposeChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let journeyIdArg = args[0] as! String + api.dispose(journeyId: journeyIdArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + disposeChannel.setMessageHandler(nil) + } + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/PingJourneyPlugin.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/PingJourneyPlugin.swift new file mode 100644 index 00000000..603c23ac --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/PingJourneyPlugin.swift @@ -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. + */ + +import Flutter +import UIKit + +/// Registers the generated `PingJourneyHostApi` Pigeon channel. +public class PingJourneyPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let api = JourneyHostApiImpl() + PingJourneyHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: api) + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/PrivacyInfo.xcprivacy b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..a34b7e2e --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyCallbackValueApplierTests.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyCallbackValueApplierTests.swift new file mode 100644 index 00000000..32e46286 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyCallbackValueApplierTests.swift @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import XCTest +import PingJourney +import PingJourneyPlugin +import PingOrchestrate +@testable import ping_journey + +/// Unit tests for `JourneyCallbackValueApplier.apply`: verifies each callback type's submitted +/// value lands on the correct native field, and that lookup/type-mismatch failures are surfaced. +final class JourneyCallbackValueApplierTests: XCTestCase { + + func testAppliesStringValueOntoNameCallbackName() throws { + let callback = NameCallback() + try apply(callback, type: "NameCallback", value: "John Doe") + + XCTAssertEqual(callback.name, "John Doe") + } + + func testAppliesStringValueOntoPasswordCallbackPassword() throws { + let callback = PasswordCallback() + try apply(callback, type: "PasswordCallback", value: "s3cr3t") + + XCTAssertEqual(callback.password, "s3cr3t") + } + + func testAppliesStringValueOntoValidatedUsernameCallbackUsername() throws { + let callback = ValidatedUsernameCallback() + try apply(callback, type: "ValidatedUsernameCallback", value: "jdoe") + + XCTAssertEqual(callback.username, "jdoe") + } + + func testAppliesStringValueOntoValidatedPasswordCallbackPassword() throws { + let callback = ValidatedPasswordCallback() + try apply(callback, type: "ValidatedPasswordCallback", value: "s3cr3t") + + XCTAssertEqual(callback.password, "s3cr3t") + } + + func testAppliesStringValueOntoTextInputCallbackText() throws { + let callback = TextInputCallback() + try apply(callback, type: "TextInputCallback", value: "some text") + + XCTAssertEqual(callback.text, "some text") + } + + func testAppliesStringValueOntoStringAttributeInputCallbackValue() throws { + let callback = StringAttributeInputCallback() + try apply(callback, type: "StringAttributeInputCallback", value: "user@example.com") + + XCTAssertEqual(callback.value, "user@example.com") + } + + func testAppliesNumericValueOntoNumberAttributeInputCallbackValueAsDouble() throws { + let callback = NumberAttributeInputCallback() + try apply(callback, type: "NumberAttributeInputCallback", value: NSNumber(value: 42)) + + XCTAssertEqual(callback.value, 42.0) + } + + func testAppliesBoolValueOntoBooleanAttributeInputCallbackValue() throws { + let callback = BooleanAttributeInputCallback() + try apply(callback, type: "BooleanAttributeInputCallback", value: true) + + XCTAssertEqual(callback.value, true) + } + + func testAppliesNumericValueOntoChoiceCallbackSelectedIndex() throws { + let callback = ChoiceCallback() + try apply(callback, type: "ChoiceCallback", value: NSNumber(value: 2)) + + XCTAssertEqual(callback.selectedIndex, 2) + } + + func testAppliesBoolValueOntoTermsAndConditionsCallbackAccepted() throws { + let callback = TermsAndConditionsCallback() + try apply(callback, type: "TermsAndConditionsCallback", value: true) + + XCTAssertEqual(callback.accepted, true) + } + + func testAppliesMapValueOntoKbaCreateCallbackSubFields() throws { + let callback = KbaCreateCallback() + try apply( + callback, + type: "KbaCreateCallback", + value: [ + "selectedQuestion": "Pet's name?", + "selectedAnswer": "Rex", + "allowUserDefinedQuestions": true, + ] + ) + + XCTAssertEqual(callback.selectedQuestion, "Pet's name?") + XCTAssertEqual(callback.selectedAnswer, "Rex") + XCTAssertEqual(callback.allowUserDefinedQuestions, true) + } + + func testKbaCreateCallbackPartialMapOnlyUpdatesProvidedSubFields() throws { + let callback = KbaCreateCallback() + callback.selectedQuestion = "existing question" + callback.selectedAnswer = "existing answer" + callback.allowUserDefinedQuestions = true + + try apply(callback, type: "KbaCreateCallback", value: ["selectedAnswer": "new answer"]) + + XCTAssertEqual(callback.selectedQuestion, "existing question") + XCTAssertEqual(callback.selectedAnswer, "new answer") + XCTAssertEqual(callback.allowUserDefinedQuestions, true) + } + + func testThrowsForUnsupportedCallbackTypeSuchAsTextOutputCallback() throws { + let callback = TextOutputCallback() + let node = makeContinueNode(actions: [callback]) + + XCTAssertThrowsError( + try JourneyCallbackValueApplier.apply( + node, + values: [CallbackValueMessage(type: "TextOutputCallback", index: 0, value: "ignored")] + ) + ) { error in + guard case JourneyHostApiError.unsupported = error else { + return XCTFail("Expected .unsupported, got \(error)") + } + } + } + + func testThrowsCallbackApplyWhenNoCallbackMatchesTypeAndIndex() throws { + let node = makeContinueNode(actions: [NameCallback()]) + + XCTAssertThrowsError( + try JourneyCallbackValueApplier.apply( + node, + values: [CallbackValueMessage(type: "PasswordCallback", index: 0, value: "x")] + ) + ) { error in + guard case let JourneyHostApiError.callbackApply(message) = error else { + return XCTFail("Expected .callbackApply, got \(error)") + } + XCTAssertEqual(message, "No active callback found for type PasswordCallback at index 0") + } + } + + func testThrowsCallbackApplyWhenValueTypeMismatchesExpectedType() throws { + let callback = NameCallback() + let node = makeContinueNode(actions: [callback]) + + XCTAssertThrowsError( + try JourneyCallbackValueApplier.apply( + node, + values: [CallbackValueMessage(type: "NameCallback", index: 0, value: NSNumber(value: 123))] + ) + ) { error in + guard case JourneyHostApiError.callbackApply = error else { + return XCTFail("Expected .callbackApply, got \(error)") + } + } + } + + func testAppliesValuesAddressedByIndexWithinSameTypeGroup() throws { + let first = NameCallback() + let second = NameCallback() + let node = makeContinueNode(actions: [first, second]) + + try JourneyCallbackValueApplier.apply( + node, + values: [CallbackValueMessage(type: "NameCallback", index: 1, value: "second value")] + ) + + XCTAssertEqual(first.name, "") + XCTAssertEqual(second.name, "second value") + } + + // --- Helpers ----------------------------------------------------------------------------- + + private func makeContinueNode(actions: [any Action]) -> ContinueNode { + let workflow = Workflow.createWorkflow() + let context = FlowContext(flowContext: SharedContext()) + return ContinueNode(context: context, workflow: workflow, input: [:], actions: actions) + } + + private func apply(_ callback: any Callback, type: String, value: Any?) throws { + let node = makeContinueNode(actions: [callback]) + try JourneyCallbackValueApplier.apply( + node, + values: [CallbackValueMessage(type: type, index: 0, value: value)] + ) + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyErrorMapperTests.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyErrorMapperTests.swift new file mode 100644 index 00000000..fc3ea006 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyErrorMapperTests.swift @@ -0,0 +1,123 @@ +/* + * 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 XCTest +import PingJourney +import PingOidc +@testable import ping_journey + +/// Unit tests for `JourneyErrorMapper`'s classification logic (`from(_:_:Error)` and +/// `from(_:_:OidcError)`). +final class JourneyErrorMapperTests: XCTestCase { + + private let code = "JOURNEY_TEST_ERROR" + + private struct SampleError: Error {} + + // --- from(_:_:Error) ----------------------------------------------------------------------- + + func testFromReturnsTheSamePigeonErrorUnchanged() { + let original = PigeonError(code: "SOME_CODE", message: "already classified", details: "state") + + let result = JourneyErrorMapper.from(code, original) + + XCTAssertTrue(result === original) + } + + func testFromClassifiesJourneyNotFoundAsState() { + let error = JourneyHostApiError.journeyNotFound("missing journey") + + let result = JourneyErrorMapper.from(code, error) + + XCTAssertEqual(result.code, code) + XCTAssertEqual(result.details as? String, "state") + } + + func testFromClassifiesStateErrorAsState() { + let error = JourneyHostApiError.stateError("bad state") + + let result = JourneyErrorMapper.from(code, error) + + XCTAssertEqual(result.details as? String, "state") + } + + func testFromClassifiesUnsupportedAsArgument() { + let error = JourneyHostApiError.unsupported("not supported") + + let result = JourneyErrorMapper.from(code, error) + + XCTAssertEqual(result.details as? String, "argument") + } + + func testFromClassifiesCallbackApplyAsArgument() { + let error = JourneyHostApiError.callbackApply("bad callback value") + + let result = JourneyErrorMapper.from(code, error) + + XCTAssertEqual(result.details as? String, "argument") + } + + func testFromClassifiesUnrecognizedErrorTypesAsUnknown() { + let error = SampleError() + + let result = JourneyErrorMapper.from(code, error) + + XCTAssertEqual(result.details as? String, "unknown") + } + + func testFromPreservesTheGivenCode() { + let error = JourneyHostApiError.stateError("bad state") + + let result = JourneyErrorMapper.from("JOURNEY_NEXT_ERROR", error) + + XCTAssertEqual(result.code, "JOURNEY_NEXT_ERROR") + } + + func testFromDispatchesOidcErrorToTheOidcOverload() { + let error = OidcError.networkError(cause: nil, message: "no connection") + + let result = JourneyErrorMapper.from(code, error as Error) + + XCTAssertEqual(result.details as? String, "network") + } + + // --- from(_:_:OidcError) -------------------------------------------------------------------- + + func testFromOidcErrorClassifiesAuthorizeErrorAsAuth() { + let error = OidcError.authorizeError(cause: nil, message: "auth failed") + + let result = JourneyErrorMapper.from(code, error) + + XCTAssertEqual(result.details as? String, "auth") + XCTAssertEqual(result.message, error.errorMessage) + } + + func testFromOidcErrorClassifiesNetworkErrorAsNetwork() { + let error = OidcError.networkError(cause: nil, message: "no connection") + + let result = JourneyErrorMapper.from(code, error) + + XCTAssertEqual(result.details as? String, "network") + } + + func testFromOidcErrorClassifiesApiErrorAsExchange() { + let error = OidcError.apiError(code: 403, message: "forbidden") + + let result = JourneyErrorMapper.from(code, error) + + XCTAssertEqual(result.details as? String, "exchange") + XCTAssertEqual(result.message, error.errorMessage) + } + + func testFromOidcErrorClassifiesUnknownAsUnknown() { + let error = OidcError.unknown(cause: nil, message: "mystery") + + let result = JourneyErrorMapper.from(code, error) + + XCTAssertEqual(result.details as? String, "unknown") + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyNodeMapperTests.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyNodeMapperTests.swift new file mode 100644 index 00000000..fd53c648 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyNodeMapperTests.swift @@ -0,0 +1,253 @@ +/* + * 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 XCTest +import PingJourney +import PingJourneyPlugin +import PingOrchestrate +@testable import ping_journey + +/// Unit tests for `JourneyNodeMapper`: one test per `Node` subtype, plus per-callback-type field +/// mapping for a representative set of the v1 callback set. +final class JourneyNodeMapperTests: XCTestCase { + + // --- Node subtype mapping --------------------------------------------------------------- + + func testMapSuccessNodeReturnsSuccessNodeType() { + let node = SuccessNode(session: EmptySession()) + + let result = JourneyNodeMapper.map(node) + + XCTAssertEqual(result.type, .successNode) + } + + func testMapErrorNodeReturnsErrorNodeTypeWithMessage() { + let flowContext = FlowContext(flowContext: SharedContext()) + let node = ErrorNode(status: 400, message: "Something went wrong", context: flowContext) + + let result = JourneyNodeMapper.map(node) + + XCTAssertEqual(result.type, .errorNode) + XCTAssertEqual(result.message, "Something went wrong") + XCTAssertEqual(result.status, 400) + } + + func testMapFailureNodeReturnsFailureNodeTypeWithCauseDescription() { + struct SampleError: Error, CustomStringConvertible { + var description: String { "bad state" } + } + let node = FailureNode(cause: SampleError()) + + let result = JourneyNodeMapper.map(node) + + XCTAssertEqual(result.type, .failureNode) + XCTAssertEqual(result.cause, "bad state") + } + + func testMapUnknownNodeTypeFallsBackToFailureNodeWithDescriptiveCause() { + // `Node` is a plain (non-sealed) protocol on iOS, unlike Android's sealed interface, so a + // custom conforming type can reach the mapper's otherwise-unreachable `default` branch. + struct CustomNode: Node {} + let node = CustomNode() + + let result = JourneyNodeMapper.map(node) + + XCTAssertEqual(result.type, .failureNode) + XCTAssertTrue(result.cause?.contains("Unknown node type") == true) + } + + func testMapContinueNodeExposesHeaderDescriptionStageAndCallbacks() { + let nameCallback = NameCallback() + let node = makeContinueNode( + input: [ + "header": "Welcome", + "description": "Please sign in", + "stage": "Login", + ], + actions: [nameCallback] + ) + + let result = JourneyNodeMapper.map(node) + + XCTAssertEqual(result.type, .continueNode) + XCTAssertEqual(result.header, "Welcome") + XCTAssertEqual(result.pageDescription, "Please sign in") + XCTAssertEqual(result.stage, "Login") + XCTAssertEqual(result.callbacks?.count, 1) + } + + func testMapContinueNodeWithNoPageMetadataDefaultsToEmptyStrings() { + let node = makeContinueNode(input: [:], actions: []) + + let result = JourneyNodeMapper.map(node) + + XCTAssertEqual(result.header, "") + XCTAssertEqual(result.pageDescription, "") + XCTAssertEqual(result.stage, "") + XCTAssertEqual(result.callbacks?.count, 0) + } + + // --- Callback field mapping (mapCallback, exercised via map(ContinueNode)) -------------- + + func testMapCallbackMapsNameCallbackPromptAndValue() async { + let callback = NameCallback() + _ = await callback.initialize(with: outputOnly([("prompt", "Enter your name")])) + callback.name = "John Doe" + + let message = await mapSingleCallback(callback) + + XCTAssertEqual(message.type, "NameCallback") + XCTAssertEqual(message.index, 0) + XCTAssertEqual(message.prompt, "Enter your name") + XCTAssertEqual(message.value as? String, "John Doe") + } + + func testMapCallbackMapsPasswordCallbackPromptButNeverLeaksPasswordValue() async { + let callback = PasswordCallback() + _ = await callback.initialize(with: outputOnly([("prompt", "Enter your password")])) + callback.password = "super-secret" + + let message = await mapSingleCallback(callback) + + XCTAssertEqual(message.type, "PasswordCallback") + XCTAssertEqual(message.prompt, "Enter your password") + XCTAssertEqual(message.value as? String, "") + } + + func testMapCallbackMapsValidatedUsernameCallbackUsernameAsValue() async { + let callback = ValidatedUsernameCallback() + callback.username = "jdoe" + + let message = await mapSingleCallback(callback) + + XCTAssertEqual(message.type, "ValidatedUsernameCallback") + XCTAssertEqual(message.value as? String, "jdoe") + } + + func testMapCallbackMapsTextOutputCallbackMessageAndMessageType() async { + let callback = TextOutputCallback() + _ = await callback.initialize( + with: [ + "output": [ + ["name": "messageType", "value": "0"], + ["name": "message", "value": "Hello!"], + ] + ] + ) + + let message = await mapSingleCallback(callback) + + XCTAssertEqual(message.type, "TextOutputCallback") + XCTAssertEqual(message.message, "Hello!") + XCTAssertEqual(message.messageType, "information") + } + + func testMapCallbackMapsChoiceCallbackPromptChoicesDefaultChoiceAndSelectedIndex() async { + let callback = ChoiceCallback() + _ = await callback.initialize( + with: [ + "output": [ + ["name": "prompt", "value": "Pick one"], + ["name": "defaultChoice", "value": 1], + ["name": "choices", "value": ["A", "B"]], + ] + ] + ) + callback.selectedIndex = 1 + + let message = await mapSingleCallback(callback) + + XCTAssertEqual(message.type, "ChoiceCallback") + XCTAssertEqual(message.prompt, "Pick one") + XCTAssertEqual(message.choices as? [String], ["A", "B"]) + XCTAssertEqual(message.defaultChoice, 1) + XCTAssertEqual(message.selectedIndex, 1) + } + + func testMapCallbackMapsKbaCreateCallbackQuestionAndAnswerFields() async { + let callback = KbaCreateCallback() + _ = await callback.initialize( + with: [ + "output": [ + ["name": "prompt", "value": "Security question"], + ["name": "predefinedQuestions", "value": ["Pet's name?", "First school?"]], + ["name": "allowUserDefinedQuestions", "value": true], + ] + ] + ) + callback.selectedQuestion = "Pet's name?" + callback.selectedAnswer = "Rex" + + let message = await mapSingleCallback(callback) + + XCTAssertEqual(message.type, "KbaCreateCallback") + XCTAssertEqual(message.prompt, "Security question") + XCTAssertEqual(message.predefinedQuestions as? [String], ["Pet's name?", "First school?"]) + XCTAssertEqual(message.selectedQuestion, "Pet's name?") + XCTAssertEqual(message.selectedAnswer, "Rex") + XCTAssertEqual(message.allowUserDefinedQuestions, true) + } + + func testMapCallbackMapsStringAttributeInputCallbackNameRequiredAndValue() async { + let callback = StringAttributeInputCallback() + _ = await callback.initialize( + with: [ + "output": [ + ["name": "name", "value": "mail"], + ["name": "prompt", "value": "Email address"], + ["name": "required", "value": true], + ] + ] + ) + callback.value = "user@example.com" + + let message = await mapSingleCallback(callback) + + XCTAssertEqual(message.type, "StringAttributeInputCallback") + XCTAssertEqual(message.name, "mail") + XCTAssertEqual(message.prompt, "Email address") + XCTAssertEqual(message.required, true) + XCTAssertEqual(message.value as? String, "user@example.com") + } + + func testMapCallbacksAssignsPerTypeIndicesIndependently() { + let name1 = NameCallback() + let name2 = NameCallback() + let password = PasswordCallback() + let node = makeContinueNode(input: [:], actions: [name1, password, name2]) + + let result = JourneyNodeMapper.map(node) + let callbacks = result.callbacks! + + XCTAssertEqual(callbacks.count, 3) + XCTAssertEqual(callbacks[0]!.type, "NameCallback") + XCTAssertEqual(callbacks[0]!.index, 0) + XCTAssertEqual(callbacks[1]!.type, "PasswordCallback") + XCTAssertEqual(callbacks[1]!.index, 0) + XCTAssertEqual(callbacks[2]!.type, "NameCallback") + XCTAssertEqual(callbacks[2]!.index, 1) + } + + // --- Helpers ----------------------------------------------------------------------------- + + private func makeContinueNode(input: [String: Any], actions: [any Action]) -> ContinueNode { + let workflow = Workflow.createWorkflow() + let context = FlowContext(flowContext: SharedContext()) + return ContinueNode(context: context, workflow: workflow, input: input, actions: actions) + } + + private func mapSingleCallback(_ callback: any Callback) async -> CallbackMessage { + let node = makeContinueNode(input: [:], actions: [callback]) + return JourneyNodeMapper.map(node).callbacks!.first!! + } + + private func outputOnly(_ entries: [(String, String)]) -> [String: Any] { + [ + "output": entries.map { ["name": $0.0, "value": $0.1] } + ] + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/ping_journey.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/ping_journey.dart new file mode 100644 index 00000000..a3d52457 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/ping_journey.dart @@ -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. + */ + +export 'package:ping_core/ping_core.dart' show PingException; + +export 'src/callback/callback.dart'; +export 'src/callback_helpers.dart'; +export 'src/callback_type.dart'; +export 'src/journey_client.dart'; +export 'src/journey_node.dart'; +export 'src/messages.g.dart'; +export 'src/session.dart'; diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/boolean_attribute_input_callback.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/boolean_attribute_input_callback.dart new file mode 100644 index 00000000..052b3878 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/boolean_attribute_input_callback.dart @@ -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. + */ + +part of 'callback.dart'; + +/// Collects a single boolean identity attribute, validated against server-defined policies. +final class BooleanAttributeInputCallback extends Callback { + /// Creates a boolean attribute input callback from its wire-message fields. + BooleanAttributeInputCallback({ + required super.type, + required super.index, + super.prompt, + this.name = '', + this.required = false, + this.value = false, + this.validateOnly = false, + this.policies, + this.failedPolicies = const [], + }); + + /// The identity attribute name this callback collects a value for. + final String name; + + /// Whether the server requires a value to be submitted for this attribute. + final bool required; + + /// The current boolean value, editable by the UI before submission. + bool value; + + /// Read-only: reflects the native value at the time this node was mapped. + /// There is no wire path to send an updated value back to native. + final bool validateOnly; + + /// Server-defined validation policies applicable to this attribute, if any. + final Map? policies; + + /// Policies that the current [value] failed validation against. + final List> failedPolicies; + + @override + CallbackValueMessage toValue() => + CallbackValueMessage(type: type, index: index, value: value); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/callback.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/callback.dart new file mode 100644 index 00000000..773f6eca --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/callback.dart @@ -0,0 +1,43 @@ +/* + * 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 '../messages.g.dart'; + +part 'name_callback.dart'; +part 'password_callback.dart'; +part 'validated_username_callback.dart'; +part 'validated_password_callback.dart'; +part 'choice_callback.dart'; +part 'kba_create_callback.dart'; +part 'terms_and_conditions_callback.dart'; +part 'text_input_callback.dart'; +part 'text_output_callback.dart'; +part 'string_attribute_input_callback.dart'; +part 'number_attribute_input_callback.dart'; +part 'boolean_attribute_input_callback.dart'; + +/// A single collectible/displayable item within a [ContinueNode]. Dart-side re-inflation of the +/// wire [CallbackMessage] — see `node_mapper.dart`. Declared as a multi-file `sealed` hierarchy +/// (via `part`) so `dart analyze` still enforces exhaustive `switch`es over subtypes across files. +sealed class Callback { + const Callback({required this.type, required this.index, this.prompt}); + + /// The native callback class name (e.g. `"NameCallback"`), matched by `node_mapper.dart` when + /// re-inflating; not a Dart type discriminator by itself. + final String type; + + /// 0-based position among callbacks of the same [type] in this node — used to address the + /// value back to native via [CallbackValueMessage]. + final int index; + + /// The text to display to the user alongside this callback's input, if any. + final String? prompt; + + /// The value to submit via `JourneyClient.next()`, or `null` if this callback contributes no + /// value (output-only, e.g. [TextOutputCallback]). + CallbackValueMessage? toValue(); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/choice_callback.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/choice_callback.dart new file mode 100644 index 00000000..04aa0fd1 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/choice_callback.dart @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +part of 'callback.dart'; + +/// Collects a single choice from a fixed list of options. +final class ChoiceCallback extends Callback { + /// Creates a choice callback, defaulting the selection to [defaultChoice] + /// unless [selectedIndex] is provided. + ChoiceCallback({ + required super.type, + required super.index, + super.prompt, + this.choices = const [], + this.defaultChoice = 0, + int? selectedIndex, + }) : selectedIndex = selectedIndex ?? defaultChoice; + + /// The list of options the user can choose from. + final List choices; + + /// The index into [choices] the server suggests as the initial selection. + final int defaultChoice; + + /// The index into [choices] currently selected by the user. + int selectedIndex; + + @override + CallbackValueMessage toValue() => + CallbackValueMessage(type: type, index: index, value: selectedIndex); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/kba_create_callback.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/kba_create_callback.dart new file mode 100644 index 00000000..5e29e2b8 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/kba_create_callback.dart @@ -0,0 +1,50 @@ +/* + * 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. + */ + +part of 'callback.dart'; + +/// Collects a knowledge-based-authentication question and answer (self-registration). +final class KbaCreateCallback extends Callback { + /// Creates a KBA (knowledge-based-authentication) create callback from its + /// wire-message fields. + KbaCreateCallback({ + required super.type, + required super.index, + super.prompt, + this.predefinedQuestions = const [], + this.selectedQuestion = '', + this.selectedAnswer = '', + this.allowUserDefinedQuestions = false, + }); + + /// The server-supplied list of security questions the user may pick from. + final List predefinedQuestions; + + /// The security question chosen (or typed) by the user. + String selectedQuestion; + + /// The user's answer to [selectedQuestion]. + String selectedAnswer; + + /// Whether the user may type their own question instead of picking one + /// from [predefinedQuestions]. + bool allowUserDefinedQuestions; + + /// Submitted as an object value + /// (`{selectedQuestion, selectedAnswer, allowUserDefinedQuestions}`), the one v1 callback + /// whose value isn't a scalar. + @override + CallbackValueMessage toValue() => CallbackValueMessage( + type: type, + index: index, + value: { + 'selectedQuestion': selectedQuestion, + 'selectedAnswer': selectedAnswer, + 'allowUserDefinedQuestions': allowUserDefinedQuestions, + }, + ); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/name_callback.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/name_callback.dart new file mode 100644 index 00000000..aff95ba3 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/name_callback.dart @@ -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. + */ + +part of 'callback.dart'; + +/// Collects a plain-text name (e.g. username at login). +final class NameCallback extends Callback { + /// Creates a name callback from its wire-message fields. + NameCallback({ + required super.type, + required super.index, + super.prompt, + this.name = '', + }); + + /// The name (e.g. username) entered by the user. + String name; + + @override + CallbackValueMessage toValue() => + CallbackValueMessage(type: type, index: index, value: name); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/number_attribute_input_callback.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/number_attribute_input_callback.dart new file mode 100644 index 00000000..6a75d380 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/number_attribute_input_callback.dart @@ -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. + */ + +part of 'callback.dart'; + +/// Collects a single numeric identity attribute, validated against server-defined policies. +final class NumberAttributeInputCallback extends Callback { + /// Creates a number attribute input callback from its wire-message fields. + NumberAttributeInputCallback({ + required super.type, + required super.index, + super.prompt, + this.name = '', + this.required = false, + this.value = 0, + this.validateOnly = false, + this.policies, + this.failedPolicies = const [], + }); + + /// The identity attribute name this callback collects a value for. + final String name; + + /// Whether the server requires a value to be submitted for this attribute. + final bool required; + + /// The current numeric value, editable by the UI before submission. + double value; + + /// Read-only: reflects the native value at the time this node was mapped. + /// There is no wire path to send an updated value back to native. + final bool validateOnly; + + /// Server-defined validation policies applicable to this attribute, if any. + final Map? policies; + + /// Policies that the current [value] failed validation against. + final List> failedPolicies; + + @override + CallbackValueMessage toValue() => + CallbackValueMessage(type: type, index: index, value: value); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/password_callback.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/password_callback.dart new file mode 100644 index 00000000..f4a0d895 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/password_callback.dart @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +part of 'callback.dart'; + +/// Collects a password. The native wire value is always sent back as `""` — the actual +/// characters are never round-tripped through native, only set locally by the UI. +final class PasswordCallback extends Callback { + /// Creates a password callback from its wire-message fields. + PasswordCallback({ + required super.type, + required super.index, + super.prompt, + this.password = '', + }); + + /// The password entered by the user, set locally by the UI. + String password; + + @override + CallbackValueMessage toValue() => + CallbackValueMessage(type: type, index: index, value: password); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/string_attribute_input_callback.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/string_attribute_input_callback.dart new file mode 100644 index 00000000..afe3889b --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/string_attribute_input_callback.dart @@ -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. + */ + +part of 'callback.dart'; + +/// Collects a single string identity attribute, validated against server-defined policies. +final class StringAttributeInputCallback extends Callback { + /// Creates a string attribute input callback from its wire-message fields. + StringAttributeInputCallback({ + required super.type, + required super.index, + super.prompt, + this.name = '', + this.required = false, + this.value = '', + this.validateOnly = false, + this.policies, + this.failedPolicies = const [], + }); + + /// The identity attribute name this callback collects a value for. + final String name; + + /// Whether the server requires a value to be submitted for this attribute. + final bool required; + + /// The current string value, editable by the UI before submission. + String value; + + /// Read-only: reflects the native value at the time this node was mapped. + /// There is no wire path to send an updated value back to native. + final bool validateOnly; + + /// Server-defined validation policies applicable to this attribute, if any. + final Map? policies; + + /// Policies that the current [value] failed validation against. + final List> failedPolicies; + + @override + CallbackValueMessage toValue() => + CallbackValueMessage(type: type, index: index, value: value); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/terms_and_conditions_callback.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/terms_and_conditions_callback.dart new file mode 100644 index 00000000..7dfcc677 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/terms_and_conditions_callback.dart @@ -0,0 +1,37 @@ +/* + * 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. + */ + +part of 'callback.dart'; + +/// Collects the user's acceptance of terms and conditions. +final class TermsAndConditionsCallback extends Callback { + /// Creates a terms and conditions callback from its wire-message fields. + TermsAndConditionsCallback({ + required super.type, + required super.index, + this.version = '', + this.terms = '', + this.createDate = '', + this.accepted = false, + }); + + /// The version identifier of the terms being presented. + final String version; + + /// The full text of the terms and conditions to display. + final String terms; + + /// The date this version of the terms was created, as supplied by the server. + final String createDate; + + /// Whether the user has accepted the terms. + bool accepted; + + @override + CallbackValueMessage toValue() => + CallbackValueMessage(type: type, index: index, value: accepted); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/text_input_callback.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/text_input_callback.dart new file mode 100644 index 00000000..2a3d3340 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/text_input_callback.dart @@ -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. + */ + +part of 'callback.dart'; + +/// Collects free-form text input. +final class TextInputCallback extends Callback { + /// Creates a text input callback, initializing [text] to [defaultText] + /// unless an explicit [text] value is provided. + TextInputCallback({ + required super.type, + required super.index, + super.prompt, + this.defaultText = '', + String? text, + }) : text = text ?? defaultText; + + /// The server-suggested initial value for this field. + final String defaultText; + + /// The current text value, editable by the UI before submission. + String text; + + @override + CallbackValueMessage toValue() => + CallbackValueMessage(type: type, index: index, value: text); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/text_output_callback.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/text_output_callback.dart new file mode 100644 index 00000000..246c2a0d --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/text_output_callback.dart @@ -0,0 +1,65 @@ +/* + * 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. + */ + +part of 'callback.dart'; + +/// Classification of a [TextOutputCallback]'s message, mirroring the native +/// `TextOutputCallbackMessageType`/`MessageType` enums. +enum TextOutputMessageType { + /// A general informational message. + information, + + /// A warning that doesn't block the Journey from continuing. + warning, + + /// An error message reported by the server. + error, + + /// A script payload to be executed by the client (e.g. reCAPTCHA/device profiling). + script, + + /// A message type not recognized by this bridge. + unknown, +} + +/// Displays a message to the user; contributes no value on `next()`. +final class TextOutputCallback extends Callback { + /// Creates a text output callback, parsing [messageType] into a + /// [TextOutputMessageType]. + TextOutputCallback({ + required super.type, + required super.index, + this.message = '', + String? messageType, + }) : messageType = _parseMessageType(messageType); + + /// The message text to display to the user. + final String message; + + /// The classification of [message] (information, warning, error, etc.). + final TextOutputMessageType messageType; + + /// Native platforms disagree on case: Android sends the Kotlin enum name + /// (`"INFORMATION"`), iOS sends the Swift enum case description (`"information"`). + static TextOutputMessageType _parseMessageType(String? value) { + switch (value?.toLowerCase()) { + case 'information': + return TextOutputMessageType.information; + case 'warning': + return TextOutputMessageType.warning; + case 'error': + return TextOutputMessageType.error; + case 'script': + return TextOutputMessageType.script; + default: + return TextOutputMessageType.unknown; + } + } + + @override + CallbackValueMessage? toValue() => null; +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/validated_password_callback.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/validated_password_callback.dart new file mode 100644 index 00000000..5873b943 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/validated_password_callback.dart @@ -0,0 +1,43 @@ +/* + * 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. + */ + +part of 'callback.dart'; + +/// Collects a password validated against server-defined policies (e.g. self-registration). +final class ValidatedPasswordCallback extends Callback { + /// Creates a validated password callback from its wire-message fields. + ValidatedPasswordCallback({ + required super.type, + required super.index, + super.prompt, + this.password = '', + this.echoOn = false, + this.validateOnly = false, + this.policies, + this.failedPolicies = const [], + }); + + /// The password entered by the user, set locally by the UI. + String password; + + /// Whether the server requests the entered characters be displayed rather than masked. + final bool echoOn; + + /// Read-only: reflects the native value at the time this node was mapped. + /// There is no wire path to send an updated value back to native. + final bool validateOnly; + + /// Server-defined password policies applicable to this field, if any. + final Map? policies; + + /// Policies that the current [password] failed validation against. + final List> failedPolicies; + + @override + CallbackValueMessage toValue() => + CallbackValueMessage(type: type, index: index, value: password); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/validated_username_callback.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/validated_username_callback.dart new file mode 100644 index 00000000..babf4c96 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback/validated_username_callback.dart @@ -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. + */ + +part of 'callback.dart'; + +/// Collects a username validated against server-defined policies (e.g. self-registration). +final class ValidatedUsernameCallback extends Callback { + /// Creates a validated username callback from its wire-message fields. + ValidatedUsernameCallback({ + required super.type, + required super.index, + super.prompt, + this.username = '', + this.validateOnly = false, + this.policies, + this.failedPolicies = const [], + }); + + /// The username entered by the user. + String username; + + /// Read-only: reflects the native value at the time this node was mapped. + /// There is no wire path to send an updated value back to native. + final bool validateOnly; + + /// Server-defined username policies applicable to this field, if any. + final Map? policies; + + /// Policies that the current [username] failed validation against. + final List> failedPolicies; + + @override + CallbackValueMessage toValue() => + CallbackValueMessage(type: type, index: index, value: username); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback_helpers.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback_helpers.dart new file mode 100644 index 00000000..2a0585d6 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback_helpers.dart @@ -0,0 +1,125 @@ +/* + * 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 'callback_type.dart'; + +/// How a callback's value should be rendered/collected. +enum FieldKind { + /// A free-form text field. + text, + + /// A masked password field. + password, + + /// A numeric input field. + number, + + /// A boolean (toggle/checkbox) field. + boolean, + + /// A selection from a fixed list of options. + choice, + + /// A knowledge-based-authentication question/answer pair. + kba, + + /// A display-only message with no user-editable value. + output, + + /// A callback type this bridge doesn't classify. + unknown, +} + +/// Whether a callback can be submitted directly, needs no input, or needs handling this bridge +/// doesn't support yet. Trimmed to the v1 callback set (no `autoCapable`/`integrationRequired` +/// callback types are in scope for v1, but the values are kept so future callback types can be +/// classified without changing this enum). +enum ExecutionMode { + /// The callback requires user-provided input before it can be submitted. + manual, + + /// The callback can be resolved automatically without user input (not used in v1). + autoCapable, + + /// The callback requires integration with an external SDK/flow (not used in v1). + integrationRequired, + + /// The callback only displays information and contributes no value. + outputOnly, + + /// The callback type isn't handled by this bridge. + unsupported, +} + +/// Pure classification logic for the v1 ~10 callback types, keyed by their native class-name +/// wire `type` string (e.g. `"NameCallback"`). +class CallbackHelpers { + const CallbackHelpers._(); + + static const _outputOnlyTypes = { + CallbackType.textOutputCallback, + CallbackType.suspendedTextOutputCallback, + }; + + static const _passwordTypes = { + CallbackType.passwordCallback, + CallbackType.validatedPasswordCallback, + }; + + static const _booleanTypes = { + CallbackType.booleanAttributeInputCallback, + CallbackType.termsAndConditionsCallback, + }; + + static const _numberTypes = {CallbackType.numberAttributeInputCallback}; + + static const _choiceTypes = {CallbackType.choiceCallback}; + + static const _kbaTypes = {CallbackType.kbaCreateCallback}; + + static const _textTypes = { + CallbackType.nameCallback, + CallbackType.textInputCallback, + CallbackType.stringAttributeInputCallback, + CallbackType.validatedUsernameCallback, + }; + + static const _manualTypes = { + CallbackType.nameCallback, + CallbackType.passwordCallback, + CallbackType.textInputCallback, + CallbackType.stringAttributeInputCallback, + CallbackType.numberAttributeInputCallback, + CallbackType.booleanAttributeInputCallback, + CallbackType.choiceCallback, + CallbackType.kbaCreateCallback, + CallbackType.termsAndConditionsCallback, + CallbackType.validatedUsernameCallback, + CallbackType.validatedPasswordCallback, + }; + + /// Priority-cascade classification — order matters in case future callback types are added to + /// more than one set. + static FieldKind resolveFieldKind(String type) { + if (_outputOnlyTypes.contains(type)) return FieldKind.output; + if (_passwordTypes.contains(type)) return FieldKind.password; + if (_booleanTypes.contains(type)) return FieldKind.boolean; + if (_numberTypes.contains(type)) return FieldKind.number; + if (_choiceTypes.contains(type)) return FieldKind.choice; + if (_kbaTypes.contains(type)) return FieldKind.kba; + if (_textTypes.contains(type)) return FieldKind.text; + return FieldKind.unknown; + } + + /// Determines how the callback identified by [type] should be handled — submitted directly, + /// requiring no input, or unsupported by this bridge. + static ExecutionMode resolveExecutionMode(String type) { + if (_outputOnlyTypes.contains(type)) return ExecutionMode.outputOnly; + if (_manualTypes.contains(type)) return ExecutionMode.manual; + return ExecutionMode.unsupported; + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback_type.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback_type.dart new file mode 100644 index 00000000..36fd7547 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback_type.dart @@ -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. + */ + +/// Native callback class-name wire type strings (`CallbackMessage.type`), shared between +/// [NodeMapper] and [CallbackHelpers] so the two can't drift or typo independently. +abstract final class CallbackType { + /// Wire type for `NameCallback`. + static const nameCallback = 'NameCallback'; + + /// Wire type for `PasswordCallback`. + static const passwordCallback = 'PasswordCallback'; + + /// Wire type for `ValidatedUsernameCallback`. + static const validatedUsernameCallback = 'ValidatedUsernameCallback'; + + /// Wire type for `ValidatedPasswordCallback`. + static const validatedPasswordCallback = 'ValidatedPasswordCallback'; + + /// Wire type for `ChoiceCallback`. + static const choiceCallback = 'ChoiceCallback'; + + /// Wire type for `KbaCreateCallback`. + static const kbaCreateCallback = 'KbaCreateCallback'; + + /// Wire type for `TermsAndConditionsCallback`. + static const termsAndConditionsCallback = 'TermsAndConditionsCallback'; + + /// Wire type for `TextInputCallback`. + static const textInputCallback = 'TextInputCallback'; + + /// Wire type for `TextOutputCallback`. + static const textOutputCallback = 'TextOutputCallback'; + + /// Wire type for `SuspendedTextOutputCallback`. + static const suspendedTextOutputCallback = 'SuspendedTextOutputCallback'; + + /// Wire type for `StringAttributeInputCallback`. + static const stringAttributeInputCallback = 'StringAttributeInputCallback'; + + /// Wire type for `NumberAttributeInputCallback`. + static const numberAttributeInputCallback = 'NumberAttributeInputCallback'; + + /// Wire type for `BooleanAttributeInputCallback`. + static const booleanAttributeInputCallback = 'BooleanAttributeInputCallback'; +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/journey_client.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/journey_client.dart new file mode 100644 index 00000000..4090ad77 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/journey_client.dart @@ -0,0 +1,89 @@ +/* + * 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 'package:flutter/services.dart'; +import 'package:ping_core/ping_core.dart'; + +import 'journey_node.dart'; +import 'messages.g.dart'; +import 'node_mapper.dart'; +import 'session.dart'; + +/// Public Dart facade over the generated [PingJourneyHostApi]. Wraps every call so native +/// failures surface as a typed [PingException] rather than a raw [PlatformException]. +class JourneyClient { + JourneyClient._(this._journeyId, this._hostApi); + + final String _journeyId; + final PingJourneyHostApi _hostApi; + + /// Builds the native `Journey` for [config] and returns a client bound to it. + static Future configure( + JourneyConfigMessage config, { + PingJourneyHostApi? hostApi, + }) async { + final api = hostApi ?? PingJourneyHostApi(); + final journeyId = await _guard(() => api.configureJourney(config)); + return JourneyClient._(journeyId, api); + } + + /// Starts the named Journey and returns the first node. + Future start( + String name, { + bool forceAuth = false, + bool noSession = false, + }) async { + final message = await _guard( + () => _hostApi.start( + _journeyId, + name, + StartOptionsMessage(forceAuth: forceAuth, noSession: noSession), + ), + ); + return NodeMapper.map(message, _journeyId); + } + + /// Submits the current [node]'s callback values and returns the next node. + Future next(ContinueNode node) async { + final values = node.callbacks + .map((callback) => callback.toValue()) + .whereType() + .toList(); + final message = await _guard(() => _hostApi.next(_journeyId, values)); + return NodeMapper.map(message, _journeyId); + } + + /// Fetches the access/refresh token + userinfo for a completed Journey. + Future user() async { + final message = await _guard(() => _hostApi.getSession(_journeyId)); + if (message == null) return null; + return Session.fromJson({ + 'accessToken': message.accessToken, + 'refreshToken': message.refreshToken, + 'expiresIn': message.expiresIn, + 'userInfo': message.userInfo, + }); + } + + /// Signs the user out of the current session, returning whether the sign-off succeeded. + Future signOff() => _guard(() => _hostApi.signOff(_journeyId)); + + /// Releases the native `Journey` resources backing this client. + Future dispose() => _guard(() => _hostApi.dispose(_journeyId)); + + static Future _guard(Future Function() body) async { + try { + return await body(); + } on PlatformException catch (error) { + throw PingException( + error.code, + error.details is String ? error.details as String : 'unknown', + error.message ?? 'Unknown journey error', + ); + } + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/journey_node.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/journey_node.dart new file mode 100644 index 00000000..b2313c66 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/journey_node.dart @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import 'callback/callback.dart'; + +/// A step in a Journey. Dart-side re-inflation of the wire [NodeMessage]/`NodeType` pair — see +/// `node_mapper.dart`. +sealed class JourneyNode { + const JourneyNode(); +} + +/// The Journey is waiting on user input: render [callbacks] and submit via `JourneyClient.next`. +final class ContinueNode extends JourneyNode { + /// Creates a continue node from its wire-message fields. + const ContinueNode({ + required this.journeyId, + required this.callbacks, + this.header, + this.description, + this.stage, + }); + + /// The native `Journey` handle this node belongs to; needed to call `next()`. + final String journeyId; + + /// The callbacks to render and collect values for before calling `JourneyClient.next()`. + final List callbacks; + + /// The title to display for this step, if provided by the server. + final String? header; + + /// Additional descriptive text to display for this step, if provided by the server. + final String? description; + + /// The server-defined stage identifier for this step, if provided. + final String? stage; +} + +/// The Journey completed successfully; call `JourneyClient.user()` for tokens/userinfo. +final class SuccessNode extends JourneyNode { + /// Creates a success node. + const SuccessNode(); +} + +/// A recoverable error was reported by the server (e.g. invalid credentials). +final class ErrorNode extends JourneyNode { + /// Creates an error node from its wire-message fields. + const ErrorNode({required this.message, this.status}); + + /// The human-readable error message reported by the server. + final String message; + + /// HTTP status, when the native SDK exposes one (iOS only; Android has no equivalent field). + final int? status; +} + +/// An unrecoverable failure (network, native SDK exception, or unmapped node type). +final class FailureNode extends JourneyNode { + /// Creates a failure node describing why the Journey could not continue. + const FailureNode({required this.cause}); + + /// A description of the underlying failure (e.g. network error, native exception). + final String cause; +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/messages.g.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/messages.g.dart new file mode 100644 index 00000000..2b11cf9a --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/messages.g.dart @@ -0,0 +1,859 @@ +// 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. +// Autogenerated from Pigeon (v27.1.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: unused_import, unused_shown_name +// ignore_for_file: type=lint + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List; + +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; + +Object? _extractReplyValueOrThrow( + List? replyList, + String channelName, { + required bool isNullValid, +}) { + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } + return replyList.firstOrNull; +} + +bool _deepEquals(Object? a, Object? b) { + if (identical(a, b)) { + return true; + } + if (a is double && b is double) { + if (a.isNaN && b.isNaN) { + return true; + } + return a == b; + } + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + if (a.length != b.length) { + return false; + } + for (final MapEntry entryA in a.entries) { + bool found = false; + for (final MapEntry entryB in b.entries) { + if (_deepEquals(entryA.key, entryB.key)) { + if (_deepEquals(entryA.value, entryB.value)) { + found = true; + break; + } else { + return false; + } + } + } + if (!found) { + return false; + } + } + return true; + } + return a == b; +} + +int _deepHash(Object? value) { + if (value is List) { + return Object.hashAll(value.map(_deepHash)); + } + if (value is Map) { + int result = 0; + for (final MapEntry entry in value.entries) { + result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value); + } + return result; + } + if (value is double && value.isNaN) { + // Normalize NaN to a consistent hash. + return 0x7FF8000000000000.hashCode; + } + if (value is double && value == 0.0) { + // Normalize -0.0 to 0.0 so they have the same hash code. + return 0.0.hashCode; + } + return value.hashCode; +} + + +/// Node type tag mirroring the native `Node` sealed hierarchy +/// (`ContinueNode`/`SuccessNode`/`ErrorNode`/`FailureNode`). +enum NodeType { + continueNode, + successNode, + errorNode, + failureNode, +} + +/// Flat, wire-serializable Journey configuration. OIDC fields are hoisted to +/// the top level rather than nested, since Pigeon classes can't express an +/// "OIDC configured only if any OIDC field is present" union cleanly. +class JourneyConfigMessage { + JourneyConfigMessage({ + required this.serverUrl, + this.realm, + this.cookie, + this.timeoutMillis, + this.clientId, + this.discoveryEndpoint, + this.redirectUri, + this.scopes, + this.acrValues, + this.signOutRedirectUri, + this.state, + this.nonce, + this.uiLocales, + this.refreshThreshold, + this.loginHint, + this.display, + this.prompt, + this.additionalParameters, + }); + + String serverUrl; + + String? realm; + + String? cookie; + + /// Milliseconds. + int? timeoutMillis; + + String? clientId; + + String? discoveryEndpoint; + + String? redirectUri; + + List? scopes; + + String? acrValues; + + String? signOutRedirectUri; + + String? state; + + String? nonce; + + String? uiLocales; + + /// Seconds. + int? refreshThreshold; + + String? loginHint; + + String? display; + + String? prompt; + + Map? additionalParameters; + + List _toList() { + return [ + serverUrl, + realm, + cookie, + timeoutMillis, + clientId, + discoveryEndpoint, + redirectUri, + scopes, + acrValues, + signOutRedirectUri, + state, + nonce, + uiLocales, + refreshThreshold, + loginHint, + display, + prompt, + additionalParameters, + ]; + } + + Object encode() { + return _toList(); } + + static JourneyConfigMessage decode(Object result) { + result as List; + return JourneyConfigMessage( + serverUrl: result[0]! as String, + realm: result[1] as String?, + cookie: result[2] as String?, + timeoutMillis: result[3] as int?, + clientId: result[4] as String?, + discoveryEndpoint: result[5] as String?, + redirectUri: result[6] as String?, + scopes: (result[7] as List?)?.cast(), + acrValues: result[8] as String?, + signOutRedirectUri: result[9] as String?, + state: result[10] as String?, + nonce: result[11] as String?, + uiLocales: result[12] as String?, + refreshThreshold: result[13] as int?, + loginHint: result[14] as String?, + display: result[15] as String?, + prompt: result[16] as String?, + additionalParameters: (result[17] as Map?)?.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! JourneyConfigMessage || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(serverUrl, other.serverUrl) && _deepEquals(realm, other.realm) && _deepEquals(cookie, other.cookie) && _deepEquals(timeoutMillis, other.timeoutMillis) && _deepEquals(clientId, other.clientId) && _deepEquals(discoveryEndpoint, other.discoveryEndpoint) && _deepEquals(redirectUri, other.redirectUri) && _deepEquals(scopes, other.scopes) && _deepEquals(acrValues, other.acrValues) && _deepEquals(signOutRedirectUri, other.signOutRedirectUri) && _deepEquals(state, other.state) && _deepEquals(nonce, other.nonce) && _deepEquals(uiLocales, other.uiLocales) && _deepEquals(refreshThreshold, other.refreshThreshold) && _deepEquals(loginHint, other.loginHint) && _deepEquals(display, other.display) && _deepEquals(prompt, other.prompt) && _deepEquals(additionalParameters, other.additionalParameters); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'JourneyConfigMessage(serverUrl: $serverUrl, realm: $realm, cookie: $cookie, timeoutMillis: $timeoutMillis, clientId: $clientId, discoveryEndpoint: $discoveryEndpoint, redirectUri: $redirectUri, scopes: $scopes, acrValues: $acrValues, signOutRedirectUri: $signOutRedirectUri, state: $state, nonce: $nonce, uiLocales: $uiLocales, refreshThreshold: $refreshThreshold, loginHint: $loginHint, display: $display, prompt: $prompt, additionalParameters: $additionalParameters)'; + } +} + +class StartOptionsMessage { + StartOptionsMessage({ + required this.forceAuth, + required this.noSession, + }); + + bool forceAuth; + + bool noSession; + + List _toList() { + return [ + forceAuth, + noSession, + ]; + } + + Object encode() { + return _toList(); } + + static StartOptionsMessage decode(Object result) { + result as List; + return StartOptionsMessage( + forceAuth: result[0]! as bool, + noSession: result[1]! as bool, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! StartOptionsMessage || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(forceAuth, other.forceAuth) && _deepEquals(noSession, other.noSession); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'StartOptionsMessage(forceAuth: $forceAuth, noSession: $noSession)'; + } +} + +/// Flat union of the v1 callback fields (~10 callback types). `value` is +/// `Object?`, StandardMessageCodec-backed (covers String/bool/num/Map/List). +class CallbackMessage { + CallbackMessage({ + required this.type, + required this.index, + this.prompt, + this.message, + this.required, + this.value, + this.choices, + this.defaultChoice, + this.selectedIndex, + this.terms, + this.version, + this.createDate, + this.accepted, + this.defaultText, + this.predefinedQuestions, + this.selectedQuestion, + this.selectedAnswer, + this.allowUserDefinedQuestions, + this.name, + this.validateOnly, + this.policies, + this.failedPolicies, + this.echoOn, + this.messageType, + this.raw, + }); + + String type; + + int index; + + String? prompt; + + String? message; + + bool? required; + + Object? value; + + List? choices; + + int? defaultChoice; + + int? selectedIndex; + + /// Full terms-and-conditions text (TermsAndConditionsCallback). + String? terms; + + String? version; + + String? createDate; + + bool? accepted; + + /// Placeholder/default text (TextInputCallback). + String? defaultText; + + List? predefinedQuestions; + + String? selectedQuestion; + + String? selectedAnswer; + + bool? allowUserDefinedQuestions; + + /// Attribute name/label (Attribute*InputCallback), distinct from [value]. + String? name; + + /// Validate-without-advancing flag (ValidatedUsername/ValidatedPassword/ + /// Attribute*InputCallback). + bool? validateOnly; + + Map? policies; + + List?>? failedPolicies; + + /// Whether to mask input (ValidatedPasswordCallback). + bool? echoOn; + + /// TextOutputCallback message classification: INFORMATION/WARNING/ERROR/SCRIPT/UNKNOWN. + String? messageType; + + Map? raw; + + List _toList() { + return [ + type, + index, + prompt, + message, + required, + value, + choices, + defaultChoice, + selectedIndex, + terms, + version, + createDate, + accepted, + defaultText, + predefinedQuestions, + selectedQuestion, + selectedAnswer, + allowUserDefinedQuestions, + name, + validateOnly, + policies, + failedPolicies, + echoOn, + messageType, + raw, + ]; + } + + Object encode() { + return _toList(); } + + static CallbackMessage decode(Object result) { + result as List; + return CallbackMessage( + type: result[0]! as String, + index: result[1]! as int, + prompt: result[2] as String?, + message: result[3] as String?, + required: result[4] as bool?, + value: result[5], + choices: (result[6] as List?)?.cast(), + defaultChoice: result[7] as int?, + selectedIndex: result[8] as int?, + terms: result[9] as String?, + version: result[10] as String?, + createDate: result[11] as String?, + accepted: result[12] as bool?, + defaultText: result[13] as String?, + predefinedQuestions: (result[14] as List?)?.cast(), + selectedQuestion: result[15] as String?, + selectedAnswer: result[16] as String?, + allowUserDefinedQuestions: result[17] as bool?, + name: result[18] as String?, + validateOnly: result[19] as bool?, + policies: (result[20] as Map?)?.cast(), + failedPolicies: (result[21] as List?)?.cast?>(), + echoOn: result[22] as bool?, + messageType: result[23] as String?, + raw: (result[24] as Map?)?.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! CallbackMessage || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(type, other.type) && _deepEquals(index, other.index) && _deepEquals(prompt, other.prompt) && _deepEquals(message, other.message) && _deepEquals(required, other.required) && _deepEquals(value, other.value) && _deepEquals(choices, other.choices) && _deepEquals(defaultChoice, other.defaultChoice) && _deepEquals(selectedIndex, other.selectedIndex) && _deepEquals(terms, other.terms) && _deepEquals(version, other.version) && _deepEquals(createDate, other.createDate) && _deepEquals(accepted, other.accepted) && _deepEquals(defaultText, other.defaultText) && _deepEquals(predefinedQuestions, other.predefinedQuestions) && _deepEquals(selectedQuestion, other.selectedQuestion) && _deepEquals(selectedAnswer, other.selectedAnswer) && _deepEquals(allowUserDefinedQuestions, other.allowUserDefinedQuestions) && _deepEquals(name, other.name) && _deepEquals(validateOnly, other.validateOnly) && _deepEquals(policies, other.policies) && _deepEquals(failedPolicies, other.failedPolicies) && _deepEquals(echoOn, other.echoOn) && _deepEquals(messageType, other.messageType) && _deepEquals(raw, other.raw); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'CallbackMessage(type: $type, index: $index, prompt: $prompt, message: $message, required: $required, value: $value, choices: $choices, defaultChoice: $defaultChoice, selectedIndex: $selectedIndex, terms: $terms, version: $version, createDate: $createDate, accepted: $accepted, defaultText: $defaultText, predefinedQuestions: $predefinedQuestions, selectedQuestion: $selectedQuestion, selectedAnswer: $selectedAnswer, allowUserDefinedQuestions: $allowUserDefinedQuestions, name: $name, validateOnly: $validateOnly, policies: $policies, failedPolicies: $failedPolicies, echoOn: $echoOn, messageType: $messageType, raw: $raw)'; + } +} + +/// Dart -> native callback value, addressed by `{type, index}` so native can +/// re-resolve it against the cached `ContinueNode`. +class CallbackValueMessage { + CallbackValueMessage({ + required this.type, + required this.index, + this.value, + }); + + String type; + + int index; + + Object? value; + + List _toList() { + return [ + type, + index, + value, + ]; + } + + Object encode() { + return _toList(); } + + static CallbackValueMessage decode(Object result) { + result as List; + return CallbackValueMessage( + type: result[0]! as String, + index: result[1]! as int, + value: result[2], + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! CallbackValueMessage || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(type, other.type) && _deepEquals(index, other.index) && _deepEquals(value, other.value); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'CallbackValueMessage(type: $type, index: $index, value: $value)'; + } +} + +class NodeMessage { + NodeMessage({ + required this.type, + this.message, + this.cause, + this.status, + this.header, + this.pageDescription, + this.stage, + this.callbacks, + this.input, + }); + + NodeType type; + + String? message; + + String? cause; + + int? status; + + /// Page metadata (ContinueNode only) — native extension properties on + /// `ContinueNode`, exposed as real fields rather than left opaque. + String? header; + + String? pageDescription; + + String? stage; + + List? callbacks; + + Map? input; + + List _toList() { + return [ + type, + message, + cause, + status, + header, + pageDescription, + stage, + callbacks, + input, + ]; + } + + Object encode() { + return _toList(); } + + static NodeMessage decode(Object result) { + result as List; + return NodeMessage( + type: result[0]! as NodeType, + message: result[1] as String?, + cause: result[2] as String?, + status: result[3] as int?, + header: result[4] as String?, + pageDescription: result[5] as String?, + stage: result[6] as String?, + callbacks: (result[7] as List?)?.cast(), + input: (result[8] as Map?)?.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NodeMessage || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(type, other.type) && _deepEquals(message, other.message) && _deepEquals(cause, other.cause) && _deepEquals(status, other.status) && _deepEquals(header, other.header) && _deepEquals(pageDescription, other.pageDescription) && _deepEquals(stage, other.stage) && _deepEquals(callbacks, other.callbacks) && _deepEquals(input, other.input); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'NodeMessage(type: $type, message: $message, cause: $cause, status: $status, header: $header, pageDescription: $pageDescription, stage: $stage, callbacks: $callbacks, input: $input)'; + } +} + +class SessionMessage { + SessionMessage({ + required this.accessToken, + this.refreshToken, + required this.expiresIn, + this.userInfo, + }); + + String accessToken; + + String? refreshToken; + + int expiresIn; + + Map? userInfo; + + List _toList() { + return [ + accessToken, + refreshToken, + expiresIn, + userInfo, + ]; + } + + Object encode() { + return _toList(); } + + static SessionMessage decode(Object result) { + result as List; + return SessionMessage( + accessToken: result[0]! as String, + refreshToken: result[1] as String?, + expiresIn: result[2]! as int, + userInfo: (result[3] as Map?)?.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! SessionMessage || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(accessToken, other.accessToken) && _deepEquals(refreshToken, other.refreshToken) && _deepEquals(expiresIn, other.expiresIn) && _deepEquals(userInfo, other.userInfo); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'SessionMessage(accessToken: $accessToken, refreshToken: $refreshToken, expiresIn: $expiresIn, userInfo: $userInfo)'; + } +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is NodeType) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is JourneyConfigMessage) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else if (value is StartOptionsMessage) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else if (value is CallbackMessage) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is CallbackValueMessage) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is NodeMessage) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); + } else if (value is SessionMessage) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final value = readValue(buffer) as int?; + return value == null ? null : NodeType.values[value]; + case 130: + return JourneyConfigMessage.decode(readValue(buffer)!); + case 131: + return StartOptionsMessage.decode(readValue(buffer)!); + case 132: + return CallbackMessage.decode(readValue(buffer)!); + case 133: + return CallbackValueMessage.decode(readValue(buffer)!); + case 134: + return NodeMessage.decode(readValue(buffer)!); + case 135: + return SessionMessage.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class PingJourneyHostApi { + /// Constructor for [PingJourneyHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + PingJourneyHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future configureJourney(JourneyConfigMessage config) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.ping_journey.PingJourneyHostApi.configureJourney$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([config]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as String; + } + + Future start(String journeyId, String name, StartOptionsMessage options) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.ping_journey.PingJourneyHostApi.start$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([journeyId, name, options]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as NodeMessage; + } + + Future next(String journeyId, List values) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.ping_journey.PingJourneyHostApi.next$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([journeyId, values]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as NodeMessage; + } + + Future getSession(String journeyId) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.ping_journey.PingJourneyHostApi.getSession$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([journeyId]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + return pigeonVar_replyValue as SessionMessage?; + } + + Future signOff(String journeyId) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.ping_journey.PingJourneyHostApi.signOff$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([journeyId]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as bool; + } + + Future dispose(String journeyId) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.ping_journey.PingJourneyHostApi.dispose$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([journeyId]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/node_mapper.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/node_mapper.dart new file mode 100644 index 00000000..268245ed --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/node_mapper.dart @@ -0,0 +1,172 @@ +/* + * 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 'callback/callback.dart'; +import 'callback_type.dart'; +import 'journey_node.dart'; +import 'messages.g.dart'; + +/// Re-inflates wire messages (Pigeon-generated) into the Dart sealed [JourneyNode]/[Callback] +/// hierarchies. +class NodeMapper { + const NodeMapper._(); + + /// Maps a [NodeMessage] to the matching [JourneyNode] subtype. [journeyId] is injected (it's + /// not carried on the wire node itself) so [ContinueNode] can address a later `next()` call. + static JourneyNode map(NodeMessage message, String journeyId) => + switch (message.type) { + NodeType.continueNode => ContinueNode( + journeyId: journeyId, + header: message.header, + description: message.pageDescription, + stage: message.stage, + callbacks: + message.callbacks + ?.whereType() + .map(mapCallback) + .toList() ?? + const [], + ), + NodeType.successNode => const SuccessNode(), + NodeType.errorNode => ErrorNode( + message: message.message ?? '', + status: message.status, + ), + NodeType.failureNode => FailureNode( + cause: message.cause ?? 'Unknown failure', + ), + }; + + /// Maps a [CallbackMessage] to the matching [Callback] subtype, keyed by its native class-name + /// [CallbackMessage.type]. Unrecognized types are not representable in the v1 sealed hierarchy + /// and are dropped by the caller (see [ContinueNode.callbacks] construction above, via + /// `whereType`/`map` — a switch here must still be exhaustive over the *known* wire names). + static Callback mapCallback( + CallbackMessage message, + ) => switch (message.type) { + CallbackType.nameCallback => NameCallback( + type: message.type, + index: message.index, + prompt: message.prompt, + name: message.value as String? ?? '', + ), + CallbackType.passwordCallback => PasswordCallback( + type: message.type, + index: message.index, + prompt: message.prompt, + ), + CallbackType.validatedUsernameCallback => ValidatedUsernameCallback( + type: message.type, + index: message.index, + prompt: message.prompt, + username: message.value as String? ?? '', + validateOnly: message.validateOnly ?? false, + policies: message.policies?.cast(), + failedPolicies: _castFailedPolicies(message.failedPolicies), + ), + CallbackType.validatedPasswordCallback => ValidatedPasswordCallback( + type: message.type, + index: message.index, + prompt: message.prompt, + echoOn: message.echoOn ?? false, + validateOnly: message.validateOnly ?? false, + policies: message.policies?.cast(), + failedPolicies: _castFailedPolicies(message.failedPolicies), + ), + CallbackType.choiceCallback => ChoiceCallback( + type: message.type, + index: message.index, + prompt: message.prompt, + choices: message.choices?.whereType().toList() ?? const [], + defaultChoice: message.defaultChoice ?? 0, + selectedIndex: message.selectedIndex, + ), + CallbackType.kbaCreateCallback => KbaCreateCallback( + type: message.type, + index: message.index, + prompt: message.prompt, + predefinedQuestions: + message.predefinedQuestions?.whereType().toList() ?? const [], + selectedQuestion: message.selectedQuestion ?? '', + selectedAnswer: message.selectedAnswer ?? '', + allowUserDefinedQuestions: message.allowUserDefinedQuestions ?? false, + ), + CallbackType.termsAndConditionsCallback => TermsAndConditionsCallback( + type: message.type, + index: message.index, + version: message.version ?? '', + terms: message.terms ?? '', + createDate: message.createDate ?? '', + accepted: message.accepted ?? false, + ), + CallbackType.textInputCallback => TextInputCallback( + type: message.type, + index: message.index, + prompt: message.prompt, + defaultText: message.defaultText ?? '', + text: message.value as String?, + ), + CallbackType.textOutputCallback || + CallbackType.suspendedTextOutputCallback => TextOutputCallback( + type: message.type, + index: message.index, + message: message.message ?? '', + messageType: message.messageType, + ), + CallbackType.stringAttributeInputCallback => StringAttributeInputCallback( + type: message.type, + index: message.index, + prompt: message.prompt, + name: message.name ?? '', + required: message.required ?? false, + value: message.value as String? ?? '', + validateOnly: message.validateOnly ?? false, + policies: message.policies?.cast(), + failedPolicies: _castFailedPolicies(message.failedPolicies), + ), + CallbackType.numberAttributeInputCallback => NumberAttributeInputCallback( + type: message.type, + index: message.index, + prompt: message.prompt, + name: message.name ?? '', + required: message.required ?? false, + value: (message.value as num?)?.toDouble() ?? 0, + validateOnly: message.validateOnly ?? false, + policies: message.policies?.cast(), + failedPolicies: _castFailedPolicies(message.failedPolicies), + ), + CallbackType.booleanAttributeInputCallback => + BooleanAttributeInputCallback( + type: message.type, + index: message.index, + prompt: message.prompt, + name: message.name ?? '', + required: message.required ?? false, + value: message.value as bool? ?? false, + validateOnly: message.validateOnly ?? false, + policies: message.policies?.cast(), + failedPolicies: _castFailedPolicies(message.failedPolicies), + ), + _ => TextOutputCallback( + type: message.type, + index: message.index, + message: + message.message ?? + message.prompt ?? + 'Unsupported callback: ${message.type}', + ), + }; + + static List> _castFailedPolicies( + List?>? raw, + ) => + raw + ?.whereType>() + .map((m) => m.cast()) + .toList() ?? + const []; +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/session.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/session.dart new file mode 100644 index 00000000..9a0e421f --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/session.dart @@ -0,0 +1,74 @@ +/* + * 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. + */ + +/// The tokens and userinfo returned on a successful Journey (`JourneyClient.user()`). +/// +/// Public-facing counterpart of the wire `SessionMessage` — kept as a distinct type (rather than +/// exposing the Pigeon message directly) so callers get plain `Map`/JSON-shaped values, with +/// manual `fromJson`/`toJson` per DESIGN.md rather than a generated codec. +class Session { + /// Creates a session from its already-parsed fields. + const Session({ + required this.accessToken, + this.refreshToken, + required this.expiresIn, + this.userInfo = const {}, + }); + + /// The OAuth access token issued for the completed Journey. + final String accessToken; + + /// The OAuth refresh token issued for the completed Journey, if the server returned one. + final String? refreshToken; + + /// The number of seconds until [accessToken] expires. + final int expiresIn; + + /// The userinfo claims returned alongside the tokens. + final Map userInfo; + + /// Parses a JSON-shaped map (e.g. decoded from `SessionMessage`). Throws [FormatException] if + /// required fields are missing or the wrong type. + factory Session.fromJson(Map json) { + final accessToken = json['accessToken']; + if (accessToken is! String || accessToken.isEmpty) { + throw const FormatException( + 'Session.accessToken must be a non-empty string', + ); + } + final expiresIn = json['expiresIn']; + if (expiresIn is! int) { + throw const FormatException('Session.expiresIn must be an int'); + } + final refreshToken = json['refreshToken']; + if (refreshToken != null && refreshToken is! String) { + throw const FormatException( + 'Session.refreshToken must be a string when present', + ); + } + final userInfo = json['userInfo']; + if (userInfo != null && userInfo is! Map) { + throw const FormatException( + 'Session.userInfo must be a map when present', + ); + } + return Session( + accessToken: accessToken, + refreshToken: refreshToken as String?, + expiresIn: expiresIn, + userInfo: (userInfo as Map?)?.cast() ?? const {}, + ); + } + + /// Serializes this session back to a JSON-shaped map. + Map toJson() => { + 'accessToken': accessToken, + if (refreshToken != null) 'refreshToken': refreshToken, + 'expiresIn': expiresIn, + 'userInfo': userInfo, + }; +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/pigeons/copyright.txt b/flutter/flutter-sdk-bridge/ping_journey/pigeons/copyright.txt new file mode 100644 index 00000000..a9bae82c --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/pigeons/copyright.txt @@ -0,0 +1,4 @@ +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. diff --git a/flutter/flutter-sdk-bridge/ping_journey/pigeons/messages.dart b/flutter/flutter-sdk-bridge/ping_journey/pigeons/messages.dart new file mode 100644 index 00000000..c62be7c2 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/pigeons/messages.dart @@ -0,0 +1,165 @@ +// Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. + +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/src/messages.g.dart', + dartOptions: DartOptions(), + kotlinOut: + 'android/src/main/kotlin/com/pingidentity/flutter/journey/Messages.g.kt', + kotlinOptions: KotlinOptions(package: 'com.pingidentity.flutter.journey'), + swiftOut: 'ios/ping_journey/Sources/ping_journey/Messages.g.swift', + swiftOptions: SwiftOptions(), + copyrightHeader: 'pigeons/copyright.txt', + dartPackageName: 'ping_journey', + ), +) +/// Node type tag mirroring the native `Node` sealed hierarchy +/// (`ContinueNode`/`SuccessNode`/`ErrorNode`/`FailureNode`). +enum NodeType { continueNode, successNode, errorNode, failureNode } + +/// Flat, wire-serializable Journey configuration. OIDC fields are hoisted to +/// the top level rather than nested, since Pigeon classes can't express an +/// "OIDC configured only if any OIDC field is present" union cleanly. +class JourneyConfigMessage { + JourneyConfigMessage({required this.serverUrl}); + + String serverUrl; + String? realm; + String? cookie; + + /// Milliseconds. + int? timeoutMillis; + + String? clientId; + String? discoveryEndpoint; + String? redirectUri; + List? scopes; + String? acrValues; + String? signOutRedirectUri; + String? state; + String? nonce; + String? uiLocales; + + /// Seconds. + int? refreshThreshold; + String? loginHint; + String? display; + String? prompt; + Map? additionalParameters; +} + +class StartOptionsMessage { + StartOptionsMessage({required this.forceAuth, required this.noSession}); + + bool forceAuth; + bool noSession; +} + +/// Flat union of the v1 callback fields (~10 callback types). `value` is +/// `Object?`, StandardMessageCodec-backed (covers String/bool/num/Map/List). +class CallbackMessage { + CallbackMessage({required this.type, required this.index}); + + String type; + int index; + String? prompt; + String? message; + bool? required; + Object? value; + List? choices; + int? defaultChoice; + int? selectedIndex; + + /// Full terms-and-conditions text (TermsAndConditionsCallback). + String? terms; + String? version; + String? createDate; + bool? accepted; + + /// Placeholder/default text (TextInputCallback). + String? defaultText; + List? predefinedQuestions; + String? selectedQuestion; + String? selectedAnswer; + bool? allowUserDefinedQuestions; + + /// Attribute name/label (Attribute*InputCallback), distinct from [value]. + String? name; + + /// Validate-without-advancing flag (ValidatedUsername/ValidatedPassword/ + /// Attribute*InputCallback). Read-only: reflects native state at mapping + /// time; [CallbackValueMessage] has no field to send an updated value back + /// to native (same limitation as the React Native bridge). + bool? validateOnly; + Map? policies; + List?>? failedPolicies; + + /// Whether to mask input (ValidatedPasswordCallback). + bool? echoOn; + + /// TextOutputCallback message classification: INFORMATION/WARNING/ERROR/SCRIPT/UNKNOWN. + String? messageType; + Map? raw; +} + +/// Dart -> native callback value, addressed by `{type, index}` so native can +/// re-resolve it against the cached `ContinueNode`. +class CallbackValueMessage { + CallbackValueMessage({required this.type, required this.index}); + + String type; + int index; + Object? value; +} + +class NodeMessage { + NodeMessage({required this.type}); + + NodeType type; + String? message; + String? cause; + int? status; + + /// Page metadata (ContinueNode only) — native extension properties on + /// `ContinueNode`, exposed as real fields rather than left opaque. + String? header; + String? pageDescription; + String? stage; + List? callbacks; + Map? input; +} + +class SessionMessage { + SessionMessage({required this.accessToken, required this.expiresIn}); + + String accessToken; + String? refreshToken; + int expiresIn; + Map? userInfo; +} + +@HostApi() +abstract class PingJourneyHostApi { + @async + String configureJourney(JourneyConfigMessage config); + + @async + NodeMessage start(String journeyId, String name, StartOptionsMessage options); + + @async + NodeMessage next(String journeyId, List values); + + @async + SessionMessage? getSession(String journeyId); + + @async + bool signOff(String journeyId); + + @async + void dispose(String journeyId); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/pubspec.yaml b/flutter/flutter-sdk-bridge/ping_journey/pubspec.yaml new file mode 100644 index 00000000..49529d7a --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/pubspec.yaml @@ -0,0 +1,77 @@ +name: ping_journey +description: "Flutter bridge for the Ping Journey SDK: drives native Journey orchestration (nodes, callbacks, sessions) from Dart via ping_core." +version: 0.0.1 +homepage: +publish_to: none + +environment: + sdk: ^3.12.2 + flutter: '>=3.3.0' + +resolution: workspace + +dependencies: + flutter: + sdk: flutter + ping_core: + path: ../ping_core + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + pigeon: ^27.1.2 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.pingidentity.flutter.journey + pluginClass: PingJourneyPlugin + ios: + pluginClass: PingJourneyPlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/flutter/flutter-sdk-bridge/ping_journey/test/src/callback_helpers_test.dart b/flutter/flutter-sdk-bridge/ping_journey/test/src/callback_helpers_test.dart new file mode 100644 index 00000000..4fe5dad6 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/test/src/callback_helpers_test.dart @@ -0,0 +1,89 @@ +/* + * 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 'package:flutter_test/flutter_test.dart'; +import 'package:ping_journey/ping_journey.dart'; + +void main() { + group('CallbackHelpers.resolveFieldKind', () { + const expectedKinds = { + CallbackType.nameCallback: FieldKind.text, + CallbackType.passwordCallback: FieldKind.password, + CallbackType.validatedUsernameCallback: FieldKind.text, + CallbackType.validatedPasswordCallback: FieldKind.password, + CallbackType.choiceCallback: FieldKind.choice, + CallbackType.kbaCreateCallback: FieldKind.kba, + CallbackType.termsAndConditionsCallback: FieldKind.boolean, + CallbackType.textInputCallback: FieldKind.text, + CallbackType.textOutputCallback: FieldKind.output, + CallbackType.stringAttributeInputCallback: FieldKind.text, + CallbackType.numberAttributeInputCallback: FieldKind.number, + CallbackType.booleanAttributeInputCallback: FieldKind.boolean, + }; + + expectedKinds.forEach((type, expectedKind) { + test('$type -> $expectedKind', () { + expect(CallbackHelpers.resolveFieldKind(type), expectedKind); + }); + }); + + test('unrecognized type resolves to unknown', () { + expect( + CallbackHelpers.resolveFieldKind('SomeFutureCallback'), + FieldKind.unknown, + ); + }); + }); + + group('CallbackHelpers.resolveExecutionMode', () { + const manualTypes = [ + CallbackType.nameCallback, + CallbackType.passwordCallback, + CallbackType.textInputCallback, + CallbackType.stringAttributeInputCallback, + CallbackType.numberAttributeInputCallback, + CallbackType.booleanAttributeInputCallback, + CallbackType.choiceCallback, + CallbackType.kbaCreateCallback, + CallbackType.termsAndConditionsCallback, + CallbackType.validatedUsernameCallback, + CallbackType.validatedPasswordCallback, + ]; + + for (final type in manualTypes) { + test('$type -> manual', () { + expect( + CallbackHelpers.resolveExecutionMode(type), + ExecutionMode.manual, + ); + }); + } + + test('TextOutputCallback -> outputOnly', () { + expect( + CallbackHelpers.resolveExecutionMode(CallbackType.textOutputCallback), + ExecutionMode.outputOnly, + ); + }); + + test('SuspendedTextOutputCallback -> outputOnly', () { + expect( + CallbackHelpers.resolveExecutionMode( + CallbackType.suspendedTextOutputCallback, + ), + ExecutionMode.outputOnly, + ); + }); + + test('unrecognized type resolves to unsupported', () { + expect( + CallbackHelpers.resolveExecutionMode('SomeFutureCallback'), + ExecutionMode.unsupported, + ); + }); + }); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/test/src/journey_client_test.dart b/flutter/flutter-sdk-bridge/ping_journey/test/src/journey_client_test.dart new file mode 100644 index 00000000..3af09c1b --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/test/src/journey_client_test.dart @@ -0,0 +1,307 @@ +/* + * 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 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ping_journey/ping_journey.dart'; + +/// Fake [PingJourneyHostApi]: overrides every method instead of hitting a platform channel, +/// so [JourneyClient] can be exercised without a native bridge. +class _FakeHostApi extends PingJourneyHostApi { + String? configureJourneyId; + JourneyConfigMessage? lastConfig; + + String? lastStartJourneyId; + String? lastStartName; + StartOptionsMessage? lastStartOptions; + NodeMessage? startResult; + + String? lastNextJourneyId; + List? lastNextValues; + NodeMessage? nextResult; + + String? lastGetSessionJourneyId; + SessionMessage? getSessionResult; + + String? lastSignOffJourneyId; + bool signOffResult = true; + + String? lastDisposeJourneyId; + + Object? errorToThrow; + + @override + Future configureJourney(JourneyConfigMessage config) async { + if (errorToThrow != null) throw errorToThrow!; + lastConfig = config; + return configureJourneyId ?? 'journey-1'; + } + + @override + Future start( + String journeyId, + String name, + StartOptionsMessage options, + ) async { + if (errorToThrow != null) throw errorToThrow!; + lastStartJourneyId = journeyId; + lastStartName = name; + lastStartOptions = options; + return startResult ?? NodeMessage(type: NodeType.successNode); + } + + @override + Future next( + String journeyId, + List values, + ) async { + if (errorToThrow != null) throw errorToThrow!; + lastNextJourneyId = journeyId; + lastNextValues = values; + return nextResult ?? NodeMessage(type: NodeType.successNode); + } + + @override + Future getSession(String journeyId) async { + if (errorToThrow != null) throw errorToThrow!; + lastGetSessionJourneyId = journeyId; + return getSessionResult; + } + + @override + Future signOff(String journeyId) async { + if (errorToThrow != null) throw errorToThrow!; + lastSignOffJourneyId = journeyId; + return signOffResult; + } + + @override + Future dispose(String journeyId) async { + if (errorToThrow != null) throw errorToThrow!; + lastDisposeJourneyId = journeyId; + } +} + +void main() { + late _FakeHostApi hostApi; + + setUp(() { + hostApi = _FakeHostApi(); + }); + + Future configureClient() => JourneyClient.configure( + JourneyConfigMessage(serverUrl: 'https://example.com'), + hostApi: hostApi, + ); + + group('JourneyClient.configure', () { + test('forwards the config and binds the returned journeyId', () async { + hostApi.configureJourneyId = 'journey-42'; + final config = JourneyConfigMessage(serverUrl: 'https://example.com'); + + final client = await JourneyClient.configure(config, hostApi: hostApi); + + expect(hostApi.lastConfig, config); + // Bound journeyId is exercised indirectly via next/dispose below. + await client.dispose(); + expect(hostApi.lastDisposeJourneyId, 'journey-42'); + }); + + test('wraps a PlatformException into a PingException', () async { + hostApi.errorToThrow = PlatformException( + code: 'JOURNEY_CONFIGURE_ERROR', + message: 'bad config', + details: 'argument', + ); + + expect( + () => JourneyClient.configure( + JourneyConfigMessage(serverUrl: 'https://example.com'), + hostApi: hostApi, + ), + throwsA( + isA() + .having((e) => e.code, 'code', 'JOURNEY_CONFIGURE_ERROR') + .having((e) => e.type, 'type', 'argument') + .having((e) => e.message, 'message', 'bad config'), + ), + ); + }); + }); + + group('JourneyClient.start', () { + test('maps the returned NodeMessage to a JourneyNode', () async { + final client = await configureClient(); + hostApi.startResult = NodeMessage(type: NodeType.successNode); + + final node = await client.start('Login', forceAuth: true); + + expect(node, isA()); + expect(hostApi.lastStartName, 'Login'); + expect(hostApi.lastStartOptions?.forceAuth, true); + expect(hostApi.lastStartOptions?.noSession, false); + }); + }); + + group('JourneyClient.next', () { + test('submits toValue() for each callback and maps the next node', () async { + final client = await configureClient(); + hostApi.nextResult = NodeMessage(type: NodeType.successNode); + final node = ContinueNode( + journeyId: 'journey-1', + callbacks: [ + NameCallback(type: 'NameCallback', index: 0, name: 'jdoe'), + ], + ); + + await client.next(node); + + final values = hostApi.lastNextValues!; + expect(values, hasLength(1)); + expect(values.single!.value, 'jdoe'); + }); + + test('drops callbacks whose toValue() is null (output-only)', () async { + final client = await configureClient(); + hostApi.nextResult = NodeMessage(type: NodeType.successNode); + final node = ContinueNode( + journeyId: 'journey-1', + callbacks: [ + TextOutputCallback( + type: 'TextOutputCallback', + index: 0, + message: 'Welcome', + ), + ], + ); + + await client.next(node); + + expect(hostApi.lastNextValues, isEmpty); + }); + }); + + group('JourneyClient.user', () { + test('returns null when there is no session', () async { + final client = await configureClient(); + hostApi.getSessionResult = null; + + expect(await client.user(), isNull); + }); + + test('maps a SessionMessage to a Session', () async { + final client = await configureClient(); + hostApi.getSessionResult = SessionMessage( + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 3600, + userInfo: {'sub': 'user-1'}, + ); + + final session = await client.user(); + + expect(session, isNotNull); + expect(session!.accessToken, 'access-token'); + expect(session.refreshToken, 'refresh-token'); + expect(session.expiresIn, 3600); + expect(session.userInfo['sub'], 'user-1'); + }); + }); + + group('JourneyClient.signOff', () { + test('forwards the bound journeyId and returns the native result', () async { + final client = await configureClient(); + hostApi.signOffResult = true; + + expect(await client.signOff(), true); + }); + }); + + group('JourneyClient.dispose', () { + test('forwards the bound journeyId', () async { + final client = await configureClient(); + + await client.dispose(); + + expect(hostApi.lastDisposeJourneyId, isNotNull); + }); + }); + + group('JourneyClient._guard error mapping', () { + test('maps error.details String to PingException.type', () async { + final client = await configureClient(); + hostApi.errorToThrow = PlatformException( + code: 'JOURNEY_NEXT_ERROR', + message: 'no active node', + details: 'state', + ); + + expect( + () => client.next( + ContinueNode(journeyId: 'journey-1', callbacks: const []), + ), + throwsA( + isA() + .having((e) => e.code, 'code', 'JOURNEY_NEXT_ERROR') + .having((e) => e.type, 'type', 'state') + .having((e) => e.message, 'message', 'no active node'), + ), + ); + }); + + test('falls back to "unknown" type when error.details is not a String', () async { + final client = await configureClient(); + hostApi.errorToThrow = PlatformException( + code: 'JOURNEY_NEXT_ERROR', + message: 'no active node', + details: {'not': 'a string'}, + ); + + expect( + () => client.next( + ContinueNode(journeyId: 'journey-1', callbacks: const []), + ), + throwsA( + isA().having((e) => e.type, 'type', 'unknown'), + ), + ); + }); + + test('falls back to a default message when error.message is null', () async { + final client = await configureClient(); + hostApi.errorToThrow = PlatformException( + code: 'JOURNEY_NEXT_ERROR', + details: 'state', + ); + + expect( + () => client.next( + ContinueNode(journeyId: 'journey-1', callbacks: const []), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Unknown journey error', + ), + ), + ); + }); + + test('propagates non-PlatformException errors unchanged', () async { + final client = await configureClient(); + hostApi.errorToThrow = StateError('boom'); + + expect( + () => client.next( + ContinueNode(journeyId: 'journey-1', callbacks: const []), + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/test/src/node_mapper_test.dart b/flutter/flutter-sdk-bridge/ping_journey/test/src/node_mapper_test.dart new file mode 100644 index 00000000..36541e8f --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/test/src/node_mapper_test.dart @@ -0,0 +1,366 @@ +/* + * 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 'package:flutter_test/flutter_test.dart'; +import 'package:ping_journey/ping_journey.dart'; +import 'package:ping_journey/src/node_mapper.dart'; + +void main() { + group('NodeMapper.map', () { + test( + 'maps NodeType.continueNode to ContinueNode, carrying journeyId + page metadata', + () { + final message = NodeMessage( + type: NodeType.continueNode, + header: 'Sign On', + pageDescription: 'Enter your credentials', + stage: 'Login', + callbacks: [ + CallbackMessage( + type: 'NameCallback', + index: 0, + prompt: 'User Name', + value: '', + ), + ], + ); + + final node = NodeMapper.map(message, 'journey-1'); + + expect(node, isA()); + final continueNode = node as ContinueNode; + expect(continueNode.journeyId, 'journey-1'); + expect(continueNode.header, 'Sign On'); + expect(continueNode.description, 'Enter your credentials'); + expect(continueNode.stage, 'Login'); + expect(continueNode.callbacks, hasLength(1)); + expect(continueNode.callbacks.single, isA()); + }, + ); + + test('maps NodeType.successNode to SuccessNode', () { + final node = NodeMapper.map( + NodeMessage(type: NodeType.successNode), + 'journey-1', + ); + expect(node, isA()); + }); + + test( + 'maps NodeType.errorNode to ErrorNode, preserving message and status', + () { + final node = NodeMapper.map( + NodeMessage( + type: NodeType.errorNode, + message: 'Invalid credentials', + status: 401, + ), + 'journey-1', + ); + + expect(node, isA()); + final errorNode = node as ErrorNode; + expect(errorNode.message, 'Invalid credentials'); + expect(errorNode.status, 401); + }, + ); + + test( + 'maps NodeType.errorNode with no status to a null status (Android has no equivalent field)', + () { + final node = NodeMapper.map( + NodeMessage(type: NodeType.errorNode, message: 'Invalid credentials'), + 'journey-1', + ); + + expect((node as ErrorNode).status, isNull); + }, + ); + + test('maps NodeType.failureNode to FailureNode, preserving cause', () { + final node = NodeMapper.map( + NodeMessage(type: NodeType.failureNode, cause: 'Network error'), + 'journey-1', + ); + + expect(node, isA()); + expect((node as FailureNode).cause, 'Network error'); + }); + + test('falls back to a default cause when a FailureNode has none', () { + final node = NodeMapper.map( + NodeMessage(type: NodeType.failureNode), + 'journey-1', + ); + expect((node as FailureNode).cause, isNotEmpty); + }); + }); + + group('NodeMapper.mapCallback — representative ContinueNode re-inflation', () { + test( + 're-inflates NameCallback + PasswordCallback with correct per-type indices', + () { + final message = NodeMessage( + type: NodeType.continueNode, + callbacks: [ + CallbackMessage( + type: 'NameCallback', + index: 0, + prompt: 'User Name', + ), + CallbackMessage( + type: 'PasswordCallback', + index: 0, + prompt: 'Password', + ), + ], + ); + + final node = NodeMapper.map(message, 'journey-1') as ContinueNode; + + final name = node.callbacks[0]; + expect(name, isA()); + expect(name.type, 'NameCallback'); + expect(name.index, 0); + expect(name.prompt, 'User Name'); + + final password = node.callbacks[1]; + expect(password, isA()); + expect(password.type, 'PasswordCallback'); + expect(password.index, 0); + }, + ); + + test( + 'assigns a running per-type index when multiple callbacks share a type', + () { + final message = NodeMessage( + type: NodeType.continueNode, + callbacks: [ + CallbackMessage( + type: 'StringAttributeInputCallback', + index: 0, + name: 'firstName', + ), + CallbackMessage( + type: 'StringAttributeInputCallback', + index: 1, + name: 'lastName', + ), + ], + ); + + final node = NodeMapper.map(message, 'journey-1') as ContinueNode; + + expect((node.callbacks[0] as StringAttributeInputCallback).index, 0); + expect( + (node.callbacks[0] as StringAttributeInputCallback).name, + 'firstName', + ); + expect((node.callbacks[1] as StringAttributeInputCallback).index, 1); + expect( + (node.callbacks[1] as StringAttributeInputCallback).name, + 'lastName', + ); + }, + ); + }); + + group('Callback.toValue()', () { + test('NameCallback submits its text value', () { + final callback = NodeMapper.mapCallback( + CallbackMessage(type: 'NameCallback', index: 0, value: 'jdoe'), + ); + + final value = callback.toValue(); + expect(value, isNotNull); + expect(value!.type, 'NameCallback'); + expect(value.index, 0); + expect(value.value, 'jdoe'); + }); + + test('ChoiceCallback submits the selected index as an int', () { + final callback = NodeMapper.mapCallback( + CallbackMessage( + type: 'ChoiceCallback', + index: 0, + choices: const ['A', 'B'], + selectedIndex: 1, + ), + ); + + expect(callback.toValue()!.value, 1); + }); + + test('KbaCreateCallback submits an object-shaped value', () { + final callback = + NodeMapper.mapCallback( + CallbackMessage( + type: 'KbaCreateCallback', + index: 0, + predefinedQuestions: const ['What is your pet\'s name?'], + ), + ) + as KbaCreateCallback; + callback.selectedQuestion = 'What is your pet\'s name?'; + callback.selectedAnswer = 'Fido'; + + final value = callback.toValue().value as Map; + expect(value['selectedQuestion'], 'What is your pet\'s name?'); + expect(value['selectedAnswer'], 'Fido'); + expect(value['allowUserDefinedQuestions'], false); + }); + + test('TextOutputCallback is output-only and contributes no value', () { + final callback = NodeMapper.mapCallback( + CallbackMessage( + type: 'TextOutputCallback', + index: 0, + message: 'Welcome', + ), + ); + + expect(callback, isA()); + expect(callback.toValue(), isNull); + }); + + test( + 'PasswordCallback re-inflates to an empty password (native never sends it back), ' + 'then submits whatever the UI sets', + () { + final callback = + NodeMapper.mapCallback( + CallbackMessage(type: 'PasswordCallback', index: 0), + ) + as PasswordCallback; + expect(callback.password, ''); + + callback.password = 'super-secret'; + + final value = callback.toValue(); + expect(value.type, 'PasswordCallback'); + expect(value.index, 0); + expect(value.value, 'super-secret'); + }, + ); + + test('ValidatedUsernameCallback submits the username value', () { + final callback = + NodeMapper.mapCallback( + CallbackMessage( + type: 'ValidatedUsernameCallback', + index: 0, + value: 'jdoe', + ), + ) + as ValidatedUsernameCallback; + + final value = callback.toValue(); + expect(value.type, 'ValidatedUsernameCallback'); + expect(value.value, 'jdoe'); + }); + + test( + 'ValidatedPasswordCallback re-inflates to an empty password (native never sends it ' + 'back), then submits whatever the UI sets', + () { + final callback = + NodeMapper.mapCallback( + CallbackMessage(type: 'ValidatedPasswordCallback', index: 0), + ) + as ValidatedPasswordCallback; + expect(callback.password, ''); + + callback.password = 'super-secret'; + + final value = callback.toValue(); + expect(value.type, 'ValidatedPasswordCallback'); + expect(value.value, 'super-secret'); + }, + ); + + test('TermsAndConditionsCallback submits the accepted flag', () { + final callback = + NodeMapper.mapCallback( + CallbackMessage(type: 'TermsAndConditionsCallback', index: 0), + ) + as TermsAndConditionsCallback; + callback.accepted = true; + + final value = callback.toValue(); + expect(value.type, 'TermsAndConditionsCallback'); + expect(value.value, true); + }); + + test('TextInputCallback submits its text value', () { + final callback = + NodeMapper.mapCallback( + CallbackMessage( + type: 'TextInputCallback', + index: 0, + value: 'hello', + ), + ) + as TextInputCallback; + + final value = callback.toValue(); + expect(value.type, 'TextInputCallback'); + expect(value.value, 'hello'); + }); + + test('StringAttributeInputCallback submits its string value', () { + final callback = + NodeMapper.mapCallback( + CallbackMessage( + type: 'StringAttributeInputCallback', + index: 0, + name: 'firstName', + value: 'Alice', + ), + ) + as StringAttributeInputCallback; + + final value = callback.toValue(); + expect(value.type, 'StringAttributeInputCallback'); + expect(value.value, 'Alice'); + }); + + test('NumberAttributeInputCallback submits its numeric value', () { + final callback = + NodeMapper.mapCallback( + CallbackMessage( + type: 'NumberAttributeInputCallback', + index: 0, + name: 'age', + value: 42, + ), + ) + as NumberAttributeInputCallback; + + final value = callback.toValue(); + expect(value.type, 'NumberAttributeInputCallback'); + expect(value.value, 42.0); + }); + + test('BooleanAttributeInputCallback submits its boolean value', () { + final callback = + NodeMapper.mapCallback( + CallbackMessage( + type: 'BooleanAttributeInputCallback', + index: 0, + name: 'subscribed', + value: true, + ), + ) + as BooleanAttributeInputCallback; + + final value = callback.toValue(); + expect(value.type, 'BooleanAttributeInputCallback'); + expect(value.value, true); + }); + }); +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/test/src/session_test.dart b/flutter/flutter-sdk-bridge/ping_journey/test/src/session_test.dart new file mode 100644 index 00000000..38bd41f4 --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/test/src/session_test.dart @@ -0,0 +1,96 @@ +/* + * 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 'package:flutter_test/flutter_test.dart'; +import 'package:ping_journey/ping_journey.dart'; + +void main() { + group('Session.fromJson', () { + test('parses a full, well-formed payload', () { + final session = Session.fromJson({ + 'accessToken': 'access-123', + 'refreshToken': 'refresh-456', + 'expiresIn': 3600, + 'userInfo': {'sub': 'user-1'}, + }); + + expect(session.accessToken, 'access-123'); + expect(session.refreshToken, 'refresh-456'); + expect(session.expiresIn, 3600); + expect(session.userInfo, {'sub': 'user-1'}); + }); + + test('parses a payload with no refreshToken/userInfo', () { + final session = Session.fromJson({ + 'accessToken': 'access-123', + 'expiresIn': 3600, + }); + + expect(session.accessToken, 'access-123'); + expect(session.refreshToken, isNull); + expect(session.userInfo, isEmpty); + }); + + test('throws FormatException when accessToken is missing', () { + expect( + () => Session.fromJson({'expiresIn': 3600}), + throwsA(isA()), + ); + }); + + test('throws FormatException when accessToken is empty', () { + expect( + () => Session.fromJson({'accessToken': '', 'expiresIn': 3600}), + throwsA(isA()), + ); + }); + + test('throws FormatException when expiresIn has the wrong type', () { + expect( + () => Session.fromJson({ + 'accessToken': 'access-123', + 'expiresIn': '3600', + }), + throwsA(isA()), + ); + }); + + test('throws FormatException when userInfo has the wrong type', () { + expect( + () => Session.fromJson({ + 'accessToken': 'access-123', + 'expiresIn': 3600, + 'userInfo': 'not-a-map', + }), + throwsA(isA()), + ); + }); + }); + + group('Session.toJson', () { + test('round-trips through fromJson', () { + const original = Session( + accessToken: 'access-123', + refreshToken: 'refresh-456', + expiresIn: 3600, + userInfo: {'sub': 'user-1'}, + ); + + final roundTripped = Session.fromJson(original.toJson()); + + expect(roundTripped.accessToken, original.accessToken); + expect(roundTripped.refreshToken, original.refreshToken); + expect(roundTripped.expiresIn, original.expiresIn); + expect(roundTripped.userInfo, original.userInfo); + }); + + test('omits refreshToken when null', () { + const session = Session(accessToken: 'access-123', expiresIn: 3600); + expect(session.toJson().containsKey('refreshToken'), isFalse); + }); + }); +} diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml new file mode 100644 index 00000000..2be746fa --- /dev/null +++ b/flutter/pubspec.yaml @@ -0,0 +1,10 @@ +name: flutter_workspace +publish_to: none + +environment: + sdk: ^3.12.0 + +workspace: + - flutter-sdk-bridge/ping_core + - flutter-sdk-bridge/ping_journey + - flutter-journey diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1d5b2b88..c0583290 100644 --- a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ForgeRock/ping-ios-sdk", "state" : { - "revision" : "9b37c20f99eb1ce418e6dc8ab491e32fad86051e", - "version" : "2.0.0-alpha-9b37c20" + "revision" : "17b95224da7008ce823fc2b199666cd3e26991d9", + "version" : "2.1.0" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pingidentity/pingone-signals-sdk-ios.git", "state" : { - "revision" : "bf37bd85fa909428d764630c71cf0d4f3d2d7e05", - "version" : "5.3.0" + "revision" : "e41f7070fdbb43dd7762274ec610c099256a7c7e", + "version" : "5.4.0" } }, { diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/JourneyViewModel.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/JourneyViewModel.swift index 971ba7b4..ab21fc1f 100644 --- a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/JourneyViewModel.swift +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/JourneyViewModel.swift @@ -33,14 +33,14 @@ import Combine /// - discoveryEndpoint: Your OIDC discovery endpoint URL public let journey = Journey.createJourney { config in - config.serverUrl = <#"https://your-server.example.com/am"#> - config.realm = <#"your-realm"#> - config.cookie = <#"your-cookie-name"#> + config.serverUrl = "https://openam-sdks.forgeblocks.com/am" + config.realm = "alpha" + config.cookie = "iPlanetDirectoryPro" config.module(PingJourney.OidcModule.config) { oidcValue in - oidcValue.clientId = <#"your-client-id"#> - oidcValue.scopes = <#"[SCOPES]"#> - oidcValue.redirectUri = <#"yourapp://callback"#> - oidcValue.discoveryEndpoint = <#"https://your-server.example.com/am/oauth2/your-realm/.well-known/openid-configuration"#> + oidcValue.clientId = "iosClient" + oidcValue.scopes = ["openid", "email", "profile", "address"] + oidcValue.redirectUri = "frauth://com.forgerock.ios.frexample" + oidcValue.discoveryEndpoint = "https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration" } } From 6e2709089227876e6fe9dfc88277389f46e058c4 Mon Sep 17 00:00:00 2001 From: Rodrigo Reis Date: Fri, 31 Jul 2026 17:44:26 -0700 Subject: [PATCH 2/2] Addressing PR comments --- .github/workflows/flutter-ci.yml | 32 +++++++ .../android/app/src/main/AndroidManifest.xml | 1 + .../flutter-journey/android/gradle.properties | 6 ++ .../integration_test/journey_login_test.dart | 18 ++-- .../journey_registration_test.dart | 14 ++-- flutter/flutter-journey/ios/.gitignore | 1 + .../contents.xcworkspacedata | 7 ++ .../flutter-journey/lib/routing/router.dart | 10 +-- .../ui/features/config/views/config_view.dart | 29 +++++-- .../view_models/journey_view_model.dart | 7 +- .../features/journey/views/journey_view.dart | 6 ++ .../config/views/config_view_test.dart | 67 +++++++++++---- .../journey/views/journey_view_test.dart | 45 +++++++++- .../ping_core/android/build.gradle.kts | 9 +- .../android/src/main/AndroidManifest.xml | 3 +- .../pingidentity/flutter/core/CoreRuntime.kt | 13 --- .../flutter/core/PingCorePlugin.kt | 3 +- .../flutter/core/registry/Registry.kt | 5 -- .../flutter/core/registry/SimpleRegistry.kt | 4 - .../ping_core/ios/ping_core.podspec | 10 +-- .../ping_core/ios/ping_core/Package.swift | 10 +-- .../Sources/ping_core/CoreRuntime.swift | 46 ---------- .../Sources/ping_core/PingCorePlugin.swift | 3 +- .../Sources/ping_core/Registry.swift | 3 - .../Sources/ping_core/SimpleRegistry.swift | 5 -- .../ping_journey/android/build.gradle.kts | 9 +- .../android/src/main/AndroidManifest.xml | 3 +- .../flutter/journey/JourneyConfigParser.kt | 9 +- .../flutter/journey/JourneyHostApiImpl.kt | 52 +++++++----- .../flutter/journey/Messages.g.kt | 15 +++- .../callback/JourneyCallbackValueApplier.kt | 28 +++++-- .../journey/mapper/JourneyNodeMapper.kt | 27 +++++- .../JourneyCallbackValueApplierTest.kt | 41 +++++---- .../journey/mapper/JourneyNodeMapperTest.kt | 54 ++++++++++-- .../ping_journey/ios/ping_journey.podspec | 12 ++- .../ios/ping_journey/Package.swift | 10 +-- .../JourneyCallbackValueApplier.swift | 31 +++++-- .../Error/JourneyErrorMapper.swift | 25 +++++- .../ping_journey/JourneyClientFactory.swift | 2 +- .../ping_journey/JourneyConfigParser.swift | 10 ++- .../ping_journey/JourneyHostApiImpl.swift | 77 ++++++++++------- .../ping_journey/KeyedSerialExecutor.swift | 64 ++++++++++++++ .../Mapper/JourneyNodeMapper.swift | 26 +++++- .../Sources/ping_journey/Messages.g.swift | 13 ++- .../ping_journey/PingJourneyPlugin.swift | 10 +++ .../JourneyCallbackValueApplierTests.swift | 83 ++++++++++-------- .../JourneyErrorMapperTests.swift | 14 +++- .../JourneyNodeMapperTests.swift | 26 ++++-- .../ping_journey/lib/src/callback_type.dart | 10 +++ .../ping_journey/lib/src/journey_node.dart | 2 +- .../ping_journey/lib/src/messages.g.dart | 13 ++- .../ping_journey/lib/src/node_mapper.dart | 17 ++-- .../ping_journey/lib/src/session.dart | 2 +- .../ping_journey/pigeons/messages.dart | 8 +- .../test/src/node_mapper_test.dart | 84 +++++++++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 8 +- .../ViewModels/JourneyViewModel.swift | 14 ++-- 57 files changed, 805 insertions(+), 351 deletions(-) create mode 100644 .github/workflows/flutter-ci.yml create mode 100644 flutter/flutter-journey/android/gradle.properties create mode 100644 flutter/flutter-journey/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/KeyedSerialExecutor.swift diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml new file mode 100644 index 00000000..9de0fbba --- /dev/null +++ b/.github/workflows/flutter-ci.yml @@ -0,0 +1,32 @@ +name: 'Flutter Continuous Integration' + +on: + pull_request: + paths: + - 'flutter/**/*' + +jobs: + analyze-and-test: + runs-on: macos-latest + defaults: + run: + working-directory: flutter + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.4' + channel: 'stable' + + - run: flutter pub get + + - name: Analyze + run: flutter analyze + + - name: Dart/Flutter unit + widget tests + run: flutter test + + - name: Android unit tests (ping_core, ping_journey) + working-directory: flutter/flutter-journey/android + run: ./gradlew :ping_core:testDebugUnitTest :ping_journey:testDebugUnitTest diff --git a/flutter/flutter-journey/android/app/src/main/AndroidManifest.xml b/flutter/flutter-journey/android/app/src/main/AndroidManifest.xml index eaa25eca..1203806b 100644 --- a/flutter/flutter-journey/android/app/src/main/AndroidManifest.xml +++ b/flutter/flutter-journey/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + '`, etc.) and must be +/// replaced with a real tenant's `serverUrl`/`realm`/`cookie` before this test can run at all — +/// see `flutter-journey/README.md` step 3. /// /// Run standalone, not combined with `journey_registration_test.dart` in the same test binary — /// the native SDK persists the AM session cookie on-device across `Journey` instances within one @@ -51,7 +53,7 @@ void main() { await tester.enterText(find.byType(TextField), 'Login'); await tester.tap(find.text('Start Journey')); - await tester.pumpAndSettle(const Duration(seconds: 10)); + await tester.pumpAndSettle(const Duration(seconds: 20)); expect(find.text('User Name'), findsOneWidget); expect(find.text('Password'), findsOneWidget); @@ -69,18 +71,18 @@ void main() { await tester.enterText(find.byType(TextField), 'Login'); await tester.tap(find.text('Start Journey')); - await tester.pumpAndSettle(const Duration(seconds: 10)); + await tester.pumpAndSettle(const Duration(seconds: 20)); final textFields = find.byType(TextField); await tester.enterText(textFields.at(0), username); await tester.enterText(textFields.at(1), password); await tester.tap(find.text('Next')); - await tester.pumpAndSettle(const Duration(seconds: 10)); + await tester.pumpAndSettle(const Duration(seconds: 20)); expect(find.text('Journey completed successfully.'), findsOneWidget); await tester.tap(find.text('Sign Off')); - await tester.pumpAndSettle(const Duration(seconds: 10)); + await tester.pumpAndSettle(const Duration(seconds: 20)); expect(find.text('Start a Journey'), findsOneWidget); }, diff --git a/flutter/flutter-journey/integration_test/journey_registration_test.dart b/flutter/flutter-journey/integration_test/journey_registration_test.dart index 50f70a58..fd31a292 100644 --- a/flutter/flutter-journey/integration_test/journey_registration_test.dart +++ b/flutter/flutter-journey/integration_test/journey_registration_test.dart @@ -12,10 +12,12 @@ import 'package:integration_test/integration_test.dart'; import 'package:flutter_journey/main.dart'; import 'package:flutter_journey/routing/router.dart'; -/// Drives the self-registration Journey end-to-end against the shared Ping SDK demo/test tenant -/// configured in `lib/config/env.dart` (see `AGENT_NOTES.md` § Verification tenant). Requires -/// network access and a reachable tenant — there is no mock, per this project's established -/// verification approach (Phases 2-6 were all verified against the same tenant). +/// Drives the self-registration Journey end-to-end against a real, reachable tenant with a +/// Journey named `"Registration"`. +/// +/// `lib/config/env.dart` ships with placeholder values (`''`, etc.) and must be +/// replaced with a real tenant's `serverUrl`/`realm`/`cookie` before this test can run at all — +/// see `flutter-journey/README.md` step 3. /// /// Run standalone, not combined with `journey_login_test.dart` in the same test binary — see that /// file's doc comment for why. @@ -36,7 +38,7 @@ void main() { await tester.enterText(find.byType(TextField), 'Registration'); await tester.tap(find.text('Start Journey')); - await tester.pumpAndSettle(const Duration(seconds: 10)); + await tester.pumpAndSettle(const Duration(seconds: 20)); final suffix = DateTime.now().millisecondsSinceEpoch.toString(); final newUsername = 'flutterE2E$suffix'; @@ -103,7 +105,7 @@ void main() { ); await tester.pumpAndSettle(); await tester.tap(next); - await tester.pumpAndSettle(const Duration(seconds: 10)); + await tester.pumpAndSettle(const Duration(seconds: 20)); expect(find.text('Journey completed successfully.'), findsOneWidget); }, diff --git a/flutter/flutter-journey/ios/.gitignore b/flutter/flutter-journey/ios/.gitignore index 7a7f9873..bc561564 100644 --- a/flutter/flutter-journey/ios/.gitignore +++ b/flutter/flutter-journey/ios/.gitignore @@ -32,3 +32,4 @@ Runner/GeneratedPluginRegistrant.* !default.mode2v3 !default.pbxuser !default.perspectivev3 +!Runner.xcworkspace/contents.xcworkspacedata diff --git a/flutter/flutter-journey/ios/Runner.xcworkspace/contents.xcworkspacedata b/flutter/flutter-journey/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/flutter/flutter-journey/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/flutter/flutter-journey/lib/routing/router.dart b/flutter/flutter-journey/lib/routing/router.dart index 100db8c3..88acefbb 100644 --- a/flutter/flutter-journey/lib/routing/router.dart +++ b/flutter/flutter-journey/lib/routing/router.dart @@ -14,7 +14,7 @@ import 'package:flutter_journey/ui/features/journey/view_models/journey_view_mod import 'package:flutter_journey/ui/features/journey/views/journey_view.dart'; import 'package:flutter_journey/ui/features/success/views/success_view.dart'; -/// `/config` -> `/journey-name` -> `/journey` -> `/success`, per DESIGN.md. +/// `/config` -> `/journey-name` -> `/journey` -> `/success`. final GoRouter router = GoRouter( initialLocation: '/config', routes: [ @@ -26,9 +26,9 @@ final GoRouter router = GoRouter( GoRoute( path: '/journey-name', builder: (context, state) => JourneyNameView( - onSubmit: (name) { - context.read().startJourney(name); - context.go('/journey'); + onSubmit: (name) async { + await context.read().startJourney(name); + if (context.mounted) context.go('/journey'); }, ), ), @@ -45,7 +45,7 @@ final GoRouter router = GoRouter( onSignOff: () async { final viewModel = context.read(); await viewModel.signOff(); - router.go('/journey-name'); + if (context.mounted) context.go('/journey-name'); }, ), ), diff --git a/flutter/flutter-journey/lib/ui/features/config/views/config_view.dart b/flutter/flutter-journey/lib/ui/features/config/views/config_view.dart index e08ea168..4beac0f1 100644 --- a/flutter/flutter-journey/lib/ui/features/config/views/config_view.dart +++ b/flutter/flutter-journey/lib/ui/features/config/views/config_view.dart @@ -15,16 +15,31 @@ import 'package:flutter_journey/ui/core/widgets/primary_button.dart'; /// Read-only display of the active [Env] — analog of the native samples' server/realm config /// screen (Android's `Env.kt`), shown before starting a Journey. +/// +/// [serverUrl]/[realm]/[cookie]/[oidcConfig] default to [Env]'s values but are injectable so +/// tests can exercise a real (non-placeholder) config without depending on — or overriding — +/// the app-wide [Env] singleton. class ConfigView extends StatelessWidget { - const ConfigView({super.key, required this.onContinue}); + const ConfigView({ + super.key, + required this.onContinue, + this.serverUrl = Env.serverUrl, + this.realm = Env.realm, + this.cookie = Env.cookie, + this.oidcConfig = Env.oidcConfig, + }); final VoidCallback onContinue; + final String serverUrl; + final String realm; + final String cookie; + final OidcConfig? oidcConfig; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Active Environment')), - body: Padding( + body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -35,10 +50,10 @@ class ConfigView extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _ConfigRow(label: 'Server URL', value: Env.serverUrl), - _ConfigRow(label: 'Realm', value: Env.realm), - _ConfigRow(label: 'Cookie', value: Env.cookie), - if (Env.oidcConfig case final oidc?) ...[ + _ConfigRow(label: 'Server URL', value: serverUrl), + _ConfigRow(label: 'Realm', value: realm), + _ConfigRow(label: 'Cookie', value: cookie), + if (oidcConfig case final oidc?) ...[ _ConfigRow(label: 'Client ID', value: oidc.clientId), _ConfigRow( label: 'Discovery Endpoint', @@ -49,7 +64,7 @@ class ConfigView extends StatelessWidget { ], ), ), - const Spacer(), + const SizedBox(height: 24), PrimaryButton(label: 'Continue', onPressed: onContinue), ], ), diff --git a/flutter/flutter-journey/lib/ui/features/journey/view_models/journey_view_model.dart b/flutter/flutter-journey/lib/ui/features/journey/view_models/journey_view_model.dart index 3cf22921..6243c87c 100644 --- a/flutter/flutter-journey/lib/ui/features/journey/view_models/journey_view_model.dart +++ b/flutter/flutter-journey/lib/ui/features/journey/view_models/journey_view_model.dart @@ -37,7 +37,12 @@ class JourneyViewModel extends ChangeNotifier { Future signOff() async { try { - return await _repository.signOff(); + final success = await _repository.signOff(); + if (success) { + _node = null; + notifyListeners(); + } + return success; } on PingException catch (error) { _error = error.message; notifyListeners(); diff --git a/flutter/flutter-journey/lib/ui/features/journey/views/journey_view.dart b/flutter/flutter-journey/lib/ui/features/journey/views/journey_view.dart index 90e82bf7..ed8d3a0e 100644 --- a/flutter/flutter-journey/lib/ui/features/journey/views/journey_view.dart +++ b/flutter/flutter-journey/lib/ui/features/journey/views/journey_view.dart @@ -102,6 +102,11 @@ class _ContinueNodeView extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (viewModel.error != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: ErrorBanner(message: viewModel.error!), + ), if (node.header?.isNotEmpty ?? false) Padding( padding: const EdgeInsets.only(bottom: 8), @@ -117,6 +122,7 @@ class _ContinueNodeView extends StatelessWidget { ), for (final callback in node.callbacks) Padding( + key: ObjectKey(callback), padding: const EdgeInsets.symmetric(vertical: 8), child: switch (callback) { NameCallback() => NameCallbackView( diff --git a/flutter/flutter-journey/test/ui/features/config/views/config_view_test.dart b/flutter/flutter-journey/test/ui/features/config/views/config_view_test.dart index f63e3988..b2aa7868 100644 --- a/flutter/flutter-journey/test/ui/features/config/views/config_view_test.dart +++ b/flutter/flutter-journey/test/ui/features/config/views/config_view_test.dart @@ -12,36 +12,67 @@ import 'package:flutter_test/flutter_test.dart'; void main() { testWidgets( - 'renders the Server URL, Realm, and Cookie rows for the active Env', + 'renders the Server URL, Realm, and Cookie rows for the injected config', (tester) async { await tester.pumpWidget( - MaterialApp(home: ConfigView(onContinue: () {})), + MaterialApp( + home: ConfigView( + onContinue: () {}, + serverUrl: 'https://openam.example.com/am', + realm: 'alpha', + cookie: 'iPlanetDirectoryPro', + ), + ), ); expect(find.text('Active Environment'), findsOneWidget); expect(find.text('SERVER URL'), findsOneWidget); - expect(find.text(Env.serverUrl), findsOneWidget); + expect(find.text('https://openam.example.com/am'), findsOneWidget); expect(find.text('REALM'), findsOneWidget); - expect(find.text(Env.realm), findsOneWidget); + expect(find.text('alpha'), findsOneWidget); expect(find.text('COOKIE'), findsOneWidget); - expect(find.text(Env.cookie), findsOneWidget); + expect(find.text('iPlanetDirectoryPro'), findsOneWidget); }, ); - testWidgets( - 'omits the OIDC rows when Env.oidcConfig is null', - (tester) async { - await tester.pumpWidget( - MaterialApp(home: ConfigView(onContinue: () {})), - ); + testWidgets('omits the OIDC rows when oidcConfig is null', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: ConfigView(onContinue: () {}, oidcConfig: null), + ), + ); - // The sample app ships with Env.oidcConfig == null by default. - expect(Env.oidcConfig, isNull); - expect(find.text('CLIENT ID'), findsNothing); - expect(find.text('DISCOVERY ENDPOINT'), findsNothing); - expect(find.text('REDIRECT URI'), findsNothing); - }, - ); + expect(find.text('CLIENT ID'), findsNothing); + expect(find.text('DISCOVERY ENDPOINT'), findsNothing); + expect(find.text('REDIRECT URI'), findsNothing); + }); + + testWidgets('renders the OIDC rows when oidcConfig is set', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(400, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: ConfigView( + onContinue: () {}, + oidcConfig: const OidcConfig( + clientId: 'my-client', + discoveryEndpoint: 'https://openam.example.com/.well-known', + scopes: ['openid'], + redirectUri: 'myapp://callback', + ), + ), + ), + ); + + expect(find.text('CLIENT ID'), findsOneWidget); + expect(find.text('my-client'), findsOneWidget); + expect(find.text('DISCOVERY ENDPOINT'), findsOneWidget); + expect(find.text('REDIRECT URI'), findsOneWidget); + expect(find.text('myapp://callback'), findsOneWidget); + }); testWidgets('tapping Continue invokes onContinue', (tester) async { var continued = false; diff --git a/flutter/flutter-journey/test/ui/features/journey/views/journey_view_test.dart b/flutter/flutter-journey/test/ui/features/journey/views/journey_view_test.dart index 31aa77d5..3cc1a2ba 100644 --- a/flutter/flutter-journey/test/ui/features/journey/views/journey_view_test.dart +++ b/flutter/flutter-journey/test/ui/features/journey/views/journey_view_test.dart @@ -16,12 +16,22 @@ import 'package:provider/provider.dart'; /// Stubs the network-bound [JourneyRepository] so [JourneyViewModel.startJourney] resolves to a /// fixed [JourneyNode] instead of calling the real plugin. class _StubJourneyRepository extends JourneyRepository { - _StubJourneyRepository(this.node); + _StubJourneyRepository(this.node, {this.nextError}); final JourneyNode node; + /// When set, [next] throws this instead of returning a node. + final PingException? nextError; + @override Future startJourney(String journeyName) async => node; + + @override + Future next(ContinueNode node) async { + final error = nextError; + if (error != null) throw error; + return node; + } } NameCallback _nameCallback() => @@ -35,8 +45,11 @@ Future _pumpJourneyView( JourneyNode node, { VoidCallback onRestart = _noop, VoidCallback onSuccess = _noop, + PingException? nextError, }) async { - final viewModel = JourneyViewModel(repository: _StubJourneyRepository(node)); + final viewModel = JourneyViewModel( + repository: _StubJourneyRepository(node, nextError: nextError), + ); await viewModel.startJourney('Login'); await tester.pumpWidget( @@ -70,6 +83,34 @@ void main() { }, ); + testWidgets( + 'next() throwing a PingException renders the error above the same ContinueNode form', + (tester) async { + final node = ContinueNode( + journeyId: 'journey-1', + callbacks: [_nameCallback(), _passwordCallback()], + ); + + await _pumpJourneyView( + tester, + node, + nextError: const PingException( + 'JOURNEY_NEXT_ERROR', + 'network', + 'The request timed out.', + ), + ); + + await tester.tap(find.widgetWithText(ElevatedButton, 'Next')); + await tester.pumpAndSettle(); + + expect(find.text('The request timed out.'), findsOneWidget); + // The form itself is still rendered — the user isn't stuck on a blank screen. + expect(find.widgetWithText(TextField, 'User Name'), findsOneWidget); + expect(find.widgetWithText(TextField, 'Password'), findsOneWidget); + }, + ); + testWidgets( 'entering text updates the NameCallback and PasswordCallback values', (tester) async { diff --git a/flutter/flutter-sdk-bridge/ping_core/android/build.gradle.kts b/flutter/flutter-sdk-bridge/ping_core/android/build.gradle.kts index e1650792..35203ee7 100644 --- a/flutter/flutter-sdk-bridge/ping_core/android/build.gradle.kts +++ b/flutter/flutter-sdk-bridge/ping_core/android/build.gradle.kts @@ -14,6 +14,11 @@ buildscript { } } +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + allprojects { repositories { google() @@ -21,10 +26,6 @@ allprojects { } } -plugins { - id("com.android.library") -} - android { namespace = "com.pingidentity.flutter.core" diff --git a/flutter/flutter-sdk-bridge/ping_core/android/src/main/AndroidManifest.xml b/flutter/flutter-sdk-bridge/ping_core/android/src/main/AndroidManifest.xml index 83c451b3..a2f47b60 100644 --- a/flutter/flutter-sdk-bridge/ping_core/android/src/main/AndroidManifest.xml +++ b/flutter/flutter-sdk-bridge/ping_core/android/src/main/AndroidManifest.xml @@ -1,3 +1,2 @@ - + diff --git a/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/CoreRuntime.kt b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/CoreRuntime.kt index 16ce34c6..ec8b8dcb 100644 --- a/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/CoreRuntime.kt +++ b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/CoreRuntime.kt @@ -10,9 +10,6 @@ package com.pingidentity.flutter.core import com.pingidentity.flutter.core.registry.Registry import com.pingidentity.flutter.core.registry.SimpleRegistry -/** Resolves callbacks for a given Journey id, invoked across the Flutter bridge boundary. */ -public typealias JourneyCallbackResolver = suspend (String) -> List? - /** * Central place to hold process-wide registries and shared helpers used by the core module. * @@ -23,14 +20,4 @@ public typealias JourneyCallbackResolver = suspend (String) -> List? object CoreRuntime { /** Registry for Journey client instances. */ val journeyRegistry: Registry = SimpleRegistry() - - /** Resolver that exposes Journey callbacks to other packages; set by `ping_journey`. */ - @Volatile - var journeyCallbackResolver: JourneyCallbackResolver? = null - - /** - * Resolves callbacks for the provided Journey id via the registered resolver. - */ - suspend fun resolveJourneyCallbacks(journeyId: String): List? = - journeyCallbackResolver?.invoke(journeyId) } diff --git a/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/PingCorePlugin.kt b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/PingCorePlugin.kt index 87ff481d..ba30746a 100644 --- a/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/PingCorePlugin.kt +++ b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/PingCorePlugin.kt @@ -11,8 +11,7 @@ import io.flutter.embedding.engine.plugins.FlutterPlugin /** * Trivial [FlutterPlugin] registration so the `ping_core` module loads and links - * [CoreRuntime] into the app's classloader. Carries no Pigeon/method channel of its own — - * see DESIGN.md for why the registry lives here instead of in `ping_journey`. + * [CoreRuntime] into the app's classloader. Carries no Pigeon/method channel of its own. */ class PingCorePlugin : FlutterPlugin { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {} diff --git a/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/Registry.kt b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/Registry.kt index 55237506..effa1b39 100644 --- a/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/Registry.kt +++ b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/Registry.kt @@ -29,9 +29,4 @@ interface Registry { * Remove a registered instance by id. Safe to call with unknown ids. */ fun remove(id: String) - - /** - * Remove all registered instances. - */ - fun removeAll() } diff --git a/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/SimpleRegistry.kt b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/SimpleRegistry.kt index ae2c5b1a..6eaed4b1 100644 --- a/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/SimpleRegistry.kt +++ b/flutter/flutter-sdk-bridge/ping_core/android/src/main/kotlin/com/pingidentity/flutter/core/registry/SimpleRegistry.kt @@ -29,8 +29,4 @@ class SimpleRegistry : Registry { override fun remove(id: String) { map.remove(id) } - - override fun removeAll() { - map.clear() - } } diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core.podspec b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core.podspec index 70f0600a..b4a41545 100644 --- a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core.podspec +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core.podspec @@ -11,7 +11,7 @@ Shared runtime for the Ping Flutter SDK bridge: process-wide native handle registries, JSON codec, and exception types used by feature plugins. DESC s.homepage = 'https://github.com/ForgeRock/ping-ios-sdk' - s.license = { :file => '../LICENSE' } + s.license = { :file => '../../LICENSE' } s.author = { 'Ping Identity' => 'oss@pingidentity.com' } s.source = { :path => '.' } s.source_files = 'ping_core/Sources/ping_core/**/*' @@ -20,11 +20,7 @@ registries, JSON codec, and exception types used by feature plugins. # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' + s.swift_version = '5.9' - # If your plugin requires a privacy manifest, for example if it uses any - # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your - # plugin's privacy impact, and then uncomment this line. For more information, - # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files - # s.resource_bundles = {'ping_core_privacy' => ['ping_core/Sources/ping_core/PrivacyInfo.xcprivacy']} + s.resource_bundles = {'ping_core_privacy' => ['ping_core/Sources/ping_core/PrivacyInfo.xcprivacy']} end diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Package.swift b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Package.swift index 7f0f3acf..cfff69fd 100644 --- a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Package.swift +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Package.swift @@ -21,15 +21,7 @@ let package = Package( .product(name: "FlutterFramework", package: "FlutterFramework") ], resources: [ - // If your plugin requires a privacy manifest, for example if it uses any required - // reason APIs, update the PrivacyInfo.xcprivacy file to describe your plugin's - // privacy impact, and then uncomment these lines. For more information, see - // https://developer.apple.com/documentation/bundleresources/privacy_manifest_files - // .process("PrivacyInfo.xcprivacy"), - - // If you have other resources that need to be bundled with your plugin, refer to - // the following instructions to add them: - // https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package + .process("PrivacyInfo.xcprivacy") ] ) ] diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/CoreRuntime.swift b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/CoreRuntime.swift index b2f0b28e..5fa7d77b 100644 --- a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/CoreRuntime.swift +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/CoreRuntime.swift @@ -7,32 +7,6 @@ import Foundation -/// A closure that returns the callbacks for a running Journey instance. -public typealias JourneyCallbackResolver = @Sendable (String) async -> [Any]? - -/// Thread-safe storage for the optional Journey callback resolver. -/// -/// - Note: `@unchecked Sendable` is used because this class stores a mutable -/// closure reference that Swift cannot prove sendable. Access is serialized -/// with `NSLock`, so cross-thread mutation/read is synchronized. -private final class JourneyCallbackResolverStore: @unchecked Sendable { - private let lock = NSLock() - private var resolver: JourneyCallbackResolver? - - func set(_ resolver: JourneyCallbackResolver?) { - lock.lock() - self.resolver = resolver - lock.unlock() - } - - func get() -> JourneyCallbackResolver? { - lock.lock() - let current = resolver - lock.unlock() - return current - } -} - /// Central place to hold process-wide registries used by the core module. /// /// Keeps native handles alive across calls from the Flutter bridge. Every future native module @@ -41,24 +15,4 @@ private final class JourneyCallbackResolverStore: @unchecked Sendable { public enum CoreRuntime { /// Registry for Journey client instances. public static let journeyRegistry: Registry = SimpleRegistry() - - /// Internal resolver store used to avoid shared mutable global state. - private static let journeyCallbackResolverStore = JourneyCallbackResolverStore() - - /// Registers or clears the resolver that exposes Journey callbacks to other packages. - /// - /// - Parameter resolver: Resolver closure to register, or `nil` to clear. - public static func setJourneyCallbackResolver(_ resolver: JourneyCallbackResolver?) { - journeyCallbackResolverStore.set(resolver) - } - - /// Resolves Journey callbacks for the given journey instance via the registered resolver. - public static func resolveJourneyCallbacks( - _ journeyId: String - ) async -> [Any]? { - guard let resolver = journeyCallbackResolverStore.get() else { - return nil - } - return await resolver(journeyId) - } } diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/PingCorePlugin.swift b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/PingCorePlugin.swift index 640ede03..29118458 100644 --- a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/PingCorePlugin.swift +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/PingCorePlugin.swift @@ -9,8 +9,7 @@ import Flutter import UIKit /// Trivial `FlutterPlugin` registration so the `ping_core` module loads and links -/// `CoreRuntime` into the app binary. Carries no Pigeon/method channel of its own — -/// see DESIGN.md for why the registry lives here instead of in `ping_journey`. +/// `CoreRuntime` into the app binary. Carries no Pigeon/method channel of its own. public class PingCorePlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) {} } diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/Registry.swift b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/Registry.swift index 4e6ff848..eb5f6b94 100644 --- a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/Registry.swift +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/Registry.swift @@ -20,7 +20,4 @@ public protocol Registry: Sendable { /// Remove a registered instance by id. Safe to call with unknown ids. func remove(_ id: String) async - - /// Remove all registered instances. - func removeAll() async } diff --git a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/SimpleRegistry.swift b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/SimpleRegistry.swift index 3ee5f308..5672aa47 100644 --- a/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/SimpleRegistry.swift +++ b/flutter/flutter-sdk-bridge/ping_core/ios/ping_core/Sources/ping_core/SimpleRegistry.swift @@ -32,9 +32,4 @@ public actor SimpleRegistry: Registry { public func remove(_ id: String) async { map.removeValue(forKey: id) } - - /// Clear all stored handles. - public func removeAll() async { - map.removeAll() - } } diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/build.gradle.kts b/flutter/flutter-sdk-bridge/ping_journey/android/build.gradle.kts index 90c9ece9..4b0f2368 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/android/build.gradle.kts +++ b/flutter/flutter-sdk-bridge/ping_journey/android/build.gradle.kts @@ -14,6 +14,11 @@ buildscript { } } +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + allprojects { repositories { google() @@ -21,10 +26,6 @@ allprojects { } } -plugins { - id("com.android.library") -} - android { namespace = "com.pingidentity.flutter.journey" diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/AndroidManifest.xml b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/AndroidManifest.xml index fdbd3ea3..a2f47b60 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/AndroidManifest.xml +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/AndroidManifest.xml @@ -1,3 +1,2 @@ - + diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyConfigParser.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyConfigParser.kt index 6378e58c..2ec1f32b 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyConfigParser.kt +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyConfigParser.kt @@ -18,12 +18,19 @@ import com.pingidentity.journey.module.Oidc * SDK's own defaults (`realm = "root"`, `cookie = "iPlanetDirectoryPro"`) apply. */ internal object JourneyConfigParser { + /** + * Explicit cross-platform default when [JourneyConfigMessage.timeoutMillis] is unset — the + * native SDKs' own defaults diverge (Android 15s, iOS 30s), which otherwise makes the same + * unset config wait a different duration per platform with no indication in code or docs. + */ + const val DEFAULT_TIMEOUT_MILLIS = 30_000L + fun parse(config: JourneyConfigMessage): Journey = Journey { serverUrl = config.serverUrl config.realm?.let { realm = it } config.cookie?.let { cookie = it } - config.timeoutMillis?.let { timeout = it } + timeout = config.timeoutMillis ?: DEFAULT_TIMEOUT_MILLIS if (hasOidcConfig(config)) { module(Oidc) { diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyHostApiImpl.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyHostApiImpl.kt index 041fb989..850a3299 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyHostApiImpl.kt +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/JourneyHostApiImpl.kt @@ -27,6 +27,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * Implements the generated [PingJourneyHostApi]: `configureJourney`/`start` build the native @@ -36,12 +38,12 @@ import kotlinx.coroutines.launch class JourneyHostApiImpl : PingJourneyHostApi { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - /** Most recent [Node] per journeyId. */ - private val nodeMap = ConcurrentHashMap() - /** Most recent [ContinueNode] per journeyId, for future callback re-resolution. */ private val continueNodeMap = ConcurrentHashMap() + /** Serializes [next] per journeyId so a double-submit can't race two callback applications. */ + private val nextMutexes = ConcurrentHashMap() + override fun configureJourney( config: JourneyConfigMessage, callback: (Result) -> Unit @@ -78,16 +80,19 @@ class JourneyHostApiImpl : PingJourneyHostApi { callback: (Result) -> Unit ) { scope.launch { - val result = runCatching { - val currentNode = - continueNodeMap[journeyId] - ?: throw IllegalStateException( - "No active ContinueNode found for journeyId=$journeyId" - ) - JourneyCallbackValueApplier.apply(currentNode, values.filterNotNull()) - val nextNode = currentNode.next() - setNode(journeyId, nextNode) - JourneyNodeMapper.map(nextNode) + val mutex = nextMutexes.getOrPut(journeyId) { Mutex() } + val result = mutex.withLock { + runCatching { + val currentNode = + continueNodeMap[journeyId] + ?: throw IllegalStateException( + "No active ContinueNode found for journeyId=$journeyId" + ) + JourneyCallbackValueApplier.apply(currentNode, values.filterNotNull()) + val nextNode = currentNode.next() + setNode(journeyId, nextNode) + JourneyNodeMapper.map(nextNode) + } } callback(result.classifyError(JourneyErrorCodes.NEXT)) } @@ -113,7 +118,13 @@ class JourneyHostApiImpl : PingJourneyHostApi { @Suppress("UNCHECKED_CAST") JsonBridgeMapper.encodeJsonElement(uiResult.value) as? Map } - is PingResult.Failure -> null + is PingResult.Failure -> { + Log.w( + TAG, + "userinfo() failed for journeyId=$journeyId: ${uiResult.value}", + ) + null + } } SessionMessage( accessToken = token.accessToken, @@ -139,6 +150,7 @@ class JourneyHostApiImpl : PingJourneyHostApi { scope.launch { val result = runCatching { val handle = resolveHandle(journeyId) + handle.journey.signOff().getOrThrow() if (handle.hasOidc) { handle.journey.user()?.logout() } @@ -162,7 +174,6 @@ class JourneyHostApiImpl : PingJourneyHostApi { ?: throw IllegalStateException("Journey instance not found for id=$journeyId") private fun setNode(journeyId: String, node: Node) { - nodeMap[journeyId] = node if (node is ContinueNode) { continueNodeMap[journeyId] = node } else { @@ -171,16 +182,15 @@ class JourneyHostApiImpl : PingJourneyHostApi { } private fun clearNodeState(journeyId: String) { - nodeMap.remove(journeyId) - continueNodeMap.remove(journeyId) - } - - private fun removeJourney(journeyId: String) { continueNodeMap.remove(journeyId)?.let { node -> runCatching { node.close() } .onFailure { Log.w(TAG, "Failed to close ContinueNode for journeyId=$journeyId", it) } } - nodeMap.remove(journeyId) + } + + private fun removeJourney(journeyId: String) { + clearNodeState(journeyId) + nextMutexes.remove(journeyId) CoreRuntime.journeyRegistry.remove(journeyId) } diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/Messages.g.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/Messages.g.kt index c7af8d35..44aa2a5c 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/Messages.g.kt +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/Messages.g.kt @@ -398,11 +398,20 @@ data class CallbackMessage ( val name: String? = null, /** * Validate-without-advancing flag (ValidatedUsername/ValidatedPassword/ - * Attribute*InputCallback). + * Attribute*InputCallback). Read-only: reflects native state at mapping + * time; [CallbackValueMessage] has no field to send an updated value back + * to native (same limitation as the React Native bridge). */ val validateOnly: Boolean? = null, val policies: Map? = null, - val failedPolicies: List?>? = null, + /** + * Each element is a `Map?`. Typed as `List?` (rather than + * `List?>?`) because Pigeon's generated decoder does an unchecked + * `.cast?>()` on nested collection element types, which throws at + * runtime the first time a real (non-empty) map element arrives over the wire — see + * `NodeMapper._castFailedPolicies` for the safe elementwise cast this requires on read. + */ + val failedPolicies: List? = null, /** Whether to mask input (ValidatedPasswordCallback). */ val echoOn: Boolean? = null, /** TextOutputCallback message classification: INFORMATION/WARNING/ERROR/SCRIPT/UNKNOWN. */ @@ -433,7 +442,7 @@ data class CallbackMessage ( val name = pigeonVar_list[18] as String? val validateOnly = pigeonVar_list[19] as Boolean? val policies = pigeonVar_list[20] as Map? - val failedPolicies = pigeonVar_list[21] as List?>? + val failedPolicies = pigeonVar_list[21] as List? val echoOn = pigeonVar_list[22] as Boolean? val messageType = pigeonVar_list[23] as String? val raw = pigeonVar_list[24] as Map? diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplier.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplier.kt index 042d63c1..bdd9f234 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplier.kt +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplier.kt @@ -19,9 +19,11 @@ import com.pingidentity.journey.callback.TermsAndConditionsCallback import com.pingidentity.journey.callback.TextInputCallback import com.pingidentity.journey.callback.ValidatedPasswordCallback import com.pingidentity.journey.callback.ValidatedUsernameCallback +import com.pingidentity.journey.plugin.AbstractCallback import com.pingidentity.journey.plugin.Callback import com.pingidentity.journey.plugin.callbacks import com.pingidentity.orchestrate.ContinueNode +import kotlinx.serialization.json.jsonPrimitive /** * Applies Dart-submitted [CallbackValueMessage]s back onto a cached native [ContinueNode]'s live @@ -32,7 +34,7 @@ import com.pingidentity.orchestrate.ContinueNode */ internal object JourneyCallbackValueApplier { fun apply(node: ContinueNode, values: List) { - val callbacksByType = node.callbacks.groupBy { it::class.java.simpleName } + val callbacksByType = node.callbacks.groupBy(::callbackType) for (value in values) { val callback = callbacksByType[value.type]?.getOrNull(value.index.toInt()) @@ -43,6 +45,14 @@ internal object JourneyCallbackValueApplier { } } + /** + * The server-registered `"type"` string from [AbstractCallback.json], stable across R8/proguard + * minification — unlike [Any.javaClass]'s `simpleName`, which is obfuscated in a minified build. + */ + private fun callbackType(callback: Callback): String = + (callback as AbstractCallback).json["type"]?.jsonPrimitive?.content + ?: throw IllegalStateException("Callback is missing a \"type\" field: $callback") + private fun applyValue(callback: Callback, value: CallbackValueMessage) { when (callback) { is NameCallback -> callback.name = asString(value) @@ -65,11 +75,17 @@ internal object JourneyCallbackValueApplier { private fun applyKba(callback: KbaCreateCallback, value: CallbackValueMessage) { val map = asMap(value) - (map["selectedQuestion"] as? String)?.let { callback.selectedQuestion = it } - (map["selectedAnswer"] as? String)?.let { callback.selectedAnswer = it } - (map["allowUserDefinedQuestions"] as? Boolean)?.let { - callback.allowUserDefinedQuestions = it - } + callback.selectedQuestion = + map["selectedQuestion"] as? String + ?: throw IllegalArgumentException("${value.type} expects a String selectedQuestion") + callback.selectedAnswer = + map["selectedAnswer"] as? String + ?: throw IllegalArgumentException("${value.type} expects a String selectedAnswer") + callback.allowUserDefinedQuestions = + map["allowUserDefinedQuestions"] as? Boolean + ?: throw IllegalArgumentException( + "${value.type} expects a Boolean allowUserDefinedQuestions" + ) } private fun asString(value: CallbackValueMessage): String = diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapper.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapper.kt index a6e5a6e3..ffb0d79a 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapper.kt +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/main/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapper.kt @@ -12,6 +12,7 @@ import com.pingidentity.flutter.journey.NodeMessage import com.pingidentity.flutter.journey.NodeType import com.pingidentity.journey.callback.AbstractValidatedCallback import com.pingidentity.journey.callback.AttributeInputCallback +import com.pingidentity.journey.plugin.AbstractCallback import com.pingidentity.journey.callback.BooleanAttributeInputCallback import com.pingidentity.journey.callback.ChoiceCallback import com.pingidentity.journey.callback.KbaCreateCallback @@ -34,6 +35,8 @@ import com.pingidentity.orchestrate.ErrorNode import com.pingidentity.orchestrate.FailureNode import com.pingidentity.orchestrate.Node import com.pingidentity.orchestrate.SuccessNode +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive /** * Maps a native [Node] to the wire-serializable [NodeMessage], including full per-callback field @@ -53,7 +56,13 @@ internal object JourneyNodeMapper { callbacks = mapCallbacks(node), ) is SuccessNode -> NodeMessage(type = NodeType.SUCCESS_NODE) - is ErrorNode -> NodeMessage(type = NodeType.ERROR_NODE, message = node.message) + is ErrorNode -> + NodeMessage( + type = NodeType.ERROR_NODE, + message = node.message, + status = node.input["code"]?.jsonPrimitive?.intOrNull?.toLong(), + input = JsonBridgeMapper.encodeJsonElement(node.input) as? Map, + ) is FailureNode -> NodeMessage( type = NodeType.FAILURE_NODE, @@ -65,13 +74,21 @@ internal object JourneyNodeMapper { private fun mapCallbacks(node: ContinueNode): List { val typeCounts = mutableMapOf() return node.callbacks.map { callback -> - val type = callback::class.java.simpleName + val type = callbackType(callback) val index = typeCounts.getOrDefault(type, 0) typeCounts[type] = index + 1 mapCallback(callback, type, index.toLong()) } } + /** + * The server-registered `"type"` string from [AbstractCallback.json], stable across R8/proguard + * minification — unlike [Any.javaClass]'s `simpleName`, which is obfuscated in a minified build. + */ + private fun callbackType(callback: Callback): String = + (callback as AbstractCallback).json["type"]?.jsonPrimitive?.content + ?: throw IllegalStateException("Callback is missing a \"type\" field: $callback") + private fun mapCallback(callback: Callback, type: String, index: Long): CallbackMessage { var message = CallbackMessage(type = type, index = index) @@ -135,7 +152,11 @@ internal object JourneyNodeMapper { is BooleanAttributeInputCallback -> message.copy(value = callback.value) is NumberAttributeInputCallback -> message.copy(value = callback.value) is StringAttributeInputCallback -> message.copy(value = callback.value) - else -> message + else -> + message.copy( + raw = JsonBridgeMapper.encodeJsonElement((callback as AbstractCallback).json) + as? Map, + ) } } } diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplierTest.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplierTest.kt index 47cdbcca..f051c593 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplierTest.kt +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/callback/JourneyCallbackValueApplierTest.kt @@ -20,9 +20,12 @@ 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.AbstractCallback import com.pingidentity.journey.plugin.Callback import com.pingidentity.orchestrate.Action import com.pingidentity.orchestrate.ContinueNode +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.mockito.Mockito.mock import org.mockito.Mockito.`when` import kotlin.test.Test @@ -134,23 +137,17 @@ class JourneyCallbackValueApplierTest { } @Test - fun `KbaCreateCallback partial map only updates provided sub-fields`() { - val callback = KbaCreateCallback().apply { - selectedQuestion = "existing question" - selectedAnswer = "existing answer" - allowUserDefinedQuestions = true - } - - apply(callback, "KbaCreateCallback", value = mapOf("selectedAnswer" to "new answer")) + fun `KbaCreateCallback throws IllegalArgumentException when a sub-field is missing`() { + val callback = KbaCreateCallback() - assertEquals("existing question", callback.selectedQuestion) - assertEquals("new answer", callback.selectedAnswer) - assertEquals(true, callback.allowUserDefinedQuestions) + assertFailsWith { + apply(callback, "KbaCreateCallback", value = mapOf("selectedAnswer" to "new answer")) + } } @Test fun `throws for unsupported callback type such as TextOutputCallback`() { - val callback = TextOutputCallback() + val callback = TextOutputCallback().withType("TextOutputCallback") val node = mockNode(listOf(callback)) assertFailsWith { @@ -163,7 +160,7 @@ class JourneyCallbackValueApplierTest { @Test fun `throws IllegalArgumentException when no callback matches type and index`() { - val node = mockNode(listOf(NameCallback())) + val node = mockNode(listOf(NameCallback().withType("NameCallback"))) val exception = assertFailsWith { JourneyCallbackValueApplier.apply( @@ -179,7 +176,7 @@ class JourneyCallbackValueApplierTest { @Test fun `throws IllegalArgumentException when value type mismatches expected type`() { - val callback = NameCallback() + val callback = NameCallback().withType("NameCallback") val node = mockNode(listOf(callback)) assertFailsWith { @@ -192,8 +189,8 @@ class JourneyCallbackValueApplierTest { @Test fun `applies values addressed by index within same-type group`() { - val first = NameCallback() - val second = NameCallback() + val first = NameCallback().withType("NameCallback") + val second = NameCallback().withType("NameCallback") val node = mockNode(listOf(first, second)) JourneyCallbackValueApplier.apply( @@ -213,8 +210,18 @@ class JourneyCallbackValueApplierTest { return node } + /** + * Initializes [AbstractCallback.json] with the given server-registered `"type"` string, as + * [JourneyCallbackValueApplier] now keys lookups off that field rather than the runtime class + * name (see the R8/minification fix). + */ + private fun T.withType(type: String): T { + (this as AbstractCallback).init(buildJsonObject { put("type", type) }) + return this + } + private fun apply(callback: Callback, type: String, value: Any?) { - val node = mockNode(listOf(callback)) + val node = mockNode(listOf(callback.withType(type))) JourneyCallbackValueApplier.apply(node, listOf(CallbackValueMessage(type = type, index = 0, value = value))) } } diff --git a/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapperTest.kt b/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapperTest.kt index 5fe670e1..144aedc7 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapperTest.kt +++ b/flutter/flutter-sdk-bridge/ping_journey/android/src/test/kotlin/com/pingidentity/flutter/journey/mapper/JourneyNodeMapperTest.kt @@ -15,6 +15,7 @@ import com.pingidentity.journey.callback.PasswordCallback import com.pingidentity.journey.callback.StringAttributeInputCallback import com.pingidentity.journey.callback.TextOutputCallback import com.pingidentity.journey.callback.ValidatedUsernameCallback +import com.pingidentity.journey.plugin.AbstractCallback import com.pingidentity.journey.plugin.Callback import com.pingidentity.orchestrate.Action import com.pingidentity.orchestrate.ContinueNode @@ -61,6 +62,24 @@ class JourneyNodeMapperTest { assertEquals("Something went wrong", result.message) } + @Test + fun `map ErrorNode extracts status from input code field`() { + val flowContext = FlowContext(SharedContext(mutableMapOf())) + val node = ErrorNode( + context = flowContext, + message = "Login failure", + input = buildJsonObject { + put("code", 401) + put("message", "Login failure") + }, + ) + + val result = JourneyNodeMapper.map(node) + + assertEquals(401L, result.status) + assertEquals("Login failure", result.input?.get("message")) + } + @Test fun `map FailureNode returns FAILURE_NODE type with cause message`() { val node = FailureNode(cause = IllegalStateException("bad state")) @@ -94,7 +113,7 @@ class JourneyNodeMapperTest { @Test fun `map ContinueNode exposes header, description, stage and callbacks`() { - val nameCallback = NameCallback() + val nameCallback = NameCallback().withType("NameCallback") val node = mockContinueNode( input = buildJsonObject { put("header", "Welcome") @@ -129,7 +148,9 @@ class JourneyNodeMapperTest { @Test fun `mapCallback maps NameCallback prompt and value`() { - val callback = NameCallback().apply { init(outputOnly("prompt" to "Enter your name")) } + val callback = NameCallback().apply { + init(outputOnly("NameCallback", "prompt" to "Enter your name")) + } callback.name = "John Doe" val message = mapSingleCallback(callback) @@ -142,7 +163,9 @@ class JourneyNodeMapperTest { @Test fun `mapCallback maps PasswordCallback prompt but never leaks the password value`() { - val callback = PasswordCallback().apply { init(outputOnly("prompt" to "Enter your password")) } + val callback = PasswordCallback().apply { + init(outputOnly("PasswordCallback", "prompt" to "Enter your password")) + } callback.password = "super-secret" val message = mapSingleCallback(callback) @@ -154,7 +177,7 @@ class JourneyNodeMapperTest { @Test fun `mapCallback maps ValidatedUsernameCallback username as value`() { - val callback = ValidatedUsernameCallback() + val callback = ValidatedUsernameCallback().withType("ValidatedUsernameCallback") callback.username = "jdoe" val message = mapSingleCallback(callback) @@ -168,6 +191,7 @@ class JourneyNodeMapperTest { val callback = TextOutputCallback().apply { init( buildJsonObject { + put("type", "TextOutputCallback") put( "output", buildJsonArray { @@ -191,6 +215,7 @@ class JourneyNodeMapperTest { val callback = ChoiceCallback().apply { init( buildJsonObject { + put("type", "ChoiceCallback") put( "output", buildJsonArray { @@ -229,6 +254,7 @@ class JourneyNodeMapperTest { val callback = KbaCreateCallback().apply { init( buildJsonObject { + put("type", "KbaCreateCallback") put( "output", buildJsonArray { @@ -269,6 +295,7 @@ class JourneyNodeMapperTest { val callback = StringAttributeInputCallback().apply { init( buildJsonObject { + put("type", "StringAttributeInputCallback") put( "output", buildJsonArray { @@ -293,9 +320,9 @@ class JourneyNodeMapperTest { @Test fun `mapCallbacks assigns per-type indices independently`() { - val name1 = NameCallback() - val name2 = NameCallback() - val password = PasswordCallback() + val name1 = NameCallback().withType("NameCallback") + val name2 = NameCallback().withType("NameCallback") + val password = PasswordCallback().withType("PasswordCallback") val node = mockContinueNode(input = buildJsonObject {}, actions = listOf(name1, password, name2)) val result = JourneyNodeMapper.map(node) @@ -325,8 +352,9 @@ class JourneyNodeMapperTest { private fun mapSingleCallback(callback: Callback) = JourneyNodeMapper.map(mockContinueNode(buildJsonObject {}, listOf(callback))).callbacks!!.single()!! - private fun outputOnly(vararg entries: Pair) = + private fun outputOnly(type: String, vararg entries: Pair) = buildJsonObject { + put("type", type) put( "output", buildJsonArray { @@ -336,4 +364,14 @@ class JourneyNodeMapperTest { }, ) } + + /** + * Initializes [AbstractCallback.json] with the given server-registered `"type"` string, as + * [JourneyNodeMapper] now keys the wire `type` off that field rather than the runtime class + * name (see the R8/minification fix). + */ + private fun T.withType(type: String): T { + (this as AbstractCallback).init(buildJsonObject { put("type", type) }) + return this + } } diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey.podspec b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey.podspec index e66666aa..990f9ba8 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey.podspec +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey.podspec @@ -11,20 +11,18 @@ Flutter bridge for the Ping Journey SDK: drives native Journey orchestration (nodes, callbacks, sessions) from Dart via ping_core. DESC s.homepage = 'https://github.com/ForgeRock/ping-ios-sdk' - s.license = { :file => '../LICENSE' } + s.license = { :file => '../../LICENSE' } s.author = { 'Ping Identity' => 'oss@pingidentity.com' } s.source = { :path => '.' } s.source_files = 'ping_journey/Sources/ping_journey/**/*' s.dependency 'Flutter' + s.dependency 'ping_core' + s.dependency 'PingJourney', '2.0.0' s.platform = :ios, '16.0' # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' + s.swift_version = '5.9' - # If your plugin requires a privacy manifest, for example if it uses any - # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your - # plugin's privacy impact, and then uncomment this line. For more information, - # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files - # s.resource_bundles = {'ping_journey_privacy' => ['ping_journey/Sources/ping_journey/PrivacyInfo.xcprivacy']} + s.resource_bundles = {'ping_journey_privacy' => ['ping_journey/Sources/ping_journey/PrivacyInfo.xcprivacy']} end diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Package.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Package.swift index 452e0d9d..99df62e2 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Package.swift +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Package.swift @@ -25,15 +25,7 @@ let package = Package( .product(name: "PingJourney", package: "ping-ios-sdk") ], resources: [ - // If your plugin requires a privacy manifest, for example if it uses any required - // reason APIs, update the PrivacyInfo.xcprivacy file to describe your plugin's - // privacy impact, and then uncomment these lines. For more information, see - // https://developer.apple.com/documentation/bundleresources/privacy_manifest_files - // .process("PrivacyInfo.xcprivacy"), - - // If you have other resources that need to be bundled with your plugin, refer to - // the following instructions to add them: - // https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package + .process("PrivacyInfo.xcprivacy") ] ), .testTarget( diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Callback/JourneyCallbackValueApplier.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Callback/JourneyCallbackValueApplier.swift index 4764f2d3..b719b20b 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Callback/JourneyCallbackValueApplier.swift +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Callback/JourneyCallbackValueApplier.swift @@ -18,8 +18,7 @@ enum JourneyCallbackValueApplier { static func apply(_ node: ContinueNode, values: [CallbackValueMessage]) throws { var callbacksByType: [String: [any Callback]] = [:] for callback in node.callbacks { - let type = String(describing: Swift.type(of: callback)) - callbacksByType[type, default: []].append(callback) + callbacksByType[try callbackType(callback), default: []].append(callback) } for value in values { @@ -33,6 +32,17 @@ enum JourneyCallbackValueApplier { } } + /// The server-registered `"type"` string from `AbstractCallback.json`, stable across + /// release-build symbol stripping — unlike `String(describing: type(of:))`, which reflects the + /// Swift runtime type name and isn't a wire-format guarantee. + private static func callbackType(_ callback: any Callback) throws -> String { + guard let abstractCallback = callback as? AbstractCallback, + let type = abstractCallback.json[JourneyConstants.type] as? String else { + throw JourneyHostApiError.callbackApply("Callback is missing a \"type\" field: \(callback)") + } + return type + } + private static func applyValue(_ callback: any Callback, _ value: CallbackValueMessage) throws { switch callback { case let name as NameCallback: @@ -66,15 +76,20 @@ enum JourneyCallbackValueApplier { private static func applyKba(_ callback: KbaCreateCallback, _ value: CallbackValueMessage) throws { let map = try asMap(value) - if let selectedQuestion = map["selectedQuestion"] as? String { - callback.selectedQuestion = selectedQuestion + guard let selectedQuestion = map["selectedQuestion"] as? String else { + throw JourneyHostApiError.callbackApply("\(value.type) expects a String selectedQuestion") } - if let selectedAnswer = map["selectedAnswer"] as? String { - callback.selectedAnswer = selectedAnswer + guard let selectedAnswer = map["selectedAnswer"] as? String else { + throw JourneyHostApiError.callbackApply("\(value.type) expects a String selectedAnswer") } - if let allowUserDefinedQuestions = map["allowUserDefinedQuestions"] as? Bool { - callback.allowUserDefinedQuestions = allowUserDefinedQuestions + guard let allowUserDefinedQuestions = map["allowUserDefinedQuestions"] as? Bool else { + throw JourneyHostApiError.callbackApply( + "\(value.type) expects a Bool allowUserDefinedQuestions" + ) } + callback.selectedQuestion = selectedQuestion + callback.selectedAnswer = selectedAnswer + callback.allowUserDefinedQuestions = allowUserDefinedQuestions } private static func asString(_ value: CallbackValueMessage) throws -> String { diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Error/JourneyErrorMapper.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Error/JourneyErrorMapper.swift index c4212cb6..c4e60db8 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Error/JourneyErrorMapper.swift +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Error/JourneyErrorMapper.swift @@ -32,12 +32,16 @@ enum JourneyErrorMapper { switch error { case JourneyHostApiError.journeyNotFound, JourneyHostApiError.stateError: type = "state" - case JourneyHostApiError.unsupported, JourneyHostApiError.callbackApply: + case JourneyHostApiError.callbackApply: type = "argument" + case JourneyHostApiError.unsupported: + // Matches Kotlin's UnsupportedOperationException -> "unsupported" mapping for the + // same logical case (an unmapped callback type reaching value application). + type = "unsupported" default: type = "unknown" } - return PigeonError(code: code, message: String(describing: error), details: type) + return PigeonError(code: code, message: plainErrorMessage(for: error), details: type) } /// Classifies an `OidcError` value (not caught via `throws` — returned via `Result.failure`). @@ -52,3 +56,20 @@ enum JourneyErrorMapper { return PigeonError(code: code, message: error.errorMessage, details: type) } } + +/// Plain message text for an arbitrary caught `Error`, preferring `LocalizedError.errorDescription` +/// (e.g. the native SDK's `ApiError`) and `CustomStringConvertible.description` (e.g. +/// `JourneyHostApiError`'s own plain-message case payload) over `String(describing:)` — which for a +/// Swift enum with associated values dumps the case/argument reflection (e.g. +/// `stateError("No active ContinueNode found...")`) rather than clean text — and over +/// `localizedDescription`, which for a plain custom `Error` falls back to a generic bridged +/// `NSError` description rather than anything the error type itself provides. +func plainErrorMessage(for error: Error) -> String { + if let localizedError = error as? LocalizedError, let description = localizedError.errorDescription { + return description + } + if let describable = error as? CustomStringConvertible { + return describable.description + } + return error.localizedDescription +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyClientFactory.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyClientFactory.swift index 23eb72db..c316e4ff 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyClientFactory.swift +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyClientFactory.swift @@ -28,7 +28,7 @@ final class JourneyHandle: NativeHandle, @unchecked Sendable { /// Builds a native `Journey` from a `JourneyConfigMessage` and registers it in the shared /// core registry, returning the generated `journeyId`. enum JourneyClientFactory { - static func create(_ config: JourneyConfigMessage) async -> String { + static func create(_ config: JourneyConfigMessage) async throws -> String { let journey = JourneyConfigParser.parse(config) let handle = JourneyHandle(journey: journey, hasOidc: JourneyConfigParser.hasOidcFields(config)) return await CoreRuntime.journeyRegistry.register(handle) diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyConfigParser.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyConfigParser.swift index f34a4a9c..97d15d38 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyConfigParser.swift +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyConfigParser.swift @@ -15,6 +15,11 @@ import PingOidc /// those arrive with a future module). `realm`/`cookie` are left unset when absent so the native /// SDK's own defaults (`realm = "root"`, `cookie = "iPlanetDirectoryPro"`) apply. enum JourneyConfigParser { + /// Explicit cross-platform default when `JourneyConfigMessage.timeoutMillis` is unset — the + /// native SDKs' own defaults diverge (Android 15s, iOS 30s), which otherwise makes the same + /// unset config wait a different duration per platform with no indication in code or docs. + static let defaultTimeoutMillis: Int64 = 30_000 + static func parse(_ message: JourneyConfigMessage) -> Journey { Journey.createJourney { journeyConfig in applyJourneyFields(message, to: journeyConfig) @@ -30,9 +35,8 @@ enum JourneyConfigParser { journeyConfig.serverUrl = message.serverUrl if let realm = message.realm { journeyConfig.realm = realm } if let cookie = message.cookie { journeyConfig.cookie = cookie } - if let timeoutMillis = message.timeoutMillis { - journeyConfig.timeout = TimeInterval(timeoutMillis) / 1000 - } + let timeoutMillis = message.timeoutMillis ?? defaultTimeoutMillis + journeyConfig.timeout = TimeInterval(timeoutMillis) / 1000 } private static func applyOidcFields(_ message: JourneyConfigMessage, to oidcConfig: OidcClientConfig) { diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyHostApiImpl.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyHostApiImpl.swift index 93fc37cd..7bffd130 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyHostApiImpl.swift +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/JourneyHostApiImpl.swift @@ -16,16 +16,20 @@ import ping_core final class JourneyHostApiImpl: PingJourneyHostApi, @unchecked Sendable { private let lock = NSLock() - /// Most recent `Node` per journeyId. - private var nodeMap: [String: Node] = [:] - /// Most recent `ContinueNode` per journeyId, for future callback re-resolution. private var continueNodeMap: [String: ContinueNode] = [:] + /// Serializes `next()` per journeyId so a double-submit can't race two callback applications. + private let nextSerializer = KeyedSerialExecutor() + func configureJourney(config: JourneyConfigMessage, completion: @escaping (Result) -> Void) { Task { - let journeyId = await JourneyClientFactory.create(config) - completion(.success(journeyId)) + do { + let journeyId = try await JourneyClientFactory.create(config) + completion(.success(journeyId)) + } catch { + completion(.failure(JourneyErrorMapper.from(JourneyErrorCodes.configure, error))) + } } } @@ -57,20 +61,19 @@ final class JourneyHostApiImpl: PingJourneyHostApi, @unchecked Sendable { completion: @escaping (Result) -> Void ) { Task { - guard let currentNode = activeContinueNode(journeyId) else { - completion(.failure(JourneyErrorMapper.from( - JourneyErrorCodes.next, - JourneyHostApiError.stateError( - "No active ContinueNode found for journeyId=\(journeyId)" - ) - ))) - return - } do { - try JourneyCallbackValueApplier.apply(currentNode, values: values.compactMap { $0 }) - let nextNode = await currentNode.next() - setNode(journeyId: journeyId, node: nextNode) - completion(.success(JourneyNodeMapper.map(nextNode))) + let message = try await nextSerializer.run(key: journeyId) { [self] in + guard let currentNode = self.activeContinueNode(journeyId) else { + throw JourneyHostApiError.stateError( + "No active ContinueNode found for journeyId=\(journeyId)" + ) + } + try JourneyCallbackValueApplier.apply(currentNode, values: values.compactMap { $0 }) + let nextNode = await currentNode.next() + self.setNode(journeyId: journeyId, node: nextNode) + return JourneyNodeMapper.map(nextNode) + } + completion(.success(message)) } catch { completion(.failure(JourneyErrorMapper.from(JourneyErrorCodes.next, error))) } @@ -94,8 +97,11 @@ final class JourneyHostApiImpl: PingJourneyHostApi, @unchecked Sendable { case .success(let token): var userInfoMap: [String?: Any?]? = nil let uiResult = await user.userinfo(cache: false) - if case .success(let userInfo) = uiResult { + switch uiResult { + case .success(let userInfo): userInfoMap = userInfo as? [String?: Any?] + case .failure(let error): + NSLog("JourneyHostApiImpl: userinfo() failed for journeyId=%@: %@", journeyId, "\(error)") } let session = SessionMessage( accessToken: token.accessToken, @@ -118,6 +124,11 @@ final class JourneyHostApiImpl: PingJourneyHostApi, @unchecked Sendable { ))) return } + let signOffResult = await handle.journey.signOff() + if case .failure(let error) = signOffResult { + completion(.failure(JourneyErrorMapper.from(JourneyErrorCodes.signOff, error))) + return + } if handle.hasOidc { let user = await handle.journey.journeyUser() await user?.logout() @@ -129,7 +140,8 @@ final class JourneyHostApiImpl: PingJourneyHostApi, @unchecked Sendable { func dispose(journeyId: String, completion: @escaping (Result) -> Void) { Task { - closeAndClearNodeState(journeyId: journeyId) + clearNodeState(journeyId: journeyId) + await nextSerializer.remove(key: journeyId) await CoreRuntime.journeyRegistry.remove(journeyId) completion(.success(())) } @@ -152,7 +164,6 @@ final class JourneyHostApiImpl: PingJourneyHostApi, @unchecked Sendable { private func setNode(journeyId: String, node: Node) { lock.lock() defer { lock.unlock() } - nodeMap[journeyId] = node if let continueNode = node as? ContinueNode { continueNodeMap[journeyId] = continueNode } else { @@ -160,26 +171,30 @@ final class JourneyHostApiImpl: PingJourneyHostApi, @unchecked Sendable { } } - private func clearNodeState(journeyId: String) { - lock.lock() - defer { lock.unlock() } - nodeMap.removeValue(forKey: journeyId) - continueNodeMap.removeValue(forKey: journeyId) - } - /// Closes the cached `ContinueNode` (mirrors Android's `removeJourney()`) before dropping it, /// so any `Closeable` actions on the last node get a chance to release resources. - private func closeAndClearNodeState(journeyId: String) { + private func clearNodeState(journeyId: String) { lock.lock() defer { lock.unlock() } - nodeMap.removeValue(forKey: journeyId) continueNodeMap.removeValue(forKey: journeyId)?.close() } } -enum JourneyHostApiError: Error { +enum JourneyHostApiError: Error, CustomStringConvertible { case journeyNotFound(String) case unsupported(String) case stateError(String) case callbackApply(String) + + /// Plain message text, matching Kotlin's `IllegalStateException.message`/etc. shape rather + /// than Swift's default enum-case reflection (e.g. `stateError("...")`). + var description: String { + switch self { + case .journeyNotFound(let message), + .unsupported(let message), + .stateError(let message), + .callbackApply(let message): + return message + } + } } diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/KeyedSerialExecutor.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/KeyedSerialExecutor.swift new file mode 100644 index 00000000..6087fdbb --- /dev/null +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/KeyedSerialExecutor.swift @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import Foundation + +/// Serializes async work per key: two calls to `run(key:_:)` with the same `key` never overlap, +/// while calls with different keys run concurrently. Used to prevent a double-submitted `next()` +/// for the same `journeyId` from racing on the same cached `ContinueNode`'s callback fields. +actor KeyedSerialExecutor { + private var tails: [String: Task] = [:] + private var generations: [String: UInt64] = [:] + + func run(key: String, _ operation: @Sendable @escaping () async throws -> T) async throws -> T { + let previousTail = tails[key] + let generation = (generations[key] ?? 0) &+ 1 + generations[key] = generation + + let box = Box() + let tail = Task { + _ = await previousTail?.value + await box.set(result: await Self.runCatching(operation)) + } + tails[key] = tail + + await tail.value + if generations[key] == generation { + tails.removeValue(forKey: key) + generations.removeValue(forKey: key) + } + return try box.get() + } + + /// Drops any pending chain for `key`, e.g. once its journey is disposed. + func remove(key: String) { + tails.removeValue(forKey: key) + generations.removeValue(forKey: key) + } + + private static func runCatching(_ operation: () async throws -> T) async -> Result { + do { + return .success(try await operation()) + } catch { + return .failure(error) + } + } +} + +/// Single-slot holder for a `Result` set exactly once from within the executor's serialized chain. +private final class Box: @unchecked Sendable { + private var result: Result? + + func set(result: Result) { + self.result = result + } + + func get() throws -> T { + // Safe force-unwrap: `run(key:_:)` always calls `set` before reading via `get`. + try result!.get() + } +} diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Mapper/JourneyNodeMapper.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Mapper/JourneyNodeMapper.swift index cdc49418..19c9b2aa 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Mapper/JourneyNodeMapper.swift +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Mapper/JourneyNodeMapper.swift @@ -28,9 +28,14 @@ enum JourneyNodeMapper { case is SuccessNode: return NodeMessage(type: .successNode) case let errorNode as ErrorNode: - return NodeMessage(type: .errorNode, message: errorNode.message, status: errorNode.status.map { Int64($0) }) + return NodeMessage( + type: .errorNode, + message: errorNode.message, + status: errorNode.status.map { Int64($0) }, + input: errorNode.input as? [String?: Any?] + ) case let failureNode as FailureNode: - return NodeMessage(type: .failureNode, cause: String(describing: failureNode.cause)) + return NodeMessage(type: .failureNode, cause: plainErrorMessage(for: failureNode.cause)) default: return NodeMessage(type: .failureNode, cause: "Unknown node type: \(node)") } @@ -39,13 +44,24 @@ enum JourneyNodeMapper { private static func mapCallbacks(_ node: ContinueNode) -> [CallbackMessage] { var typeCounts: [String: Int64] = [:] return node.callbacks.map { callback in - let type = String(describing: Swift.type(of: callback)) + let type = callbackType(callback) let index = typeCounts[type, default: 0] typeCounts[type] = index + 1 return mapCallback(callback, type: type, index: index) } } + /// The server-registered `"type"` string from `AbstractCallback.json`, stable across + /// release-build symbol stripping — unlike `String(describing: type(of:))`, which reflects the + /// Swift runtime type name and isn't a wire-format guarantee. + private static func callbackType(_ callback: any Callback) -> String { + guard let abstractCallback = callback as? AbstractCallback, + let type = abstractCallback.json[JourneyConstants.type] as? String else { + fatalError("Callback is missing a \"type\" field: \(callback)") + } + return type + } + private static func mapCallback(_ callback: any Callback, type: String, index: Int64) -> CallbackMessage { var message = CallbackMessage(type: type, index: index) @@ -104,7 +120,9 @@ enum JourneyNodeMapper { case let stringAttribute as StringAttributeInputCallback: message.value = stringAttribute.value default: - break + if let abstractCallback = callback as? AbstractCallback { + message.raw = abstractCallback.json as? [String?: Any?] + } } return message diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Messages.g.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Messages.g.swift index f1463ba0..ef183c12 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Messages.g.swift +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/Messages.g.swift @@ -391,10 +391,17 @@ struct CallbackMessage: Hashable, CustomStringConvertible { /// Attribute name/label (Attribute*InputCallback), distinct from [value]. var name: String? = nil /// Validate-without-advancing flag (ValidatedUsername/ValidatedPassword/ - /// Attribute*InputCallback). + /// Attribute*InputCallback). Read-only: reflects native state at mapping + /// time; [CallbackValueMessage] has no field to send an updated value back + /// to native (same limitation as the React Native bridge). var validateOnly: Bool? = nil var policies: [String?: Any?]? = nil - var failedPolicies: [[String?: Any?]?]? = nil + /// Each element is a `Map?`. Typed as `List?` (rather than + /// `List?>?`) because Pigeon's generated decoder does an unchecked + /// `.cast?>()` on nested collection element types, which throws at + /// runtime the first time a real (non-empty) map element arrives over the wire — see + /// `NodeMapper._castFailedPolicies` for the safe elementwise cast this requires on read. + var failedPolicies: [Any?]? = nil /// Whether to mask input (ValidatedPasswordCallback). var echoOn: Bool? = nil /// TextOutputCallback message classification: INFORMATION/WARNING/ERROR/SCRIPT/UNKNOWN. @@ -425,7 +432,7 @@ struct CallbackMessage: Hashable, CustomStringConvertible { let name: String? = nilOrValue(pigeonVar_list[18]) let validateOnly: Bool? = nilOrValue(pigeonVar_list[19]) let policies: [String?: Any?]? = nilOrValue(pigeonVar_list[20]) - let failedPolicies: [[String?: Any?]?]? = nilOrValue(pigeonVar_list[21]) + let failedPolicies: [Any?]? = nilOrValue(pigeonVar_list[21]) let echoOn: Bool? = nilOrValue(pigeonVar_list[22]) let messageType: String? = nilOrValue(pigeonVar_list[23]) let raw: [String?: Any?]? = nilOrValue(pigeonVar_list[24]) diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/PingJourneyPlugin.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/PingJourneyPlugin.swift index 603c23ac..a281cb9e 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/PingJourneyPlugin.swift +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Sources/ping_journey/PingJourneyPlugin.swift @@ -10,8 +10,18 @@ import UIKit /// Registers the generated `PingJourneyHostApi` Pigeon channel. public class PingJourneyPlugin: NSObject, FlutterPlugin { + private var api: JourneyHostApiImpl? + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = PingJourneyPlugin() let api = JourneyHostApiImpl() + instance.api = api PingJourneyHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: api) + registrar.publish(instance) + } + + public func detachFromEngine(for registrar: FlutterPluginRegistrar) { + PingJourneyHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: nil) + api = nil } } diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyCallbackValueApplierTests.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyCallbackValueApplierTests.swift index 32e46286..4d7d3b17 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyCallbackValueApplierTests.swift +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyCallbackValueApplierTests.swift @@ -15,79 +15,79 @@ import PingOrchestrate /// value lands on the correct native field, and that lookup/type-mismatch failures are surfaced. final class JourneyCallbackValueApplierTests: XCTestCase { - func testAppliesStringValueOntoNameCallbackName() throws { + func testAppliesStringValueOntoNameCallbackName() async throws { let callback = NameCallback() - try apply(callback, type: "NameCallback", value: "John Doe") + try await apply(callback, type: "NameCallback", value: "John Doe") XCTAssertEqual(callback.name, "John Doe") } - func testAppliesStringValueOntoPasswordCallbackPassword() throws { + func testAppliesStringValueOntoPasswordCallbackPassword() async throws { let callback = PasswordCallback() - try apply(callback, type: "PasswordCallback", value: "s3cr3t") + try await apply(callback, type: "PasswordCallback", value: "s3cr3t") XCTAssertEqual(callback.password, "s3cr3t") } - func testAppliesStringValueOntoValidatedUsernameCallbackUsername() throws { + func testAppliesStringValueOntoValidatedUsernameCallbackUsername() async throws { let callback = ValidatedUsernameCallback() - try apply(callback, type: "ValidatedUsernameCallback", value: "jdoe") + try await apply(callback, type: "ValidatedUsernameCallback", value: "jdoe") XCTAssertEqual(callback.username, "jdoe") } - func testAppliesStringValueOntoValidatedPasswordCallbackPassword() throws { + func testAppliesStringValueOntoValidatedPasswordCallbackPassword() async throws { let callback = ValidatedPasswordCallback() - try apply(callback, type: "ValidatedPasswordCallback", value: "s3cr3t") + try await apply(callback, type: "ValidatedPasswordCallback", value: "s3cr3t") XCTAssertEqual(callback.password, "s3cr3t") } - func testAppliesStringValueOntoTextInputCallbackText() throws { + func testAppliesStringValueOntoTextInputCallbackText() async throws { let callback = TextInputCallback() - try apply(callback, type: "TextInputCallback", value: "some text") + try await apply(callback, type: "TextInputCallback", value: "some text") XCTAssertEqual(callback.text, "some text") } - func testAppliesStringValueOntoStringAttributeInputCallbackValue() throws { + func testAppliesStringValueOntoStringAttributeInputCallbackValue() async throws { let callback = StringAttributeInputCallback() - try apply(callback, type: "StringAttributeInputCallback", value: "user@example.com") + try await apply(callback, type: "StringAttributeInputCallback", value: "user@example.com") XCTAssertEqual(callback.value, "user@example.com") } - func testAppliesNumericValueOntoNumberAttributeInputCallbackValueAsDouble() throws { + func testAppliesNumericValueOntoNumberAttributeInputCallbackValueAsDouble() async throws { let callback = NumberAttributeInputCallback() - try apply(callback, type: "NumberAttributeInputCallback", value: NSNumber(value: 42)) + try await apply(callback, type: "NumberAttributeInputCallback", value: NSNumber(value: 42)) XCTAssertEqual(callback.value, 42.0) } - func testAppliesBoolValueOntoBooleanAttributeInputCallbackValue() throws { + func testAppliesBoolValueOntoBooleanAttributeInputCallbackValue() async throws { let callback = BooleanAttributeInputCallback() - try apply(callback, type: "BooleanAttributeInputCallback", value: true) + try await apply(callback, type: "BooleanAttributeInputCallback", value: true) XCTAssertEqual(callback.value, true) } - func testAppliesNumericValueOntoChoiceCallbackSelectedIndex() throws { + func testAppliesNumericValueOntoChoiceCallbackSelectedIndex() async throws { let callback = ChoiceCallback() - try apply(callback, type: "ChoiceCallback", value: NSNumber(value: 2)) + try await apply(callback, type: "ChoiceCallback", value: NSNumber(value: 2)) XCTAssertEqual(callback.selectedIndex, 2) } - func testAppliesBoolValueOntoTermsAndConditionsCallbackAccepted() throws { + func testAppliesBoolValueOntoTermsAndConditionsCallbackAccepted() async throws { let callback = TermsAndConditionsCallback() - try apply(callback, type: "TermsAndConditionsCallback", value: true) + try await apply(callback, type: "TermsAndConditionsCallback", value: true) XCTAssertEqual(callback.accepted, true) } - func testAppliesMapValueOntoKbaCreateCallbackSubFields() throws { + func testAppliesMapValueOntoKbaCreateCallbackSubFields() async throws { let callback = KbaCreateCallback() - try apply( + try await apply( callback, type: "KbaCreateCallback", value: [ @@ -102,21 +102,22 @@ final class JourneyCallbackValueApplierTests: XCTestCase { XCTAssertEqual(callback.allowUserDefinedQuestions, true) } - func testKbaCreateCallbackPartialMapOnlyUpdatesProvidedSubFields() throws { + func testKbaCreateCallbackThrowsCallbackApplyWhenASubFieldIsMissing() async throws { let callback = KbaCreateCallback() - callback.selectedQuestion = "existing question" - callback.selectedAnswer = "existing answer" - callback.allowUserDefinedQuestions = true - try apply(callback, type: "KbaCreateCallback", value: ["selectedAnswer": "new answer"]) - - XCTAssertEqual(callback.selectedQuestion, "existing question") - XCTAssertEqual(callback.selectedAnswer, "new answer") - XCTAssertEqual(callback.allowUserDefinedQuestions, true) + do { + try await apply(callback, type: "KbaCreateCallback", value: ["selectedAnswer": "new answer"]) + XCTFail("Expected .callbackApply to be thrown") + } catch JourneyHostApiError.callbackApply { + // expected + } catch { + XCTFail("Expected .callbackApply, got \(error)") + } } - func testThrowsForUnsupportedCallbackTypeSuchAsTextOutputCallback() throws { + func testThrowsForUnsupportedCallbackTypeSuchAsTextOutputCallback() async throws { let callback = TextOutputCallback() + _ = await callback.initialize(with: ["type": "TextOutputCallback"]) let node = makeContinueNode(actions: [callback]) XCTAssertThrowsError( @@ -131,8 +132,10 @@ final class JourneyCallbackValueApplierTests: XCTestCase { } } - func testThrowsCallbackApplyWhenNoCallbackMatchesTypeAndIndex() throws { - let node = makeContinueNode(actions: [NameCallback()]) + func testThrowsCallbackApplyWhenNoCallbackMatchesTypeAndIndex() async throws { + let callback = NameCallback() + _ = await callback.initialize(with: ["type": "NameCallback"]) + let node = makeContinueNode(actions: [callback]) XCTAssertThrowsError( try JourneyCallbackValueApplier.apply( @@ -147,8 +150,9 @@ final class JourneyCallbackValueApplierTests: XCTestCase { } } - func testThrowsCallbackApplyWhenValueTypeMismatchesExpectedType() throws { + func testThrowsCallbackApplyWhenValueTypeMismatchesExpectedType() async throws { let callback = NameCallback() + _ = await callback.initialize(with: ["type": "NameCallback"]) let node = makeContinueNode(actions: [callback]) XCTAssertThrowsError( @@ -163,9 +167,11 @@ final class JourneyCallbackValueApplierTests: XCTestCase { } } - func testAppliesValuesAddressedByIndexWithinSameTypeGroup() throws { + func testAppliesValuesAddressedByIndexWithinSameTypeGroup() async throws { let first = NameCallback() + _ = await first.initialize(with: ["type": "NameCallback"]) let second = NameCallback() + _ = await second.initialize(with: ["type": "NameCallback"]) let node = makeContinueNode(actions: [first, second]) try JourneyCallbackValueApplier.apply( @@ -185,7 +191,10 @@ final class JourneyCallbackValueApplierTests: XCTestCase { return ContinueNode(context: context, workflow: workflow, input: [:], actions: actions) } - private func apply(_ callback: any Callback, type: String, value: Any?) throws { + private func apply(_ callback: any Callback, type: String, value: Any?) async throws { + if let abstractCallback = callback as? AbstractCallback { + _ = await abstractCallback.initialize(with: ["type": type]) + } let node = makeContinueNode(actions: [callback]) try JourneyCallbackValueApplier.apply( node, diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyErrorMapperTests.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyErrorMapperTests.swift index fc3ea006..1a27a01c 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyErrorMapperTests.swift +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyErrorMapperTests.swift @@ -45,12 +45,14 @@ final class JourneyErrorMapperTests: XCTestCase { XCTAssertEqual(result.details as? String, "state") } - func testFromClassifiesUnsupportedAsArgument() { + func testFromClassifiesUnsupportedAsUnsupported() { + // Matches Kotlin's UnsupportedOperationException -> "unsupported" mapping for the same + // logical case (an unmapped callback type reaching value application). let error = JourneyHostApiError.unsupported("not supported") let result = JourneyErrorMapper.from(code, error) - XCTAssertEqual(result.details as? String, "argument") + XCTAssertEqual(result.details as? String, "unsupported") } func testFromClassifiesCallbackApplyAsArgument() { @@ -61,6 +63,14 @@ final class JourneyErrorMapperTests: XCTestCase { XCTAssertEqual(result.details as? String, "argument") } + func testFromUsesThePlainErrorMessageNotEnumReflection() { + let error = JourneyHostApiError.stateError("No active ContinueNode found for journeyId=abc") + + let result = JourneyErrorMapper.from(code, error) + + XCTAssertEqual(result.message, "No active ContinueNode found for journeyId=abc") + } + func testFromClassifiesUnrecognizedErrorTypesAsUnknown() { let error = SampleError() diff --git a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyNodeMapperTests.swift b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyNodeMapperTests.swift index fd53c648..d18d311d 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyNodeMapperTests.swift +++ b/flutter/flutter-sdk-bridge/ping_journey/ios/ping_journey/Tests/ping_journeyTests/JourneyNodeMapperTests.swift @@ -60,8 +60,9 @@ final class JourneyNodeMapperTests: XCTestCase { XCTAssertTrue(result.cause?.contains("Unknown node type") == true) } - func testMapContinueNodeExposesHeaderDescriptionStageAndCallbacks() { + func testMapContinueNodeExposesHeaderDescriptionStageAndCallbacks() async { let nameCallback = NameCallback() + _ = await nameCallback.initialize(with: ["type": "NameCallback"]) let node = makeContinueNode( input: [ "header": "Welcome", @@ -95,7 +96,9 @@ final class JourneyNodeMapperTests: XCTestCase { func testMapCallbackMapsNameCallbackPromptAndValue() async { let callback = NameCallback() - _ = await callback.initialize(with: outputOnly([("prompt", "Enter your name")])) + _ = await callback.initialize( + with: outputOnly("NameCallback", [("prompt", "Enter your name")]) + ) callback.name = "John Doe" let message = await mapSingleCallback(callback) @@ -108,7 +111,9 @@ final class JourneyNodeMapperTests: XCTestCase { func testMapCallbackMapsPasswordCallbackPromptButNeverLeaksPasswordValue() async { let callback = PasswordCallback() - _ = await callback.initialize(with: outputOnly([("prompt", "Enter your password")])) + _ = await callback.initialize( + with: outputOnly("PasswordCallback", [("prompt", "Enter your password")]) + ) callback.password = "super-secret" let message = await mapSingleCallback(callback) @@ -120,6 +125,7 @@ final class JourneyNodeMapperTests: XCTestCase { func testMapCallbackMapsValidatedUsernameCallbackUsernameAsValue() async { let callback = ValidatedUsernameCallback() + _ = await callback.initialize(with: ["type": "ValidatedUsernameCallback"]) callback.username = "jdoe" let message = await mapSingleCallback(callback) @@ -132,6 +138,7 @@ final class JourneyNodeMapperTests: XCTestCase { let callback = TextOutputCallback() _ = await callback.initialize( with: [ + "type": "TextOutputCallback", "output": [ ["name": "messageType", "value": "0"], ["name": "message", "value": "Hello!"], @@ -150,6 +157,7 @@ final class JourneyNodeMapperTests: XCTestCase { let callback = ChoiceCallback() _ = await callback.initialize( with: [ + "type": "ChoiceCallback", "output": [ ["name": "prompt", "value": "Pick one"], ["name": "defaultChoice", "value": 1], @@ -172,6 +180,7 @@ final class JourneyNodeMapperTests: XCTestCase { let callback = KbaCreateCallback() _ = await callback.initialize( with: [ + "type": "KbaCreateCallback", "output": [ ["name": "prompt", "value": "Security question"], ["name": "predefinedQuestions", "value": ["Pet's name?", "First school?"]], @@ -196,6 +205,7 @@ final class JourneyNodeMapperTests: XCTestCase { let callback = StringAttributeInputCallback() _ = await callback.initialize( with: [ + "type": "StringAttributeInputCallback", "output": [ ["name": "name", "value": "mail"], ["name": "prompt", "value": "Email address"], @@ -214,10 +224,13 @@ final class JourneyNodeMapperTests: XCTestCase { XCTAssertEqual(message.value as? String, "user@example.com") } - func testMapCallbacksAssignsPerTypeIndicesIndependently() { + func testMapCallbacksAssignsPerTypeIndicesIndependently() async { let name1 = NameCallback() + _ = await name1.initialize(with: ["type": "NameCallback"]) let name2 = NameCallback() + _ = await name2.initialize(with: ["type": "NameCallback"]) let password = PasswordCallback() + _ = await password.initialize(with: ["type": "PasswordCallback"]) let node = makeContinueNode(input: [:], actions: [name1, password, name2]) let result = JourneyNodeMapper.map(node) @@ -245,9 +258,10 @@ final class JourneyNodeMapperTests: XCTestCase { return JourneyNodeMapper.map(node).callbacks!.first!! } - private func outputOnly(_ entries: [(String, String)]) -> [String: Any] { + private func outputOnly(_ type: String, _ entries: [(String, String)]) -> [String: Any] { [ - "output": entries.map { ["name": $0.0, "value": $0.1] } + "type": type, + "output": entries.map { ["name": $0.0, "value": $0.1] }, ] } } diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback_type.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback_type.dart index 36fd7547..7e9caf0c 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback_type.dart +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/callback_type.dart @@ -20,6 +20,16 @@ abstract final class CallbackType { /// Wire type for `ValidatedPasswordCallback`. static const validatedPasswordCallback = 'ValidatedPasswordCallback'; + /// Wire type for the registration-flow variant of [validatedUsernameCallback] — the native + /// SDKs' `AbstractCallback.json["type"]` is the AM server's own callback class name + /// (`ValidatedCreateUsernameCallback`), not the SDK's local `ValidatedUsernameCallback` class + /// name, even though both re-inflate to the same native `ValidatedUsernameCallback` type. + static const validatedCreateUsernameCallback = 'ValidatedCreateUsernameCallback'; + + /// Wire type for the registration-flow variant of [validatedPasswordCallback] — see + /// [validatedCreateUsernameCallback]. + static const validatedCreatePasswordCallback = 'ValidatedCreatePasswordCallback'; + /// Wire type for `ChoiceCallback`. static const choiceCallback = 'ChoiceCallback'; diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/journey_node.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/journey_node.dart index b2313c66..af22a76a 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/lib/src/journey_node.dart +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/journey_node.dart @@ -54,7 +54,7 @@ final class ErrorNode extends JourneyNode { /// The human-readable error message reported by the server. final String message; - /// HTTP status, when the native SDK exposes one (iOS only; Android has no equivalent field). + /// HTTP status of the server error response, when present. final int? status; } diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/messages.g.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/messages.g.dart index 2b11cf9a..8f6a153a 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/lib/src/messages.g.dart +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/messages.g.dart @@ -368,12 +368,19 @@ class CallbackMessage { String? name; /// Validate-without-advancing flag (ValidatedUsername/ValidatedPassword/ - /// Attribute*InputCallback). + /// Attribute*InputCallback). Read-only: reflects native state at mapping + /// time; [CallbackValueMessage] has no field to send an updated value back + /// to native (same limitation as the React Native bridge). bool? validateOnly; Map? policies; - List?>? failedPolicies; + /// Each element is a `Map?`. Typed as `List?` (rather than + /// `List?>?`) because Pigeon's generated decoder does an unchecked + /// `.cast?>()` on nested collection element types, which throws at + /// runtime the first time a real (non-empty) map element arrives over the wire — see + /// `NodeMapper._castFailedPolicies` for the safe elementwise cast this requires on read. + List? failedPolicies; /// Whether to mask input (ValidatedPasswordCallback). bool? echoOn; @@ -440,7 +447,7 @@ class CallbackMessage { name: result[18] as String?, validateOnly: result[19] as bool?, policies: (result[20] as Map?)?.cast(), - failedPolicies: (result[21] as List?)?.cast?>(), + failedPolicies: result[21] as List?, echoOn: result[22] as bool?, messageType: result[23] as String?, raw: (result[24] as Map?)?.cast(), diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/node_mapper.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/node_mapper.dart index 268245ed..c6a9acbb 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/lib/src/node_mapper.dart +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/node_mapper.dart @@ -42,9 +42,10 @@ class NodeMapper { }; /// Maps a [CallbackMessage] to the matching [Callback] subtype, keyed by its native class-name - /// [CallbackMessage.type]. Unrecognized types are not representable in the v1 sealed hierarchy - /// and are dropped by the caller (see [ContinueNode.callbacks] construction above, via - /// `whereType`/`map` — a switch here must still be exhaustive over the *known* wire names). + /// [CallbackMessage.type]. Unrecognized types are not representable in the v1 sealed hierarchy, + /// so the `_` case below renders them as a generic [TextOutputCallback] with a placeholder + /// message instead of dropping them. [CallbackMessage.raw] (the native callback's full JSON) is + /// still populated on the wire for these types, for debugging/diagnostic purposes. static Callback mapCallback( CallbackMessage message, ) => switch (message.type) { @@ -59,7 +60,8 @@ class NodeMapper { index: message.index, prompt: message.prompt, ), - CallbackType.validatedUsernameCallback => ValidatedUsernameCallback( + CallbackType.validatedUsernameCallback || + CallbackType.validatedCreateUsernameCallback => ValidatedUsernameCallback( type: message.type, index: message.index, prompt: message.prompt, @@ -68,7 +70,8 @@ class NodeMapper { policies: message.policies?.cast(), failedPolicies: _castFailedPolicies(message.failedPolicies), ), - CallbackType.validatedPasswordCallback => ValidatedPasswordCallback( + CallbackType.validatedPasswordCallback || + CallbackType.validatedCreatePasswordCallback => ValidatedPasswordCallback( type: message.type, index: message.index, prompt: message.prompt, @@ -162,10 +165,10 @@ class NodeMapper { }; static List> _castFailedPolicies( - List?>? raw, + List? raw, ) => raw - ?.whereType>() + ?.whereType>() .map((m) => m.cast()) .toList() ?? const []; diff --git a/flutter/flutter-sdk-bridge/ping_journey/lib/src/session.dart b/flutter/flutter-sdk-bridge/ping_journey/lib/src/session.dart index 9a0e421f..49694694 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/lib/src/session.dart +++ b/flutter/flutter-sdk-bridge/ping_journey/lib/src/session.dart @@ -9,7 +9,7 @@ /// /// Public-facing counterpart of the wire `SessionMessage` — kept as a distinct type (rather than /// exposing the Pigeon message directly) so callers get plain `Map`/JSON-shaped values, with -/// manual `fromJson`/`toJson` per DESIGN.md rather than a generated codec. +/// manual `fromJson`/`toJson`. class Session { /// Creates a session from its already-parsed fields. const Session({ diff --git a/flutter/flutter-sdk-bridge/ping_journey/pigeons/messages.dart b/flutter/flutter-sdk-bridge/ping_journey/pigeons/messages.dart index c62be7c2..785edcf4 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/pigeons/messages.dart +++ b/flutter/flutter-sdk-bridge/ping_journey/pigeons/messages.dart @@ -97,7 +97,13 @@ class CallbackMessage { /// to native (same limitation as the React Native bridge). bool? validateOnly; Map? policies; - List?>? failedPolicies; + + /// Each element is a `Map?`. Typed as `List?` (rather than + /// `List?>?`) because Pigeon's generated decoder does an unchecked + /// `.cast?>()` on nested collection element types, which throws at + /// runtime the first time a real (non-empty) map element arrives over the wire — see + /// `NodeMapper._castFailedPolicies` for the safe elementwise cast this requires on read. + List? failedPolicies; /// Whether to mask input (ValidatedPasswordCallback). bool? echoOn; diff --git a/flutter/flutter-sdk-bridge/ping_journey/test/src/node_mapper_test.dart b/flutter/flutter-sdk-bridge/ping_journey/test/src/node_mapper_test.dart index 36541e8f..4ceaf865 100644 --- a/flutter/flutter-sdk-bridge/ping_journey/test/src/node_mapper_test.dart +++ b/flutter/flutter-sdk-bridge/ping_journey/test/src/node_mapper_test.dart @@ -264,6 +264,37 @@ void main() { expect(value.value, 'jdoe'); }); + test( + 'maps the "ValidatedCreateUsernameCallback" wire type (the AM server\'s own callback ' + 'name on self-registration Journeys) to ValidatedUsernameCallback, not the ' + 'TextOutputCallback fallback (regression: this wire name differs from the native SDK\'s ' + 'local ValidatedUsernameCallback class name)', + () { + final callback = NodeMapper.mapCallback( + CallbackMessage( + type: 'ValidatedCreateUsernameCallback', + index: 0, + value: 'jdoe', + ), + ); + + expect(callback, isA()); + expect(callback.toValue()!.type, 'ValidatedCreateUsernameCallback'); + }, + ); + + test( + 'maps the "ValidatedCreatePasswordCallback" wire type to ValidatedPasswordCallback, not ' + 'the TextOutputCallback fallback (see ValidatedCreateUsernameCallback test above)', + () { + final callback = NodeMapper.mapCallback( + CallbackMessage(type: 'ValidatedCreatePasswordCallback', index: 0), + ); + + expect(callback, isA()); + }, + ); + test( 'ValidatedPasswordCallback re-inflates to an empty password (native never sends it ' 'back), then submits whatever the UI sets', @@ -346,6 +377,59 @@ void main() { expect(value.value, 42.0); }); + test( + 'ValidatedPasswordCallback re-inflates failedPolicies decoded off a platform channel ' + '(regression: CallbackMessage.decode hands back untyped Map entries, ' + 'which must not be force-cast to Map)', + () { + final wireMessage = CallbackMessage.decode([ + 'ValidatedPasswordCallback', // type + 0, // index + null, // prompt + null, // message + null, // required + null, // value + null, // choices + null, // defaultChoice + null, // selectedIndex + null, // terms + null, // version + null, // createDate + null, // accepted + null, // defaultText + null, // predefinedQuestions + null, // selectedQuestion + null, // selectedAnswer + null, // allowUserDefinedQuestions + null, // name + false, // validateOnly + null, // policies + [ + { + 'policyRequirement': 'MIN_LENGTH', + 'params': {'minLength': 8}, + }, + ], // failedPolicies + true, // echoOn + null, // messageType + null, // raw + ]); + + final callback = + NodeMapper.mapCallback(wireMessage) as ValidatedPasswordCallback; + + expect(callback.failedPolicies, hasLength(1)); + expect( + callback.failedPolicies.single['policyRequirement'], + 'MIN_LENGTH', + ); + expect( + (callback.failedPolicies.single['params'] as Map)['minLength'], + 8, + ); + }, + ); + test('BooleanAttributeInputCallback submits its boolean value', () { final callback = NodeMapper.mapCallback( diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c0583290..1d5b2b88 100644 --- a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ForgeRock/ping-ios-sdk", "state" : { - "revision" : "17b95224da7008ce823fc2b199666cd3e26991d9", - "version" : "2.1.0" + "revision" : "9b37c20f99eb1ce418e6dc8ab491e32fad86051e", + "version" : "2.0.0-alpha-9b37c20" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pingidentity/pingone-signals-sdk-ios.git", "state" : { - "revision" : "e41f7070fdbb43dd7762274ec610c099256a7c7e", - "version" : "5.4.0" + "revision" : "bf37bd85fa909428d764630c71cf0d4f3d2d7e05", + "version" : "5.3.0" } }, { diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/JourneyViewModel.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/JourneyViewModel.swift index ab21fc1f..971ba7b4 100644 --- a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/JourneyViewModel.swift +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/JourneyViewModel.swift @@ -33,14 +33,14 @@ import Combine /// - discoveryEndpoint: Your OIDC discovery endpoint URL public let journey = Journey.createJourney { config in - config.serverUrl = "https://openam-sdks.forgeblocks.com/am" - config.realm = "alpha" - config.cookie = "iPlanetDirectoryPro" + config.serverUrl = <#"https://your-server.example.com/am"#> + config.realm = <#"your-realm"#> + config.cookie = <#"your-cookie-name"#> config.module(PingJourney.OidcModule.config) { oidcValue in - oidcValue.clientId = "iosClient" - oidcValue.scopes = ["openid", "email", "profile", "address"] - oidcValue.redirectUri = "frauth://com.forgerock.ios.frexample" - oidcValue.discoveryEndpoint = "https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration" + oidcValue.clientId = <#"your-client-id"#> + oidcValue.scopes = <#"[SCOPES]"#> + oidcValue.redirectUri = <#"yourapp://callback"#> + oidcValue.discoveryEndpoint = <#"https://your-server.example.com/am/oauth2/your-realm/.well-known/openid-configuration"#> } }