Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2024 - 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.
Expand All @@ -15,6 +15,8 @@ import com.pingidentity.davinci.collector.LabelCollector
import com.pingidentity.davinci.collector.MultiSelectCollector
import com.pingidentity.davinci.collector.PasswordCollector
import com.pingidentity.davinci.collector.PhoneNumberCollector
import com.pingidentity.davinci.collector.PollingCollector
import com.pingidentity.davinci.collector.QRCodeCollector
import com.pingidentity.davinci.collector.SingleSelectCollector
import com.pingidentity.davinci.collector.SubmitCollector
import com.pingidentity.davinci.collector.TextCollector
Expand Down Expand Up @@ -54,5 +56,8 @@ internal class CollectorRegistry : ModuleInitializer() {
CollectorFactory.register("DEVICE_REGISTRATION", ::DeviceRegistrationCollector)
CollectorFactory.register("DEVICE_AUTHENTICATION", ::DeviceAuthenticationCollector)
CollectorFactory.register("PHONE_NUMBER", ::PhoneNumberCollector)

CollectorFactory.register("POLLING", ::PollingCollector)
CollectorFactory.register("QR_CODE", ::QRCodeCollector)
}
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/

package com.pingidentity.davinci.collector

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.pingidentity.davinci.plugin.Collector
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.io.encoding.Base64

