Skip to content

Commit 79e1f3c

Browse files
committed
SDKS-4679 & SDKS-4681: Add Polling and QR Code collectors for DaVinci flows
- Implement `PollingCollector` to handle asynchronous operations like push notifications and OOB authentication, supporting both simple delays and active challenge status polling. - Implement `QRCodeCollector` to handle Base64-encoded QR code images for device pairing and MFA setup. - Add Compose UI components (`Polling.kt`, `QRCode.kt`) and integrate them into `ContinueNode`. - Register new collectors in `CollectorRegistry`. - Add comprehensive unit tests for `PollingCollector` and `QRCodeCollector` logic.
1 parent 70a5349 commit 79e1f3c

17 files changed

Lines changed: 1834 additions & 53 deletions

File tree

davinci/src/main/kotlin/com/pingidentity/davinci/CollectorRegistry.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2024 - 2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -15,6 +15,8 @@ import com.pingidentity.davinci.collector.LabelCollector
1515
import com.pingidentity.davinci.collector.MultiSelectCollector
1616
import com.pingidentity.davinci.collector.PasswordCollector
1717
import com.pingidentity.davinci.collector.PhoneNumberCollector
18+
import com.pingidentity.davinci.collector.PollingCollector
19+
import com.pingidentity.davinci.collector.QRCodeCollector
1820
import com.pingidentity.davinci.collector.SingleSelectCollector
1921
import com.pingidentity.davinci.collector.SubmitCollector
2022
import com.pingidentity.davinci.collector.TextCollector
@@ -54,5 +56,8 @@ internal class CollectorRegistry : ModuleInitializer() {
5456
CollectorFactory.register("DEVICE_REGISTRATION", ::DeviceRegistrationCollector)
5557
CollectorFactory.register("DEVICE_AUTHENTICATION", ::DeviceAuthenticationCollector)
5658
CollectorFactory.register("PHONE_NUMBER", ::PhoneNumberCollector)
59+
60+
CollectorFactory.register("POLLING", ::PollingCollector)
61+
CollectorFactory.register("QR_CODE", ::QRCodeCollector)
5762
}
5863
}

davinci/src/main/kotlin/com/pingidentity/davinci/collector/PollingCollector.kt

Lines changed: 411 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
8+
package com.pingidentity.davinci.collector
9+
10+
import android.graphics.Bitmap
11+
import android.graphics.BitmapFactory
12+
import com.pingidentity.davinci.plugin.Collector
13+
import kotlinx.serialization.json.JsonObject
14+
import kotlinx.serialization.json.jsonPrimitive
15+
import kotlin.io.encoding.Base64
16+
17+
/**
18+
* A collector that handles QR code display in DaVinci authentication flows.
19+
*
20+
* The QRCodeCollector is used to display QR codes to users for authentication scenarios such as:
21+
* - Device pairing
22+
* - Multi-factor authentication setup
23+
* - Out-of-band authentication
24+
* - Cross-device authentication flows
25+
*
26+
* The QR code content is typically provided as a Base64-encoded image string that can be
27+
* decoded and displayed as a bitmap.
28+
*
29+
* ## Content Format
30+
*
31+
* The [content] field typically contains a data URI string with Base64-encoded image data:
32+
* ```
33+
* data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
34+
* ```
35+
*
36+
* @see Collector
37+
* @see bitmap
38+
*/
39+
class QRCodeCollector : Collector<Nothing> {
40+
41+
/**
42+
* The QR code content as a Base64-encoded data URI string.
43+
*
44+
* This typically contains the full data URI including the MIME type and Base64 prefix:
45+
* `data:image/png;base64,{base64-encoded-data}`
46+
*
47+
* The [bitmap] method extracts and decodes this content to create a displayable bitmap.
48+
*/
49+
lateinit var content: String
50+
private set
51+
52+
/**
53+
* Fallback text to display when the QR code cannot be rendered.
54+
*
55+
* This text provides an alternative way for users to complete the authentication
56+
* if the QR code cannot be displayed or scanned. It may contain:
57+
* - A manual entry code
58+
* - Instructions for alternative authentication methods
59+
* - Error or help information
60+
*/
61+
lateinit var fallbackText: String
62+
private set
63+
64+
/**
65+
* Initializes the QRCodeCollector with configuration from the input JSON.
66+
*
67+
* Extracts and sets the following parameters:
68+
* - [content]: QR code content as Base64-encoded data URI (default: "")
69+
* - [fallbackText]: Alternative text if QR code cannot be displayed (default: "")
70+
*
71+
* @param input JSON object containing the collector configuration with fields:
72+
* - `content`: The Base64-encoded QR code image data
73+
* - `fallbackText`: Alternative text for display
74+
* @return This QRCodeCollector instance for method chaining
75+
*
76+
* @see Collector.init
77+
*/
78+
override fun init(input: JsonObject): QRCodeCollector {
79+
super.init(input)
80+
content = input["content"]?.jsonPrimitive?.content ?: ""
81+
fallbackText = input["fallbackText"]?.jsonPrimitive?.content ?: ""
82+
return this
83+
}
84+
85+
/**
86+
* Converts the Base64-encoded QR code content to a displayable Bitmap.
87+
*
88+
* This method:
89+
* 1. Extracts the Base64 data from the [content] string (after "base64,")
90+
* 2. Decodes the Base64 string to a byte array
91+
* 3. Converts the byte array to a Bitmap using BitmapFactory
92+
*
93+
* ## Content Format
94+
*
95+
* The method expects [content] to be in data URI format:
96+
* ```
97+
* data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
98+
* ```
99+
*
100+
* The substring after "base64," is extracted and decoded.
101+
*
102+
* @return A Bitmap representation of the QR code, or `null` if decoding fails
103+
*
104+
* @see content
105+
* @see fallbackText
106+
*/
107+
fun bitmap(): Bitmap? {
108+
return try {
109+
// Decode Base64 content and convert to Bitmap
110+
val decodedBytes = Base64.decode(content.substringAfter("base64,"))
111+
BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size)
112+
} catch (e: Exception) {
113+
// Return null if decoding fails
114+
null
115+
}
116+
}
117+
}

