react-native-acoustic-connect-beta 19.0.14 → 19.0.16

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 (37) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/ConnectConfig.example.json +1 -1
  3. package/Examples/SampleUI/ConnectConfig.json +2 -2
  4. package/README.md +164 -1
  5. package/android/build.gradle +64 -0
  6. package/android/config.gradle +123 -12
  7. package/android/src/main/assets/ConnectAdvancedConfig.json +1 -1
  8. package/android/src/main/assets/ConnectBasicConfig.properties +1 -1
  9. package/android/src/main/java/com/acousticconnectrn/HybridAcousticConnectRN.kt +210 -20
  10. package/ios/ConnectRNParsing.swift +229 -0
  11. package/ios/HybridAcousticConnectRN.swift +125 -27
  12. package/lib/commonjs/TLTRN.js +26 -3
  13. package/lib/commonjs/TLTRN.js.map +1 -1
  14. package/lib/commonjs/index.js +3 -0
  15. package/lib/commonjs/index.js.map +1 -1
  16. package/lib/module/TLTRN.js +26 -3
  17. package/lib/module/TLTRN.js.map +1 -1
  18. package/lib/module/index.js +5 -0
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/typescript/src/TLTRN.d.ts +22 -1
  21. package/lib/typescript/src/TLTRN.d.ts.map +1 -1
  22. package/lib/typescript/src/index.d.ts +1 -0
  23. package/lib/typescript/src/index.d.ts.map +1 -1
  24. package/lib/typescript/src/specs/react-native-acoustic-connect.nitro.d.ts +150 -10
  25. package/lib/typescript/src/specs/react-native-acoustic-connect.nitro.d.ts.map +1 -1
  26. package/nitrogen/generated/android/c++/JHybridAcousticConnectRNSpec.cpp +5 -9
  27. package/nitrogen/generated/android/c++/JHybridAcousticConnectRNSpec.hpp +1 -1
  28. package/nitrogen/generated/android/kotlin/com/margelo/nitro/acousticconnectrn/HybridAcousticConnectRNSpec.kt +2 -1
  29. package/nitrogen/generated/ios/AcousticConnectRN-Swift-Cxx-Umbrella.hpp +1 -0
  30. package/nitrogen/generated/ios/c++/HybridAcousticConnectRNSpecSwift.hpp +2 -1
  31. package/nitrogen/generated/ios/swift/HybridAcousticConnectRNSpec.swift +1 -1
  32. package/nitrogen/generated/ios/swift/HybridAcousticConnectRNSpec_cxx.swift +2 -25
  33. package/nitrogen/generated/shared/c++/HybridAcousticConnectRNSpec.hpp +2 -1
  34. package/package.json +2 -1
  35. package/src/TLTRN.ts +31 -3
  36. package/src/index.ts +5 -0
  37. package/src/specs/react-native-acoustic-connect.nitro.ts +152 -10
@@ -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 map — including 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,
@@ -896,7 +910,10 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
896
910
  * Log Current Screen Layout using native side background thread.
897
911
  *
898
912
  * @param name Page name or title e.g. "Login View Controller"; Must not be empty.
899
- * @param delay The delay in milliseconds before logging the event.
913
+ * @param delay The delay in milliseconds before logging the event. A negative
914
+ * value means "use the `CaptureLayoutDelay` configured for this
915
+ * screen", which is what the JS wrapper sends when the caller
916
+ * names no delay.
900
917
  * @return True if the operation was successful, false otherwise.
901
918
  */
902
919
  override fun logScreenLayout(name: String, delay: Double): Boolean {
@@ -907,13 +924,45 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
907
924
  result = logScreenLayout(
908
925
  Objects.requireNonNull<Activity?>(getCurrentActivity()),
909
926
  name,
910
- if (delay.toInt() < 0) 300 else delay.toInt(),
927
+ resolveCaptureLayoutDelayMs(name, delay),
911
928
  true
912
929
  )
913
930
  }
914
931
  return result
915
932
  }
916
933
 
