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
Expand Up @@ -12,6 +12,7 @@ import com.usercentrics.sdk.v2.settings.data.CustomizationFont
import com.usercentrics.sdk.v2.settings.data.FirstLayer
import com.usercentrics.sdk.v2.settings.data.PublishedApp
import com.usercentrics.sdk.v2.settings.data.SecondLayer
import com.usercentrics.sdk.v2.settings.data.ConsentOrPaySettings
import com.usercentrics.sdk.v2.settings.data.TCF2ChangedPurposes
import com.usercentrics.sdk.v2.settings.data.TCF2Settings
import com.usercentrics.sdk.v2.settings.data.UsercentricsCategory
Expand Down Expand Up @@ -246,9 +247,17 @@ private fun TCF2Settings.serialize(): WritableMap {
"changedPurposes" to changedPurposes?.serialize(),
"acmV2Enabled" to acmV2Enabled,
"selectedATPIds" to selectedATPIds,
"consentOrPay" to consentOrPay?.serialize(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[VALIDATION] You added "consentOrPay" to the TCF2Settings map (new line 250). Ensure the nested ConsentOrPaySettings data is serialized into a shape accepted by toWritableMap() (and ultimately the RN bridge) — specifically: 1) handle nulls safely (use consentOrPay?.let { ... } ), 2) normalize key types for nested maps (publisherRestrictions/specialFeatures) to string keys (JS requires string keys), and 3) convert nested structures to WritableMap/Array where applicable. Also update related Android unit-test expected maps so they include the new "consentOrPay" entry (see android/src/androidTest/java/com/usercentrics/reactnative/mock/GetCMPDataMock.kt expectedTCF2Settings around lines 448-509).

private fun TCF2Settings.serialize(): WritableMap {
    return mapOf(
        // ...existing fields...
        "selectedATPIds" to selectedATPIds,
        "consentOrPay" to consentOrPay?.serialize(),
    ).toWritableMap()
}

private fun ConsentOrPaySettings.serialize(): WritableMap = mapOf(
    "enableConsentOrPay" to enableConsentOrPay,
    "showTogglesForVendors" to showTogglesForVendors,
    // ensure keys are strings for JS
    "publisherRestrictions" to publisherRestrictions
        ?.mapKeys { it.key.toString() }
        ?.toMap(),
    "specialFeatures" to specialFeatures
        ?.mapKeys { it.key.toString() }
        ?.toMap(),
).toWritableMap()

).toWritableMap()
}

private fun ConsentOrPaySettings.serialize(): Map<String, Any?> = mapOf(
"enableConsentOrPay" to enableConsentOrPay,
"showTogglesForVendors" to showTogglesForVendors,
"publisherRestrictions" to publisherRestrictions,
"specialFeatures" to specialFeatures
Comment on lines +257 to +258
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: publisherRestrictions and specialFeatures are forwarded as raw nested maps, but the React Native serializer in this module only safely handles nested maps with string keys and primitive JS-compatible values. These Consent-or-Pay maps are keyed by IDs and can contain non-string/non-primitive values from the SDK, which can cause runtime cast/serialization failures or silently dropped entries. Normalize both maps before exporting (stringify keys and map values to bridge-safe primitives). [type error]

Severity Level: Major ⚠️
- ❌ Android `getCMPData` can crash when Consent-or-Pay maps complex.
- ⚠️ JS cannot reliably read Consent-or-Pay restrictions on Android.
Steps of Reproduction ✅
1. From JS, call `Usercentrics.getCMPData()` as in
`sample/src/screens/CustomUI.tsx:12-15`, which invokes the exported React Native method to
fetch CMP data.

2. On Android, this is bridged to `getCMPData` in
`android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt:85-87`, which
executes `usercentricsProxy.instance.getCMPData().serialize()` and returns the result to
JS.

3. `UsercentricsCMPData.serialize()` in
`android/src/main/java/com/usercentrics/reactnative/extensions/UsercentricsCMPDataExtensions.kt:27-36`
calls `UsercentricsSettings.serialize()` and then `TCF2Settings.serialize()` (lines
189-251), which includes `"consentOrPay" to consentOrPay?.serialize()` (line 250) and,
inside `ConsentOrPaySettings.serialize()` (lines 254-259), forwards
`"publisherRestrictions" to publisherRestrictions` and `"specialFeatures" to
specialFeatures` without any normalization.

4. The resulting `Map<String, Any?>` is converted to a React Native `WritableMap` via
`Map<String, Any?>.toWritableMap()` in
`android/src/main/java/com/usercentrics/reactnative/extensions/ReadableMapExtensions.kt:15-56`,
where nested maps are handled by `is Map<*, *>` and cast to `Map<String, Any>` (line
36-38) and only primitive / JS-safe value types are supported in the `when` branches. If
`ConsentOrPaySettings.publisherRestrictions` or `specialFeatures` (coming from the native
SDK) use non-String keys or non-primitive values (e.g. enums or other objects), this cast
or value handling will either throw at runtime (ClassCastException on keys) or silently
drop entries, resulting in broken or incomplete `consentOrPay` data returned by
`getCMPData()` to JS.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** android/src/main/java/com/usercentrics/reactnative/extensions/UsercentricsCMPDataExtensions.kt
**Line:** 257:258
**Comment:**
	*Type Error: `publisherRestrictions` and `specialFeatures` are forwarded as raw nested maps, but the React Native serializer in this module only safely handles nested maps with string keys and primitive JS-compatible values. These Consent-or-Pay maps are keyed by IDs and can contain non-string/non-primitive values from the SDK, which can cause runtime cast/serialization failures or silently dropped entries. Normalize both maps before exporting (stringify keys and map values to bridge-safe primitives).

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

)
Comment on lines +254 to +259
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[REFACTORING] The new private fun ConsentOrPaySettings.serialize() (lines 254-259) currently returns a Kotlin Map<String,Any?> while other serializer helpers in this file return WritableMap (or rely on .toWritableMap()). For consistency and to avoid subtle nested conversion bugs, make this return a WritableMap (e.g. build a map and call .toWritableMap()). Also explicitly convert publisherRestrictions and specialFeatures into JS-friendly structures (Map keys -> String, values -> primitives). Example: publisherRestrictions?.mapKeys { it.key.toString() }?.toMap() and then include .toWritableMap(). This reduces runtime surprises across Android RN bridge conversions.