davinci/src/main/kotlin/com/pingidentity/davinci/module/Transform.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2024 - 2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -11,8 +11,10 @@ import com.pingidentity.davinci.collector.Form
1111
import com.pingidentity.davinci.plugin.Collector
1212
import com.pingidentity.davinci.plugin.CollectorFactory
1313
import com.pingidentity.davinci.plugin.DaVinci
14+
import com.pingidentity.davinci.plugin.collectors
1415
import com.pingidentity.exception.ApiException
1516
import com.pingidentity.oidc.exception.AuthorizeException
17+
import com.pingidentity.orchestrate.ContinueNode
1618
import com.pingidentity.orchestrate.ErrorNode
1719
import com.pingidentity.orchestrate.FailureNode
1820
import com.pingidentity.orchestrate.FlowContext
@@ -116,6 +118,22 @@ private fun transform(
116118
)
117119
}
118120

121+
val eventName = json["eventName"]?.jsonPrimitive?.content
122+
if (eventName == "rewindStateToLastRenderedUI" || eventName == "rewindStateToSpecificRenderedUI") {
123+
val existing = context.flowContext.getValue<ContinueNode>(CONTINUE_NODE)
124+
?: return FailureNode(IllegalStateException("Rewind state to last rendered UI failed."))
125+
// Create a new Connector instance with the same so that Jetpack Compose
126+
// sees a different object reference and triggers recomposition and its collectors.
127+
return Connector(
128+
existing.context,
129+
(existing as Connector).daVinci,
130+
existing.input,
131+
existing.collectors,
132+
).apply {
133+
CollectorFactory.inject(this)
134+
}
135+
}
136+
119137
val collectors = mutableListOf<Collector<*>>()
120138
if ("form" in json) collectors.addAll(Form.parse(daVinci, json))
121139

davinci/src/test/kotlin/com/pingidentity/davinci/CollectorRegistryTest.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
/*
2-
* Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2024 - 2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
66
*/
77