934
+ /**
935
+ * Resolves the capture delay, in milliseconds, to apply for [name].
936
+ *
937
+ * A non-negative [delay] is the caller's explicit choice and passes
938
+ * straight through. A negative one defers to `CaptureLayoutDelay` in the
939
+ * layout config, which is what makes that setting reach the React Native
940
+ * wrapper at all — the wrapper used to hardcode 0 here, so the capture
941
+ * fired at the start of the screen transition and the configured value
942
+ * was inert.
943
+ *
944
+ * [LayoutUtil.getLayoutInfo] returns the merged rule for the screen: the
945
+ * global settings as a baseline with any per-screen rule applied over
946
+ * them, so this picks up either without re-implementing the precedence.
947
+ * It reads the config off disk, so a malformed or absent block falls back
948
+ * to [DEFAULT_CAPTURE_LAYOUT_DELAY_MS] rather than propagating a throw
949
+ * into a logging call.
950
+ */
951
+ internal fun resolveCaptureLayoutDelayMs(name: String, delay: Double): Int {
952
+ if (delay >= 0) {
953
+ return delay.toInt()
954
+ }
955
+ return try {
956
+ LayoutUtil.getLayoutInfo(name)
957
+ ?.optInt(CAPTURE_LAYOUT_DELAY_KEY, DEFAULT_CAPTURE_LAYOUT_DELAY_MS)
958
+ ?.coerceAtLeast(0)
959
+ ?: DEFAULT_CAPTURE_LAYOUT_DELAY_MS
960
+ } catch (e: Exception) {
961
+ Log.w(TAG, "Could not read $CAPTURE_LAYOUT_DELAY_KEY for \"$name\"; using ${DEFAULT_CAPTURE_LAYOUT_DELAY_MS}ms.", e)
962
+ DEFAULT_CAPTURE_LAYOUT_DELAY_MS
963
+ }
964
+ }
965
+
917
966
  /**
918
967
  * Logs a dialog show event with the specified dialog information.
919
968
  *
@@ -1187,7 +1236,7 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1187
1236
  for (key in values.keys) {
1188
1237
  val value = values[key]
1189
1238
  if (value != null) {
1190
- eventValues[key] = value.toString()
1239
+ eventValues[key] = variantToString(value)
1191
1240
  }
1192
1241
  }
1193
1242
 
@@ -1200,9 +1249,42 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1200
1249
  return result
1201
1250
  }
1202
1251
 
1252
+ /**
1253
+ * Unwraps a Nitro variant to the underlying JS value it carries — a
1254
+ * [Boolean], [String], or [Double].
1255
+ *
1256
+ * The generated `Variant_*` type is a sealed **data** class, so calling
1257
+ * `toString()` on the variant itself yields the case wrapper
1258
+ * (`"Second(value=pro)"`) instead of the value (`"pro"`). Every conversion
1259
+ * that reads a variant must unwrap it first. Mirrors the iOS bridge's
1260
+ * `ConnectRNParsing.convertVariantToAny`, keeping emitted payloads
1261
+ * identical across platforms.
1262
+ *
1263
+ * @param value The variant to unwrap.
1264
+ * @return The wrapped Boolean, String, or Double.
1265
+ */
1266
+ private fun unwrapVariant(value: Variant_Boolean_String_Double): Any =
1267
+ value.match(first = { it }, second = { it }, third = { it })
1268
+
1269
+ /**
1270
+ * Unwraps a Nitro variant (see [unwrapVariant]) and renders it as a string,
1271
+ * for the SDK entry points that take `HashMap<String, String>` payloads.
1272
+ *
1273
+ * Booleans render as `"true"`/`"false"` and numbers via Kotlin's [Double]
1274
+ * formatting (a JS `2` arrives as `2.0` and renders `"2.0"`); strings pass
1275
+ * through unchanged.
1276
+ *
1277
+ * @param value The variant to stringify.
1278
+ * @return The wrapped value's string form.
1279
+ */
1280
+ private fun variantToString(value: Variant_Boolean_String_Double): String =
1281
+ unwrapVariant(value).toString()
1282
+
1203
1283
  /**
1204
1284
  * Converts a map of Variant_Boolean_String_Double to a HashMap<String?, String?>.
1205
1285
  *
1286
+ * Values are unwrapped before stringifying (see [variantToString]).
1287
+ *
1206
1288
  * @param values The map to be converted.
1207
1289
  * @return A HashMap<String?, String?> representation of the input map which library can use.
1208
1290
  */
@@ -1212,30 +1294,106 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1212
1294
  for (key in values.keys) {
1213
1295
  val value = values[key]
1214
1296
  if (value != null) {
1215
- map[key] = value.toString()
1297
+ map[key] = variantToString(value)
1216
1298
  }
1217
1299
  }