private fun ConsentOrPaySettings.serialize(): WritableMap = mapOf(
    "enableConsentOrPay" to enableConsentOrPay,
    "showTogglesForVendors" to showTogglesForVendors,
    "publisherRestrictions" to publisherRestrictions
        ?.mapKeys { it.key.toString() }
        ?.toMap(),
    "specialFeatures" to specialFeatures
        ?.mapKeys { it.key.toString() }
        ?.toMap(),
).toWritableMap()


private fun TCF2Settings.getResurfacePeriodCompat(): Int {
val intValue = runCatching {
javaClass.getMethod("getResurfacePeriod").invoke(this) as? Int
Expand Down
11 changes: 11 additions & 0 deletions ios/Extensions/UsercentricsCMPData+Dict.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,21 @@ extension TCF2Settings {
"acmV2Enabled": self.acmV2Enabled,
"selectedATPIds": self.selectedATPIds,
"resurfacePeriod": self.resurfacePeriod,
"consentOrPay": self.consentOrPay?.toDictionary() as Any,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[VALIDATION] You added mapping "consentOrPay": self.consentOrPay?.toDictionary() (line 239). Ensure the toDictionary() produces JS-bridge-safe types: convert KotlinBoolean to Bool (.boolValue) where needed and convert any Kotlin map/dictionary keys to Swift String keys before returning. If consentOrPay fields can be nil, keep the optional handling (as you already do) but confirm callers expect null vs empty object.

extension TCF2Settings {
    func toDictionary() -> NSDictionary {
        return [
            // ...existing fields...
            "selectedATPIds": self.selectedATPIds,
            "resurfacePeriod": self.resurfacePeriod,
            "consentOrPay": self.consentOrPay?.toDictionary() as Any,
        ]
    }
}

extension ConsentOrPaySettings {
    func toDictionary() -> [String: Any] {
        return [
            "enableConsentOrPay": self.enableConsentOrPay.boolValue,
            "showTogglesForVendors": self.showTogglesForVendors.boolValue,
            "publisherRestrictions": self.publisherRestrictions as [String: Any],
            "specialFeatures": self.specialFeatures as [String: Any],
        ]
    }
}

]
}
}

