react-native-acoustic-connect-beta 19.0.18 → 19.0.20

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 19.0.20 (2026-09-09)
2
+
3
+ ### Reverts
4
+
5
+ * Revert "Beta ReactNativeConnect build: 18.0.36" ([609ad7d](https://github.com/aipoweredmarketer/react-native-acoustic-connect-beta/commit/609ad7d3244ec06f27dced5864d40585c5b3c9cf))
6
+ ## 19.0.19 (2026-09-08)
7
+
8
+ ### Reverts
9
+
10
+ * Revert "Beta ReactNativeConnect build: 18.0.36" ([609ad7d](https://github.com/aipoweredmarketer/react-native-acoustic-connect-beta/commit/609ad7d3244ec06f27dced5864d40585c5b3c9cf))
1
11
  ## 19.0.18 (2026-09-07)
2
12
 
3
13
  ### Reverts
package/README.md CHANGED
@@ -396,6 +396,26 @@ for details.
396
396
 
397
397
  ## API reference
398
398
 
399
+ > **What the logging methods' `boolean` means.** Every `log*` method returns
400
+ > whether the native SDK **accepted the event for delivery** — never whether
401
+ > the collector received it, and never whether the collector kept it. Events
402
+ > are queued on device and posted in batches later, so no return value from
403
+ > these calls can attest to delivery, and a server-side rejection (a signal
404
+ > that fails schema validation, say) happens long after you have already been
405
+ > told `true`. Treat the value as "the SDK took this", and use the collector or
406
+ > the platform log as the source of truth for what shipped.
407
+ >
408
+ > A `false` means the SDK rejected the call outright and nothing was queued.
409
+ > The bridge also writes that to `logcat` (tag `AcousticConnectRN`) and
410
+ > `os_log` (subsystem `com.acoustic.AcousticConnectRN`, category `bridge`), so
411
+ > a rejection is visible without inspecting return values you would otherwise
412
+ > never read.
413
+ >
414
+ > `logScreenLayout` is weaker still: with the configured delay — the normal
415
+ > case — a `true` means only that the capture was **scheduled**. What the
416
+ > capture found when it eventually ran, and whether it produced a layout
417
+ > message at all, is reported in the platform log only.
418
+
399
419
  ### `AcousticConnectRN.enable(): boolean`
400
420
 
401
421
  Re-enables the SDK after a prior `disable()`. Reads all configuration from
@@ -463,6 +483,13 @@ AcousticConnectRN.logSignal(pageView, 1)
463
483
  > AcousticConnectRN.logSignal({ cart: { items: 3 } }, 1) // both platforms
464
484
  > AcousticConnectRN.logSignal({ items: 3 }, 1) // iOS only
465
485
  > ```
486
+ >
487
+ > The Android bridge logs a warning naming the keys the SDK is about to
488
+ > discard, so the loss shows up in `logcat` instead of only in a dashboard
489
+ > that never fills in. It does not coerce the value: making the number survive
490
+ > as a JSON string would replace a missing field with a differently-typed one
491
+ > and diverge from iOS in a new way. The fix belongs in the Android SDK's
492
+ > serializer.
466
493
 
467
494
  ### `AcousticConnectRN.logCustomEvent(eventName, values, level): boolean`
468
495
 
@@ -472,6 +499,20 @@ string values end to end, so there is no native route for nested JSON; widening
472
499
  it would preserve the nesting on iOS and silently drop it on Android. Use
473
500
  `logSignal` when the payload needs structure.
474
501
 
502
+ TypeScript will reject a nested value, but types are erased at runtime — a
503
+ payload built from an API response or widened through `any` still reaches the
504
+ bridge nested, gets reshaped by the native SDK, and returns `true`. The wrapper
505
+ logs a `console.warn` naming the offending keys when that happens (once per
506
+ call site, in dev and in production; the payload itself is passed through
507
+ unchanged). If you see it, move the payload to `logSignal`.
508
+
509
+ Numbers are rendered the same way on both platforms — a JS `2` arrives as `2`,
510
+ not `2.0`. Note the remaining difference in *type*: because the Android path is
511
+ string-typed, an Android custom-event number lands on the wire as a JSON string
512
+ (`"2"`) where iOS sends a JSON number (`2`). `logSignal` carries native types on
513
+ both platforms; reach for it when a dashboard or Composer rule compares the
514
+ value numerically.
515
+
475
516
  ### Push configuration (`ConnectConfig.json`)
476
517
 
477
518
  | Field | Type | Default | Semantics |
@@ -560,6 +601,41 @@ layout config:
560
601
  > clicks, and custom events still flow. It only drops the image data, which is
561
602
  > what a subscription without session replay has no consumer for.
562
603
 
604
+ #### `NumberOfWebViews` turns off iOS layout capture — leave it at `0`
605
+
606
+ `AutoLayout` rules accept a `NumberOfWebViews` key. On iOS, any value greater
607
+ than zero marks the screens that rule covers as web-view screens, and the SDK's
608
+ automatic layout capture is skipped for a web-view screen. Its effect is not
609
+ limited to screens that actually host a `WebView`: the value is read as a
610
+ declaration about the rule, not a count that gets verified.
611
+
612
+ The trap is where it is usually set. `GlobalScreenSettings` is the rule that
613
+ applies to **every** screen with no more specific rule of its own, and React
614
+ Native screens are all in that position — they are hosted by the same generic
615
+ container class, so no per-screen native rule matches them. So a single
616
+ `"NumberOfWebViews": 1` under `GlobalScreenSettings` switches off
617
+ auto-instrumented layout capture for the entire app on iOS, including screens
618
+ with no web content at all:
619
+
620
+ ```json
621
+ {
622
+ "Connect": {
623
+ "layoutConfigIos": {
624
+ "AutoLayout": {
625
+ "GlobalScreenSettings": { "NumberOfWebViews": 0 }
626
+ }
627
+ }
628
+ }
629
+ }
630
+ ```
631
+
632
+ Screen views, clicks, and custom events are unaffected — the symptom is layout
633
+ messages going missing while everything else keeps arriving, which reads like a
634
+ capture failure rather than a setting. Keep `NumberOfWebViews` at `0`, the value
635
+ every shipped template uses. To stand layout capture down deliberately, set
636
+ `CaptureLayoutOn: 0` on the rule instead — it is the key that means that, and it
637
+ leaves `NumberOfWebViews` free to describe the screen.
638
+
563
639
  #### Capture timing (`CaptureLayoutDelay`)
564
640
 
565
641
  `AutoLayout.GlobalScreenSettings.CaptureLayoutDelay` is how long, in
@@ -601,6 +677,58 @@ default, chosen so both platforms behave the same; the native SDKs' own bundled
601
677
  defaults differ from each other and are tuned for the lifecycle trigger rather
602
678
  than the React Navigation one.
603
679
 
680
+ #### Where screen-view and layout messages come from, and how to reduce duplicates
681
+
682
+ The two message kinds have different sources, and they differ per platform:
683
+
684
+ **Screen views (one source per platform).** On Android, the `<Connect>` wrapper
685
+ is the *only* source — it emits on React Navigation's `state` event. On iOS,
686
+ the native SDK's auto-instrumentation is the *only* source — it emits when a
687
+ screen's view controller reports its appearance. The wrapper never emits a
688
+ screen view on iOS; its role there is to hand the current *route name* to the
689
+ native SDK (which is what makes messages read `Checkout` rather than a native
690
+ container class) and to drive referrer chaining across transitions.
691
+
692
+ Duplicate screen views observed on iOS are therefore not two sources
693
+ overlapping — they are the single native source firing more than once when the
694
+ same screen's view controller reports several appearances for one transition.
695
+ They inflate event volumes and skew per-screen counts; they do not lose data.
696
+ There is no configuration key that collapses them today; a native-side dedupe
697
+ is under investigation. Do not try to solve it by removing the wrapper: on iOS
698
+ that costs route-based names and referrer chaining, and on Android it removes
699
+ screen views entirely. A same-name filter in the wrapper would not help either
700
+ — the wrapper is not the emitter on iOS, and such a filter would swallow
701
+ legitimate repeats like a stack pushing the same screen name twice.
702
+
703
+ **Layouts (two sources on iOS).** With automatic layout capture enabled in
704
+ config, an iOS transition can produce two layout messages: one from the
705
+ wrapper's `logScreenLayout` call and one from the native auto-instrumentation.
706
+ Control it from config, on the native side that has the duplicate:
707
+
708
+ ```json
709
+ {
710
+ "Connect": {
711
+ "layoutConfigIos": {
712
+ "AutoLayout": {
713
+ "GlobalScreenSettings": { "CaptureLayoutOn": 0 }
714
+ }
715
+ }
716
+ }
717
+ }
718
+ ```
719
+
720
+ `CaptureLayoutOn: 0` stands the **native** automatic layout capture down and
721
+ leaves the wrapper's route-named captures as the single source. Set it per
722
+ screen rather than globally if only some screens are noisy. Screen views,
723
+ clicks, and custom events are unaffected by this key.
724
+
725
+ If you would rather keep the native captures and have the wrapper stay out of
726
+ the way, omit `navigationRef` and do not rely on `<Connect>` for screen naming
727
+ — but expect native container class names in place of your route names.
728
+
729
+ Measure before changing either default: the volume depends on your navigator
730
+ structure, and both defaults are what every shipped sample uses.
731
+
604
732
  ### `<Connect>` props
605
733
 
606
734
  | Prop | Type | Required | Description |
@@ -1,4 +1,4 @@
1
- #Mon Sep 07 11:29:39 GMT 2026
1
+ #Wed Sep 09 11:26:30 GMT 2026
2
2
  UseWhiteList=true
3
3
  PrintScreen=3
4
4
  UseRandomSample=false
@@ -579,7 +579,12 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
579
579
  value: Double,
580
580
  moduleName: String
581
581
  ): Boolean {
582
- val result = EOCore.updateConfig(key, value.toString(), EOCore.getLifecycleObject(moduleName))
582
+ // Rendered through formatJsNumber, not Double.toString: EOCore stores
583
+ // config items as strings, and a JS 0 spelled "0.0" is not the same
584
+ // token as the "0" the JSON config files carry, so a consumer setting
585
+ // a whole-number item at runtime wrote a value that no longer matched
586
+ // what the same key looks like on iOS (a native number) or on disk.
587
+ val result = EOCore.updateConfig(key, formatJsNumber(value), EOCore.getLifecycleObject(moduleName))
583
588
  return result
584
589
  }
585
590
 
@@ -667,7 +672,7 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
667
672
  level: Double
668
673
  ): Boolean {
669
674
  val result = Connect.logCustomEvent(eventName, convertToMap(values), level.toInt())
670
- return result
675
+ return warnIfRejected(result, "logCustomEvent", eventName)
671
676
  }
672
677
 
673
678
  /**
@@ -688,6 +693,38 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
688
693
  level: Double
689
694
  ): Boolean {
690
695
  val result = Connect.logSignal(toSignalPayload(values.toHashMap()), level.toInt())
696
+ return warnIfRejected(result, "logSignal")
697
+ }
698
+
699
+ /**
700
+ * Surfaces a `false` returned by a native logging call, and passes it
701
+ * through unchanged.
702
+ *
703
+ * The bridge's boolean means "the SDK accepted this for delivery", never
704
+ * "the collector received it" — nothing on the device knows the latter. But
705
+ * the *accepted* half was itself invisible: a `false` propagated to JS as a
706
+ * bare return value that the wrapper's callers almost never inspect, with
707
+ * nothing in logcat. Callers reporting "the event never arrived" had no way
708
+ * to tell a rejected call from a delivery problem. One line at warn level
709
+ * distinguishes them.
710
+ *
711
+ * Deliberately does not change the return value or throw: an analytics
712
+ * bridge must not turn a rejected event into an app-visible failure.
713
+ *
714
+ * @param result The value the native call returned.
715
+ * @param api Name of the bridge method, for the log line.
716
+ * @param detail Optional extra identifier, e.g. an event name.
717
+ * @return [result], unchanged.
718
+ */
719
+ private fun warnIfRejected(result: Boolean, api: String, detail: String? = null): Boolean {
720
+ if (!result) {
721
+ val suffix = if (detail != null) " ($detail)" else ""
722
+ Log.w(
723
+ TAG,
724
+ "[bridge] $api$suffix: the Connect SDK did not accept the event; nothing was queued " +
725
+ "for delivery. Check that the SDK is enabled and the payload is valid."
726
+ )
727
+ }
691
728
  return result
692
729
  }
693
730
 
@@ -737,7 +774,11 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
737
774
  signalType = resolvedSignalType,
738
775
  additionalParameters = params
739
776
  )
740
- return Promise.resolved(result)
777
+ // An identity signal that the SDK accepts can still be rejected
778
+ // downstream by schema validation — a mismatched method attribute has
779
+ // cost a customer 22 signals while every call reported success. The
780
+ // bridge cannot see that; it can at least report the half it does see.
781
+ return Promise.resolved(warnIfRejected(result, "logIdentity", resolvedSignalType))
741
782
  }
742
783
 
743
784
  /**
@@ -969,16 +1010,61 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
969
1010
  // crash on an ordinary screen change.
970
1011
  val activity = getCurrentActivity()
971
1012
  if (activity != null && LayoutUtil.canCaptureUserEvents(null, name)) {
972
- result = logScreenLayout(
1013
+ result = logAutomaticScreenLayout(
973
1014
  activity,
974
1015
  name,
975
- resolveCaptureLayoutDelayMs(name, delay),
976
- true
1016
+ resolveCaptureLayoutDelayMs(name, delay)
977
1017
  )
978
1018
  }
979
1019
  return result
980
1020
  }
981
1021
 
1022
+ /**
1023
+ * Captures the layout of [activity] as an *automatic* capture — the kind
1024
+ * the wrapper triggers itself, on navigation or behind a dialog — rather
1025
+ * than a manual `logScreenLayout` API call from the host app.
1026
+ *
1027
+ * Every wrapper-initiated capture must go through here, because the
1028
+ * distinction is what makes the configured `ScreenShot` value reach the
1029
+ * native screenshot gate at all:
1030
+ *
1031
+ * - `Connect`'s four-argument overload hardcodes `manualLog = true`, and
1032
+ * the native gate reads the configured `ScreenShot` only on the
1033
+ * `manualLog = false` branch. Passing a literal `true` for the screenshot
1034
+ * flag — which every wrapper call site used to do — therefore took the
1035
+ * manual branch unconditionally, so `ScreenShot: false` could not be
1036
+ * honoured from config and a full-page image shipped in every layout
1037
+ * message regardless. Hence the five-argument overload, with
1038
+ * `manualLog = false`.
1039
+ * - [LayoutUtil.canTakeScreenShot] resolves the same merged layout rule the
1040
+ * native automatic path uses (global settings as a baseline, any
1041
+ * per-screen rule applied over them). It is preferred over reading
1042
+ * `ScreenShot` directly because it is `has()`-guarded: a per-screen rule
1043
+ * that omits the key defaults to capturing rather than throwing a
1044
+ * `JSONException`, which higher up is swallowed and costs the whole
1045
+ * layout message. Its first argument may be null while the page name is
1046
+ * non-empty, the same pattern as `canCaptureUserEvents(null, name)`.
1047
+ *
1048
+ * Note `CaptureScreenshotOn` is not consulted anywhere on Android: its only
1049
+ * native reader has no callers, so `ScreenShot` is the one wired-up switch.
1050
+ *
1051
+ * [activity] is nullable to match the five-argument overload; callers that
1052
+ * need a non-null one check or assert before calling.
1053
+ */
1054
+ private fun logAutomaticScreenLayout(
1055
+ activity: Activity?,
1056
+ name: String,
1057
+ delayMs: Int
1058
+ ): Boolean {
1059
+ return logScreenLayout(
1060
+ activity,
1061
+ name,
1062
+ delayMs,
1063
+ false,
1064
+ LayoutUtil.canTakeScreenShot(null, name)
1065
+ )
1066
+ }
1067
+
982
1068
  /**
983
1069
  * Resolves the capture delay, in milliseconds, to apply for [name].
984
1070
  *
@@ -1047,12 +1133,12 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1047
1133
  } else {
1048
1134
  Log.v(TAG, "Warning: Could not find dialog object for $dialogId after delay")
1049
1135
  // Fallback to regular screen layout capture
1050
- logScreenLayout(Objects.requireNonNull<Activity?>(getCurrentActivity()), dialogTitle, 0, true)
1136
+ logAutomaticScreenLayout(Objects.requireNonNull<Activity?>(getCurrentActivity()), dialogTitle, 0)
1051
1137
  }
1052
1138
  } catch (e: Exception) {
1053
1139
  Log.v(TAG, "Error in delayed dialog capture: ${e.message}")
1054
1140
  // Fallback to regular screen layout capture
1055
- logScreenLayout(Objects.requireNonNull<Activity?>(getCurrentActivity()), dialogTitle, 0, true)
1141
+ logAutomaticScreenLayout(Objects.requireNonNull<Activity?>(getCurrentActivity()), dialogTitle, 0)
1056
1142
  }
1057
1143
  }, DIALOG_CAPTURE_DELAY_MS) // Use configurable delay
1058
1144
 
@@ -1071,7 +1157,7 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1071
1157
  }
1072
1158
  } else {
1073
1159
  // Default fallback
1074
- result = logScreenLayout(Objects.requireNonNull<Activity?>(getCurrentActivity()), dialogTitle, 300, true)
1160
+ result = logAutomaticScreenLayout(Objects.requireNonNull<Activity?>(getCurrentActivity()), dialogTitle, 300)
1075
1161
  }
1076
1162
  }
1077
1163
  } catch (e: Exception) {
@@ -1318,15 +1404,56 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1318
1404
  * Unwraps a Nitro variant (see [unwrapVariant]) and renders it as a string,
1319
1405
  * for the SDK entry points that take `HashMap<String, String>` payloads.
1320
1406
  *
1321
- * Booleans render as `"true"`/`"false"` and numbers via Kotlin's [Double]
1322
- * formatting (a JS `2` arrives as `2.0` and renders `"2.0"`); strings pass
1323
- * through unchanged.
1407
+ * Booleans render as `"true"`/`"false"`, strings pass through unchanged,
1408
+ * and numbers go through [formatJsNumber] so they read the way the same
1409
+ * JS value reads on iOS.
1324
1410
  *
1325
1411
  * @param value The variant to stringify.
1326
1412
  * @return The wrapped value's string form.
1327
1413
  */
1328
1414
  private fun variantToString(value: Variant_Boolean_String_Double): String =
1329
- unwrapVariant(value).toString()
1415
+ when (val unwrapped = unwrapVariant(value)) {
1416
+ is Double -> formatJsNumber(unwrapped)
1417
+ else -> unwrapped.toString()
1418
+ }
1419
+
1420
+ /**
1421
+ * Renders a JS number the way JS itself — and therefore the iOS bridge —
1422
+ * renders it: `2` stays `"2"`, `2.5` stays `"2.5"`.
1423
+ *
1424
+ * Every JS number crosses Nitro as a [Double], so a JS `2` arrives as
1425
+ * `2.0`, and Kotlin's [Double.toString] spells that `"2.0"`. iOS hands the
1426
+ * same value to the SDK as a native number, which `NSJSONSerialization`
1427
+ * writes as `2`. The result was a cross-platform payload split on any
1428
+ * whole number — `"seats": "2.0"` on Android against `"seats": 2` on iOS —
1429
+ * which breaks dashboards and Composer rules that compare the two.
1430
+ * Dropping the trailing `.0` closes the *textual* half of that gap.
1431
+ *
1432
+ * It does not close the *type* half on the custom-event path: the SDK's
1433
+ * `logCustomEvent` chain is typed `HashMap<String, String>` end to end, so
1434
+ * an Android custom-event number is a JSON string (`"2"`) where iOS sends a
1435
+ * JSON number (`2`). Only `logSignal` carries native types on Android, and
1436
+ * that path deliberately does not come through here — see [toSignalPayload].
1437
+ *
1438
+ * Integral values are rendered through [Long] only below 2^53, which spans
1439
+ * every integer JS itself represents exactly; beyond that the [Double] form
1440
+ * is kept rather than inventing digits a `Long` round-trip would imply.
1441
+ * Non-finite values keep Kotlin's spelling (`"NaN"`, `"Infinity"`) — there
1442
+ * is no JSON representation to match, and stringifying is strictly safer
1443
+ * than throwing out of an analytics call.
1444
+ *
1445
+ * @param value The number to render.
1446
+ * @return Its string form, without a trailing `.0` for whole numbers.
1447
+ */
1448
+ internal fun formatJsNumber(value: Double): String {
1449
+ if (!value.isFinite()) {
1450
+ return value.toString()
1451
+ }
1452
+ if (value == Math.floor(value) && Math.abs(value) < 9007199254740992.0) {
1453
+ return value.toLong().toString()
1454
+ }
1455
+ return value.toString()
1456
+ }
1330
1457
 
1331
1458
  /**
1332
1459
  * Converts a map of Variant_Boolean_String_Double to a HashMap<String?, String?>.
@@ -1363,12 +1490,26 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1363
1490
  * `null` becomes [JSONObject.NULL] rather than a Kotlin `null`, which
1364
1491
  * `org.json` requires for an explicit JSON `null`.
1365
1492
  *
1366
- * Known limitation: a **top-level** numeric value is still dropped, because
1367
- * `getHashValues` has no `Number` branch and the bridge cannot influence
1368
- * that walk. Numbers nested inside an object or array are unaffected — the
1369
- * enclosing [JSONObject]/[JSONArray] is built here, so `org.json`
1370
- * serializes them normally. iOS carries top-level numbers fine; this is an
1371
- * SDK-side asymmetry, pre-dating this conversion.
1493
+ * Known limitation: a **top-level** numeric value is still dropped, and
1494
+ * cannot be rescued from here. `Signal.getJSON()` serializes the payload
1495
+ * through EOCore's `JsonUtil.getHashValues`, whose `instanceof` ladder has
1496
+ * no `Number` branch, so the entry is skipped with no error. Numbers nested
1497
+ * inside an object or array are unaffected the enclosing
1498
+ * [JSONObject]/[JSONArray] is built here, so `org.json` serializes them
1499
+ * normally. iOS carries top-level numbers fine; this is an SDK-side
1500
+ * asymmetry, pre-dating this conversion.
1501
+ *
1502
+ * Two non-fixes, so they are not attempted again: coercing the number to a
1503
+ * [String] would make it survive as a JSON string, diverging from iOS's
1504
+ * JSON number in a *new* way and silently changing the shape of data
1505
+ * already-shipping clients receive; and there is no `org.json` wrapper that
1506
+ * `getHashValues` accepts and that serializes as a bare number, so the
1507
+ * shape cannot be preserved either. The real fix is a `Number` branch in
1508
+ * `getHashValues` (Android SDK).
1509
+ *
1510
+ * What this method does do is [warnAboutDroppedTopLevelNumbers] — name the
1511
+ * keys the SDK is about to discard, so the loss is visible in logcat
1512
+ * instead of silent.
1372
1513
  *
1373
1514
  * A [JSONException] aborts the whole payload rather than escaping to the
1374
1515
  * caller. Non-finite numbers are the case that raises it: `org.json`
@@ -1395,9 +1536,44 @@ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
1395
1536
  Log.w(TAG, "[bridge] logSignal payload is not representable as JSON — dropping it: ${e.message}")
1396
1537
  return HashMap()
1397
1538
  }
1539
+ warnAboutDroppedTopLevelNumbers(map)
1398
1540
  return map
1399
1541
  }
1400
1542
 
1543
+ /**
1544
+ * Logs a warning naming the top-level numeric keys that the SDK's signal
1545
+ * serializer will discard (see [toSignalPayload] for why it does).
1546
+ *
1547
+ * The point is purely to stop the loss being silent. Before this, a JS
1548
+ * `logSignal({ orderTotal: 24.99 })` returned `true`, emitted a type-21
1549
+ * message, and simply had no `orderTotal` in it — indistinguishable, from
1550
+ * the caller's side, from a signal that arrived intact. The same call on
1551
+ * iOS carries the value, so a cross-platform dashboard shows the metric
1552
+ * populated on one platform and empty on the other with nothing anywhere
1553
+ * to explain it.
1554
+ *
1555
+ * Iterates the already-converted map so it reflects exactly what is handed
1556
+ * to the SDK. Booleans and strings are not mentioned: `getHashValues`
1557
+ * accumulates both.
1558
+ *
1559
+ * @param payload The converted payload, as returned to `logSignal`.
1560
+ */
1561
+ private fun warnAboutDroppedTopLevelNumbers(payload: Map<String?, Any?>) {
1562
+ val dropped = payload.entries
1563
+ .filter { it.value is Number }
1564
+ .mapNotNull { it.key }
1565
+ if (dropped.isEmpty()) {
1566
+ return
1567
+ }
1568
+ Log.w(
1569
+ TAG,
1570
+ "[bridge] logSignal: top-level numeric value(s) ${dropped.sorted()} will be dropped by the " +
1571
+ "Android SDK's signal serializer, which accumulates only strings, booleans and nested " +
1572
+ "JSON. iOS sends them. Nest the value under an object to carry it on both platforms, " +
1573
+ "e.g. { cart: { total: 24.99 } } instead of { total: 24.99 }."
1574
+ )
1575
+ }
1576
+
1401
1577
  /**
1402
1578
  * Recursive helper for [toSignalPayload]. Maps Kotlin containers onto their
1403
1579
  * `org.json` equivalents and leaves scalars alone.