@usercentrics/react-native-sdk 2.25.1 → 2.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/android/build-legacy.gradle +1 -1
  2. package/android/build.gradle.kts +1 -1
  3. package/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModule.kt +102 -2
  4. package/android/src/main/java/com/usercentrics/reactnative/RNUsercentricsModuleSpec.kt +19 -1
  5. package/android/src/main/java/com/usercentrics/reactnative/extensions/BannerSettingsExtensions.kt +2 -1
  6. package/android/src/main/java/com/usercentrics/reactnative/extensions/GppDataExtensions.kt +83 -0
  7. package/android/src/main/java/com/usercentrics/reactnative/extensions/UserDecisionExtensions.kt +2 -1
  8. package/android/src/main/java/com/usercentrics/reactnative/extensions/UsercentricsCMPDataExtensions.kt +44 -2
  9. package/android/src/test/java/com/usercentrics/reactnative/extensions/NullabilityTest.kt +189 -0
  10. package/ios/Extensions/GppData+Dict.swift +45 -0
  11. package/ios/Extensions/GppSectionChangePayload+Dict.swift +10 -0
  12. package/ios/Extensions/UIImage+UsercentricsLogoDict.swift +31 -6
  13. package/ios/Extensions/UsercentricsCMPData+Dict.swift +6 -2
  14. package/ios/Manager/UsercentricsManager.swift +28 -3
  15. package/ios/RNUsercentricsModule.mm +11 -7
  16. package/ios/RNUsercentricsModule.swift +56 -15
  17. package/ios/RNUsercentricsModuleSpec.h +13 -0
  18. package/lib/NativeUsercentrics.d.ts +8 -2
  19. package/lib/NativeUsercentrics.js.map +1 -1
  20. package/lib/Usercentrics.d.ts +8 -2
  21. package/lib/Usercentrics.js +23 -3
  22. package/lib/Usercentrics.js.map +1 -1
  23. package/lib/fabric/NativeUsercentricsModule.d.ts +7 -1
  24. package/lib/fabric/NativeUsercentricsModule.js.map +1 -1
  25. package/lib/models/CCPASettings.d.ts +3 -1
  26. package/lib/models/CCPASettings.js +3 -1
  27. package/lib/models/CCPASettings.js.map +1 -1
  28. package/lib/models/GppData.d.ts +6 -0
  29. package/lib/models/GppData.js +8 -0
  30. package/lib/models/GppData.js.map +1 -0
  31. package/lib/models/GppSectionChangePayload.d.ts +4 -0
  32. package/lib/models/GppSectionChangePayload.js +6 -0
  33. package/lib/models/GppSectionChangePayload.js.map +1 -0
  34. package/lib/models/TCF2Settings.d.ts +2 -2
  35. package/lib/models/TCF2Settings.js +2 -2
  36. package/lib/models/TCF2Settings.js.map +1 -1
  37. package/lib/models/UsercentricsSettings.d.ts +3 -1
  38. package/lib/models/UsercentricsSettings.js +3 -1
  39. package/lib/models/UsercentricsSettings.js.map +1 -1
  40. package/lib/models/index.d.ts +2 -0
  41. package/lib/models/index.js +2 -0
  42. package/lib/models/index.js.map +1 -1
  43. package/package.json +2 -2
  44. package/src/NativeUsercentrics.ts +10 -1
  45. package/src/Usercentrics.tsx +31 -3
  46. package/src/__tests__/index.test.ts +66 -1
  47. package/src/__tests__/mocks.ts +17 -1
  48. package/src/fabric/NativeUsercentricsModule.ts +8 -1
  49. package/src/models/CCPASettings.tsx +7 -1
  50. package/src/models/GppData.tsx +15 -0
  51. package/src/models/GppSectionChangePayload.tsx +7 -0
  52. package/src/models/TCF2Settings.tsx +3 -3
  53. package/src/models/UsercentricsSettings.tsx +6 -0
  54. package/src/models/index.tsx +2 -0
@@ -1,4 +1,4 @@
1
- def usercentrics_version = "2.25.1"
1
+ def usercentrics_version = "2.26.1"
2
2
  version usercentrics_version
3
3
 
