react-native-acoustic-connect-beta
v19.0.21
Published
BETA: React native plugin for Acoustic Connect
Readme
react-native-acoustic-connect-beta
React Native plugin for the Acoustic Connect SDK. Captures user interactions, screen replays, and analytics events on iOS and Android. Supports optional push notifications via the Connect backend.
For the full product overview see the Connect SDK overview on the developer portal.
Requirements
- React Native 0.82.x – 0.85.x with the new architecture
- React 19.1.1 or newer (or whatever your RN version pins)
react-native-nitro-modulesat the exact version this package pins inpeerDependencies(currently0.35.9) — your app must resolve exactly this version; see Nitro version pin- Node 20 or newer
- JDK 17 or newer for Android builds — the same JDK React Native 0.82 itself requires. The Android module compiles at Java 17; newer JDKs work.
- iOS deployment target ≥ 15.1, AcousticConnect / AcousticConnectDebug pod ≥ 2.0.5
- Android
minSdk≥ 26,compileSdk≥ 35,io.github.go-acoustic:connectin[11.0.11, 12.0.0) - Expo SDK 55+ is supported via the bundled Expo Config Plugin — development builds only (Expo Go is not supported); see Using with Expo SDK 55+.
Nitro version pin
This package pins react-native-nitro-modules to an exact version
(currently 0.35.9) in its peerDependencies — deliberately not a range.
Your app must resolve exactly that version.
Why (important): Nitro patch releases can contain breaking changes. The SDK
ships native bindings generated against one specific Nitro version, and Nitro's
generated-code ↔ runtime contract is not patch-safe. For example, a
0.35.4 → 0.35.9 patch bump changed Nitro's native registration and caused
this SDK's HybridObject to fail to load with a ClassNotFoundException, taking
the entire module offline at runtime. Because even a patch can break it, a
version range (^/~/>=…<…) cannot guarantee compatibility — so the pin is
exact.
If your app resolves a different Nitro version, the SDK may fail to start. You
can force past the peer check with --legacy-peer-deps / --force, but
then you own making it work — we cannot guard against breaking changes in
arbitrary future Nitro patch releases. When this SDK adopts a newer Nitro, it
ships in a new SDK release with the pin bumped; upgrade the SDK and Nitro
together.
Installation
npm install react-native-acoustic-connect-beta react-native-nitro-modules
cd ios && pod installThe plugin reads a ConnectConfig.json from your project root at install time
(iOS via the podspec, Android via config.gradle) and bakes the values into the
native bundles. ConnectConfig.json is the single source of truth — there
is no runtime override path. A minimal config looks like:
{
"Connect": {
"AppKey": "your-app-key",
"PostMessageUrl": "https://collector.example.com/collectorPost",
"KillSwitchUrl": "https://collector.example.com/collector/switch/your-app-key",
"useRelease": false,
"PushEnabled": false,
"iOSPushMode": "automatic",
"iOSAppGroupIdentifier": null,
"AndroidNotificationIconResName": null
}
}Field summary (see API reference for full semantics):
| Field | Default | Purpose |
| --- | --- | --- |
| AppKey | (required) | Your Connect application key. |
| PostMessageUrl | (required) | Collector endpoint URL. |
| KillSwitchUrl | (optional) | Kill-switch endpoint URL. |
| useRelease | false | true selects the release AcousticConnect iOS pod over the debug variant. |
| iOSVersion | "" | Pin a specific iOS pod version; empty = newest in the supported range. |
| AndroidVersion | "" | Pin a specific Android Connect SDK version; empty = newest in the supported range. |
| PushEnabled | false | Master switch. On Android, also gates the connect-push-fcm artifact inclusion. |
| iOSPushMode | "automatic" | iOS-only: "automatic" (SDK owns APNs delegate) or "manual" (app owns it). Ignored when PushEnabled is false. |
| iOSAppGroupIdentifier | null | iOS App Group shared with the Notification Service / Notification Content extension. |
| AndroidNotificationIconResName | null | Drawable resource name (no extension) for the Android notification small icon. |
What npm install / pod install do for you
Both commands run bootstrap steps against your project, not just the package's own build:
npm installtriggers a postinstall step that (idempotently, best-effort):- scaffolds a starter
ConnectConfig.jsonat your project root if one isn't already there, - adds the Android permissions Connect needs
(
INTERNET,ACCESS_NETWORK_STATE,ACCESS_WIFI_STATE,ACCESS_FINE_LOCATION) toandroid/app/src/main/AndroidManifest.xml, - wires
android/app/build.gradleto apply the SDK'sconfig.gradle.
- scaffolds a starter
pod installmerges yourConnectConfig.jsoninto the iOS pod's generated config bundle at install time (see the podspec).
None of this touches files outside your project's android//ios/
directories, and re-running either command is safe. After install, run
npx acoustic-connect doctor to validate the result — see
Setup CLI below.
Setup CLI (acoustic-connect)
The package ships a small CLI, installed as acoustic-connect (invoke it with
npx acoustic-connect <command>), that validates and scaffolds the native
side of an integration. Both commands auto-detect bare RN vs. Expo and are
idempotent — safe to re-run, and they never overwrite a file that's already
there.
npx acoustic-connect doctor [dir] [--require-push]
Checks the things that otherwise fail late and confusingly at build time:
- Node version.
ConnectConfig.jsonvalues —AppKey,PostMessageUrl/KillSwitchUrl(must be realhttpsURLs, not the placeholder host),iOSAppGroupIdentifierformat,iOSDevelopmentTeamformat.- Android identifiers — the package/namespace is a Java-safe name, and it has
a matching client in
google-services.json(Expo also checks for a staleandroid/from an incrementalexpo prebuildafter anandroid.packagechange). - iOS entitlements — bare projects need
aps-environment+ an App Group entitlement (fromsetup-ios-push, below, or hand-authored). - (macOS only) whether a local Apple Development signing identity is installed, when push is on.
The push-related checks above are hard failures only when
Connect.PushEnabled is true (or --require-push is passed) — a non-push
integration only needs AppKey. doctor exits non-zero on any failure, so
it's usable as a CI gate (npx acoustic-connect doctor --require-push).
dir defaults to the current directory. If ConnectConfig.json is missing,
doctor scaffolds it first (from a project-local ConnectConfig.example.json
if you have one, else from the copy bundled with the package) before running
the checks above — so it's also a fine first command to run in a fresh
project.
npx acoustic-connect setup-ios-push [dir]
Bare-workflow only — the Expo Config Plugin does the equivalent automatically
on expo prebuild. Scaffolds the two iOS push extensions:
ConnectNSE(Notification Service Extension — rich-media attachments, delivery tracking)ConnectNCE(Notification Content Extension — expanded rich-media UI)
It writes each extension's Swift source, Info.plist, and entitlements from
the SDK's templates (substituting your App Group), adds the App Group +
aps-environment entitlement to the host app if it doesn't have one yet, and
wires both targets into your .xcodeproj via a bundled Ruby script — which
needs ruby and the xcodeproj gem (gem install xcodeproj; ships with
CocoaPods, so most Mac dev setups already have it).
Requires macOS and Connect.iOSAppGroupIdentifier already set in
ConnectConfig.json — run doctor first if it's missing.
Using with Expo SDK 55+
Expo SDK 55 and newer is supported via the bundled Expo Config Plugin.
Development builds only — Expo Go is not supported: Nitro Modules and
native push registration require native code that Expo Go cannot load. There
is no runtime Expo Go detection or fallback; use
expo-dev-client
or EAS Build.
Install
npx create-expo-app@latest my-app # Expo SDK 55+, new architecture enabled
cd my-app
npx expo install expo-dev-client expo-build-properties
npm install react-native-acoustic-connect-beta react-native-nitro-modulesexpo-build-properties is needed to raise Android minSdkVersion to 26
(the Connect Android SDK floor — Expo templates default to a lower value):
{
"expo": {
"plugins": [
["expo-build-properties", { "android": { "minSdkVersion": 26 } }]
]
}
}Configure
Put
ConnectConfig.jsonat the project root — the same file documented in Installation. For push, setPushEnabled,iOSPushMode,iOSAppGroupIdentifier, andiOSDevelopmentTeam(your 10-char Apple Team ID) in theConnectblock. Runnpx acoustic-connect doctorto validate it — whenPushEnabledistrue, the doctor fails (exits non-zero) on any missing push input (collector URLs, App Group, signing team,google-services.json, app ids); when push is off it needs onlyAppKey.Add the plugin to
app.json:
{
"expo": {
"plugins": ["react-native-acoustic-connect-beta"]
}
}The plugin reads the App Group from Connect.iOSAppGroupIdentifier and the
signing team from Connect.iOSDevelopmentTeam in ConnectConfig.json — the
same values the SDK reads at runtime, so the entitlement and the runtime config
agree by construction. To override either for the native project only (rarely
needed), pass plugin props; props take precedence over ConnectConfig.json:
{
"expo": {
"plugins": [
[
"react-native-acoustic-connect-beta",
{
"iosAppGroupIdentifier": "group.com.example.myapp",
"iosDevelopmentTeam": "ABCDE12345"
}
]
]
}
}iosDevelopmentTeam is required for push: during expo prebuild the plugin
stamps DEVELOPMENT_TEAM onto the host app and both push extensions. Without a
team, a CLI build falls back to ad-hoc signing, which drops the aps-environment
entitlement — so iOS issues no APNs token and push silently fails.
Build
npx expo prebuild
npx expo run:ios -- --extra-params "-allowProvisioningUpdates" # or: eas build --profile development --platform ios
npx expo run:android # or: eas build --profile development --platform androidiOS push needs provisioning. Setting iOSDevelopmentTeam lets the plugin
stamp the team, but xcodebuild still has to fetch/create the Development
certificate and provisioning profiles (the host + both extension App IDs, with
the Push Notifications and App Groups capabilities). Pass
-allowProvisioningUpdates so it does that automatically — it requires your
Apple ID to be added once in Xcode → Settings → Accounts. eas build
manages the whole signing chain itself, so no flag is needed there; CI/headless
runs can use an App Store Connect API key
(-authenticationKeyPath/-authenticationKeyID/-authenticationKeyIssuerID with
-allowProvisioningUpdates). For the bare workflow, the equivalent is
react-native run-ios --extra-params "-allowProvisioningUpdates".
During expo prebuild the plugin automatically:
- adds a
ConnectNSENotification Service Extension target to the Xcode project (rich-media push attachments + delivery tracking), withNotificationService.swiftgenerated from the SDK template; - adds a
ConnectNCENotification Content Extension target (rich expansion UI — renders the attached media / expanded body when the user expands an Acoustic notification), withNotificationViewController.swiftgenerated from the SDK template; - adds the App Group entitlement to the host app and to both extensions, so all three processes share the same pending store;
- appends
ConnectNSEandConnectNCEtargets to the generatedPodfile, each linking the same Connect SDK pod as the app.
Re-running expo prebuild — with or without --clean — is idempotent: no
duplicate targets, entitlements, or Podfile entries.
The NCE renders notifications whose category matches
ACOUSTIC_RICH_NOTIFICATION or ACTIONABLE_NOTIFICATION (the categories the
Connect backend sets on rich-media pushes). No app-side configuration is
required — the category identifiers are baked into the generated target's
Info.plist.
EAS Build
The plugin works with EAS Build
out of the box — the NSE and NCE targets are provisioned by the same
expo prebuild step EAS runs on its build workers. iOS extension targets are
signed automatically when you use EAS-managed credentials (eas credentials),
which provisions the host app and both extension App IDs together.
eas build --profile development --platform ios
eas build --profile development --platform androidA working example — including a sample
eas.json — lives in
Examples/expo.
Quick start
1. Initialise the SDK
The SDK auto-initialises at module load time using the values from
ConnectConfig.json. For most apps there is no JS-side init code to write —
just import the package and you're done:
// index.js
import AcousticConnectRN from 'react-native-acoustic-connect-beta'
// SDK is already initialising on the main actor / main looper. No further
// setup required.For consent-gated apps (GDPR, CCPA, COPPA), use the lifecycle pair:
import AcousticConnectRN from 'react-native-acoustic-connect-beta'
// At app start, if you don't yet have user consent:
AcousticConnectRN.disable()
// Later, after the user opts in:
AcousticConnectRN.enable()enable() and disable() are both parameterless — all configuration comes
from ConnectConfig.json. They're idempotent at the native layer; calling
enable() on an already-running SDK is a no-op.
2. Add the <Connect> wrapper (declarative, in your tree)
Wrap your NavigationContainer in <Connect> to enable navigation tracking,
touch capture, and optional keyboard / dialog interception. The wrapper does
not own SDK lifecycle — that's been done in step 1 — so it can mount, unmount,
or remount freely without disrupting the session.
import { useNavigationContainerRef, NavigationContainer } from '@react-navigation/native'
import { Connect } from 'react-native-acoustic-connect-beta'
export default function App() {
const navigationRef = useNavigationContainerRef()
return (
<Connect
navigationRef={navigationRef}
captureKeyboardEvents
captureDialogEvents
>
<NavigationContainer ref={navigationRef}>
{/* your screens */}
</NavigationContainer>
</Connect>
)
}<Connect> is optional. Apps that only need custom event logging (no automatic
screen / touch / keyboard tracking) can skip it entirely.
3. Log events (imperative, anywhere)
import AcousticConnectRN from 'react-native-acoustic-connect-beta'
// Custom application event (flat key/value pairs)
AcousticConnectRN.logCustomEvent('checkout_started', { cartId: 'abc' }, 1)
// Signal — accepts arbitrary JSON, including nested objects and arrays
AcousticConnectRN.logSignal(
{
signalContent: { signalType: 'pageview', pageCategory: 'checkout' },
audience: [{ name: 'Account ID', value: '4815162342' }],
},
1
)
// Force a logical screen name (e.g. for non-NavigationContainer screens)
AcousticConnectRN.setCurrentScreenName('CheckoutScreen')
// Manual exception capture
AcousticConnectRN.logExceptionEvent(
'Payment failed',
err.stack ?? '',
/* unhandled */ false
)Dialog tracking helpers (useDialogTracking, DialogListener,
withAcousticAutoDialog) instrument React Native Alert.alert(...) and custom
dialogs automatically. See the
developer portal
for details.
API reference
What the logging methods'
booleanmeans. Everylog*method returns whether the native SDK accepted the event for delivery — never whether the collector received it, and never whether the collector kept it. Events are queued on device and posted in batches later, so no return value from these calls can attest to delivery, and a server-side rejection (a signal that fails schema validation, say) happens long after you have already been toldtrue. Treat the value as "the SDK took this", and use the collector or the platform log as the source of truth for what shipped.A
falsemeans the SDK rejected the call outright and nothing was queued. The bridge also writes that tologcat(tagAcousticConnectRN) andos_log(subsystemcom.acoustic.AcousticConnectRN, categorybridge), so a rejection is visible without inspecting return values you would otherwise never read.
logScreenLayoutis weaker still: with the configured delay — the normal case — atruemeans only that the capture was scheduled. What the capture found when it eventually ran, and whether it produced a layout message at all, is reported in the platform log only.
AcousticConnectRN.enable(): boolean
Re-enables the SDK after a prior disable(). Reads all configuration from
ConnectConfig.json. Returns true on accepted dispatch; false only when
the platform can't satisfy a precondition (e.g. Android without an
Application context yet). Idempotent — the native SDK short-circuits if it's
already running.
The SDK also auto-initialises at module load using the same configuration, so you typically don't need to call
enable()at all. The method exists to pair withdisable()for opt-out / consent flows.
AcousticConnectRN.disable(): boolean
Stops data capture, flushes pending messages, releases push state. Idempotent.
AcousticConnectRN.logSignal(values, level): boolean
Logs a signal. values accepts arbitrary JSON — nested objects and arrays,
not just scalars — and the payload is carried through without flattening or
reshaping:
AcousticConnectRN.logSignal(
{
signalContent: {
signalType: 'pageview',
url: 'https://app.example.com/dashboard',
pageCategory: 'dashboard',
},
audience: [
{ name: 'Account Name', value: 'Acme Corp' },
{ name: 'Account ID', value: '4815162342' },
],
},
1
)Flat scalar payloads keep working unchanged — the type is a superset of
Record<string, string | number | boolean>.
An object literal needs no type import. If you want to name a payload — a
shared constant, a helper parameter — the package exports SignalValues for it:
import AcousticConnectRN, {
type SignalValues,
} from 'react-native-acoustic-connect-beta'
const pageView: SignalValues = {
signalContent: { signalType: 'pageview', pageCategory: 'checkout' },
}
AcousticConnectRN.logSignal(pageView, 1)Android: send numbers nested, not at the top level. The Android SDK's signal serializer carries strings, booleans, objects and arrays at the top level of the payload but drops a top-level numeric value. Numbers nested inside an object or array are unaffected. iOS carries top-level numbers normally, so put numbers one level down when you need both platforms to agree:
AcousticConnectRN.logSignal({ cart: { items: 3 } }, 1) // both platforms AcousticConnectRN.logSignal({ items: 3 }, 1) // iOS onlyThe Android bridge logs a warning naming the keys the SDK is about to discard, so the loss shows up in
logcatinstead of only in a dashboard that never fills in. It does not coerce the value: making the number survive as a JSON string would replace a missing field with a differently-typed one and diverge from iOS in a new way. The fix belongs in the Android SDK's serializer.
AcousticConnectRN.logCustomEvent(eventName, values, level): boolean
Logs a named custom event. Unlike logSignal, values is flat — strings,
numbers and booleans only. The Android SDK's custom-event path is typed for
string values end to end, so there is no native route for nested JSON; widening
it would preserve the nesting on iOS and silently drop it on Android. Use
logSignal when the payload needs structure.
TypeScript will reject a nested value, but types are erased at runtime — a
payload built from an API response or widened through any still reaches the
bridge nested, gets reshaped by the native SDK, and returns true. The wrapper
logs a console.warn naming the offending keys when that happens (once per
call site, in dev and in production; the payload itself is passed through
unchanged). If you see it, move the payload to logSignal.
Numbers are rendered the same way on both platforms — a JS 2 arrives as 2,
not 2.0. Note the remaining difference in type: because the Android path is
string-typed, an Android custom-event number lands on the wire as a JSON string
("2") where iOS sends a JSON number (2). logSignal carries native types on
both platforms; reach for it when a dashboard or Composer rule compares the
value numerically.
Push configuration (ConnectConfig.json)
| Field | Type | Default | Semantics |
| --- | --- | --- | --- |
| PushEnabled | boolean | false | Cross-platform master switch. On Android, drives connect-push-fcm artifact inclusion at build time. |
| iOSPushMode | "automatic" / "manual" | "automatic" | iOS-only. Ignored when PushEnabled is false. |
| iOSAppGroupIdentifier | string | null | null | iOS App Group shared with NSE / NCE for rich push payloads. Required when push is on. |
| iOSDevelopmentTeam | string | null | null | 10-char Apple Team ID used to sign the host + push extensions. Required for iOS push — without it the build drops aps-environment and the OS issues no APNs token. |
| AndroidNotificationIconResName | string | null | null | Drawable resource name for the Android notification small icon. |
When
PushEnabledistrue,npx acoustic-connect doctortreats the push-required inputs above (plus collector URLs,google-services.json, and the app ids) as hard failures and exits non-zero. When push is off it validates onlyAppKey— a non-push integration needs nothing more.
iOS push modes
"automatic"— the iOS Connect SDK manages APNs token registration internally. The host app only requests user permission viaUNUserNotificationCenter; token delivery and forwarding to the Connect backend are handled by the SDK."manual"— the host app owns APNs delegate callbacks (application(_:didRegisterForRemoteNotificationsWithDeviceToken:)) and forwards tokens explicitly viaConnectSDK.shared.push.didRegisterWithToken(token).
Android push
The iOSPushMode field is iOS-only; Android push is gated solely by
PushEnabled. On Android, FCM requires a FirebaseMessagingService subclass
that the host app declares in its AndroidManifest.xml, so push is always
app-driven. The host app is responsible for:
- shipping
google-services.jsoninandroid/app/, - implementing
FirebaseMessagingService.onNewToken(...), - forwarding the FCM token to the Connect SDK.
The Android push-forwarding API itself is wired under follow-up work — until
that lands, PushEnabled: true on Android only changes which artifact is on
the classpath; the host-side token forwarding API is not yet exposed.
Screen-capture configuration (ConnectConfig.json)
Screen layout and screenshot capture are configured per platform, because the two native SDKs read their defaults from different places.
| Field | Type | Default | Semantics |
| --- | --- | --- | --- |
| layoutConfig | object | absent | absent | Shared baseline for both platforms. Supports AutoLayout (screen capture rules) and AppendMapIds. |
| layoutConfigIos | object | absent | absent | iOS-specific overrides, deep-merged over layoutConfig. |
| layoutConfigAndroid | object | absent | absent | Android-specific overrides, deep-merged over layoutConfig. |
| GetImageDataOnScreenLayout | boolean | SDK default | Cross-platform. When false, layout messages carry no screenshot image data. |
AutoLayout holds a GlobalScreenSettings object plus optional per-screen
overrides keyed by screen name, where a per-screen value wins over the global
one. To turn screenshot capture off on both platforms:
{
"Connect": {
"layoutConfig": {
"AutoLayout": {
"GlobalScreenSettings": { "ScreenShot": false, "CaptureScreenshotOn": 0 }
}
}
}
}Put anything common in layoutConfig and use layoutConfigIos /
layoutConfigAndroid only for what differs. The platform block is deep-merged
over the shared one, so it refines the baseline rather than replacing it: an
iOS block setting just ScreenShot keeps the shared Masking, per-screen
rules, and every other shared value. Arrays are replaced outright rather than
concatenated, so a platform block can shorten or clear a shared list such as
MaskIdList.
When no block applies, each native SDK keeps the defaults from its own bundled layout config:
- iOS — the defaults ship inside the Connect pod's
ConnectResources.bundleand are copied into your.app, sopod installreplaces them on every run. There is no app-level override file, which is whyConnectConfig.jsonis the only supported route — do not edit anything underPods/. - Android — the plugin writes your merged block to its own
ConnectLayoutConfig.jsonasset. If your app shipsandroid/app/src/main/assets/ConnectLayoutConfig.json, Android asset merging gives your copy priority and theConnectConfig.jsonblock is ignored; the build prints a warning when both exist. Keep one or the other.
Turning screenshots off does not disable behavioural capture: screen views, clicks, and custom events still flow. It only drops the image data, which is what a subscription without session replay has no consumer for.
NumberOfWebViews turns off iOS layout capture — leave it at 0
AutoLayout rules accept a NumberOfWebViews key. On iOS, any value greater
than zero marks the screens that rule covers as web-view screens, and the SDK's
automatic layout capture is skipped for a web-view screen. Its effect is not
limited to screens that actually host a WebView: the value is read as a
declaration about the rule, not a count that gets verified.
The trap is where it is usually set. GlobalScreenSettings is the rule that
applies to every screen with no more specific rule of its own, and React
Native screens are all in that position — they are hosted by the same generic
container class, so no per-screen native rule matches them. So a single
"NumberOfWebViews": 1 under GlobalScreenSettings switches off
auto-instrumented layout capture for the entire app on iOS, including screens
with no web content at all:
{
"Connect": {
"layoutConfigIos": {
"AutoLayout": {
"GlobalScreenSettings": { "NumberOfWebViews": 0 }
}
}
}
}Screen views, clicks, and custom events are unaffected — the symptom is layout
messages going missing while everything else keeps arriving, which reads like a
capture failure rather than a setting. Keep NumberOfWebViews at 0, the value
every shipped template uses. To stand layout capture down deliberately, set
CaptureLayoutOn: 0 on the rule instead — it is the key that means that, and it
leaves NumberOfWebViews free to describe the screen.
Capture timing (CaptureLayoutDelay)
AutoLayout.GlobalScreenSettings.CaptureLayoutDelay is how long, in
milliseconds, the SDK waits before capturing the layout. It matters more in
React Native than in a native app: the wrapper triggers capture from React
Navigation's state change, which fires at the start of a screen transition,
whereas a native app captures from the view-controller lifecycle, after it. Too
short a delay and the capture catches a half-drawn screen.
The templates ship 500, which clears a typical transition animation on both
platforms. Tune it globally, or per screen for one that is slower than the
rest:
{
"Connect": {
"layoutConfig": {
"AutoLayout": {
"GlobalScreenSettings": { "CaptureLayoutDelay": 500 },
"Checkout": { "CaptureLayoutDelay": 900 }
}
}
}
}TLTRN.logScreenLayout(name) uses the configured value. Pass a second argument
to override it for one call — TLTRN.logScreenLayout(name, 0) captures
immediately, TLTRN.logScreenLayout(name, 900) waits 900 ms.
A fixed delay cannot cover a screen whose content arrives asynchronously: if
data lands after the delay elapses, the capture shows the screen without it.
Raising the global delay to cover the slowest fetch delays every other screen
too. Call TLTRN.logScreenLayout(name) again when the content is on screen
instead — each call captures the layout as it stands at that moment.
Leaving the block out entirely gives you 500 ms as well. That is the wrapper's default, chosen so both platforms behave the same; the native SDKs' own bundled defaults differ from each other and are tuned for the lifecycle trigger rather than the React Navigation one.
Where screen-view and layout messages come from, and how to reduce duplicates
The two message kinds have different sources, and they differ per platform:
Screen views (one source per platform). On Android, the <Connect> wrapper
is the only source — it emits on React Navigation's state event. On iOS,
the native SDK's auto-instrumentation is the only source — it emits when a
screen's view controller reports its appearance. The wrapper never emits a
screen view on iOS; its role there is to hand the current route name to the
native SDK (which is what makes messages read Checkout rather than a native
container class) and to drive referrer chaining across transitions.
Duplicate screen views observed on iOS are therefore not two sources overlapping — they are the single native source firing more than once when the same screen's view controller reports several appearances for one transition. They inflate event volumes and skew per-screen counts; they do not lose data. There is no configuration key that collapses them today; a native-side dedupe is under investigation. Do not try to solve it by removing the wrapper: on iOS that costs route-based names and referrer chaining, and on Android it removes screen views entirely. A same-name filter in the wrapper would not help either — the wrapper is not the emitter on iOS, and such a filter would swallow legitimate repeats like a stack pushing the same screen name twice.
Layouts (two sources on iOS). With automatic layout capture enabled in
config, an iOS transition can produce two layout messages: one from the
wrapper's logScreenLayout call and one from the native auto-instrumentation.
Control it from config, on the native side that has the duplicate:
{
"Connect": {
"layoutConfigIos": {
"AutoLayout": {
"GlobalScreenSettings": { "CaptureLayoutOn": 0 }
}
}
}
}CaptureLayoutOn: 0 stands the native automatic layout capture down and
leaves the wrapper's route-named captures as the single source. Set it per
screen rather than globally if only some screens are noisy. Screen views,
clicks, and custom events are unaffected by this key.
If you would rather keep the native captures and have the wrapper stay out of
the way, omit navigationRef and do not rely on <Connect> for screen naming
— but expect native container class names in place of your route names.
Measure before changing either default: the volume depends on your navigator structure, and both defaults are what every shipped sample uses.
<Connect> props
| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| children | ReactNode | yes | Your NavigationContainer (or any subtree). |
| navigationRef | RefObject | recommended | Ref from useNavigationContainerRef(). Enables screen-name tracking. |
| captureKeyboardEvents | boolean | yes | Capture iOS/Android keyboard show/hide events. |
| captureDialogEvents | boolean | no (default false) | Auto-track Alert.alert(...) calls. |
Other methods
The plugin exposes the full Connect SDK surface — custom events, signals,
exceptions, location, screen layout, click / text-change events, dialog events,
and config-item getters/setters. Signatures are stable across platforms; see
src/specs/react-native-acoustic-connect.nitro.ts for the typed Nitro spec and
the
developer portal
for end-to-end recipes.
Migration from earlier versions
See Migration-Guide.md for the steps to move from the
legacy NativeModules.AcousticConnectRN interface to the current ESM exports
and the <Connect> component.
Troubleshooting
npm install peer-dependency conflicts
This package pins react-native-nitro-modules to an exact version (see
Nitro version pin). If npm install errors on the Nitro
peer, your app is resolving a different Nitro version. Check it with:
npm ls react-native-nitro-modulesSupported fix: align your app to the exact version this package requires (and match React Native / React per the Requirements).
--legacy-peer-deps / --force will silence the error, but the pin is
intentional: a mismatched Nitro version can break the SDK at runtime (an
init-time ClassNotFoundException), and bypassing means you take on that
risk — we cannot guard against breaking changes in arbitrary Nitro patch
releases. Match the version rather than bypass it.
iOS — push session reaches the collector but no notifications arrive (no APNs token)
Fingerprint: analytics and pushRegistration still reach the collector
normally, but mobileToken stays null forever — and, notably, no
didFailToRegisterForRemoteNotificationsWithError (or any push-related
failure) shows up in logs either. It just silently never happens. That
absence of any error is what makes this look like a missing/broken app-side
callback or an SDK bug at first glance; it's almost always a signing
artifact instead. Run npx acoustic-connect doctor first — it detects this
exact condition (missing/invalid iOSDevelopmentTeam, no Development
certificate) up front, before you go digging through portal config.
Cause: a CLI build (expo run:ios / react-native run-ios / xcodebuild)
doesn't auto-pick a signing team the way the Xcode GUI does. With no team, it
signs ad-hoc and drops the aps-environment entitlement, so iOS issues no
APNs token — the app still launches and analytics reach the collector, but no
pushRegistration with a mobileToken is ever sent.
Setting Connect.iOSDevelopmentTeam in ConnectConfig.json by itself does
nothing. It's just a source value — it has no effect on the build until
expo prebuild / npx acoustic-connect setup-ios-push actually stamps
DEVELOPMENT_TEAM onto the host and the ConnectNSE and ConnectNCE
targets in the generated .pbxproj. Re-run that step any time
iOSDevelopmentTeam changes, and after every fresh clone — a teammate's
machine may already have the stamp baked in from a prior run; yours doesn't
until you run it too. This is the classic "works for me, not for them" trap:
every portal-side check (App ID push capability, provisioning profile,
entitlements file contents, App Group registration, .p8 key upload) can
look perfect and still not reflect what's actually embedded in the binary
that got installed on the device.
Fix:
- Set
Connect.iOSDevelopmentTeam(10-char Apple Team ID) inConnectConfig.json(or pass theiosDevelopmentTeamplugin prop). Re-runexpo prebuild/npx acoustic-connect setup-ios-pushso the team is stamped on the host + extensions. - Build with
-allowProvisioningUpdates(see Build) and your Apple ID added in Xcode → Settings → Accounts, so a Development certificate + profiles are provisioned.
Verify the fix actually landed — rerunning the steps above isn't proof by itself; confirm the build artifacts directly:
# 1. DEVELOPMENT_TEAM must be stamped on the host AND both push extensions
grep DEVELOPMENT_TEAM ios/*.xcodeproj/project.pbxproj
# 2. The *installed* binary must carry the entitlement — check the actual
# .app, not just the source .entitlements file in your repo
codesign -d --entitlements :- --xml /path/to/YourApp.app
# must show aps-environment in the outputIf DEVELOPMENT_TEAM is missing for any of the three targets, or codesign
doesn't show aps-environment, the stamp didn't take (or a stale build was
reused) — re-run setup-ios-push / prebuild and do a clean rebuild.
iOS — Expo Android build fails: No matching client found for package name …
Your app.json android.package isn't registered in the active
google-services.json (FCM matches by package). The config plugin and
acoustic-connect doctor now catch this up front. Register that exact package
in the same Firebase project and re-download google-services.json — and note
that changing android.package requires a clean prebuild
(npx expo prebuild --platform android --clean); an incremental prebuild keeps
the stale applicationId.
iOS — pod install fails with [Connect] requires AcousticConnect >= 2.0.5
You've pinned an older iOSVersion in ConnectConfig.json. Bump it to a
2.0.5+ release (or leave it empty for the newest available) and re-run
pod install.
Android — Gradle resolution fails on io.github.go-acoustic:connect
The strict constraint at [11.0.11, 12.0.0) is rejecting your pin. Bump
AndroidVersion in ConnectConfig.json to a release within that range (or
leave it empty for the newest 11.x available). 12.x is intentionally outside
the supported range pending compatibility validation — track that work
separately if you need it.
Gradle can't find node
Common when Gradle is launched outside an NVM-loaded shell. Either:
- Stop the daemon (
./gradlew --stop) and re-runnpm run androidfrom a shell wherewhich noderesolves, or - Symlink node onto a stable PATH:
ln -sf "$(which node)" /opt/homebrew/bin/node.
For more, see the Connect React Native integration guide and the sample app walk-through.
