react-native-acoustic-connect-beta 19.0.13 → 19.0.15

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 (32) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/ConnectConfig.example.json +7 -7
  3. package/README.md +123 -1
  4. package/android/build.gradle +64 -0
  5. package/android/config.gradle +123 -12
  6. package/android/src/main/assets/ConnectAdvancedConfig.json +1 -1
  7. package/android/src/main/assets/ConnectBasicConfig.properties +1 -1
  8. package/android/src/main/java/com/acousticconnectrn/HybridAcousticConnectRN.kt +164 -18
  9. package/cli/index.mjs +0 -0
  10. package/ios/AcousticConnectRNConfig.json +153 -8
  11. package/ios/ConnectRNParsing.swift +152 -0
  12. package/ios/HybridAcousticConnectRN.swift +56 -15
  13. package/lib/commonjs/index.js +3 -0
  14. package/lib/commonjs/index.js.map +1 -1
  15. package/lib/module/index.js +5 -0
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/typescript/src/index.d.ts +1 -0
  18. package/lib/typescript/src/index.d.ts.map +1 -1
  19. package/lib/typescript/src/specs/react-native-acoustic-connect.nitro.d.ts +139 -10
  20. package/lib/typescript/src/specs/react-native-acoustic-connect.nitro.d.ts.map +1 -1
  21. package/nitrogen/generated/android/c++/JHybridAcousticConnectRNSpec.cpp +5 -9
  22. package/nitrogen/generated/android/c++/JHybridAcousticConnectRNSpec.hpp +1 -1
  23. package/nitrogen/generated/android/kotlin/com/margelo/nitro/acousticconnectrn/HybridAcousticConnectRNSpec.kt +2 -1
  24. package/nitrogen/generated/ios/AcousticConnectRN-Swift-Cxx-Umbrella.hpp +1 -0
  25. package/nitrogen/generated/ios/c++/HybridAcousticConnectRNSpecSwift.hpp +2 -1
  26. package/nitrogen/generated/ios/swift/HybridAcousticConnectRNSpec.swift +1 -1
  27. package/nitrogen/generated/ios/swift/HybridAcousticConnectRNSpec_cxx.swift +2 -25
  28. package/nitrogen/generated/shared/c++/HybridAcousticConnectRNSpec.hpp +2 -1
  29. package/package.json +2 -1
  30. package/src/index.ts +5 -0
  31. package/src/specs/react-native-acoustic-connect.nitro.ts +141 -10
  32. package/Examples/SampleUI/ConnectConfig.json +0 -180
@@ -62,6 +62,7 @@ import com.margelo.nitro.acousticconnectrn.PushPermissionResult
62
62
  import com.margelo.nitro.acousticconnectrn.Variant_Boolean_String_Double
63
63
  import com.margelo.nitro.acousticconnectrn.Variant_NullType_Boolean
64
64
  import com.margelo.nitro.acousticconnectrn.Variant_NullType_String
65
+ import com.margelo.nitro.core.AnyMap
65
66
  import com.margelo.nitro.core.ArrayBuffer
66
67
  import com.margelo.nitro.core.NullType
67
68
  import com.margelo.nitro.core.Promise
@@ -70,6 +71,9 @@ import com.tl.uic.model.ScreenviewType
70
71
  import com.tl.uic.util.DialogUtil
71
72
  import com.tl.uic.util.LayoutUtil
72
73
  import com.tl.uic.util.keyboardview.KeyboardView
74
+ import org.json.JSONArray
75
+ import org.json.JSONException
76
+ import org.json.JSONObject
73
77
  import java.util.Objects
74
78
 
75
79
 
@@ -592,7 +596,8 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
592
596
  value: Variant_Boolean_String_Double,
593
597
  moduleName: String
594
598
  ): Boolean {
595
- val result = EOCore.updateConfig(key, value.toString(), EOCore.getLifecycleObject(moduleName))
599
+ val result =
600
+ EOCore.updateConfig(key, variantToString(value), EOCore.getLifecycleObject(moduleName))
596
601
  return result
597
602
  }
598
603
 