4
4
  buildscript {
@@ -1,4 +1,4 @@
1
- val usercentricsVersion = "2.25.1"
1
+ val usercentricsVersion = "2.26.1"
2
2
  val reactNativeVersion = "+"
3
3
 
4
4
  fun BooleanProperty(name: String): Boolean {
@@ -1,10 +1,14 @@
1
1
  package com.usercentrics.reactnative
2
2
 
3
3
  import com.facebook.react.bridge.*
4
+ import com.facebook.react.modules.core.DeviceEventManagerModule
5
+ import com.usercentrics.sdk.UsercentricsDisposableEvent
6
+ import com.usercentrics.sdk.UsercentricsEvent
4
7
  import com.usercentrics.reactnative.api.UsercentricsProxy
5
8
  import com.usercentrics.reactnative.extensions.*
6
9
  import com.usercentrics.sdk.UsercentricsAnalyticsEventType
7
10
  import com.usercentrics.sdk.models.settings.UsercentricsConsentType
11
+ import com.usercentrics.sdk.services.gpp.GppSectionChangePayload
8
12
  import com.usercentrics.sdk.services.tcf.TCFDecisionUILayer
9
13
 
10
14
  internal class RNUsercentricsModule(
@@ -12,6 +16,8 @@ internal class RNUsercentricsModule(
12
16
  private val usercentricsProxy: UsercentricsProxy,
13
17
  private val reactContextProvider: ReactContextProvider,
14
18
  ) : RNUsercentricsModuleSpec(reactContext) {
19
+ private var gppSectionChangeSubscription: UsercentricsDisposableEvent<GppSectionChangePayload>? = null
20
+ private var gppSectionChangeListenersCount = 0
15
21
 
16
22
  override fun getName() = NAME
17
23
 
@@ -79,6 +85,16 @@ internal class RNUsercentricsModule(
79
85
  promise.resolve(usercentricsProxy.instance.getABTestingVariant())
80
86
  }
81
87
 
88
+ @ReactMethod
89
+ override fun getDpsMetadata(templateId: String, promise: Promise) {
90
+ val metadata = usercentricsProxy.instance.getDpsMetadata(templateId)
91
+ if (metadata == null) {
92
+ promise.resolve(null)
93
+ } else {
94
+ promise.resolve(metadata.toWritableMap())
95
+ }
96
+ }
97
+
82
98
  @ReactMethod
83
99
  override fun getConsents(promise: Promise) {
84
100
  promise.resolve(usercentricsProxy.instance.getConsents().toWritableArray())
@@ -121,6 +137,28 @@ internal class RNUsercentricsModule(
121
137
  promise.resolve(usercentricsProxy.instance.getUSPData().serialize())
122
138
  }
123
139
 
140
+ @ReactMethod
141
+ override fun getGPPData(promise: Promise) {
142
+ promise.resolve(usercentricsProxy.instance.getGPPData().serializeGppData())
143
+ }
144
+
145
+ @ReactMethod
146
+ override fun getGPPString(promise: Promise) {
147
+ promise.resolve(usercentricsProxy.instance.getGPPString())
148
+ }
149
+
150
+ @ReactMethod
151
+ override fun setGPPConsent(sectionName: String, fieldName: String, value: ReadableMap) {
152
+ if (!value.hasKey("value")) return
153
+ val parsedValue = if (value.getType("value") == ReadableType.Null) {
154
+ null
155
+ } else {
156
+ readableMapValueToAny(value)
157
+ }
158
+ val safeValue = parsedValue ?: org.json.JSONObject.NULL
159
+ usercentricsProxy.instance.setGPPConsent(sectionName, fieldName, safeValue)
160
+ }
161
+
124
162
  @ReactMethod
125
163
  override fun changeLanguage(language: String, promise: Promise) {
126
164
  usercentricsProxy.instance.changeLanguage(language, {
@@ -149,10 +187,10 @@ internal class RNUsercentricsModule(
149
187
  }
150
188
 
151
189
  @ReactMethod
152
- override fun denyAllForTCF(fromLayer: Double, consentType: Double, unsavedPurposeLIDecisions: ReadableArray, promise: Promise) {
190
+ override fun denyAllForTCF(fromLayer: Double, consentType: Double, unsavedPurposeLIDecisions: ReadableArray, unsavedVendorLIDecisions: ReadableArray, promise: Promise) {
153
191
  promise.resolve(
154
192
  usercentricsProxy.instance.denyAllForTCF(
155
- TCFDecisionUILayer.values()[fromLayer.toInt()], UsercentricsConsentType.values()[consentType.toInt()], unsavedPurposeLIDecisions.deserializePurposeLIDecisionsMap()
193
+ TCFDecisionUILayer.values()[fromLayer.toInt()], UsercentricsConsentType.values()[consentType.toInt()], unsavedPurposeLIDecisions.deserializePurposeLIDecisionsMap(), unsavedVendorLIDecisions.deserializePurposeLIDecisionsMap()
156
194
  ).toWritableArray()
157
195
  )
158
196
  }
@@ -216,11 +254,73 @@ internal class RNUsercentricsModule(
216
254
  })
217
255
  }
218
256
 
257
+ @ReactMethod
258
+ override fun addListener(eventName: String) {
259
+ if (eventName != ON_GPP_SECTION_CHANGE_EVENT) return
260
+
261
+ gppSectionChangeListenersCount++
262
+ if (gppSectionChangeSubscription != null) return
263
+
264
+ gppSectionChangeSubscription = UsercentricsEvent.onGppSectionChange { payload ->
265
+ emitEvent(ON_GPP_SECTION_CHANGE_EVENT, payload.serializeGppPayload())
266
+ }
267
+ }
268
+
269
+ @ReactMethod
270
+ override fun removeListeners(count: Double) {
271
+ gppSectionChangeListenersCount = (gppSectionChangeListenersCount - count.toInt()).coerceAtLeast(0)
272
+ if (gppSectionChangeListenersCount == 0) {
273
+ gppSectionChangeSubscription?.dispose()
274
+ gppSectionChangeSubscription = null
275
+ }
276
+ }
277
+
278
+ override fun invalidate() {
279
+ gppSectionChangeSubscription?.dispose()
280
+ gppSectionChangeSubscription = null
281
+ gppSectionChangeListenersCount = 0
282
+ super.invalidate()
283
+ }
284
+
285
+ private fun emitEvent(eventName: String, payload: WritableMap) {
286
+ reactApplicationContext
287
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
288
+ .emit(eventName, payload)
289
+ }
290
+
291
+ private fun readableMapValueToAny(map: ReadableMap): Any? {
292
+ if (!map.hasKey("value")) return null
293
+ return when (map.getType("value")) {
294
+ ReadableType.Null -> null
295
+ ReadableType.Boolean -> map.getBoolean("value")
296
+ ReadableType.Number -> normalizeNumber(map.getDouble("value"))
297
+ ReadableType.String -> map.getString("value")
298
+ ReadableType.Map -> normalizeCompositeValue(map.getMap("value")?.toHashMap())
299
+ ReadableType.Array -> normalizeCompositeValue(map.getArray("value")?.toArrayList())
300
+ }
301
+ }
302
+
303
+ private fun normalizeCompositeValue(value: Any?): Any? {
304
+ return when (value) {
305
+ is Double -> normalizeNumber(value)
306
+ is ArrayList<*> -> value.map { normalizeCompositeValue(it) }
307
+ is HashMap<*, *> -> value.entries.associate { (key, nestedValue) ->
308
+ key.toString() to normalizeCompositeValue(nestedValue)
309
+ }
310
+ else -> value
311
+ }
312
+ }
313
+
314
+ private fun normalizeNumber(value: Double): Any {
315
+ return if (value % 1.0 == 0.0) value.toInt() else value
316
+ }
317
+
219
318
  private fun runOnUiThread(block: () -> Unit) {
220
319
  UiThreadUtil.runOnUiThread(block)
221
320
  }
222
321
 
223
322
  companion object {
224
323
  const val NAME = "RNUsercentricsModule"
324
+ const val ON_GPP_SECTION_CHANGE_EVENT = "onGppSectionChange"
225
325
  }
226
326
  }
@@ -51,15 +51,27 @@ abstract class RNUsercentricsModuleSpec internal constructor(context: ReactAppli
51
51
  @ReactMethod
52
52
  abstract fun getUSPData(promise: Promise)
53
53
 
54
+ @ReactMethod
55
+ abstract fun getGPPData(promise: Promise)
56
+
57
+ @ReactMethod
58
+ abstract fun getGPPString(promise: Promise)
59
+
54
60
  @ReactMethod
55
61
  abstract fun getABTestingVariant(promise: Promise)
56
62
 
63
+ @ReactMethod
64
+ abstract fun getDpsMetadata(templateId: String, promise: Promise)
65
+
57
66
  @ReactMethod
58
67
  abstract fun setCMPId(id: Double)
59
68
 
60
69
  @ReactMethod
61
70
  abstract fun setABTestingVariant(variant: String)
62
71
 
72
+ @ReactMethod
73
+ abstract fun setGPPConsent(sectionName: String, fieldName: String, value: ReadableMap)
74
+
63
75
  @ReactMethod
64
76
  abstract fun changeLanguage(language: String, promise: Promise)
65
77
 
@@ -73,7 +85,7 @@ abstract class RNUsercentricsModuleSpec internal constructor(context: ReactAppli
73
85
  abstract fun denyAll(consentType: Double, promise: Promise)
74
86
 
75
87
  @ReactMethod
76
- abstract fun denyAllForTCF(fromLayer: Double, consentType: Double, unsavedPurposeLIDecisions: ReadableArray, promise: Promise)
88
+ abstract fun denyAllForTCF(fromLayer: Double, consentType: Double, unsavedPurposeLIDecisions: ReadableArray, unsavedVendorLIDecisions: ReadableArray, promise: Promise)
77
89
 
78
90
  @ReactMethod
79
91
  abstract fun saveDecisions(decisions: ReadableArray, consentType: Double, promise: Promise)
@@ -93,6 +105,12 @@ abstract class RNUsercentricsModuleSpec internal constructor(context: ReactAppli
93
105
  @ReactMethod
94
106
  abstract fun track(event: Double)
95
107
 
108
+ @ReactMethod
109
+ abstract fun addListener(eventName: String)
110
+
111
+ @ReactMethod
112
+ abstract fun removeListeners(count: Double)
113
+
96
114
  companion object {
97
115
  const val NAME = "RNUsercentricsModule"
98
116
  }
@@ -137,9 +137,10 @@ internal fun ReadableMap.buttonLayoutFromMap(context: Context): ButtonLayout? {
137
137
 
138
138
  internal fun ReadableMap.buttonSettingsFromMap(context: Context): ButtonSettings {
139
139
  val assetManager = context.assets
140
+ val buttonType = getString("buttonType")?.deserializeButtonType() ?: ButtonType.MORE
140
141
 
141
142
  return ButtonSettings(
142
- type = getString("buttonType")!!.deserializeButtonType(),
143
+ type = buttonType,
143
144
  isAllCaps = getBooleanOrNull("isAllCaps"),
144
145
  font = assetManager.createFontFromName(getString("fontName")),
145
146
  textColor = getString("textColorHex")?.deserializeColor(),
@@ -0,0 +1,83 @@
1
+ package com.usercentrics.reactnative.extensions
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.WritableArray
5
+ import com.facebook.react.bridge.WritableMap
6
+ import com.usercentrics.sdk.services.gpp.GppData
7
+ import com.usercentrics.sdk.services.gpp.GppSectionChangePayload
8
+
9
+ private fun normalizeNumber(value: Double): Any {
10
+ return if (value % 1.0 == 0.0 && value <= Int.MAX_VALUE && value >= Int.MIN_VALUE) {
11
+ value.toInt()
12
+ } else {
13
+ value
14
+ }
15
+ }
16
+
17
+ private fun Any?.toWritableValue(): Any? {
18
+ return when (this) {
19
+ null -> null
20
+ is Boolean -> this
21
+ is Number -> normalizeNumber(this.toDouble())
22
+ is String -> this
23
+ is Map<*, *> -> this.toWritableMap()
24
+ is Iterable<*> -> this.toWritableArray()
25
+ is Array<*> -> this.asList().toWritableArray()
26
+ else -> this.toString()
27
+ }
28
+ }
29
+
30
+ private fun Map<*, *>.toWritableMap(): WritableMap {
31
+ val result = Arguments.createMap()
32
+ for ((key, value) in this) {
33
+ val fieldName = key?.toString() ?: continue
34
+ when (val writableValue = value.toWritableValue()) {
35
+ null -> result.putNull(fieldName)
36
+ is Boolean -> result.putBoolean(fieldName, writableValue)
37
+ is Int -> result.putInt(fieldName, writableValue)
38
+ is Double -> result.putDouble(fieldName, writableValue)
39
+ is String -> result.putString(fieldName, writableValue)
40
+ is WritableMap -> result.putMap(fieldName, writableValue)
41
+ is WritableArray -> result.putArray(fieldName, writableValue)
42
+ else -> result.putString(fieldName, writableValue.toString())
43
+ }
44
+ }
45
+ return result
46
+ }
47
+
48
+ private fun Iterable<*>.toWritableArray(): WritableArray {
49
+ val result = Arguments.createArray()
50
+ for (item in this) {
51
+ when (val writableValue = item.toWritableValue()) {
52
+ null -> result.pushNull()
53
+ is Boolean -> result.pushBoolean(writableValue)
54
+ is Int -> result.pushInt(writableValue)
55
+ is Double -> result.pushDouble(writableValue)
56
+ is String -> result.pushString(writableValue)
57
+ is WritableMap -> result.pushMap(writableValue)
58
+ is WritableArray -> result.pushArray(writableValue)
59
+ else -> result.pushString(writableValue.toString())
60
+ }
61
+ }
62
+ return result
63
+ }
64
+
65
+ internal fun GppData.serializeGppData(): WritableMap {
66
+ val sectionsMap = Arguments.createMap()
67
+ sections.forEach { (sectionName, fields) ->
68
+ val fieldsMap = fields.toWritableMap()
69
+ sectionsMap.putMap(sectionName, fieldsMap)
70
+ }
71
+
72
+ val result = Arguments.createMap()
73
+ result.putString("gppString", gppString)
74
+ result.putArray("applicableSections", applicableSections.serialize())
75
+ result.putMap("sections", sectionsMap)
76
+ return result
77
+ }
78
+
79
+ internal fun GppSectionChangePayload.serializeGppPayload(): WritableMap {
80
+ val result = Arguments.createMap()
81
+ result.putString("data", data)
82
+ return result
83
+ }
@@ -14,9 +14,10 @@ internal fun ReadableArray.deserializeUserDecision(): List<UserDecision> {
14
14
  for (i in 0 until size()) {
15
15
  val map = getMap(i)
16
16
  map?.let {
17
+ val serviceId = it.getString("serviceId") ?: return@let
17
18
  decisionList.add(
18
19
  UserDecision(
19
- it.getString("serviceId")!!,
20
+ serviceId,
20
21
  it.getBoolean("consent")
21
22
  )
22
23
  )
@@ -63,7 +63,9 @@ private fun UsercentricsSettings.serialize(): WritableMap {
63
63
  "framework" to framework?.ordinal,
64
64
  "publishedApps" to publishedApps?.map { it.serialize() },
65
65
  "renewConsentsTimestamp" to renewConsentsTimestamp,
66
- "consentWebhook" to consentWebhook
66
+ "consentWebhook" to consentWebhook,
67
+ "gppSignalingEnabled" to getGppSignalingEnabledCompat(),
68
+ "gpcSignalHonoured" to getGpcSignalHonouredCompat(),
67
69
  ).toWritableMap()
68
70
  }
69
71
 
@@ -178,6 +180,8 @@ private fun CCPASettings.serialize(): WritableMap {
178
180
  "secondLayerDescription" to secondLayerDescription,
179
181
  "secondLayerHideLanguageSwitch" to secondLayerHideLanguageSwitch,
180
182
  "btnMoreInfo" to btnMoreInfo,
183
+ "mspaCoveredTransaction" to getMspaCoveredTransactionCompat(),
184
+ "mspaMode" to getMspaModeCompat(),
181
185
  ).toWritableMap()
182
186
  }
183
187
 
@@ -219,7 +223,7 @@ private fun TCF2Settings.serialize(): WritableMap {
219
223
  "disabledSpecialFeatures" to disabledSpecialFeatures,
220
224
  "firstLayerShowDescriptions" to firstLayerShowDescriptions,
221
225
  "hideNonIabOnFirstLayer" to hideNonIabOnFirstLayer,
222
- "resurfacePeriodEnded" to resurfacePeriodEnded,
226
+ "resurfacePeriod" to getResurfacePeriodCompat(),
223
227
  "resurfacePurposeChanged" to resurfacePurposeChanged,
224
228
  "resurfaceVendorAdded" to resurfaceVendorAdded,
225
229
  "firstLayerDescription" to firstLayerDescription,
@@ -245,6 +249,44 @@ private fun TCF2Settings.serialize(): WritableMap {
245
249
  ).toWritableMap()
246
250
  }
247
251
 
252
+ private fun TCF2Settings.getResurfacePeriodCompat(): Int {
253
+ val intValue = runCatching {
254
+ javaClass.getMethod("getResurfacePeriod").invoke(this) as? Int
255
+ }.getOrNull()
256
+ if (intValue != null) return intValue
257
+ val boolValue = runCatching {
258
+ javaClass.getMethod("getResurfacePeriodEnded").invoke(this)
259
+ }.getOrNull() ?: runCatching {
260
+ javaClass.getMethod("isResurfacePeriodEnded").invoke(this)
261
+ }.getOrNull()
262
+ return if (boolValue as? Boolean == true) 1 else 0
263
+ }
264
+
265
+ private fun UsercentricsSettings.getGppSignalingEnabledCompat(): Boolean {
266
+ return runCatching {
267
+ javaClass.getMethod("getGppSignalingEnabled").invoke(this) as? Boolean
268
+ }.getOrNull() ?: false
269
+ }
270
+
271
+ private fun UsercentricsSettings.getGpcSignalHonouredCompat(): Boolean {
272
+ return runCatching {
273
+ javaClass.getMethod("getGpcSignalHonoured").invoke(this) as? Boolean
274
+ }.getOrNull() ?: false
275
+ }
276
+
277
+ private fun CCPASettings.getMspaCoveredTransactionCompat(): Boolean {
278
+ return runCatching {
279
+ javaClass.getMethod("getMspaCoveredTransaction").invoke(this) as? Boolean
280
+ }.getOrNull() ?: false
281
+ }
282
+
283
+ private fun CCPASettings.getMspaModeCompat(): Int? {
284
+ return runCatching {
285
+ val mode = javaClass.getMethod("getMspaMode").invoke(this) ?: return@runCatching null
286
+ mode.javaClass.getMethod("ordinal").invoke(mode) as? Int
287
+ }.getOrNull()
288
+ }
289
+
248
290
  private fun UsercentricsCustomization.serialize(): WritableMap {
249
291
  return mapOf(
250
292
  "color" to color?.serialize(),
@@ -0,0 +1,189 @@
1
+ package com.usercentrics.reactnative.extensions
2
+
3
+ import com.facebook.react.bridge.JavaOnlyArray
4
+ import com.facebook.react.bridge.JavaOnlyMap
5
+ import org.junit.Test
6
+ import org.junit.Assert.*
7
+
8
+ /**
9
+ * Tests for nullability handling in React Native 0.77+
10
+ * These tests ensure that null values from ReadableArray/ReadableMap are handled safely
11
+ */
12
+ class NullabilityTest {
13
+
14
+ @Test
15
+ fun `deserializeUserDecision handles null map entries`() {
16
+ // Given: array with null entries
17
+ val array = JavaOnlyArray().apply {
18
+ pushMap(JavaOnlyMap.of("serviceId", "service1", "consent", true))
19
+ pushNull() // Simulate null entry
20
+ pushMap(JavaOnlyMap.of("serviceId", "service2", "consent", false))
21
+ }
22
+
23
+ // When: deserializing
24
+ val result = array.deserializeUserDecision()
25
+
26
+ // Then: null entries are skipped, valid entries are processed
27
+ assertEquals(2, result.size)
28
+ assertEquals("service1", result[0].serviceId)
29
+ assertEquals(true, result[0].consent)
30
+ assertEquals("service2", result[1].serviceId)
31
+ assertEquals(false, result[1].consent)
32
+ }
33
+
34
+ @Test
35
+ fun `deserializePurposeLIDecisionsMap handles null map entries`() {
36
+ // Given: array with null entries
37
+ val array = JavaOnlyArray().apply {
38
+ pushMap(JavaOnlyMap.of("id", 1, "legitimateInterestConsent", true))
39
+ pushNull() // Simulate null entry
40
+ pushMap(JavaOnlyMap.of("id", 2, "legitimateInterestConsent", false))
41
+ }
42
+
43
+ // When: deserializing
44
+ val result = array.deserializePurposeLIDecisionsMap()
45
+
46
+ // Then: null entries are skipped
47
+ assertNotNull(result)
48
+ assertEquals(2, result?.size)
49
+ assertEquals(true, result?.get(1))
50
+ assertEquals(false, result?.get(2))
51
+ }
52
+
53
+ @Test
54
+ fun `deserializePurposeLIDecisionsMap returns null for empty array`() {
55
+ // Given: empty array
56
+ val array = JavaOnlyArray()
57
+
58
+ // When: deserializing
59
+ val result = array.deserializePurposeLIDecisionsMap()
60
+
61
+ // Then: returns null
62
+ assertNull(result)
63
+ }
64
+
65
+ @Test
66
+ fun `deserializeTCFUserDecisions handles null purpose entries`() {
67
+ // Given: TCF decisions with null entries in purposes array
68
+ val purposesArray = JavaOnlyArray().apply {
69
+ pushMap(JavaOnlyMap.of("id", 1, "consent", true))
70
+ pushNull() // Simulate null entry
71
+ pushMap(JavaOnlyMap.of("id", 2, "consent", false))
72
+ }
73
+
74
+ val tcfMap = JavaOnlyMap.of("purposes", purposesArray)
75
+
76
+ // When: deserializing
77
+ val result = tcfMap.deserializeTCFUserDecisions()
78
+
79
+ // Then: null entries are skipped
80
+ assertNotNull(result.purposes)
81
+ assertEquals(2, result.purposes?.size)
82
+ }
83
+
84
+ @Test
85
+ fun `deserializeTCFUserDecisions handles null special features entries`() {
86
+ // Given: TCF decisions with null entries in specialFeatures array
87
+ val specialFeaturesArray = JavaOnlyArray().apply {
88
+ pushMap(JavaOnlyMap.of("id", 1, "consent", true))
89
+ pushNull()
90
+ pushMap(JavaOnlyMap.of("id", 2, "consent", false))
91
+ }
92
+
93
+ val tcfMap = JavaOnlyMap.of("specialFeatures", specialFeaturesArray)
94
+
95
+ // When: deserializing
96
+ val result = tcfMap.deserializeTCFUserDecisions()
97
+
98
+ // Then: null entries are skipped
99
+ assertNotNull(result.specialFeatures)
100
+ assertEquals(2, result.specialFeatures?.size)
101
+ }
102
+
103
+ @Test
104
+ fun `deserializeTCFUserDecisions handles null vendors entries`() {
105
+ // Given: TCF decisions with null entries in vendors array
106
+ val vendorsArray = JavaOnlyArray().apply {
107
+ pushMap(JavaOnlyMap.of("id", 1, "consent", true))
108
+ pushNull()
109
+ pushMap(JavaOnlyMap.of("id", 2, "consent", false))
110
+ }
111
+
112
+ val tcfMap = JavaOnlyMap.of("vendors", vendorsArray)
113
+
114
+ // When: deserializing
115
+ val result = tcfMap.deserializeTCFUserDecisions()
116
+
117
+ // Then: null entries are skipped
118
+ assertNotNull(result.vendors)
119
+ assertEquals(2, result.vendors?.size)
120
+ }
121
+
122
+ @Test
123
+ fun `deserializeTCFUserDecisions handles null adTechProviders entries`() {
124
+ // Given: TCF decisions with null entries in adTechProviders array
125
+ val adTechArray = JavaOnlyArray().apply {
126
+ pushMap(JavaOnlyMap.of("id", 1, "consent", true))
127
+ pushNull()
128
+ pushMap(JavaOnlyMap.of("id", 2, "consent", false))
129
+ }
130
+
131
+ val tcfMap = JavaOnlyMap.of("adTechProviders", adTechArray)
132
+
133
+ // When: deserializing
134
+ val result = tcfMap.deserializeTCFUserDecisions()
135
+
136
+ // Then: null entries are skipped
137
+ assertNotNull(result.adTechProviders)
138
+ assertEquals(2, result.adTechProviders.size)
139
+ }
140
+
141
+ @Test
142
+ fun `deserializeTCFUserDecisions handles missing arrays gracefully`() {
143
+ // Given: empty TCF map
144
+ val tcfMap = JavaOnlyMap()
145
+
146
+ // When: deserializing
147
+ val result = tcfMap.deserializeTCFUserDecisions()
148
+
149
+ // Then: all fields are null or empty
150
+ assertNull(result.purposes)
151
+ assertNull(result.specialFeatures)
152
+ assertNull(result.vendors)
153
+ assertEquals(0, result.adTechProviders.size)
154
+ }
155
+
156
+ @Test
157
+ fun `deserializeUserDecision handles array with all null entries`() {
158
+ // Given: array with only null entries
159
+ val array = JavaOnlyArray().apply {
160
+ pushNull()
161
+ pushNull()
162
+ pushNull()
163
+ }
164
+
165
+ // When: deserializing
166
+ val result = array.deserializeUserDecision()
167
+
168
+ // Then: returns empty list
169
+ assertEquals(0, result.size)
170
+ }
171
+
172
+ @Test
173
+ fun `deserializeUserDecision skips entries with null serviceId`() {
174
+ // Given: array with entry missing serviceId
175
+ val array = JavaOnlyArray().apply {
176
+ pushMap(JavaOnlyMap.of("serviceId", "service1", "consent", true))
177
+ pushMap(JavaOnlyMap.of("consent", false)) // Missing serviceId
178
+ pushMap(JavaOnlyMap.of("serviceId", "service2", "consent", true))
179
+ }
180
+
181
+ // When: deserializing
182
+ val result = array.deserializeUserDecision()
183
+
184
+ // Then: entry without serviceId is skipped
185
+ assertEquals(2, result.size)
186
+ assertEquals("service1", result[0].serviceId)
187
+ assertEquals("service2", result[1].serviceId)
188
+ }
189
+ }
@@ -0,0 +1,45 @@
1
+ import Foundation
2
+ import Usercentrics
3
+
4
+ private func bridgeValue(_ value: Any) -> Any {
5
+ switch value {
6
+ case let dictionary as [String: Any]:
7
+ return bridgeDictionary(dictionary)
8
+ case let array as [Any]:
9
+ return array.map { item -> Any in
10
+ if let nested = item as? [String: Any] {
11
+ return bridgeDictionary(nested)
12
+ }
13
+ if let nestedArray = item as? [Any] {
14
+ return nestedArray.map { nestedItem -> Any in
15
+ if let nestedDict = nestedItem as? [String: Any] {
16
+ return bridgeDictionary(nestedDict)
17
+ }
18
+ return nestedItem
19
+ } as NSArray
20
+ }
21
+ return item
22
+ } as NSArray
23
+ default:
24
+ return value
25
+ }
26
+ }
27
+
28
+ private func bridgeDictionary(_ dictionary: [String: Any]) -> NSDictionary {
29
+ let mapped = dictionary.mapValues { bridgeValue($0) }
30
+ return mapped as NSDictionary
31
+ }
32
+
33
+ extension GppData {
34
+ func toDictionary() -> NSDictionary {
35
+ let bridgedSections = sections.reduce(into: [String: Any]()) { partialResult, section in
36
+ partialResult[section.key] = bridgeDictionary(section.value)
37
+ }
38
+
39
+ return [
40
+ "gppString": self.gppString,
41
+ "applicableSections": self.applicableSections as NSArray,
42
+ "sections": bridgedSections as NSDictionary,
43
+ ]
44
+ }
45
+ }
@@ -0,0 +1,10 @@
1
+ import Foundation
2
+ import Usercentrics
3
+
4
+ extension GppSectionChangePayload {
5
+ func toDictionary() -> NSDictionary {
6
+ return [
7
+ "data": self.data
8
+ ]
9
+ }
10
+ }