1218
1300
  return map
1219
1301
  }
1220
1302
 
1221
1303
  /**
1222
- * Converts a map of Variant_Boolean_String_Double to a HashMap<String?, String?>.
1304
+ * Converts an [AnyMap]-derived map into the payload `Connect.logSignal`
1305
+ * expects, rebuilding nested structures as [JSONObject] / [JSONArray].
1223
1306
  *
1224
- * @param values The map to be converted.
1225
- * @return A HashMap<String?, String?> representation of the input map which library can use.
1307
+ * The rebuild is mandatory, not cosmetic. The SDK serializes the signal
1308
+ * through EOCore's `JsonUtil.getHashValues`, which walks the top-level
1309
+ * entries and accumulates only `String`, `Boolean`, `JSONObject`,
1310
+ * `JSONArray` and `ByteArray` — anything else is **silently dropped**. So
1311
+ * a nested `Map`/`List` handed over as-is would vanish from the emitted
1312
+ * signal without any error. Converting here is what makes nested payloads
1313
+ * survive the wire.
1314
+ *
1315
+ * `null` becomes [JSONObject.NULL] rather than a Kotlin `null`, which
1316
+ * `org.json` requires for an explicit JSON `null`.
1317
+ *
1318
+ * Known limitation: a **top-level** numeric value is still dropped, because
1319
+ * `getHashValues` has no `Number` branch and the bridge cannot influence
1320
+ * that walk. Numbers nested inside an object or array are unaffected — the
1321
+ * enclosing [JSONObject]/[JSONArray] is built here, so `org.json`
1322
+ * serializes them normally. iOS carries top-level numbers fine; this is an
1323
+ * SDK-side asymmetry, pre-dating this conversion.
1324
+ *
1325
+ * A [JSONException] aborts the whole payload rather than escaping to the
1326
+ * caller. Non-finite numbers are the case that raises it: `org.json`
1327
+ * rejects them, and [toJsonValue] raises them explicitly so a top-level one
1328
+ * behaves like a nested one. Either way a JS `Infinity` or `NaN` would
1329
+ * otherwise throw straight out of `logSignal` and take the host app's call
1330
+ * site down — unacceptable for an analytics SDK, and a behaviour change
1331
+ * from the scalar-only bridge, which could not throw. Dropping the whole
1332
+ * payload also matches iOS, where a single non-finite value fails
1333
+ * `isValidJSONObject:` and the `signal` key is omitted entirely.
1334
+ *
1335
+ * @param values The map from [AnyMap.toHashMap].
1336
+ * @return A HashMap the SDK's signal serializer can consume, or an empty
1337
+ * map if the payload could not be represented as JSON.
1226
1338
  */
1227
1339
  // 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
1340
+ internal fun toSignalPayload(values: Map<String, Any?>): java.util.HashMap<String?, Any?> {
1341
+ val map = HashMap<String?, Any?>(values.size)
1342
+ try {
1343
+ for ((key, value) in values) {
1344
+ map[key] = toJsonValue(value)
1234
1345
  }
1346
+ } catch (e: JSONException) {
1347
+ Log.w(TAG, "[bridge] logSignal payload is not representable as JSON — dropping it: ${e.message}")
1348
+ return HashMap()
1235
1349
  }
1236
1350
  return map
1237
1351
  }
1238
1352
 