88
import com.pingidentity.davinci.CollectorRegistry
9+
import com.pingidentity.davinci.collector.DeviceAuthenticationCollector
10+
import com.pingidentity.davinci.collector.DeviceRegistrationCollector
911
import com.pingidentity.davinci.collector.FlowCollector
1012
import com.pingidentity.davinci.collector.LabelCollector
1113
import com.pingidentity.davinci.collector.MultiSelectCollector
1214
import com.pingidentity.davinci.collector.PasswordCollector
15+
import com.pingidentity.davinci.collector.PhoneNumberCollector
16+
import com.pingidentity.davinci.collector.PollingCollector
17+
import com.pingidentity.davinci.collector.QRCodeCollector
1318
import com.pingidentity.davinci.collector.SingleSelectCollector
1419
import com.pingidentity.davinci.collector.SubmitCollector
1520
import com.pingidentity.davinci.collector.TextCollector
@@ -61,9 +66,15 @@ class CollectorRegistryTest {
6166
add(buildJsonObject { put("inputType", "SINGLE_SELECT") })
6267
add(buildJsonObject { put("inputType", "MULTI_SELECT") })
6368
add(buildJsonObject { put("inputType", "MULTI_SELECT") })
69+
add(buildJsonObject { put("inputType", "DEVICE_REGISTRATION") })
70+
add(buildJsonObject { put("inputType", "DEVICE_AUTHENTICATION") })
71+
add(buildJsonObject { put("inputType", "PHONE_NUMBER") })
72+
add(buildJsonObject { put("type", "POLLING") })
73+
add(buildJsonObject { put("type", "QR_CODE") })
6474
}
6575

6676
val collectors = CollectorFactory.collector(mockk(), jsonArray)
77+
assertEquals(16, collectors.size)
6778
assertEquals(TextCollector::class.java, collectors[0]::class.java)
6879
assertEquals(PasswordCollector::class.java, collectors[1]::class.java)
6980
assertEquals(PasswordCollector::class.java, collectors[2]::class.java)
@@ -75,6 +86,11 @@ class CollectorRegistryTest {
7586
assertEquals(SingleSelectCollector::class.java, collectors[8]::class.java)
7687
assertEquals(MultiSelectCollector::class.java, collectors[9]::class.java)
7788
assertEquals(MultiSelectCollector::class.java, collectors[10]::class.java)
89+
assertEquals(DeviceRegistrationCollector::class.java, collectors[11]::class.java)
90+
assertEquals(DeviceAuthenticationCollector::class.java, collectors[12]::class.java)
91+
assertEquals(PhoneNumberCollector::class.java, collectors[13]::class.java)
92+
assertEquals(PollingCollector::class.java, collectors[14]::class.java)
93+
assertEquals(QRCodeCollector::class.java, collectors[15]::class.java)
7894
}
7995

8096
@TestRailCase(21280)
@@ -88,10 +104,11 @@ class CollectorRegistryTest {
88104
add(buildJsonObject { put("type", "SUBMIT_BUTTON") })
89105
add(buildJsonObject { put("inputType", "ACTION") })
90106
add(buildJsonObject { put("type", "UNKNOWN") })
107+
add(buildJsonObject { put("type", "QR_CODE") })
91108
}
92109

93110
val collectors = CollectorFactory.collector(mockk(), jsonArray)
94-
assertEquals(4, collectors.size)
111+
assertEquals(5, collectors.size)
95112
}
96113

97114
}

davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.Response.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2024 - 2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -199,6 +199,20 @@ fun customHTMLTemplateWithInvalidPassword() = ByteReadChannel(
199199
)
200200

201201