@@ -668,15 +673,21 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
668
673
  /**
669
674
  * Logs a signal with the specified values.
670
675
  *
671
- * @param values A map of values associated with the signal.
676
+ * Takes an [AnyMap] rather than a map of Nitro variants so JS callers can
677
+ * send arbitrary JSON — nested objects and arrays included. See
678
+ * [toSignalPayload] for why the nesting has to be rebuilt as
679
+ * [JSONObject]/[JSONArray] rather than handed over as plain Kotlin
680
+ * collections.
681
+ *
682
+ * @param values The signal payload; objects, arrays and scalars are all carried through.
672
683
  * @param level Set a custom log level to the event. This will override the configured log level for that event.
673
684
  * @return True if the operation was successful, false otherwise.
674
685
  */
675
686
  override fun logSignal(
676
- values: Map<String, Variant_Boolean_String_Double>,
687
+ values: AnyMap,
677
688
  level: Double
678
689
  ): Boolean {
679
- val result = Connect.logSignal(convertToMapAny(values), level.toInt())
690
+ val result = Connect.logSignal(toSignalPayload(values.toHashMap()), level.toInt())
680
691
  return result
681
692
  }
682
693
 
@@ -697,8 +708,11 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
697
708
  * @param identifierValue Identifier value, e.g. "user@example.com".
698
709
  * @param signalType Optional signal type; defaults to "loggedIn" when omitted.
699
710
  * @param additionalParameters Optional extra key/value pairs merged into the
700
- * signal. Defaults to `{ "registrationMethod": "email" }` only when null
701
- * (omitted); an explicit mapincluding an empty one — is used as-is.
711
+ * signal. Defaults, only when null (omitted), to the method attribute the
712
+ * resolved signal type requires`{ "loginMethod": "email" }` for
713
+ * `loggedIn`, `{ "registrationMethod": "email" }` otherwise (see
714
+ * [defaultIdentityParameters]). An explicit map — including an empty one
715
+ * — is used as-is.
702
716
  * @return A promise resolving to true if the signal was queued, false otherwise.
703
717
  */