/**
* A collector that handles QR code display in DaVinci authentication flows.
*
* The QRCodeCollector is used to display QR codes to users for authentication scenarios such as:
* - Device pairing
* - Multi-factor authentication setup
* - Out-of-band authentication
* - Cross-device authentication flows
*
* The QR code content is typically provided as a Base64-encoded image string that can be
* decoded and displayed as a bitmap.
*
* ## Content Format
*
* The [content] field typically contains a data URI string with Base64-encoded image data:
* ```
* data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
* ```
*
* @see Collector
* @see bitmap
*/
class QRCodeCollector : Collector<Nothing> {

/**
* The QR code content as a Base64-encoded data URI string.
*
* This typically contains the full data URI including the MIME type and Base64 prefix:
* `data:image/png;base64,{base64-encoded-data}`
*
* The [bitmap] method extracts and decodes this content to create a displayable bitmap.
*/
lateinit var content: String
private set

/**
* Fallback text to display when the QR code cannot be rendered.
*
* This text provides an alternative way for users to complete the authentication
* if the QR code cannot be displayed or scanned. It may contain:
* - A manual entry code
* - Instructions for alternative authentication methods
* - Error or help information
*/
lateinit var fallbackText: String
private set

/**
* Initializes the QRCodeCollector with configuration from the input JSON.
*
* Extracts and sets the following parameters:
* - [content]: QR code content as Base64-encoded data URI (default: "")
* - [fallbackText]: Alternative text if QR code cannot be displayed (default: "")
*
* @param input JSON object containing the collector configuration with fields:
* - `content`: The Base64-encoded QR code image data
* - `fallbackText`: Alternative text for display
* @return This QRCodeCollector instance for method chaining
*
* @see Collector.init
*/
override fun init(input: JsonObject): QRCodeCollector {
super.init(input)
content = input["content"]?.jsonPrimitive?.content ?: ""
fallbackText = input["fallbackText"]?.jsonPrimitive?.content ?: ""
return this
}

/**
* Converts the Base64-encoded QR code content to a displayable Bitmap.
*
* This method:
* 1. Extracts the Base64 data from the [content] string (after "base64,")
* 2. Decodes the Base64 string to a byte array
* 3. Converts the byte array to a Bitmap using BitmapFactory
*
* ## Content Format
*
* The method expects [content] to be in data URI format:
* ```
* data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
* ```
*
* The substring after "base64," is extracted and decoded.
*
* @return A Bitmap representation of the QR code, or `null` if decoding fails
*
* @see content
* @see fallbackText
*/
fun bitmap(): Bitmap? {
Comment thread
vibhorgoswami marked this conversation as resolved.
return try {
// Decode Base64 content and convert to Bitmap
val decodedBytes = Base64.decode(content.substringAfter("base64,"))
BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size)
} catch (e: Exception) {
// Return null if decoding fails
null
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2024 - 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.
Expand All @@ -11,8 +11,10 @@ import com.pingidentity.davinci.collector.Form
import com.pingidentity.davinci.plugin.Collector
import com.pingidentity.davinci.plugin.CollectorFactory
import com.pingidentity.davinci.plugin.DaVinci
import com.pingidentity.davinci.plugin.collectors
import com.pingidentity.exception.ApiException
import com.pingidentity.oidc.exception.AuthorizeException
import com.pingidentity.orchestrate.ContinueNode
import com.pingidentity.orchestrate.ErrorNode
import com.pingidentity.orchestrate.FailureNode
import com.pingidentity.orchestrate.FlowContext
Expand Down Expand Up @@ -116,6 +118,22 @@ private fun transform(
)
}

val eventName = json["eventName"]?.jsonPrimitive?.content
if (eventName == "rewindStateToLastRenderedUI" || eventName == "rewindStateToSpecificRenderedUI") {
val existing = context.flowContext.getValue<ContinueNode>(CONTINUE_NODE)
?: return FailureNode(IllegalStateException("Rewind state to last rendered UI failed."))
// Create a new Connector instance with the same so that Jetpack Compose
// sees a different object reference and triggers recomposition and its collectors.
return Connector(
existing.context,
(existing as Connector).daVinci,
existing.input,
existing.collectors,
).apply {
CollectorFactory.inject(this)
}
}
Comment on lines +121 to +135
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify how CONTINUE_NODE is written and read, and whether any non-Connector ContinueNode is ever stored.
rg -nP --type=kotlin -C3 '\bCONTINUE_NODE\b'
rg -nP --type=kotlin -C3 'flowContext\[CONTINUE_NODE\]|getValue<ContinueNode>'
# Find all subclasses/producers of ContinueNode in the davinci module
ast-grep --pattern $'class $_ : ContinueNode($$$)'
ast-grep --pattern $'class $_($$$) : ContinueNode($$$)'

Repository: ForgeRock/ping-android-sdk

Length of output: 4933


🏁 Script executed:

# Find ContinueNode definition and all implementations
rg -nP --type=kotlin 'interface ContinueNode|class.*ContinueNode|sealed.*ContinueNode'

# Find Connector class definition and constructor
rg -nP --type=kotlin -B5 -A10 'class Connector'

# Find where daVinci is sourced in Transform.kt
rg -nP --type=kotlin -B10 -A10 'Transform.kt.*daVinci'

# Look at the Connector constructor specifically
fd -e kt -x grep -l "class Connector" {} \;

Repository: ForgeRock/ping-android-sdk

Length of output: 3307


🏁 Script executed:

# Read the Connector.kt node hook where CONTINUE_NODE is stored
cat -n davinci/src/main/kotlin/com/pingidentity/davinci/module/Connector.kt | sed -n '135,150p'

# Read the full Transform.kt context around the rewind logic
cat -n davinci/src/main/kotlin/com/pingidentity/davinci/module/Transform.kt | sed -n '110,140p'

# Find the Transform function signature to see where daVinci/workflow comes from
rg -nP --type=kotlin -B5 'fun transform' davinci/src/main/kotlin/com/pingidentity/davinci/module/Transform.kt | head -40

Repository: ForgeRock/ping-android-sdk

Length of output: 2022


🏁 Script executed:

# Get full Transform function signature
cat -n davinci/src/main/kotlin/com/pingidentity/davinci/module/Transform.kt | sed -n '104,115p'

# Check ContinueNode definition in orchestrate
cat -n foundation/orchestrate/src/main/kotlin/com/pingidentity/orchestrate/Node.kt | sed -n '32,50p'

# Search for all ContinueNode subclasses across the entire codebase
rg -nP --type=kotlin ': ContinueNode\(' --glob='!**/test/**'

Repository: ForgeRock/ping-android-sdk

Length of output: 1562


🏁 Script executed:

# Check if journey module defines its own CONTINUE_NODE
rg -nP --type=kotlin 'CONTINUE_NODE' journey/

# Verify that the CONTINUE_NODE key is davinci-specific
grep -r "CONTINUE_NODE" davinci/src/main/kotlin/ | grep -E 'const|val'

Repository: ForgeRock/ping-android-sdk

Length of output: 364


🏁 Script executed:

# Check how Connector.context is used internally
rg -nP --type=kotlin -A5 'this.context|existing.context' davinci/src/main/kotlin/com/pingidentity/davinci/module/Connector.kt

# Look at how ContinueNode.next() calls workflow.next() with its context
cat -n foundation/orchestrate/src/main/kotlin/com/pingidentity/orchestrate/Node.kt | sed -n '38,60p'

# Check what happens after a node is returned from transform — how is it used
rg -nP --type=kotlin -B3 -A3 'transform(context|Transform.transform' davinci/src/main/kotlin/com/pingidentity/davinci/module/ | head -50

Repository: ForgeRock/ping-android-sdk

Length of output: 877


🏁 Script executed:

# Find where transform() is called and how its return value is used
rg -nP --type=kotlin 'transform\(' davinci/src/main/kotlin/com/pingidentity/davinci/module/

# Check if there's any documentation or tests showing the rewind context intent
fd -e kt davinci/src/test -exec grep -l "rewind\|Rewind" {} \;

Repository: ForgeRock/ping-android-sdk

Length of output: 581


🏁 Script executed:

# Find where transform() is called and understand the flow
rg -nP --type=kotlin -B5 -A5 'return@transform transform' davinci/src/main/kotlin/

# Search for rewind in davinci test files
find davinci/src/test -name "*.kt" -type f -exec grep -l "rewind\|Rewind" {} \;

# Check what workflow.next() does when passed the node's context
rg -nP --type=kotlin 'fun next\(' foundation/orchestrate/ --glob='!**/test/**'

Repository: ForgeRock/ping-android-sdk

Length of output: 2018


🏁 Script executed:

# Check the test files for rewind behavior
cat davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt | head -100

# Also check the Response test file
cat davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.Response.kt | head -100

Repository: ForgeRock/ping-android-sdk

Length of output: 8223


🏁 Script executed:

# Verify the context usage: when a Connector calls next(), what context does it use?
rg -nP --type=kotlin -A10 'suspend fun next' foundation/orchestrate/src/main/kotlin/com/pingidentity/orchestrate/Node.kt

# Check how workflow.next() uses the context parameter
cat -n foundation/orchestrate/src/main/kotlin/com/pingidentity/orchestrate/Workflow.kt | sed -n '145,160p'

# Look for any tests involving rewind to understand intent
rg -nP --type=kotlin 'rewindState' davinci/src/test/

Repository: ForgeRock/ping-android-sdk

Length of output: 2713


🏁 Script executed:

# Read the rewind test to understand the expected behavior
cat -n davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt | sed -n '557,600p'

# Also read the second rewind test
cat -n davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt | sed -n '601,640p'

Repository: ForgeRock/ping-android-sdk

Length of output: 4514


Use safe cast for Connector instead of unchecked cast.

The existing as Connector cast will throw ClassCastException if CONTINUE_NODE ever holds a ContinueNode that isn't a Connector. While only Connector is currently stored in the davinci module, this is not enforced at compile time. Adopt the safe cast pattern already used in ErrorNode.kt (line 32) for consistency and defensiveness:

val existing = context.flowContext.getValue<ContinueNode>(CONTINUE_NODE) as? Connector
    ?: return FailureNode(IllegalStateException("Rewind state to last rendered UI failed."))

Additionally, confirm that reusing existing.context (the old FlowContext snapshot) is the intended behavior for rewind semantics. The new Connector is constructed with the previous context, which means subsequent next() calls will operate against that captured context state rather than the current active context. This intentionally preserves the old state (including cookies, headers, PAR tokens) at the cost of divergence. If this is by design, add an explicit comment explaining the rewind semantics; if not, pass context (the current active context) to the new Connector.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@davinci/src/main/kotlin/com/pingidentity/davinci/module/Transform.kt` around
lines 121 - 135, Replace the unchecked cast on the retrieved ContinueNode with a
safe cast to Connector: change how you obtain existing (use as? Connector with a
null check returning FailureNode) so ClassCastException cannot be thrown; update
the Connector construction to either use the current active context (context) if
rewind should not reuse the old FlowContext, or keep existing.context but add an
explicit comment above the creation explaining the intentional rewind semantics
(preserving old cookies/headers/tokens) so the behavior is clear; target
symbols: CONTINUE_NODE, ContinueNode, Connector, existing.context and
CollectorFactory.inject.


val collectors = mutableListOf<Collector<*>>()
if ("form" in json) collectors.addAll(Form.parse(daVinci, json))

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
/*
* Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2024 - 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 com.pingidentity.davinci.CollectorRegistry
import com.pingidentity.davinci.collector.DeviceAuthenticationCollector
import com.pingidentity.davinci.collector.DeviceRegistrationCollector
import com.pingidentity.davinci.collector.FlowCollector
import com.pingidentity.davinci.collector.LabelCollector
import com.pingidentity.davinci.collector.MultiSelectCollector
import com.pingidentity.davinci.collector.PasswordCollector
import com.pingidentity.davinci.collector.PhoneNumberCollector
import com.pingidentity.davinci.collector.PollingCollector
import com.pingidentity.davinci.collector.QRCodeCollector
import com.pingidentity.davinci.collector.SingleSelectCollector
import com.pingidentity.davinci.collector.SubmitCollector
import com.pingidentity.davinci.collector.TextCollector
Expand Down Expand Up @@ -61,9 +66,15 @@ class CollectorRegistryTest {
add(buildJsonObject { put("inputType", "SINGLE_SELECT") })
add(buildJsonObject { put("inputType", "MULTI_SELECT") })
add(buildJsonObject { put("inputType", "MULTI_SELECT") })
add(buildJsonObject { put("inputType", "DEVICE_REGISTRATION") })
add(buildJsonObject { put("inputType", "DEVICE_AUTHENTICATION") })
add(buildJsonObject { put("inputType", "PHONE_NUMBER") })
add(buildJsonObject { put("type", "POLLING") })
add(buildJsonObject { put("type", "QR_CODE") })
}

val collectors = CollectorFactory.collector(mockk(), jsonArray)
assertEquals(16, collectors.size)
assertEquals(TextCollector::class.java, collectors[0]::class.java)
assertEquals(PasswordCollector::class.java, collectors[1]::class.java)
assertEquals(PasswordCollector::class.java, collectors[2]::class.java)
Expand All @@ -75,6 +86,11 @@ class CollectorRegistryTest {
assertEquals(SingleSelectCollector::class.java, collectors[8]::class.java)
assertEquals(MultiSelectCollector::class.java, collectors[9]::class.java)
assertEquals(MultiSelectCollector::class.java, collectors[10]::class.java)
assertEquals(DeviceRegistrationCollector::class.java, collectors[11]::class.java)
assertEquals(DeviceAuthenticationCollector::class.java, collectors[12]::class.java)
assertEquals(PhoneNumberCollector::class.java, collectors[13]::class.java)
assertEquals(PollingCollector::class.java, collectors[14]::class.java)
assertEquals(QRCodeCollector::class.java, collectors[15]::class.java)
}

@TestRailCase(21280)
Expand All @@ -88,10 +104,11 @@ class CollectorRegistryTest {
add(buildJsonObject { put("type", "SUBMIT_BUTTON") })
add(buildJsonObject { put("inputType", "ACTION") })
add(buildJsonObject { put("type", "UNKNOWN") })
add(buildJsonObject { put("type", "QR_CODE") })
}

val collectors = CollectorFactory.collector(mockk(), jsonArray)
assertEquals(4, collectors.size)
assertEquals(5, collectors.size)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,20 @@ fun customHTMLTemplateWithInvalidPassword() = ByteReadChannel(
)


fun rewindStateToLastRenderedUIResponse() =
ByteReadChannel(
"{\n" +
" \"eventName\": \"rewindStateToLastRenderedUI\"\n" +
"}",
)

fun rewindStateToSpecificRenderedUIResponse() =
ByteReadChannel(
"{\n" +
" \"eventName\": \"rewindStateToSpecificRenderedUI\"\n" +
"}",
)

fun tokeErrorResponse() =
ByteReadChannel(
"{\n" +
Expand Down
89 changes: 89 additions & 0 deletions davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNotSame
import kotlin.test.assertNull
import kotlin.test.assertTrue

Expand Down Expand Up @@ -551,4 +552,92 @@ class DaVinciTest {
"expires_in": 60
}
""".trimIndent()

@Test
fun `DaVinci rewindStateToLastRenderedUI returns previous ContinueNode`() = runTest {
// Override the mock engine so /customHTMLTemplate returns a rewind event
mockEngine = MockEngine { request ->
when (request.url.encodedPath) {
"/.well-known/openid-configuration" ->
respond(openIdConfigurationResponse(), HttpStatusCode.OK, headers)
"/authorize" ->
respond(authorizeResponse(), HttpStatusCode.OK, authorizeResponseHeaders)
"/customHTMLTemplate" ->
respond(rewindStateToLastRenderedUIResponse(), HttpStatusCode.OK, customHTMLTemplateHeaders)
else ->
respond(ByteReadChannel(""), HttpStatusCode.InternalServerError)
}
}

val daVinci = DaVinci {
httpClient = KtorHttpClient(HttpClient(mockEngine))
module(Oidc) {
clientId = "test"
discoveryEndpoint = "http://localhost/.well-known/openid-configuration"
scopes = mutableSetOf("openid", "email", "address")
redirectUri = "http://localhost:8080"
storage = { MemoryStorage() }
}
module(Cookie) {
storage = { MemoryStorage() }
}
}

// start() stores the returned ContinueNode in FlowContext via the ContinueNode module
val firstNode = daVinci.start()
assertTrue(firstNode is ContinueNode)

// next() receives rewindStateToLastRenderedUI → transform retrieves the stored ContinueNode
val rewindNode = firstNode.next()

assertTrue(rewindNode is ContinueNode)
// Must be the exact same instance that was stored in FlowContext
assertNotSame(firstNode, rewindNode)
assertEquals(firstNode.id, rewindNode.id)
assertEquals(firstNode.name, rewindNode.name)
}

@Test
fun `DaVinci rewindStateToSpecificRenderedUI returns previous ContinueNode`() = runTest {
// Override the mock engine so /customHTMLTemplate returns a rewind event
mockEngine = MockEngine { request ->
when (request.url.encodedPath) {
"/.well-known/openid-configuration" ->
respond(openIdConfigurationResponse(), HttpStatusCode.OK, headers)
"/authorize" ->
respond(authorizeResponse(), HttpStatusCode.OK, authorizeResponseHeaders)
"/customHTMLTemplate" ->
respond(rewindStateToSpecificRenderedUIResponse(), HttpStatusCode.OK, customHTMLTemplateHeaders)
else ->
respond(ByteReadChannel(""), HttpStatusCode.InternalServerError)
}
}

val daVinci = DaVinci {
httpClient = KtorHttpClient(HttpClient(mockEngine))
module(Oidc) {
clientId = "test"
discoveryEndpoint = "http://localhost/.well-known/openid-configuration"
scopes = mutableSetOf("openid", "email", "address")
redirectUri = "http://localhost:8080"
storage = { MemoryStorage() }
}
module(Cookie) {
storage = { MemoryStorage() }
}
}

// start() stores the returned ContinueNode in FlowContext via the ContinueNode module
val firstNode = daVinci.start()
assertTrue(firstNode is ContinueNode)

// next() receives rewindStateToSpecificRenderedUI → transform retrieves the stored ContinueNode
val rewindNode = firstNode.next()

assertTrue(rewindNode is ContinueNode)
// Must be the exact same instance that was stored in FlowContext
assertNotSame(firstNode, rewindNode)
assertEquals(firstNode.id, rewindNode.id)
assertEquals(firstNode.name, rewindNode.name)
}
}
Loading
Loading