1353
+ /**
1354
+ * Recursive helper for [toSignalPayload]. Maps Kotlin containers onto their
1355
+ * `org.json` equivalents and leaves scalars alone.
1356
+ *
1357
+ * Nitro's JNI layer converts the whole tree to plain Java types before it
1358
+ * reaches Kotlin — a JS object becomes a `HashMap`, a JS array an
1359
+ * `ArrayList`, and scalars become `Double` / `Boolean` / `Long` / `String`
1360
+ * — so there are no `AnyValue` wrappers left to unwrap here. [List] is
1361
+ * therefore the branch a JS array actually takes; [Array] is handled too,
1362
+ * to guard against a future Nitro representation change.
1363
+ *
1364
+ * @param value A value from an [AnyMap]-derived container.
1365
+ * @return The `org.json`-compatible equivalent.
1366
+ */
1367
+ private fun toJsonValue(value: Any?): Any =
1368
+ when (value) {
1369
+ null -> JSONObject.NULL
1370
+ is Map<*, *> -> JSONObject().apply {
1371
+ for ((nestedKey, nestedValue) in value) {
1372
+ put(nestedKey.toString(), toJsonValue(nestedValue))
1373
+ }
1374
+ }
1375
+ is Array<*> -> JSONArray().apply {
1376
+ for (element in value) put(toJsonValue(element))
1377
+ }
1378
+ is List<*> -> JSONArray().apply {
1379
+ for (element in value) put(toJsonValue(element))
1380
+ }
1381
+ // Checked here rather than left to `org.json`. A non-finite number
1382
+ // nested in an object or array would be rejected by that container's
1383
+ // `put`, but a TOP-LEVEL one is only ever handed to `HashMap.put`,
1384
+ // which accepts anything — so without this the payload would reach
1385
+ // the SDK and lose just that one key, while the nested case loses
1386
+ // the whole payload. Raising it uniformly keeps the two consistent
1387
+ // and matches iOS, where one non-finite value fails
1388
+ // `isValidJSONObject:` for the entire signal.
1389
+ is Double -> if (value.isFinite()) {
1390
+ value
1391
+ } else {
1392
+ throw JSONException("non-finite number: $value")
1393
+ }
1394
+ else -> value
1395
+ }
1396
+
1239
1397
  /**
1240
1398
  * Gets the current activity from the ReactApplicationContext.
1241
1399
  *
@@ -1383,6 +1541,15 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1383
1541
  const val TAG = "AcousticConnectRN"
1384
1542
  const val DIALOG_CAPTURE_DELAY_MS = 500L // Configurable delay for dialog screenshot capture
1385
1543
 
1544
+ /** Key holding the per-screen capture delay, in milliseconds, inside a layout-config rule. */
1545
+ const val CAPTURE_LAYOUT_DELAY_KEY = "CaptureLayoutDelay"
1546
+
1547
+ /**
1548
+ * Delay applied when the layout config names none. Matches the value the
1549
+ * Connect SDK ships in its own `TealeafLayoutConfig.json`.
1550
+ */
1551
+ const val DEFAULT_CAPTURE_LAYOUT_DELAY_MS = 500
1552
+
1386
1553
  // Class probed at runtime to detect whether the connect-push-fcm
1387
1554
  // transport artifact was included in the build (i.e. automatic mode,
1388
1555
  // where Connect's own FirebaseMessagingService is the PushReceived
@@ -1390,5 +1557,28 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1390
1557
  // by the connect-push-fcm artifact.
1391
1558
  private const val CONNECT_PUSH_FCM_PROBE_CLASS =
1392
1559
  "com.acoustic.connect.android.connectmod.push.services.fcm.FCMPushService"
1560
+
1561
+ /**
1562
+ * Default `additionalParameters` for an identity signal, matched to the
1563
+ * signal type the bridge will actually send.
1564
+ *
1565
+ * Connect's signal schema requires a *method* attribute on identity
1566
+ * signals, and the required key differs per type: `loggedIn` requires
1567
+ * `loginMethod`, `accountRegistered` requires `registrationMethod`.
1568
+ * Supplying the wrong one fails schema validation and the signal is
1569
+ * discarded, while [Connect.logIdentificationEvent] still reports
1570
+ * success — so the caller sees nothing wrong. Before this mapping
1571
+ * existed the bridge paired its `loggedIn` default with
1572
+ * `registrationMethod`, which meant every defaulted identity call was
1573
+ * silently dropped.
1574
+ *
1575
+ * Only consulted when the caller omits `additionalParameters`
1576
+ * entirely; an explicitly-provided map is passed through untouched.
1577
+ */
1578
+ internal fun defaultIdentityParameters(signalType: String): Map<String, String> =
1579
+ when (signalType) {
1580
+ "loggedIn" -> mapOf("loginMethod" to "email")
1581
+ else -> mapOf("registrationMethod" to "email")
1582
+ }
1393
1583
  }
1394
1584
  }