202+
fun rewindStateToLastRenderedUIResponse() =
203+
ByteReadChannel(
204+
"{\n" +
205+
" \"eventName\": \"rewindStateToLastRenderedUI\"\n" +
206+
"}",
207+
)
208+
209+
fun rewindStateToSpecificRenderedUIResponse() =
210+
ByteReadChannel(
211+
"{\n" +
212+
" \"eventName\": \"rewindStateToSpecificRenderedUI\"\n" +
213+
"}",
214+
)
215+
202216
fun tokeErrorResponse() =
203217
ByteReadChannel(
204218
"{\n" +

davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2024 - 2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -60,6 +60,7 @@ import kotlin.test.assertContains
6060
import kotlin.test.assertEquals
6161
import kotlin.test.assertNotNull
6262
import kotlin.test.assertNull
63+
import kotlin.test.assertSame
6364
import kotlin.test.assertTrue
6465

6566
@RunWith(RobolectricTestRunner::class)
@@ -464,4 +465,92 @@ class DaVinciTest {
464465
assertEquals("default-checkbox", it.value)
465466
}
466467
}
468+
469+
@Test
470+
fun `DaVinci rewindStateToLastRenderedUI returns previous ContinueNode`() = runTest {
471+
// Override the mock engine so /customHTMLTemplate returns a rewind event
472+
mockEngine = MockEngine { request ->
473+
when (request.url.encodedPath) {
474+
"/.well-known/openid-configuration" ->
475+
respond(openIdConfigurationResponse(), HttpStatusCode.OK, headers)
476+
"/authorize" ->
477+
respond(authorizeResponse(), HttpStatusCode.OK, authorizeResponseHeaders)
478+
"/customHTMLTemplate" ->
479+
respond(rewindStateToLastRenderedUIResponse(), HttpStatusCode.OK, customHTMLTemplateHeaders)
480+
else ->
481+
respond(ByteReadChannel(""), HttpStatusCode.InternalServerError)
482+
}
483+
}
484+
485+
val daVinci = DaVinci {
486+
httpClient = KtorHttpClient(HttpClient(mockEngine))
487+
module(Oidc) {
488+
clientId = "test"
489+
discoveryEndpoint = "http://localhost/.well-known/openid-configuration"
490+
scopes = mutableSetOf("openid", "email", "address")
491+
redirectUri = "http://localhost:8080"
492+
storage = { MemoryStorage() }
493+
}
494+
module(Cookie) {
495+
storage = { MemoryStorage() }
496+
}
497+
}
498+
499+
// start() stores the returned ContinueNode in FlowContext via the ContinueNode module
500+
val firstNode = daVinci.start()
501+
assertTrue(firstNode is ContinueNode)
502+
503+
// next() receives rewindStateToLastRenderedUI → transform retrieves the stored ContinueNode
504+
val rewindNode = firstNode.next()
505+
506+
assertTrue(rewindNode is ContinueNode)
507+
// Must be the exact same instance that was stored in FlowContext
508+
assertSame(firstNode, rewindNode)
509+
assertEquals(firstNode.id, rewindNode.id)
510+
assertEquals(firstNode.name, rewindNode.name)
511+
}
512+
513+
@Test
514+
fun `DaVinci rewindStateToSpecificRenderedUI returns previous ContinueNode`() = runTest {
515+
// Override the mock engine so /customHTMLTemplate returns a rewind event
516+
mockEngine = MockEngine { request ->
517+
when (request.url.encodedPath) {
518+
"/.well-known/openid-configuration" ->
519+
respond(openIdConfigurationResponse(), HttpStatusCode.OK, headers)
520+
"/authorize" ->
521+
respond(authorizeResponse(), HttpStatusCode.OK, authorizeResponseHeaders)
522+
"/customHTMLTemplate" ->
523+
respond(rewindStateToSpecificRenderedUIResponse(), HttpStatusCode.OK, customHTMLTemplateHeaders)
524+
else ->
525+
respond(ByteReadChannel(""), HttpStatusCode.InternalServerError)
526+
}
527+
}
528+
529+
val daVinci = DaVinci {
530+
httpClient = KtorHttpClient(HttpClient(mockEngine))
531+
module(Oidc) {
532+
clientId = "test"
533+
discoveryEndpoint = "http://localhost/.well-known/openid-configuration"
534+
scopes = mutableSetOf("openid", "email", "address")
535+
redirectUri = "http://localhost:8080"
536+
storage = { MemoryStorage() }
537+
}
538+
module(Cookie) {
539+
storage = { MemoryStorage() }
540+
}
541+
}
542+
543+
// start() stores the returned ContinueNode in FlowContext via the ContinueNode module
544+
val firstNode = daVinci.start()
545+
assertTrue(firstNode is ContinueNode)
546+
547+
// next() receives rewindStateToSpecificRenderedUI → transform retrieves the stored ContinueNode
548+
val rewindNode = firstNode.next()
549+
550+
assertTrue(rewindNode is ContinueNode)
551+
// Must be the exact same instance that was stored in FlowContext
552+
assertSame(firstNode, rewindNode)
553+
assertEquals(firstNode.id, rewindNode.id)
554+
assertEquals(firstNode.name, rewindNode.name)
555+
}
467556
}

0 commit comments

Comments
 (0)