Skip to content

SDKS-4613 Create simple journey sample app for flutter using new Ping Orchestration SDKs - #124

Open
rodrigoareis wants to merge 1 commit into
flutterfrom
SDKS-4613
Open

SDKS-4613 Create simple journey sample app for flutter using new Ping Orchestration SDKs#124
rodrigoareis wants to merge 1 commit into
flutterfrom
SDKS-4613

Conversation

@rodrigoareis

@rodrigoareis rodrigoareis commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a Flutter sample app demonstrating authentication against a PingAM/PingOne Advanced Identity Cloud Journey, built on a new native-wrapper bridge plugin that exposes the published native Android/iOS Ping Orchestration SDKs to Flutter. This bridge — not a pure-Dart reimplementation — wraps the existing native SDKs via
Pigeon-generated, type-safe Dart ⇄ Kotlin ⇄ Swift bindings.

  • flutter/flutter-sdk-bridge/ — the bridge, split into two federated plugin packages:
    • ping_core — shared, process-wide native registry (CoreRuntime) for live SDK handles
      (Journey, ContinueNode, ...) that never cross the platform channel, plus small shared
      helpers (PingException, JSON codec). Exposes no Pigeon HostApi of its own.
    • ping_journey — the Pigeon schema, generated Dart/Kotlin/Swift code, and native Journey
      orchestration logic (JourneyClientFactory, JourneyConfigParser, JourneyHostApiImpl,
      node/callback mappers, error mapper). Depends on ping_core and the native Journey SDK
      (Android com.pingidentity.sdks:journey, iOS SPM PingJourney, both pinned to 2.0.0).
  • flutter/flutter-journey/ — the sample app itself:
    • Starts a named Journey and dynamically renders its callbacks (Name, Password, Validated
      Username/Password, Choice, KBA, Terms and Conditions, Text Input/Output, and
      String/Number/Boolean Attribute Input).
    • Advances the flow via next() and recovers from an ErrorNode/FailureNode with a
      "Try Again" button.
    • Retrieves the access/refresh token and userinfo after a successful login (OIDC module).
    • Supports sign-off and restart.
  • Dart pub workspace wiring at flutter/pubspec.yaml so flutter pub get from flutter/
    resolves flutter-sdk-bridge/ping_core, flutter-sdk-bridge/ping_journey, and
    flutter-journey together.

Test plan

  • flutter pub get from flutter/ resolves the workspace cleanly
  • flutter test passes for ping_core, ping_journey, and flutter-journey
    (unit + widget tests)
  • flutter run from flutter-journey/ against a configured PingAM/AIC tenant:
    • Login Journey renders callbacks and completes successfully
    • Self-registration Journey completes successfully
    • Post-login token/userinfo retrieval works when oidcConfig is set
    • Error/failure node shows the "Try Again" recovery path
    • Sign-off returns to the start screen
  • integration_test/journey_login_test.dart and journey_registration_test.dart pass
    against a booted simulator/emulator or device (run standalone, not combined)
  • iOS and Android both build (Xcode / Android Studio or flutter build)

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b24567af-3cdd-4702-bdd7-cabcab312b7c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch SDKS-4613

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@george-bafaloukas-forgerock george-bafaloukas-forgerock left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice piece of work 👏

  • The core architecture is the right call. Keeping live Journey/ContinueNode objects native and passing only opaque UUID handles across the channel is exactly how a Pigeon bridge over a stateful SDK should be built. That decision is what makes the rest of the design tractable.
  • Pigeon codegen is consistent at v27.1.2 across Dart, Kotlin and Swift — no stale-generation drift anywhere in 210 files, which is easy to get wrong and annoying to debug later.
  • The test suite is well above what sample apps usually ship. node_mapper_test.dart (~20 cases), a complete fake HostApi in journey_client_test.dart, widget tests for the journey/success/config views plus five callback views, and on the native side real SDK types with negative paths. JourneyNodeMapperTests.swift:109-119 asserting that PasswordCallback never leaks its value is a security-minded test somebody chose to write. JourneyNodeMapperTests.swift:51-61 deliberately reaches an otherwise-unreachable default branch and documents why it's reachable on iOS but not Android — that's real care.
  • The genuinely subtle cross-platform traps were spotted and handled. TextOutputCallback._parseMessageType normalising Android's "INFORMATION" against iOS's "information"; the ms→s timeout conversion in JourneyConfigParser.swift:34 being correct because iOS WorkflowConfig.timeout is a TimeInterval in seconds while Android's is Long millis; the ErrorNode.status asymmetry being explicitly documented rather than silently papered over. Those are the details that separate a bridge that works from one that only works on the platform you tested.
  • Housekeeping is clean too: CHANGELOGs in every package, public_member_api_docs: true enforced on the bridge with the public API actually documented, and no secrets, keystores or local.properties committed.