@@ -104,6 +104,167 @@ 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
+
191
+ // MARK: - Capture-layout delay
192
+
193
+ /// Config-store key holding the resolved `AutoLayout` block. Spelled out
194
+ /// rather than taken from Tealeaf's `kConfigurableItemAutoLayout` macro
195
+ /// because the bridge deliberately links against `Connect` only — see the
196
+ /// note on `ConnectConfigStore`. Same string either way.
197
+ static let autoLayoutConfigKey = "AutoLayout"
198
+
199
+ /// Key holding the per-screen capture delay inside an `AutoLayout` rule.
200
+ /// The value is in **milliseconds** — the auto-instrumentation path
201
+ /// dispatches with `NSEC_PER_MSEC`, and `TLFAutoLayoutConfig` names its
202
+ /// property `delayInMS`.
203
+ private static let captureLayoutDelayKey = "CaptureLayoutDelay"
204
+
205
+ /// Rule names holding the settings that apply to every screen. The second
206
+ /// is the legacy spelling; the native SDK reads whichever is present, so
207
+ /// this does too.
208
+ private static let globalScreenSettingsKeys = ["GlobalScreenSettings", "IBMGlobalScreenSettings"]
209
+
210
+ /// Delay applied when nothing in the resolved `AutoLayout` block names one.
211
+ /// Matches `TLFAutoInstrumentationManger`'s own fallback.
212
+ static let defaultCaptureLayoutDelayMs: Double = 500
213
+
214
+ /// Resolves the `CaptureLayoutDelay` (milliseconds) that applies to
215
+ /// `name`, from the `AutoLayout` block the native SDK holds in its config
216
+ /// store.
217
+ ///
218
+ /// Resolution mirrors `TLFAutoInstrumentationManger`: the global rule
219
+ /// supplies the baseline, and a rule named after the screen overrides it.
220
+ /// The JS wrapper looks the screen up by its React Navigation route name,
221
+ /// which is the key `ConnectConfig.json`'s `layoutConfig`/`layoutConfigIos`
222
+ /// blocks are written against.
223
+ ///
224
+ /// Returns ``defaultCaptureLayoutDelayMs`` when the block is missing,
225
+ /// malformed, or names no delay — never a value that would make the caller
226
+ /// silently skip the deferral it asked for.
227
+ static func captureLayoutDelayMs(for name: String, in autoLayout: [String: Any]?) -> Double {
228
+ guard let autoLayout else { return defaultCaptureLayoutDelayMs }
229
+
230
+ if let screenDelay = delayValue(in: autoLayout[name]) {
231
+ return screenDelay
232
+ }
233
+ for key in globalScreenSettingsKeys {
234
+ if let globalDelay = delayValue(in: autoLayout[key]) {
235
+ return globalDelay
236
+ }
237
+ }
238
+ return defaultCaptureLayoutDelayMs
239
+ }
240
+
241
+ /// Pulls `CaptureLayoutDelay` out of one `AutoLayout` rule. `nil` means
242
+ /// "this rule says nothing about the delay", which is what lets the caller
243
+ /// fall through to the next source rather than treating a missing rule as
244
+ /// an explicit zero.
245
+ private static func delayValue(in rule: Any?) -> Double? {
246
+ guard let rule = rule as? [String: Any] else { return nil }
247
+ guard let raw = rule[captureLayoutDelayKey] else { return nil }
248
+ if let number = raw as? NSNumber { return max(0, number.doubleValue) }
249
+ if let text = raw as? String, let parsed = Double(text) { return max(0, parsed) }
250
+ configLog.warning("\(captureLayoutDelayKey, privacy: .public) is set but is not a number. Ignoring it.")
251
+ return nil
252
+ }
253
+
254
+ /// Whether `ConnectConfig.json` supplies an `AutoLayout` block at all.
255
+ ///
256
+ /// Equivalent to asking whether ``resolveLayoutOverrides(from:)`` would
257
+ /// return an `AutoLayout` override — `deepMerging` only ever adds keys, so
258
+ /// the merged block carries `AutoLayout` exactly when some contributing
259
+ /// block does — but silent, and cheap enough to run at construction. The
260
+ /// resolver logs which sources it applied; calling it twice would print
261
+ /// that summary twice.
262
+ static func suppliesAutoLayout(_ connectData: [String: Any]) -> Bool {
263
+ return layoutConfigKeys.contains { name in
264
+ (connectData[name] as? [String: Any])?[autoLayoutConfigKey] != nil
265
+ }
266
+ }
267
+
107
268
  /// Maps the JS-facing module name to the config store's module name
108
269
  /// ("Connect" → "TLFCoreModule").