extension ConsentOrPaySettings {
func toDictionary() -> [String: Any] {
return [
"enableConsentOrPay": self.enableConsentOrPay,
"showTogglesForVendors": self.showTogglesForVendors,
"publisherRestrictions": self.publisherRestrictions,
"specialFeatures": self.specialFeatures
Comment on lines +249 to +250
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The Consent-or-Pay dictionaries are exposed directly to JS without converting keys/values to RN-safe JSON primitives. If these SDK maps use numeric keys or non-primitive value types, bridging to JavaScript can fail or produce unusable objects. Convert them into [String: String] (or another explicit JS-safe shape) before putting them into the exported dictionary. [api mismatch]

Severity Level: Major ⚠️
- ❌ iOS bridge may fail when Consent-or-Pay maps non-primitive.
- ⚠️ JS clients may see malformed Consent-or-Pay data on iOS.
Steps of Reproduction ✅
1. From JS, call `Usercentrics.getCMPData()` as demonstrated in
`sample/src/screens/CustomUI.tsx:12-15`; on iOS this is bridged to the native module's
`getCMPData` (verified in
`sample/ios/sampleTests/RNUsercentricsModuleTests.swift:529-533`, which expects an
`NSDictionary` result).

2. The native module builds that `NSDictionary` via `UsercentricsCMPData.toDictionary()`
in `ios/Extensions/UsercentricsCMPData+Dict.swift:4-13`, which embeds
`self.settings.toDictionary()`; `UsercentricsSettings.toDictionary()` (lines 17-50) then
includes `"tcf2": self.tcf2?.toDictionary() as Any` (line 26).

3. `TCF2Settings.toDictionary()` in
`ios/Extensions/UsercentricsCMPData+Dict.swift:176-241` includes `"consentOrPay":
self.consentOrPay?.toDictionary() as Any` (line 239), which calls
`ConsentOrPaySettings.toDictionary()` defined at lines 244-252.

4. `ConsentOrPaySettings.toDictionary()` currently forwards `"publisherRestrictions":
self.publisherRestrictions` and `"specialFeatures": self.specialFeatures` directly (lines
247-250). If the underlying `ConsentOrPaySettings` in the Usercentrics iOS SDK represents
these as dictionaries with non-String keys or non-primitive values (e.g. enums/objects),
the React Native bridge—which only safely transports
NSString/NSNumber/NSArray/NSDictionary/NSNull—can fail to marshal them correctly, leading
to runtime bridging errors or JS receiving unusable structures where TypeScript expects
`Record<string, string>` (`src/models/TCF2Settings.tsx:15-22`).

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** ios/Extensions/UsercentricsCMPData+Dict.swift
**Line:** 249:250
**Comment:**
	*Api Mismatch: The Consent-or-Pay dictionaries are exposed directly to JS without converting keys/values to RN-safe JSON primitives. If these SDK maps use numeric keys or non-primitive value types, bridging to JavaScript can fail or produce unusable objects. Convert them into `[String: String]` (or another explicit JS-safe shape) before putting them into the exported dictionary.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

]
}
}
Comment on lines +244 to +253
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[CRITICAL_BUG] The new extension ConsentOrPaySettings.toDictionary() returns fields directly (lines 244-253). This may cause compile/runtime issues: Kotlin booleans from the KMP bindings are often KotlinBoolean and need to be converted with .boolValue (see pattern in ios/Extensions/TCFData+Dict.swift lines ~85-115 where consent?.boolValue is used). Also publisherRestrictions and specialFeatures may be Kotlin map types — convert them to native [String: Any] (map keys to String) and ensure values are JS-serializable. Update to mirror existing conversion patterns (use .boolValue for KotlinBoolean and explicit dictionary transforms) to avoid crashes or incorrect values in JS.

extension ConsentOrPaySettings {
    func toDictionary() -> [String: Any] {
        return [
            "enableConsentOrPay": self.enableConsentOrPay.boolValue,
            "showTogglesForVendors": self.showTogglesForVendors.boolValue,
            // Ensure keys are Strings and values are JSON-serializable
            "publisherRestrictions": self.publisherRestrictions.reduce(into: [String: String]()) { result, entry in
                if let key = entry.key as? String, let value = entry.value as? String {
                    result[key] = value
                }
            },
            "specialFeatures": self.specialFeatures.reduce(into: [String: String]()) { result, entry in
                if let key = entry.key as? String, let value = entry.value as? String {
                    result[key] = value
                }
            },
        ]
    }
}


extension UsercentricsCustomization {
func toDictionary() -> NSDictionary {
Expand Down
25 changes: 25 additions & 0 deletions src/models/TCF2Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export class TCF2Settings {
changedPurposes: TCF2ChangedPurposes
acmV2Enabled: boolean
selectedATPIds: number[]
consentOrPay?: TCF2ConsentOrPaySettings

constructor(
firstLayerTitle: string,
Expand Down Expand Up @@ -119,6 +120,7 @@ export class TCF2Settings {
firstLayerHideButtonDeny?: boolean,
firstLayerMobileVariant?: FirstLayerMobileVariant,
dataSharedOutsideEUText?: string,
consentOrPay?: TCF2ConsentOrPaySettings,
) {
this.firstLayerTitle = firstLayerTitle
this.secondLayerTitle = secondLayerTitle
Expand Down Expand Up @@ -179,6 +181,7 @@ export class TCF2Settings {
this.changedPurposes = changedPurposes
this.acmV2Enabled = acmV2Enabled
this.selectedATPIds = selectedATPIds
this.consentOrPay = consentOrPay
}
}

Expand Down Expand Up @@ -207,3 +210,25 @@ export class TCF2ChangedPurposes {
this.legIntPurposes = legIntPurposes
}
}

export class TCF2ConsentOrPaySettings {

enableConsentOrPay: boolean
showTogglesForVendors: boolean
/** Maps TCF Purpose ID (as string) to "flexible". Absent entries are mandatory. */
publisherRestrictions: Record<string, string>
/** Maps Special Feature ID (as string) to "flexible". Absent entries are mandatory. */
specialFeatures: Record<string, string>

constructor(
enableConsentOrPay: boolean,
showTogglesForVendors: boolean,
publisherRestrictions: Record<string, string>,
specialFeatures: Record<string, string>,
) {
this.enableConsentOrPay = enableConsentOrPay
this.showTogglesForVendors = showTogglesForVendors
this.publisherRestrictions = publisherRestrictions
this.specialFeatures = specialFeatures
}
}
Loading