The findings below are almost all in the areas the tests don't reach yet — JourneyHostApiImpl and JourneyConfigParser on both platforms, plus the build/packaging files. That's not a coincidence, and it's the most useful thing I can tell you: the code that got tested is in good shape.

What I'd like to see before merge

Grouping the blocking ones so the shape is clear:

1. The release and packaging paths appear never to have been exercised. ping_journey.podspec declares only s.dependency 'Flutter' while its sources import PingJourney, PingOrchestrate, PingOidc, PingJourneyPlugin and ping_core — any CocoaPods host app fails to compile. Both Android build.gradle.kts files have plugins {} after group/buildscript/allprojects, which Gradle's Kotlin DSL rejects outright. The release manifest has no INTERNET permission. And Class.simpleName as the callback wire key breaks under R8 in release while working perfectly in debug.

2. Sign-off doesn't reliably end the session — and returns true regardless. Details inline; I checked this against the pinned journey-2.0.0 sources rather than inferring it.

3. Concurrent next() isn't serialised on either platform, so a double-tapped Submit is a real failure mode (worse on iOS, where it's a data race rather than just a lost update).

4. Two changes look unrelated to SDKS-4613 — the hardcoded tenant in the SwiftUI sample and the ping-ios-sdk 2.1.0 bump. Happy to be told these were deliberate.

A CI job for the Flutter tree would have caught a good number of these automatically — .github/workflows/CI.yml is currently scoped to paths: ['javascript/**/*'], so nothing in this PR was verified by CI. Worth landing alongside.

I've marked the non-blocking items clearly as [optional] — please feel free to skip, defer or disagree with any of those; they're observations, not requests. Happy to talk through any of the blocking ones if you see it differently, and glad to re-review quickly.


Note on my own verification: I could not run flutter analyze, flutter test, Gradle or Xcode — no Flutter/Android/Xcode toolchain available to me here. Everything below is from reading the PR head (888fa92) against merge base aff3c6c, cross-checked against the pinned SDK sources (journey-2.0.0-sources.jar, ping-ios-sdk 2.0.0). The Gradle plugins {} ordering and podspec compile failures are inferred from the build files rather than observed from a failed build, so please sanity-check those two against a real build.

s.author = { 'Ping Identity' => 'oss@pingidentity.com' }
s.source = { :path => '.' }
s.source_files = 'ping_journey/Sources/ping_journey/**/*'
s.dependency 'Flutter'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — the podspec can't compile: none of the native dependencies are declared.

source_files globs in sources that import PingJourney (JourneyHostApiImpl.swift:9), PingOrchestrate (:10), ping_core (:11), PingJourneyPlugin (Mapper/JourneyNodeMapper.swift) and PingOidc (JourneyConfigParser.swift) — but the only declared dependency is Flutter.

Any host app on CocoaPods fails at compile with error: no such module 'PingJourney'. That's still the default flutter create output, and README.md:82 explicitly offers CocoaPods as "an optional fallback for host apps not yet on SPM". pod lib lint (which line 3 tells the reader to run) would surface this immediately.