704
718
  override fun logIdentity(
@@ -707,8 +721,8 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
707
721
  signalType: String?,
708
722
  additionalParameters: Map<String, String>?
709
723
  ): Promise<Boolean> {
710
- val params = additionalParameters ?: mapOf("registrationMethod" to "email")
711
724
  val resolvedSignalType = signalType ?: "loggedIn"
725
+ val params = additionalParameters ?: defaultIdentityParameters(resolvedSignalType)
712
726
  val result = params["url"]?.let { url ->
713
727
  Connect.logIdentificationEvent(
714
728
  identifierName,
@@ -1187,7 +1201,7 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1187
1201
  for (key in values.keys) {
1188
1202
  val value = values[key]
1189
1203
  if (value != null) {
1190
- eventValues[key] = value.toString()
1204
+ eventValues[key] = variantToString(value)
1191
1205
  }
1192
1206
  }
1193
1207
 
@@ -1200,9 +1214,42 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1200
1214
  return result
1201
1215
  }
1202
1216
 
1217
+ /**
1218
+ * Unwraps a Nitro variant to the underlying JS value it carries — a
1219
+ * [Boolean], [String], or [Double].
1220
+ *
1221
+ * The generated `Variant_*` type is a sealed **data** class, so calling
1222
+ * `toString()` on the variant itself yields the case wrapper
1223
+ * (`"Second(value=pro)"`) instead of the value (`"pro"`). Every conversion
1224
+ * that reads a variant must unwrap it first. Mirrors the iOS bridge's
1225
+ * `ConnectRNParsing.convertVariantToAny`, keeping emitted payloads
1226
+ * identical across platforms.
1227
+ *
1228
+ * @param value The variant to unwrap.
1229
+ * @return The wrapped Boolean, String, or Double.
1230
+ */
1231
+ private fun unwrapVariant(value: Variant_Boolean_String_Double): Any =
1232
+ value.match(first = { it }, second = { it }, third = { it })
1233
+
1234
+ /**
1235
+ * Unwraps a Nitro variant (see [unwrapVariant]) and renders it as a string,
1236
+ * for the SDK entry points that take `HashMap<String, String>` payloads.
1237
+ *
1238
+ * Booleans render as `"true"`/`"false"` and numbers via Kotlin's [Double]
1239
+ * formatting (a JS `2` arrives as `2.0` and renders `"2.0"`); strings pass
1240
+ * through unchanged.
1241
+ *
1242
+ * @param value The variant to stringify.
1243
+ * @return The wrapped value's string form.
1244
+ */
1245
+ private fun variantToString(value: Variant_Boolean_String_Double): String =
1246
+ unwrapVariant(value).toString()
1247
+
1203
1248
  /**
1204
1249
  * Converts a map of Variant_Boolean_String_Double to a HashMap<String?, String?>.
1205
1250
  *
1251
+ * Values are unwrapped before stringifying (see [variantToString]).
1252
+ *
1206
1253
  * @param values The map to be converted.
1207
1254
  * @return A HashMap<String?, String?> representation of the input map which library can use.
1208
1255
  */
@@ -1212,30 +1259,106 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1212
1259
  for (key in values.keys) {
1213
1260
  val value = values[key]
1214
1261
  if (value != null) {
1215
- map[key] = value.toString()
1262
+ map[key] = variantToString(value)
1216
1263
  }
1217
1264
  }
1218
1265
  return map
1219
1266
  }
1220
1267
 
1221
1268
  /**
1222
- * Converts a map of Variant_Boolean_String_Double to a HashMap<String?, String?>.
1269
+ * Converts an [AnyMap]-derived map into the payload `Connect.logSignal`
1270
+ * expects, rebuilding nested structures as [JSONObject] / [JSONArray].
1223
1271
  *
1224
- * @param values The map to be converted.
1225
- * @return A HashMap<String?, String?> representation of the input map which library can use.
1272
+ * The rebuild is mandatory, not cosmetic. The SDK serializes the signal
1273
+ * through EOCore's `JsonUtil.getHashValues`, which walks the top-level
1274
+ * entries and accumulates only `String`, `Boolean`, `JSONObject`,
1275
+ * `JSONArray` and `ByteArray` — anything else is **silently dropped**. So
1276
+ * a nested `Map`/`List` handed over as-is would vanish from the emitted
1277
+ * signal without any error. Converting here is what makes nested payloads
1278
+ * survive the wire.
1279
+ *
1280
+ * `null` becomes [JSONObject.NULL] rather than a Kotlin `null`, which
1281
+ * `org.json` requires for an explicit JSON `null`.
1282
+ *
1283
+ * Known limitation: a **top-level** numeric value is still dropped, because
1284
+ * `getHashValues` has no `Number` branch and the bridge cannot influence
1285
+ * that walk. Numbers nested inside an object or array are unaffected — the
1286
+ * enclosing [JSONObject]/[JSONArray] is built here, so `org.json`
1287
+ * serializes them normally. iOS carries top-level numbers fine; this is an
1288
+ * SDK-side asymmetry, pre-dating this conversion.
1289
+ *
1290
+ * A [JSONException] aborts the whole payload rather than escaping to the
1291
+ * caller. Non-finite numbers are the case that raises it: `org.json`
1292
+ * rejects them, and [toJsonValue] raises them explicitly so a top-level one
1293
+ * behaves like a nested one. Either way a JS `Infinity` or `NaN` would
1294
+ * otherwise throw straight out of `logSignal` and take the host app's call
1295
+ * site down — unacceptable for an analytics SDK, and a behaviour change
1296
+ * from the scalar-only bridge, which could not throw. Dropping the whole
1297
+ * payload also matches iOS, where a single non-finite value fails
1298
+ * `isValidJSONObject:` and the `signal` key is omitted entirely.
1299
+ *
1300
+ * @param values The map from [AnyMap.toHashMap].
1301
+ * @return A HashMap the SDK's signal serializer can consume, or an empty
1302
+ * map if the payload could not be represented as JSON.
1226
1303
  */
1227
1304
  // internal (not private) so the unit tests in src/test can exercise it.
1228
- internal fun convertToMapAny(values: Map<String, Variant_Boolean_String_Double>): java.util.HashMap<String?, Any?>? {
1229
- val map = HashMap<String?, Any?>()
1230
- for (key in values.keys) {
1231
- val value = values[key]
1232
- if (value != null) {
1233
- map[key] = value
1305
+ internal fun toSignalPayload(values: Map<String, Any?>): java.util.HashMap<String?, Any?> {
1306
+ val map = HashMap<String?, Any?>(values.size)
1307
+ try {
1308
+ for ((key, value) in values) {
1309
+ map[key] = toJsonValue(value)
1234
1310
  }
1311
+ } catch (e: JSONException) {
1312
+ Log.w(TAG, "[bridge] logSignal payload is not representable as JSON — dropping it: ${e.message}")
1313
+ return HashMap()
1235
1314
  }
1236
1315
  return map
1237
1316
  }
1238
1317
 
1318
+ /**
1319
+ * Recursive helper for [toSignalPayload]. Maps Kotlin containers onto their
1320
+ * `org.json` equivalents and leaves scalars alone.
1321
+ *
1322
+ * Nitro's JNI layer converts the whole tree to plain Java types before it
1323
+ * reaches Kotlin — a JS object becomes a `HashMap`, a JS array an
1324
+ * `ArrayList`, and scalars become `Double` / `Boolean` / `Long` / `String`
1325
+ * — so there are no `AnyValue` wrappers left to unwrap here. [List] is
1326
+ * therefore the branch a JS array actually takes; [Array] is handled too,
1327
+ * to guard against a future Nitro representation change.
1328
+ *
1329
+ * @param value A value from an [AnyMap]-derived container.
1330
+ * @return The `org.json`-compatible equivalent.
1331
+ */
1332
+ private fun toJsonValue(value: Any?): Any =
1333
+ when (value) {
1334
+ null -> JSONObject.NULL
1335
+ is Map<*, *> -> JSONObject().apply {
1336
+ for ((nestedKey, nestedValue) in value) {
1337
+ put(nestedKey.toString(), toJsonValue(nestedValue))
1338
+ }
1339
+ }
1340
+ is Array<*> -> JSONArray().apply {
1341
+ for (element in value) put(toJsonValue(element))
1342
+ }
1343
+ is List<*> -> JSONArray().apply {
1344
+ for (element in value) put(toJsonValue(element))
1345
+ }
1346
+ // Checked here rather than left to `org.json`. A non-finite number
1347
+ // nested in an object or array would be rejected by that container's
1348
+ // `put`, but a TOP-LEVEL one is only ever handed to `HashMap.put`,
1349
+ // which accepts anything — so without this the payload would reach
1350
+ // the SDK and lose just that one key, while the nested case loses
1351
+ // the whole payload. Raising it uniformly keeps the two consistent
1352
+ // and matches iOS, where one non-finite value fails
1353
+ // `isValidJSONObject:` for the entire signal.
1354
+ is Double -> if (value.isFinite()) {
1355
+ value
1356
+ } else {
1357
+ throw JSONException("non-finite number: $value")
1358
+ }
1359
+ else -> value
1360
+ }
1361
+
1239
1362
  /**
1240
1363
  * Gets the current activity from the ReactApplicationContext.
1241
1364
  *
@@ -1390,5 +1513,28 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1390
1513
  // by the connect-push-fcm artifact.
1391
1514
  private const val CONNECT_PUSH_FCM_PROBE_CLASS =
1392
1515
  "com.acoustic.connect.android.connectmod.push.services.fcm.FCMPushService"
1516
+
1517
+ /**
1518
+ * Default `additionalParameters` for an identity signal, matched to the
1519
+ * signal type the bridge will actually send.
1520
+ *
1521
+ * Connect's signal schema requires a *method* attribute on identity
1522
+ * signals, and the required key differs per type: `loggedIn` requires
1523
+ * `loginMethod`, `accountRegistered` requires `registrationMethod`.
1524
+ * Supplying the wrong one fails schema validation and the signal is
1525
+ * discarded, while [Connect.logIdentificationEvent] still reports
1526
+ * success — so the caller sees nothing wrong. Before this mapping
1527
+ * existed the bridge paired its `loggedIn` default with
1528
+ * `registrationMethod`, which meant every defaulted identity call was
1529
+ * silently dropped.
1530
+ *
1531
+ * Only consulted when the caller omits `additionalParameters`
1532
+ * entirely; an explicitly-provided map is passed through untouched.
1533
+ */
1534
+ internal fun defaultIdentityParameters(signalType: String): Map<String, String> =
1535
+ when (signalType) {
1536
+ "loggedIn" -> mapOf("loginMethod" to "email")
1537
+ else -> mapOf("registrationMethod" to "email")
1538
+ }
1393
1539
  }
1394
1540
  }
package/cli/index.mjs CHANGED
File without changes
@@ -1,15 +1,160 @@
1
1
  {
2
2
  "Connect": {
3
+ "AppKey": "b6c3709b7a4c479bb4b5a9fb8fec324c",
4
+ "KillSwitchUrl": "https://lib-us-2.brilliantcollector.com/collector/switch/b6c3709b7a4c479bb4b5a9fb8fec324c",
5
+ "PostMessageUrl": "https://lib-us-2.brilliantcollector.com/collector/collectorPost",
3
6
  "AndroidVersion": "",
4
- "AppKey": "YOUR_CONNECT_APP_KEY_HERE",
5
- "KillSwitchUrl": "YOUR_KILL_SWITCH_URL_HERE",
6
- "PostMessageUrl": "YOUR_POST_MESSAGE_URL_HERE",
7
- "PushEnabled": true,
8
- "iOSPushMode": "automatic",
9
- "iOSAppGroupIdentifier": "group.co.acoustic.mobile.connect.rn.expodemo",
10
- "iOSDevelopmentTeam": "YOUR_TEAM_ID",
11
- "AndroidNotificationIconResName": "ic_notification",
12
7
  "iOSVersion": "",
8
+ "PushEnabled": false,
9
+ "iOSPushMode": "automatic",
10
+ "iOSAppGroupIdentifier": null,
11
+ "AndroidNotificationIconResName": null,
12
+ "layoutConfigAndroid": {
13
+ "AppendMapIds": {
14
+ "[w,9290],[v,0]": {
15
+ "mid": "ASimpleUIView"
16
+ },
17
+ "idxPathValue": {
18
+ "mid": "giveAdditionalId2"
19
+ },
20
+ "tag2999999": {
21
+ "mid": "giveAdditionalId1"
22
+ }
23
+ },
24
+ "AutoLayout": {
25
+ "ExampleMaskingPage": {
26
+ "CaptureLayoutDelay": 0,
27
+ "CaptureLayoutOn": 0,
28
+ "CaptureScreenVisits": false,
29
+ "CaptureScreenshotOn": 0,
30
+ "CaptureUserEvents": false,
31
+ "DisplayName": "",
32
+ "Masking": {
33
+ "HasCustomMask": true,
34
+ "HasMasking": true,
35
+ "MaskAccessibilityIdList": [],
36
+ "MaskAccessibilityLabelList": [],
37
+ "MaskIdList": [
38
+ "^9[0-9][0-9][0-9]$",
39
+ "^\\[wvv,0\\],\\[dddv,0\\],\\[v,0\\],\\[v,0\\],\\[v,0\\],\\[b,0\\](.)*$"
40
+ ],
41
+ "MaskValueList": [
42
+ "^4[0-9]{12}(?:[0-9]{3})?$",
43
+ "^3[47][0-9]{13}$",
44
+ "^65[4-9][0-9]{13}|64[4-9][0-9]{13}|6011[0-9]{12}|(622(?:12[6-9]|1[3-9][0-9]|[2-8][0-9][0-9]|9[01][0-9]|92[0-5])[0-9]{10})$",
45
+ "^(5[1-5][0-9]{14}|2(22[1-9][0-9]{12}|2[3-9][0-9]{13}|[3-6][0-9]{14}|7[0-1][0-9]{13}|720[0-9]{12}))$"
46
+ ],
47
+ "Sensitive": {
48
+ "capitalCaseAlphabet": "X",
49
+ "number": "9",
50
+ "smallCaseAlphabet": "x",
51
+ "symbol": "#"
52
+ }
53
+ },
54
+ "NumberOfWebViews": 0,
55
+ "ScreenChange": false,
56
+ "ScreenShot": false
57
+ },
58
+ "GlobalScreenSettings": {
59
+ "CaptureLayoutDelay": 1,
60
+ "CaptureLayoutOn": 2,
61
+ "CaptureScreenVisits": false,
62
+ "CaptureScreenshotOn": 2,
63
+ "CaptureUserEvents": true,
64
+ "DisplayName": "",
65
+ "Masking": {
66
+ "HasCustomMask": true,
67
+ "HasMasking": true,
68
+ "MaskAccessibilityIdList": [],
69
+ "MaskAccessibilityLabelList": [],
70
+ "MaskIdList": [],
71
+ "MaskValueList": [],
72
+ "Sensitive": {
73
+ "capitalCaseAlphabet": "X",
74
+ "number": "9",
75
+ "smallCaseAlphabet": "x",
76
+ "symbol": "#"
77
+ }
78
+ },
79
+ "NumberOfWebViews": 0,
80
+ "ScreenChange": true,
81
+ "ScreenShot": true
82
+ }
83
+ }
84
+ },
85
+ "layoutConfigIos": {
86
+ "AppendMapIds": {
87
+ "[w,9290],[v,0]": {
88
+ "mid": "ASimpleUIView"
89
+ },
90
+ "idxPathValue": {
91
+ "mid": "giveAdditionalId2"
92
+ },
93
+ "tag2999999": {
94
+ "mid": "giveAdditionalId1"
95
+ }
96
+ },
97
+ "AutoLayout": {
98
+ "ExampleMaskingPage": {
99
+ "CaptureLayoutDelay": 0,
100
+ "CaptureLayoutOn": 0,
101
+ "CaptureScreenVisits": false,
102
+ "CaptureScreenshotOn": 0,
103
+ "CaptureUserEvents": false,
104
+ "DisplayName": "",
105
+ "Masking": {
106
+ "HasCustomMask": true,
107
+ "HasMasking": true,
108
+ "MaskAccessibilityIdList": [],
109
+ "MaskAccessibilityLabelList": [],
110
+ "MaskIdList": [
111
+ "^9[0-9][0-9][0-9]$",
112
+ "^\\[wvv,0\\],\\[dddv,0\\],\\[v,0\\],\\[v,0\\],\\[v,0\\],\\[b,0\\](.)*$"
113
+ ],
114
+ "MaskValueList": [
115
+ "^4[0-9]{12}(?:[0-9]{3})?$",
116
+ "^3[47][0-9]{13}$",
117
+ "^65[4-9][0-9]{13}|64[4-9][0-9]{13}|6011[0-9]{12}|(622(?:12[6-9]|1[3-9][0-9]|[2-8][0-9][0-9]|9[01][0-9]|92[0-5])[0-9]{10})$",
118
+ "^(5[1-5][0-9]{14}|2(22[1-9][0-9]{12}|2[3-9][0-9]{13}|[3-6][0-9]{14}|7[0-1][0-9]{13}|720[0-9]{12}))$"
119
+ ],
120
+ "Sensitive": {
121
+ "capitalCaseAlphabet": "X",
122
+ "number": "9",
123
+ "smallCaseAlphabet": "x",
124
+ "symbol": "#"
125
+ }
126
+ },
127
+ "NumberOfWebViews": 0,
128
+ "ScreenChange": false,
129
+ "ScreenShot": false
130
+ },
131
+ "GlobalScreenSettings": {
132
+ "CaptureLayoutDelay": 1,
133
+ "CaptureLayoutOn": 2,
134
+ "CaptureScreenVisits": false,
135
+ "CaptureScreenshotOn": 2,
136
+ "CaptureUserEvents": true,
137
+ "DisplayName": "",
138
+ "Masking": {
139
+ "HasCustomMask": true,
140
+ "HasMasking": true,
141
+ "MaskAccessibilityIdList": [],
142
+ "MaskAccessibilityLabelList": [],
143
+ "MaskIdList": [],
144
+ "MaskValueList": [],
145
+ "Sensitive": {
146
+ "capitalCaseAlphabet": "X",
147
+ "number": "9",
148
+ "smallCaseAlphabet": "x",
149
+ "symbol": "#"
150
+ }
151
+ },
152
+ "NumberOfWebViews": 0,
153
+ "ScreenChange": true,
154
+ "ScreenShot": true
155
+ }
156
+ }
157
+ },
13
158
  "useRelease": false
14
159
  }
15
160
  }
@@ -104,6 +104,90 @@ internal enum ConnectRNParsing {
104
104
  // PushErrorInfo is a C++-backed nitro type, and this file is also
105
105
  // compiled into the UnitTests bundle, which builds without C++ interop.
106
106
 
107
+ /// `Connect` config keys that can carry an iOS layout-config block, in
108
+ /// **base-to-override order**: the shared block is the base, and the
109
+ /// iOS-specific block is layered on top of it.
110
+ ///
111
+ /// `layoutConfigIos` is what the shipped `ConnectConfig.json` template
112
+ /// writes, and the podspec copies the consumer's file into the resource
113
+ /// bundle verbatim (no key rename), so that is the key that actually
114
+ /// arrives at runtime. `layoutConfig` is the shared, cross-platform block:
115
+ /// a consumer can set a common baseline there and refine it per platform.
116
+ ///
117
+ /// `layoutConfigAndroid` is deliberately absent: applying it here would
118
+ /// push Android screen rules onto the iOS SDK.
119
+ static let layoutConfigKeys = ["layoutConfig", "layoutConfigIos"]
120
+
121
+ /// Entries of the layout-config block that map onto config-store overrides.
122
+ private static let layoutOverrideKeys = ["AutoLayout", "AppendMapIds"]
123
+
124
+ /// Recursively merges `override` onto `base`.
125
+ ///
126
+ /// Nested objects merge key by key, so a shared baseline survives a
127
+ /// platform block that only refines part of it. Every other value —
128
+ /// including arrays such as `MaskIdList` — is replaced outright rather than
129
+ /// concatenated, so a platform block can shorten a shared list.
130
+ static func deepMerging(_ base: [String: Any], _ override: [String: Any]) -> [String: Any] {
131
+ var merged = base
132
+ for (key, overrideValue) in override {
133
+ if let baseChild = merged[key] as? [String: Any],
134
+ let overrideChild = overrideValue as? [String: Any] {
135
+ merged[key] = deepMerging(baseChild, overrideChild)
136
+ } else {
137
+ merged[key] = overrideValue
138
+ }
139
+ }
140
+ return merged
141
+ }
142
+
143
+ /// Resolves the iOS layout-config block into the config-store overrides to
144
+ /// apply, keyed by the name the native SDK reads. `AutoLayout` becomes
145
+ /// `kConfigurableItemAutoLayout`, which `TLFAutoInstrumentationManger`
146
+ /// consumes for per-screen settings such as `ScreenShot` and
147
+ /// `CaptureScreenshotOn`.
148
+ ///
149
+ /// Both keys in ``layoutConfigKeys`` contribute: the shared `layoutConfig`
150
+ /// is applied first and `layoutConfigIos` is deep-merged over it, so an
151
+ /// iOS-specific value wins key by key while the shared baseline is kept for
152
+ /// everything the iOS block does not mention. A malformed block is logged
153
+ /// and skipped rather than aborting the rest of the resolution.
154
+ ///
155
+ /// Returns an empty dictionary when nothing is applicable, in which case
156
+ /// the caller applies nothing and the SDK keeps the defaults from its own
157
+ /// bundled `ConnectLayoutConfig.json`.
158
+ static func resolveLayoutOverrides(from connectData: [String: Any]) -> [String: Any] {
159
+ var block: [String: Any] = [:]
160
+ var contributing: [String] = []
161
+
162
+ for name in layoutConfigKeys {
163
+ guard let value = connectData[name] else { continue }
164
+ guard let candidate = value as? [String: Any] else {
165
+ configLog.error("Connect.\(name, privacy: .public) in ConnectConfig.json is not a JSON object. Ignoring it; iOS screen-capture settings fall back to the SDK defaults.")
166
+ continue
167
+ }
168
+ block = deepMerging(block, candidate)
169
+ contributing.append(name)
170
+ }
171
+
172
+ guard !contributing.isEmpty else { return [:] }
173
+
174
+ var overrides: [String: Any] = [:]
175
+ for name in layoutOverrideKeys {
176
+ if let override = block[name] {
177
+ overrides[name] = override
178
+ }
179
+ }
180
+
181
+ let sources = contributing.joined(separator: " <- ")
182
+ if overrides.isEmpty {
183
+ configLog.warning("Connect.\(sources, privacy: .public) carries no recognised override (expected AutoLayout and/or AppendMapIds). iOS screen-capture settings fall back to the SDK defaults.")
184
+ } else {
185
+ let applied = overrides.keys.sorted().joined(separator: ", ")
186
+ configLog.info("Applying layout overrides [\(applied, privacy: .public)] resolved from Connect.\(sources, privacy: .public)")
187
+ }
188
+ return overrides
189
+ }
190
+
107
191
  /// Maps the JS-facing module name to the config store's module name
108
192
  /// ("Connect" → "TLFCoreModule").
109
193
  static func storeModuleName(for moduleName: String) -> String {
@@ -130,6 +214,51 @@ internal enum ConnectRNParsing {
130
214
  return result
131
215
  }
132
216
 
217
+ /// Normalises a dictionary produced by `AnyMap.toDictionary()` into one
218
+ /// that `NSJSONSerialization.isValidJSONObject:` accepts, which is the
219
+ /// only gate `CTSignalMessage` applies before embedding the payload.
220
+ ///
221
+ /// Two things need fixing up. `AnyMap` models JS `null` as a Swift `nil`,
222
+ /// and a dictionary holding `Optional.none` is not JSON-representable —
223
+ /// `NSNull` is. And the nesting is optional-typed all the way down
224
+ /// (`[String: Any?]` / `[Any?]`), so the walk has to be recursive rather
225
+ /// than a single top-level pass. Values that are already scalars pass
226
+ /// through untouched, so a flat payload converts to exactly what the
227
+ /// previous scalar-only bridge produced.
228
+ ///
229
+ /// Deliberately takes plain Swift collections rather than `AnyMap`: this
230
+ /// file is compiled into the UnitTests bundle, which builds without C++
231
+ /// interop and therefore cannot see nitro's `AnyMap`/`AnyValue` types.
232
+ /// The `AnyMap` unwrapping stays in `HybridAcousticConnectRN`.
233
+ ///
234
+ /// - Parameter input: dictionary from `AnyMap.toDictionary()`.
235
+ /// - Returns: the same structure with optionals resolved.
236
+ static func jsonSafeDictionary(_ input: [String: Any?]) -> [String: Any] {
237
+ var result = [String: Any](minimumCapacity: input.count)
238
+ for (key, value) in input {
239
+ result[key] = jsonSafeValue(value)
240
+ }
241
+ return result
242
+ }
243
+
244
+ /// Recursive helper for ``jsonSafeDictionary(_:)``. Maps `nil` to `NSNull`
245
+ /// and rebuilds nested dictionaries/arrays; every other value — `String`,
246
+ /// `Double`, `Int64`, `Bool` — is already JSON-representable and is
247
+ /// returned as-is.
248
+ ///
249
+ /// - Parameter value: a value from an `AnyMap`-derived container.
250
+ /// - Returns: a JSON-representable equivalent.
251
+ static func jsonSafeValue(_ value: Any?) -> Any {
252
+ guard let value else { return NSNull() }
253
+ if let nested = value as? [String: Any?] {
254
+ return jsonSafeDictionary(nested)
255
+ }
256
+ if let array = value as? [Any?] {
257
+ return array.map { jsonSafeValue($0) }
258
+ }
259
+ return value
260
+ }
261
+
133
262
  static func convertVariantToAny(_ variant: Variant_Bool_String_Double) -> Any {
134
263
  switch variant {
135
264
  case .first(let boolValue):
@@ -141,6 +270,29 @@ internal enum ConnectRNParsing {
141
270
  }
142
271
  }
143
272
 
273
+ /// Default `additionalParameters` for an identity signal, matched to the
274
+ /// signal type the bridge will actually send.
275
+ ///
276
+ /// Connect's signal schema requires a *method* attribute on identity
277
+ /// signals, and the required key differs per type: `loggedIn` requires
278
+ /// `loginMethod`, `accountRegistered` requires `registrationMethod`.
279
+ /// Supplying the wrong one fails schema validation and the signal is
280
+ /// discarded, while `identity.log` still reports success — so the caller
281
+ /// sees nothing wrong. Before this mapping existed the bridge paired its
282
+ /// `loggedIn` default with `registrationMethod`, which meant every
283
+ /// defaulted identity call was silently dropped.
284
+ ///
285
+ /// Only consulted when the caller omits `additionalParameters` entirely;
286
+ /// an explicitly-provided map is passed through untouched.
287
+ static func defaultIdentityParameters(for signalType: String) -> [String: String] {
288
+ switch signalType {
289
+ case "loggedIn":
290
+ return ["loginMethod": "email"]
291
+ default:
292
+ return ["registrationMethod": "email"]
293
+ }
294
+ }
295
+
144
296
  static func logLevel(from level: Double) -> kConnectMonitoringLevelType {
145
297
  let intValue: Int = Int(level)
146
298
  if intValue == 0 {