109
270
  static func storeModuleName(for moduleName: String) -> String {
@@ -130,6 +291,51 @@ internal enum ConnectRNParsing {
130
291
  return result
131
292
  }
132
293
 
294
+ /// Normalises a dictionary produced by `AnyMap.toDictionary()` into one
295
+ /// that `NSJSONSerialization.isValidJSONObject:` accepts, which is the
296
+ /// only gate `CTSignalMessage` applies before embedding the payload.
297
+ ///
298
+ /// Two things need fixing up. `AnyMap` models JS `null` as a Swift `nil`,
299
+ /// and a dictionary holding `Optional.none` is not JSON-representable —
300
+ /// `NSNull` is. And the nesting is optional-typed all the way down
301
+ /// (`[String: Any?]` / `[Any?]`), so the walk has to be recursive rather
302
+ /// than a single top-level pass. Values that are already scalars pass
303
+ /// through untouched, so a flat payload converts to exactly what the
304
+ /// previous scalar-only bridge produced.
305
+ ///
306
+ /// Deliberately takes plain Swift collections rather than `AnyMap`: this
307
+ /// file is compiled into the UnitTests bundle, which builds without C++
308
+ /// interop and therefore cannot see nitro's `AnyMap`/`AnyValue` types.
309
+ /// The `AnyMap` unwrapping stays in `HybridAcousticConnectRN`.
310
+ ///
311
+ /// - Parameter input: dictionary from `AnyMap.toDictionary()`.
312
+ /// - Returns: the same structure with optionals resolved.
313
+ static func jsonSafeDictionary(_ input: [String: Any?]) -> [String: Any] {
314
+ var result = [String: Any](minimumCapacity: input.count)
315
+ for (key, value) in input {
316
+ result[key] = jsonSafeValue(value)
317
+ }
318
+ return result
319
+ }
320
+
321
+ /// Recursive helper for ``jsonSafeDictionary(_:)``. Maps `nil` to `NSNull`
322
+ /// and rebuilds nested dictionaries/arrays; every other value — `String`,
323
+ /// `Double`, `Int64`, `Bool` — is already JSON-representable and is
324
+ /// returned as-is.
325
+ ///
326
+ /// - Parameter value: a value from an `AnyMap`-derived container.
327
+ /// - Returns: a JSON-representable equivalent.
328
+ static func jsonSafeValue(_ value: Any?) -> Any {
329
+ guard let value else { return NSNull() }
330
+ if let nested = value as? [String: Any?] {
331
+ return jsonSafeDictionary(nested)
332
+ }
333
+ if let array = value as? [Any?] {
334
+ return array.map { jsonSafeValue($0) }
335
+ }
336
+ return value
337
+ }
338
+
133
339
  static func convertVariantToAny(_ variant: Variant_Bool_String_Double) -> Any {
134
340
  switch variant {
135
341
  case .first(let boolValue):
@@ -141,6 +347,29 @@ internal enum ConnectRNParsing {
141
347
  }
142
348
  }
143
349
 
350
+ /// Default `additionalParameters` for an identity signal, matched to the
351
+ /// signal type the bridge will actually send.
352
+ ///
353
+ /// Connect's signal schema requires a *method* attribute on identity
354
+ /// signals, and the required key differs per type: `loggedIn` requires
355
+ /// `loginMethod`, `accountRegistered` requires `registrationMethod`.
356
+ /// Supplying the wrong one fails schema validation and the signal is
357
+ /// discarded, while `identity.log` still reports success — so the caller
358
+ /// sees nothing wrong. Before this mapping existed the bridge paired its
359
+ /// `loggedIn` default with `registrationMethod`, which meant every
360
+ /// defaulted identity call was silently dropped.
361
+ ///
362
+ /// Only consulted when the caller omits `additionalParameters` entirely;
363
+ /// an explicitly-provided map is passed through untouched.
364
+ static func defaultIdentityParameters(for signalType: String) -> [String: String] {
365
+ switch signalType {
366
+ case "loggedIn":
367
+ return ["loginMethod": "email"]
368
+ default:
369
+ return ["registrationMethod": "email"]
370
+ }
371
+ }
372
+
144
373
  static func logLevel(from level: Double) -> kConnectMonitoringLevelType {
145
374
  let intValue: Int = Int(level)
146
375
  if intValue == 0 {