This also makes README.md:75-77 inaccurate — "Both platforms pin the native Ping SDKs to 2.0.0" holds for the SPM path (Package.swift:17, exact: "2.0.0") but the CocoaPods path pins nothing, because it declares nothing.

Two smaller things on the same file: s.license = { :file => '../LICENSE' } (line 14) resolves to ping_journey/LICENSE, which doesn't exist — the LICENSE is at flutter-sdk-bridge/LICENSE, one level up (same bug in ping_core.podspec:14). And s.swift_version = '5.0' (line 23) contradicts Package.swift's swift-tools-version: 5.9.

}
}

plugins {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — plugins {} is in a position Gradle rejects, and the Kotlin plugin is never applied.

This block sits after group/version (lines 1-2), buildscript {} (4-15) and allprojects {} (17-22). In the Kotlin DSL only buildscript {} and pluginManagement {} may precede plugins {}; anything else fails the script with "all plugins blocks must appear before any other statements in the script file". Same in ping_core/android/build.gradle.kts:24.

The Flutter Kotlin-DSL plugin template uses apply(plugin = "com.android.library") / apply(plugin = "org.jetbrains.kotlin.android") specifically to avoid this — this reads like a refactor of that template where plugins {} was swapped in but not moved to the top.

The same edit also dropped the Kotlin Android plugin: kotlin-gradle-plugin:2.3.20 is on the buildscript classpath (line 13) but never applied, which leaves kotlin { compilerOptions { … } } (68-72) without a receiver and the versionless testImplementation("org.jetbrains.kotlin:kotlin-test") (79) without the alignment that normally resolves it. AGP 9's built-in Kotlin support may cover part of that — but the ordering error is unconditional.

I couldn't run Gradle to confirm, so worth a quick ./gradlew :ping_journey:assembleRelease to see the actual message.

@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — no INTERNET permission, so release builds have no network access.

android.permission.INTERNET appears only in src/debug/AndroidManifest.xml and src/profile/AndroidManifest.xml. That's the Flutter template's debug-only injection for hot reload — it is deliberately not merged into release.

So flutter build apk --release produces an authentication sample that cannot reach the network: every journey request fails, and because debug worked perfectly the developer has no signal pointing at the manifest. Needs an explicit <uses-permission android:name="android.permission.INTERNET" /> here.

*/
internal object JourneyCallbackValueApplier {
fun apply(node: ContinueNode, values: List<CallbackValueMessage>) {
val callbacksByType = node.callbacks.groupBy { it::class.java.simpleName }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — Class.simpleName as the wire key breaks under R8, release-only.

This groupBy { it::class.java.simpleName } and the matching JourneyNodeMapper.kt:68 (callback::class.java.simpleName) make the entire {type, index} protocol depend on class names surviving minification. They don't: with isMinifyEnabled = true (very common for release), NameCallback becomes something like a.b.c.

Failure chain: the mapper emits CallbackMessage(type = "c") → Dart's node_mapper.dart switch has no case for it → falls to _ => → the login screen renders the literal text Unsupported callback: c where the username and password fields should be. Submitting is equally broken, since this groupBy can never match "NameCallback" again. Works flawlessly in debug, completely broken in release.

Worth noting neither bridge module declares consumerProguardFiles, and none of the four Ping AARs ships a proguard.txt, so nothing protects these names today.

The native SDK avoids this entirely — CallbackRegistry.callback() keys off the server-sent "type" string. AbstractCallback.json already carries that value, so a stable key is available without a protocol change.

scope.launch {
val result = runCatching {
val handle = resolveHandle(journeyId)
if (handle.hasOidc) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — signOff can leave the session fully intact and still report success.

I verified this against the pinned journey-2.0.0 sources rather than inferring it, because the consequence is significant.

The only thing that actually terminates a Journey session is Workflow.signOff() — the SDK's Session module hooks it to POST …/sessions?_action=logout and call tokenStorage.delete(). This bridge never calls it directly; it's reached only via UserDelegate.logout(), which needs both hasOidc == true and journey.user() != null. And Journey.user() returns null when session() is null (User.kt:40-42).

Scenario — session-only journey (serverUrl, no OIDC; a supported config, per hasOidcConfig): user logs in, taps Sign out. handle.hasOidc is false, so this if body is skipped entirely. Only local node caches are cleared, and line 146 returns true. Dart shows the logged-out screen, but the SSO cookie is still in EncryptedDataStoreStorage and the AM session is still valid server-side. On the next start() the Session module replays the stored cookie and the user is silently re-authenticated with no credentials. On a shared or kiosk device, the next person gets the previous user's session.

Second scenario, OIDC path: if the SSO cookie expired but tokens are still stored, user() returns null, logout() never runs, tokens are never revoked or deleted — and this still returns true.

Separately, UserDelegate.logout() swallows Workflow.signOff()'s Result.failure, so even the happy path returns true when server-side logout failed. As written the Boolean return is a constant, which makes if (await signOff()) on the Dart side misleading.

JourneyHostApiImpl.swift:110-127 has the same structure, so this needs fixing on both sides.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[optional] allowUserDefinedQuestions is a server-authoritative flag being set from client input.

This is a policy flag the server declares; letting Dart write it back through the bridge inverts that. Low practical impact since the server re-validates, but it's the kind of thing worth not modelling in a reference bridge.

Separately, applyKba (lines 66-73) silently no-ops on bad input — (map["selectedAnswer"] as? String)?.let { … } means a wrong type or missing key applies nothing and next() proceeds with an empty answer, which the server rejects for reasons the developer can't see. Every sibling helper (asString/asBoolean/asInt/asDouble/asMap, lines 75-94) throws IllegalArgumentException on mismatch instead. The test at JourneyCallbackValueApplierTest.kt:137-149 currently pins the silent behaviour as intended, so this may well be deliberate — flagging in case it isn't.

For comparison, JourneyCallbackValueApplier.swift bounds-checks with guard value.index >= 0, Int(value.index) < matching.count where Kotlin relies on getOrNull(…) ?: throw. Same outcome, but the error text reaching Dart differs.


/// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[optional] This doc comment contradicts the implementation.

It says unrecognized types "are dropped by the caller", but line 154's _ => renders them: TextOutputCallback(… message: … ?? 'Unsupported callback: ${message.type}').

The risk is an integrator trusting the doc, assuming unknown callbacks are invisible, and shipping a UI that prints Unsupported callback: XyzCallback to end users. Since the fallback is arguably the better behaviour, updating the comment to describe it seems like the right fix.

path: '/journey-name',
builder: (context, state) => JourneyNameView(
onSubmit: (name) {
context.read<JourneyViewModel>().startJourney(name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[optional] startJourney is fired without await before navigating.

startJourney calls await dispose() then start(), but navigation happens immediately on line 31, so JourneyView builds against whatever node the view model still holds. On a re-entry that's the previous journey's node, briefly rendering a stale form. And if startJourney throws, the resulting _error is never displayed on the screen the user just landed on (see my note on journey_view_model.dart).

Line 48 also reaches for the global router where the surrounding code uses context.go — worth making consistent.

Two more small ones in the same area: journey_view.dart:48,58-64 navigates from build via addPostFrameCallback guarded by _successHandled. It works, and there's a test covering the exactly-once behaviour — but an explicit view-model event or stream would be a sturdier pattern to demonstrate in a sample. And journey_view_model.dart's signOff() never clears _node, so post-logout state still references the completed journey.

Comment thread flutter/pubspec.yaml
@@ -0,0 +1,10 @@
name: flutter_workspace

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[optional] Root workspace declares no dependencies, but analysis_options.yaml needs one.

flutter/analysis_options.yaml is include: package:flutter_lints/flutter.yaml, and this file has no dependencies or dev_dependencies, so flutter_lints can't be resolved at the root — root-level analysis either errors or silently applies no lints. A dev_dependencies: flutter_lints: entry here should sort it.

A couple of other packaging odds and ends, all optional:

  • PrivacyInfo.xcprivacy exists in both ping_core and ping_journey but is commented out in Package.swift:28/:32 and in both podspecs (:29). So it ships as neither a processed SPM resource nor a pod resource bundle — under CocoaPods the **/* glob sweeps it into source_files instead. The plugin transitively pulls in PingStorage (Keychain) and PingDeviceId, so App Store Connect's required-reason-API validation has nothing to read. Either uncomment both lines or delete the files.
  • Both AndroidManifest.xml files in the bridge still declare the package="…" attribute alongside namespace in Gradle. AGP removed support for that attribute; against AGP 9.0.1 it's at minimum a hard warning. Current Flutter templates omit it.
  • flutter-journey/android/app/build.gradle.kts:30-32 signs release with the debug keystore (template default, and the TODOs are still there), and ios/Runner/Info.plist has no CFBundleURLTypes — README step 5 asks the user to add it by hand, but without it the frauth:// redirect never returns to the app and the sample dead-ends after authentication. Pre-wiring that would save every first-time reader the same debugging session.
  • ios/RunnerTests/RunnerTests.swift is still the empty template testExample(), and ping_core has no native tests on either platform (ping_core/android/src/ contains only main/, though build.gradle.kts:42-44 configures a src/test/kotlin source set) — which means the registry at the centre of the leak finding has no test anywhere.

@@ -0,0 +1,165 @@
// Copyright (c) 2026 Ping Identity Corporation. All rights reserved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[optional] Some dead protocol surface, and a few dangling doc references.

NodeMessage.input and CallbackMessage.raw are declared on the wire and consumed by neither platform (raw in particular would solve the unmapped-callback dead-end — see my note on JourneyNodeMapper.swift), and validateOnly is documented as read-only with no return path. Unused fields in a generated-code contract get expensive to remove once anyone depends on the schema, so now is the cheap moment to either fill or drop them.

Unrelated but same category — these paths don't exist in the repo:

  • DESIGN.md, referenced in PingCorePlugin.swift:13, PingCorePlugin.kt:15, ping_core/lib/src/session.dart:12 and flutter-journey/lib/routing/router.dart:17
  • AGENT_NOTES.md, referenced in both integration tests

Each one sends a reader looking for a file that isn't there. If DESIGN.md exists somewhere internal it's worth either committing it or dropping the pointers; AGENT_NOTES.md looks like a working-notes file that didn't make the commit.

Two more, both minor:

  • JourneyHandle's doc comment (JourneyClientFactory.swift:14-17) says Journey.journeyUser() "never returns nil" — it returns nil when the shared context has no OIDC config or when session() is nil (User.kt:40-42). The hasOidc flag is still worth keeping, but a wrong justification on a defensive flag invites someone to delete it. The Kotlin comment (JourneyClientFactory.kt:18-21) is accurate for Android.
  • messageType on the wire is whatever each platform's reflection prints (String(describing:) vs .name). It works because Dart normalises case, but String(describing:) on an enum isn't a stability guarantee — it'll shift the moment either SDK renames a case. Normalising at the native boundary would make the contract explicit.
  • kba_create_callback_view.dart:13 uses const _customQuestion = '__custom__' as a DropdownMenuItem sentinel; a predefined question with that exact text would collide. Unlikely, but a nullable value or a wrapper type would rule it out.


plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Android project uses AGP 9.0.1, but Flutter 3.44 still relies on the legacy Android Gradle Plugin DSL. Android Studio failed for me during Gradle sync with an ApplicationExtensionImpl to AbstractAppExtension cast error.

To preserve compatibility with Flutter 3.44, I added gradle.properties with the following, and gradle sync succeeded...:

android.newDsl=false
android.builtInKotlin=false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had an issue opening the Runner.xcworkspace - it was opening blank in Xcode...
It turned out that the root .gitignore excludes *.xcworkspacedata globally. Claude added the following in this .gitignore file and Runner.xcworkspace in Xcode worked correctly.

!Runner.xcworkspace/contents.xcworkspacedata

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants