react-native-acoustic-connect-beta 19.0.6 → 19.0.8

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.
@@ -115,12 +115,45 @@ Pod::Spec.new do |s|
115
115
  # Implementation (C++ objects)
116
116
  "cpp/**/*.{hpp,cpp}",
117
117
  ]
118
+ # Unit tests live in ios/Tests and belong to the UnitTests test_spec below,
119
+ # not to the library target (source_files globs ios/**/*.swift).
120
+ s.exclude_files = "ios/Tests/**"
118
121
 
119
122
  s.pod_target_xcconfig = {
120
123
  # C++ compiler flags, mainly for folly.
121
- "GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) FOLLY_NO_CONFIG FOLLY_CFG_NO_COROUTINES"
124
+ "GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) FOLLY_NO_CONFIG FOLLY_CFG_NO_COROUTINES",
125
+ # @testable import (UnitTests test_spec) needs testability in Debug.
126
+ "ENABLE_TESTABILITY[config=Debug]" => "YES"
122
127
  }
123
128
 
129
+ # Native unit tests. Run through a consumer app's Pods project:
130
+ # Podfile: pod 'AcousticConnectRN', :path => '../..', :testspecs => ['UnitTests']
131
+ # xcodebuild test -workspace <app>.xcworkspace -scheme AcousticConnectRN \
132
+ # -destination 'platform=iOS Simulator,name=<booted sim>'
133
+ # The tests use Swift Testing. They do NOT `@testable import` the pod
134
+ # module — see the source_files comment below for why that's impossible.
135
+ s.test_spec 'UnitTests' do |ts|
136
+ # The sources under test are compiled directly into the test bundle
137
+ # instead of `@testable import`ing the pod module: nitrogen's generated
138
+ # Swift compatibility header emits C++ thunks referencing nitro C++ types
139
+ # (margelo::nitro::ArrayBufferHolder) without including their headers, so
140
+ # the AcousticConnectRN clang module cannot be built by any Swift client —
141
+ # including a test target. Compiling the files here is safe: the linker
142
+ # pulls static-archive members lazily, so the parent lib's copies of
143
+ # these objects are never loaded and no duplicate symbols arise.
144
+ ts.source_files = [
145
+ 'ios/Tests/**/*.swift',
146
+ 'ios/ConnectRNParsing.swift',
147
+ 'nitrogen/generated/ios/swift/Variant_Bool_String_Double.swift',
148
+ ]
149
+ # ios/ConnectRNParsing.swift does `import Connect`. CocoaPods test_specs
150
+ # inherit the parent spec's dependencies implicitly, so this line is
151
+ # observably redundant today — declared explicitly anyway so the test
152
+ # bundle's dependency on the Connect SDK doesn't rely on that inheritance
153
+ # behavior remaining unchanged (e.g. a future migration off CocoaPods).
154
+ ts.dependency dependencyName, *dependencyRequirements
155
+ end
156
+
124
157
  s.resource_bundle = {
125
158
  'AcousticConnectRNConfig' => ['ios/AcousticConnectRNConfig.json'],
126
159
  }
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 19.0.8 (2026-07-03)
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.7 (2026-07-03)
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.6](https://github.com/aipoweredmarketer/react-native-acoustic-connect-beta/compare/19.0.5...19.0.6) (2026-07-02)
2
12
  ## [19.0.5](https://github.com/aipoweredmarketer/react-native-acoustic-connect-beta/compare/19.0.4...19.0.5) (2026-07-02)
3
13
  ## [19.0.4](https://github.com/aipoweredmarketer/react-native-acoustic-connect-beta/compare/19.0.3...19.0.4) (2026-07-01)
@@ -0,0 +1,15 @@
1
+ {
2
+ "Connect": {
3
+ "AndroidNotificationIconResName": "ic_notification",
4
+ "AndroidVersion": "11.0.12-beta",
5
+ "AppKey": "YOUR_CONNECT_APP_KEY_HERE",
6
+ "KillSwitchUrl": "YOUR_KILL_SWITCH_URL_HERE",
7
+ "PostMessageUrl": "YOUR_POST_MESSAGE_URL_HERE",
8
+ "PushEnabled": false,
9
+ "iOSAppGroupIdentifier": "YOUR_APP_GROUP_ID_HERE",
10
+ "iOSDevelopmentTeam": "YOUR_TEAM_ID",
11
+ "iOSPushMode": "automatic",
12
+ "iOSVersion": "",
13
+ "useRelease": false
14
+ }
15
+ }
package/README.md CHANGED
@@ -84,6 +84,81 @@ Field summary (see [API reference](#api-reference) for full semantics):
84
84
  | `iOSAppGroupIdentifier` | `null` | iOS App Group shared with the Notification Service / Notification Content extension. |
85
85
  | `AndroidNotificationIconResName` | `null` | Drawable resource name (no extension) for the Android notification small icon. |
86
86
 
87
+ ### What `npm install` / `pod install` do for you
88
+
89
+ Both commands run bootstrap steps against **your** project, not just the
90
+ package's own build:
91
+
92
+ - `npm install` triggers a postinstall step that (idempotently, best-effort):
93
+ - scaffolds a starter `ConnectConfig.json` at your project root if one
94
+ isn't already there,
95
+ - adds the Android permissions Connect needs
96
+ (`INTERNET`, `ACCESS_NETWORK_STATE`, `ACCESS_WIFI_STATE`,
97
+ `ACCESS_FINE_LOCATION`) to `android/app/src/main/AndroidManifest.xml`,
98
+ - wires `android/app/build.gradle` to apply the SDK's `config.gradle`.
99
+ - `pod install` merges your `ConnectConfig.json` into the iOS pod's
100
+ generated config bundle at install time (see the podspec).
101
+
102
+ None of this touches files outside your project's `android/`/`ios/`
103
+ directories, and re-running either command is safe. After install, run
104
+ `npx acoustic-connect doctor` to validate the result — see
105
+ [Setup CLI](#setup-cli-acoustic-connect) below.
106
+
107
+ ## Setup CLI (`acoustic-connect`)
108
+
109
+ The package ships a small CLI, installed as `acoustic-connect` (invoke it with
110
+ `npx acoustic-connect <command>`), that validates and scaffolds the native
111
+ side of an integration. Both commands auto-detect bare RN vs. Expo and are
112
+ idempotent — safe to re-run, and they never overwrite a file that's already
113
+ there.
114
+
115
+ ### `npx acoustic-connect doctor [dir] [--require-push]`
116
+
117
+ Checks the things that otherwise fail late and confusingly at build time:
118
+
119
+ - Node version.
120
+ - `ConnectConfig.json` values — `AppKey`, `PostMessageUrl` / `KillSwitchUrl`
121
+ (must be real `https` URLs, not the placeholder host), `iOSAppGroupIdentifier`
122
+ format, `iOSDevelopmentTeam` format.
123
+ - Android identifiers — the package/namespace is a Java-safe name, and it has
124
+ a matching client in `google-services.json` (Expo also checks for a stale
125
+ `android/` from an incremental `expo prebuild` after an `android.package`
126
+ change).
127
+ - iOS entitlements — bare projects need `aps-environment` + an App Group
128
+ entitlement (from `setup-ios-push`, below, or hand-authored).
129
+ - (macOS only) whether a local Apple Development signing identity is
130
+ installed, when push is on.
131
+
132
+ The push-related checks above are **hard failures** only when
133
+ `Connect.PushEnabled` is `true` (or `--require-push` is passed) — a non-push
134
+ integration only needs `AppKey`. `doctor` exits non-zero on any failure, so
135
+ it's usable as a CI gate (`npx acoustic-connect doctor --require-push`).
136
+
137
+ `dir` defaults to the current directory. If `ConnectConfig.json` is missing,
138
+ `doctor` scaffolds it first (from a project-local `ConnectConfig.example.json`
139
+ if you have one, else from the copy bundled with the package) before running
140
+ the checks above — so it's also a fine first command to run in a fresh
141
+ project.
142
+
143
+ ### `npx acoustic-connect setup-ios-push [dir]`
144
+
145
+ Bare-workflow only — the Expo Config Plugin does the equivalent automatically
146
+ on `expo prebuild`. Scaffolds the two iOS push extensions:
147
+
148
+ - `ConnectNSE` (Notification Service Extension — rich-media attachments,
149
+ delivery tracking)
150
+ - `ConnectNCE` (Notification Content Extension — expanded rich-media UI)
151
+
152
+ It writes each extension's Swift source, `Info.plist`, and entitlements from
153
+ the SDK's templates (substituting your App Group), adds the App Group +
154
+ `aps-environment` entitlement to the host app if it doesn't have one yet, and
155
+ wires both targets into your `.xcodeproj` via a bundled Ruby script — which
156
+ needs `ruby` and the `xcodeproj` gem (`gem install xcodeproj`; ships with
157
+ CocoaPods, so most Mac dev setups already have it).
158
+
159
+ Requires macOS and `Connect.iOSAppGroupIdentifier` already set in
160
+ `ConnectConfig.json` — run `doctor` first if it's missing.
161
+
87
162
  ## Using with Expo SDK 55+
88
163
 
89
164
  Expo SDK 55 and newer is supported via the bundled Expo Config Plugin.
@@ -409,11 +484,36 @@ releases. Match the version rather than bypass it.
409
484
 
410
485
  ### iOS — push session reaches the collector but no notifications arrive (no APNs token)
411
486
 
412
- A CLI build (`expo run:ios` / `react-native run-ios` / `xcodebuild`) doesn't
413
- auto-pick a signing team the way the Xcode GUI does. With no team, it signs
414
- ad-hoc and **drops the `aps-environment` entitlement**, so iOS issues no APNs
415
- token the app still launches and analytics reach the collector, but no
416
- `pushRegistration` (with a `mobileToken`) is ever sent. Fix:
487
+ **Fingerprint:** analytics and `pushRegistration` still reach the collector
488
+ normally, but `mobileToken` stays `null` forever and, notably, **no
489
+ `didFailToRegisterForRemoteNotificationsWithError` (or any push-related
490
+ failure) shows up in logs either.** It just silently never happens. That
491
+ absence of any error is what makes this look like a missing/broken app-side
492
+ callback or an SDK bug at first glance; it's almost always a signing
493
+ artifact instead. Run `npx acoustic-connect doctor` first — it detects this
494
+ exact condition (missing/invalid `iOSDevelopmentTeam`, no Development
495
+ certificate) up front, before you go digging through portal config.
496
+
497
+ Cause: a CLI build (`expo run:ios` / `react-native run-ios` / `xcodebuild`)
498
+ doesn't auto-pick a signing team the way the Xcode GUI does. With no team, it
499
+ signs ad-hoc and **drops the `aps-environment` entitlement**, so iOS issues no
500
+ APNs token — the app still launches and analytics reach the collector, but no
501
+ `pushRegistration` with a `mobileToken` is ever sent.
502
+
503
+ **Setting `Connect.iOSDevelopmentTeam` in `ConnectConfig.json` by itself does
504
+ nothing.** It's just a source value — it has no effect on the build until
505
+ `expo prebuild` / `npx acoustic-connect setup-ios-push` actually stamps
506
+ `DEVELOPMENT_TEAM` onto the host **and** the `ConnectNSE` **and** `ConnectNCE`
507
+ targets in the generated `.pbxproj`. Re-run that step any time
508
+ `iOSDevelopmentTeam` changes, and after every fresh clone — a teammate's
509
+ machine may already have the stamp baked in from a prior run; yours doesn't
510
+ until you run it too. This is the classic "works for me, not for them" trap:
511
+ every portal-side check (App ID push capability, provisioning profile,
512
+ entitlements file contents, App Group registration, `.p8` key upload) can
513
+ look perfect and still not reflect what's actually embedded in the binary
514
+ that got installed on the device.
515
+
516
+ Fix:
417
517
 
418
518
  1. Set `Connect.iOSDevelopmentTeam` (10-char Apple Team ID) in
419
519
  `ConnectConfig.json` (or pass the `iosDevelopmentTeam` plugin prop). Re-run
@@ -423,8 +523,22 @@ token — the app still launches and analytics reach the collector, but no
423
523
  ID added in Xcode → Settings → Accounts, so a Development certificate +
424
524
  profiles are provisioned.
425
525
 
426
- `npx acoustic-connect doctor` fails with this exact remedy when push is on and
427
- the team is missing (or when no Development certificate is in your keychain).
526
+ **Verify the fix actually landed** rerunning the steps above isn't proof by
527
+ itself; confirm the build artifacts directly:
528
+
529
+ ```bash
530
+ # 1. DEVELOPMENT_TEAM must be stamped on the host AND both push extensions
531
+ grep DEVELOPMENT_TEAM ios/*.xcodeproj/project.pbxproj
532
+
533
+ # 2. The *installed* binary must carry the entitlement — check the actual
534
+ # .app, not just the source .entitlements file in your repo
535
+ codesign -d --entitlements :- --xml /path/to/YourApp.app
536
+ # must show aps-environment in the output
537
+ ```
538
+
539
+ If `DEVELOPMENT_TEAM` is missing for any of the three targets, or `codesign`
540
+ doesn't show `aps-environment`, the stamp didn't take (or a stale build was
541
+ reused) — re-run `setup-ios-push` / `prebuild` and do a clean rebuild.
428
542
 
429
543
  ### iOS — Expo Android build fails: `No matching client found for package name …`
430
544
 
@@ -1,4 +1,4 @@
1
- #Thu Jul 02 10:44:10 PDT 2026
1
+ #Fri Jul 03 03:03:02 PDT 2026
2
2
  UseWhiteList=true
3
3
  PrintScreen=3
4
4
  UseRandomSample=false
@@ -0,0 +1,88 @@
1
+ // Copyright (C) 2026 Acoustic, L.P. All rights reserved.
2
+ //
3
+ // Unit tests for ensureConnectConfig's local→bundled→missing fallback in
4
+ // cli/doctor.mjs. Uses Node's built-in test runner (node:test) — no build
5
+ // step needed, cli/ is plain ESM.
6
+ //
7
+ // npm run test:cli
8
+
9
+ import { test } from 'node:test'
10
+ import assert from 'node:assert/strict'
11
+ import * as fs from 'node:fs'
12
+ import * as os from 'node:os'
13
+ import * as path from 'node:path'
14
+
15
+ import { ensureConnectConfig } from '../doctor.mjs'
16
+ import { Reporter } from '../lib.mjs'
17
+
18
+ function tmpDir() {
19
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'connect-doctor-test-'))
20
+ }
21
+
22
+ // ensureConnectConfig's 'exists' branch reports as `pass('ConnectConfig.json present')`
23
+ // (label includes the detail) while the other branches use a plain
24
+ // 'ConnectConfig.json' label with a separate detail string — match by prefix
25
+ // so the assertion doesn't depend on that formatting quirk.
26
+ function statusFor(reporter, labelPrefix) {
27
+ return reporter.results.find((r) => r.label.startsWith(labelPrefix))?.status
28
+ }
29
+
30
+ test('ensureConnectConfig: prefers a project-local ConnectConfig.example.json over the bundled one', () => {
31
+ const dir = tmpDir()
32
+ fs.writeFileSync(
33
+ path.join(dir, 'ConnectConfig.example.json'),
34
+ JSON.stringify({ Connect: { AppKey: 'from-local-example' } }),
35
+ )
36
+ const reporter = new Reporter()
37
+
38
+ ensureConnectConfig(reporter, dir, {
39
+ bundledExample: path.join(dir, 'never-read.json'),
40
+ })
41
+
42
+ const written = JSON.parse(fs.readFileSync(path.join(dir, 'ConnectConfig.json'), 'utf8'))
43
+ assert.equal(written.Connect.AppKey, 'from-local-example')
44
+ assert.equal(statusFor(reporter, 'ConnectConfig.json'), 'warn')
45
+ })
46
+
47
+ test('ensureConnectConfig: falls back to the bundled example when no project-local one exists', () => {
48
+ const dir = tmpDir()
49
+ const bundledDir = tmpDir()
50
+ const bundledExample = path.join(bundledDir, 'ConnectConfig.example.json')
51
+ fs.writeFileSync(bundledExample, JSON.stringify({ Connect: { AppKey: 'from-bundled-example' } }))
52
+ const reporter = new Reporter()
53
+
54
+ ensureConnectConfig(reporter, dir, { bundledExample })
55
+
56
+ const written = JSON.parse(fs.readFileSync(path.join(dir, 'ConnectConfig.json'), 'utf8'))
57
+ assert.equal(written.Connect.AppKey, 'from-bundled-example')
58
+ assert.equal(statusFor(reporter, 'ConnectConfig.json'), 'warn')
59
+ })
60
+
61
+ test('ensureConnectConfig: fails cleanly (no crash, no file written) when both are missing', () => {
62
+ const dir = tmpDir()
63
+ const reporter = new Reporter()
64
+
65
+ ensureConnectConfig(reporter, dir, {
66
+ bundledExample: path.join(dir, 'does-not-exist.json'),
67
+ })
68
+
69
+ assert.equal(fs.existsSync(path.join(dir, 'ConnectConfig.json')), false)
70
+ assert.equal(statusFor(reporter, 'ConnectConfig.json'), 'fail')
71
+ })
72
+
73
+ test('ensureConnectConfig: never overwrites an existing ConnectConfig.json', () => {
74
+ const dir = tmpDir()
75
+ fs.writeFileSync(
76
+ path.join(dir, 'ConnectConfig.json'),
77
+ JSON.stringify({ Connect: { AppKey: 'already-configured' } }),
78
+ )
79
+ const reporter = new Reporter()
80
+
81
+ ensureConnectConfig(reporter, dir, {
82
+ bundledExample: path.join(dir, 'never-read.json'),
83
+ })
84
+
85
+ const written = JSON.parse(fs.readFileSync(path.join(dir, 'ConnectConfig.json'), 'utf8'))
86
+ assert.equal(written.Connect.AppKey, 'already-configured')
87
+ assert.equal(statusFor(reporter, 'ConnectConfig.json'), 'pass')
88
+ })
package/cli/doctor.mjs CHANGED
@@ -4,8 +4,9 @@
4
4
  // Connect integration that otherwise fail late and confusingly at build time:
5
5
  // the App Group format, a Java-safe Android package, and the
6
6
  // google-services.json package match. It also scaffolds the gitignored
7
- // ConnectConfig.json from the committed example so a fresh clone has something
8
- // to edit.
7
+ // ConnectConfig.json from a project-local ConnectConfig.example.json if
8
+ // present, else from the copy bundled at the package root — so a fresh
9
+ // project has something to edit.
9
10
  //
10
11
  // Auto-detects the project shape:
11
12
  // - Expo : a root app.json with an `expo` block (identifiers live there;
@@ -20,6 +21,7 @@
20
21
 
21
22
  import fs from 'node:fs'
22
23
  import path from 'node:path'
24
+ import {fileURLToPath} from 'node:url'
23
25
 
24
26
  import {
25
27
  Reporter,
@@ -32,6 +34,10 @@ import {
32
34
  section,
33
35
  } from './lib.mjs'
34
36
 
37
+ // Package root (one level up from cli/) — where the bundled
38
+ // ConnectConfig.example.json ships for real (published) consumers.
39
+ const sdkRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
40
+
35
41
  // App Group format per Apple + the Config Plugin's own validator
36
42
  // (plugin/src/withConnectNSE.ts: APP_GROUP_PATTERN).
37
43
  const APP_GROUP_PATTERN = /^group\.[A-Za-z0-9.-]+$/
@@ -101,10 +107,22 @@ function checkNode(reporter) {
101
107
  )
102
108
  }
103
109
 
104
- // Scaffold the per-developer ConnectConfig.json from the committed example.
105
- function ensureConnectConfig(reporter, dir) {
110
+ // Scaffold the per-developer ConnectConfig.json from an example. Prefers a
111
+ // project-local ConnectConfig.example.json (e.g. this repo's own
112
+ // Examples/bare-workflow and Examples/expo, which commit a customized one) and
113
+ // falls back to the copy bundled with the package — the project-local file
114
+ // generally isn't there for a real (published) consumer.
115
+ //
116
+ // `bundledExample` is overridable purely so cli/__tests__/doctor.test.mjs can
117
+ // exercise the "both missing" branch without touching the real package file.
118
+ export function ensureConnectConfig(
119
+ reporter,
120
+ dir,
121
+ {bundledExample = path.join(sdkRoot, 'ConnectConfig.example.json')} = {},
122
+ ) {
106
123
  const dest = path.join(dir, 'ConnectConfig.json')
107
- const src = path.join(dir, 'ConnectConfig.example.json')
124
+ const localExample = path.join(dir, 'ConnectConfig.example.json')
125
+ const src = fileExists(localExample) ? localExample : bundledExample
108
126
  const result = copyIfMissing(src, dest)
109
127
  if (result === 'created')
110
128
  reporter.warn(
@@ -0,0 +1,154 @@
1
+ // Copyright (C) 2026 Acoustic, L.P. All rights reserved.
2
+ //
3
+ // NOTICE: This file contains material that is confidential and proprietary to
4
+ // Acoustic, L.P. and/or other developers. No license is granted under any intellectual or
5
+ // industrial property rights of Acoustic, L.P. except as may be provided in an agreement with
6
+ // Acoustic, L.P. Any unauthorized copying or distribution of content from this file is
7
+ // prohibited.
8
+
9
+ import Foundation
10
+ import Connect
11
+ import OSLog
12
+
13
+ // Same subsystem/category as the bridge's config logger so the extraction
14
+ // does not change where these lines surface in Console.app.
15
+ private let configLog = Logger(subsystem: "com.acoustic.AcousticConnectRN", category: "config")
16
+
17
+ /// Pure parsing / mapping / conversion logic extracted from
18
+ /// `HybridAcousticConnectRN` (behaviour-preserving extraction) so it can be
19
+ /// unit-tested without constructing the hybrid — the hybrid's `init`
20
+ /// dispatches `load()`, which enables the real SDK with the bundled AppKey.
21
+ internal enum ConnectRNParsing {
22
+
23
+ internal enum IOSPushMode: Equatable {
24
+ case automatic, manual
25
+ var descriptor: String { self == .automatic ? "automatic" : "manual" }
26
+ }
27
+
28
+ /// Lenient `PushEnabled` parser — accepts a `Bool` directly, or a string
29
+ /// like `"true"`/`"false"`/`"yes"`/`"no"`. Anything else falls back to
30
+ /// `false` with a warning so a typo in the JSON doesn't silently enable
31
+ /// push when the developer thought they'd turned it off.
32
+ static func parsePushEnabled(_ raw: Any?) -> Bool {
33
+ if let b = raw as? Bool { return b }
34
+ if let n = raw as? NSNumber { return n.boolValue }
35
+ if let s = raw as? String {
36
+ switch s.lowercased() {
37
+ case "true", "yes", "1": return true
38
+ case "false", "no", "0", "": return false
39
+ default:
40
+ configLog.warning("PushEnabled string \"\(s, privacy: .public)\" not recognised. Falling back to false.")
41
+ return false
42
+ }
43
+ }
44
+ if raw != nil {
45
+ configLog.warning("PushEnabled is set but is neither a Bool nor a recognised string. Falling back to false.")
46
+ }
47
+ return false
48
+ }
49
+
50
+ static func parseIOSPushMode(_ raw: String?) -> IOSPushMode {
51
+ switch raw?.lowercased() {
52
+ case "automatic", "auto", nil, "":
53
+ return .automatic
54
+ case "manual":
55
+ return .manual
56
+ default:
57
+ configLog.error("iOSPushMode \"\(raw ?? "", privacy: .public)\" not recognised. Allowed values: \"automatic\", \"manual\". Falling back to \"automatic\".")
58
+ return .automatic
59
+ }
60
+ }
61
+
62
+ /// Resolves `PushEnabled` + `iOSPushMode` + `iOSAppGroupIdentifier` from the
63
+ /// bundled config into a `ConnectPushConfig`. Validation errors are logged
64
+ /// at `.error`; soft mismatches at `.warning`. The SDK always falls back to
65
+ /// a safe default (`.off`) so an invalid config never crashes the app.
66
+ static func resolvePushConfig(from connectData: [String: Any]) -> ConnectPushConfig {
67
+ let pushEnabled = parsePushEnabled(connectData["PushEnabled"])
68
+ let iOSPushModeRaw = connectData["iOSPushMode"] as? String
69
+ let groupId = connectData["iOSAppGroupIdentifier"] as? String
70
+
71
+ configLog.info("PushEnabled: \(pushEnabled)")
72
+
73
+ // Inconsistency: iOSPushMode is set but PushEnabled is false. The
74
+ // mode value would never take effect; warn the developer so they
75
+ // know to fix one or the other.
76
+ if !pushEnabled, let raw = iOSPushModeRaw, !raw.isEmpty {
77
+ configLog.error("iOSPushMode \"\(raw, privacy: .public)\" is set but PushEnabled is false. Ignoring iOSPushMode; SDK will run with push disabled.")
78
+ return .off
79
+ }
80
+
81
+ guard pushEnabled else {
82
+ configLog.info("iOSPushMode: (not used, PushEnabled is false)")
83
+ configLog.info("iOSAppGroupIdentifier: (not used, PushEnabled is false)")
84
+ return .off
85
+ }
86
+
87
+ let mode = parseIOSPushMode(iOSPushModeRaw)
88
+ configLog.info("iOSPushMode: \(mode.descriptor, privacy: .public)")
89
+ if let groupId = groupId, !groupId.isEmpty {
90
+ configLog.info("iOSAppGroupIdentifier: \(groupId, privacy: .public)")
91
+ } else {
92
+ configLog.warning("iOSAppGroupIdentifier is not set. Required if your app uses a Notification Service or Notification Content extension to render rich push payloads.")
93
+ }
94
+
95
+ switch mode {
96
+ case .automatic:
97
+ return ConnectPushConfig(mode: .automatic, appGroupIdentifier: groupId)
98
+ case .manual:
99
+ return ConnectPushConfig(mode: .manual, appGroupIdentifier: groupId)
100
+ }
101
+ }
102
+
103
+ // Note: nsError(from: PushErrorInfo) stays in HybridAcousticConnectRN —
104
+ // PushErrorInfo is a C++-backed nitro type, and this file is also
105
+ // compiled into the UnitTests bundle, which builds without C++ interop.
106
+
107
+ /// Maps the JS-facing module name to the config store's module name
108
+ /// ("Connect" → "TLFCoreModule").
109
+ static func storeModuleName(for moduleName: String) -> String {
110
+ if moduleName.caseInsensitiveCompare("Connect") == .orderedSame {
111
+ return "TLFCoreModule"
112
+ }
113
+ return moduleName
114
+ }
115
+
116
+ static func convertToAnyDictionary(input: [String: Variant_Bool_String_Double]) -> [String: Any] {
117
+ var result: [String: Any] = [:]
118
+
119
+ for (key, value) in input {
120
+ switch value {
121
+ case .first(let boolValue):
122
+ result[key] = boolValue
123
+ case .second(let stringValue):
124
+ result[key] = stringValue
125
+ case .third(let doubleValue):
126
+ result[key] = doubleValue
127
+ }
128
+ }
129
+
130
+ return result
131
+ }
132
+
133
+ static func convertVariantToAny(_ variant: Variant_Bool_String_Double) -> Any {
134
+ switch variant {
135
+ case .first(let boolValue):
136
+ return boolValue
137
+ case .second(let stringValue):
138
+ return stringValue
139
+ case .third(let doubleValue):
140
+ return doubleValue
141
+ }
142
+ }
143
+
144
+ static func logLevel(from level: Double) -> kConnectMonitoringLevelType {
145
+ let intValue: Int = Int(level)
146
+ if intValue == 0 {
147
+ return kConnectMonitoringLevelType.connectMonitoringLevelIgnore
148
+ } else if intValue == 1 {
149
+ return kConnectMonitoringLevelType.connectMonitoringLevelCellularAndWiFi
150
+ } else {
151
+ return kConnectMonitoringLevelType.connectMonitoringLevelWiFi
152
+ }
153
+ }
154
+ }
@@ -143,7 +143,7 @@ class HybridAcousticConnectRN: HybridAcousticConnectRNSpec {
143
143
  configLog.info("KillSwitchUrl: \(killSwitch, privacy: .public)")
144
144
  }
145
145
 
146
- let pushConfig = self.resolvePushConfig(from: connectData)
146
+ let pushConfig = ConnectRNParsing.resolvePushConfig(from: connectData)
147
147
 
148
148
  // Non-release integrations (useRelease:false — the AcousticConnectDebug
149
149
  // SDK variant) turn on the Connect/Tealeaf/EOCore verbose native logging
@@ -174,84 +174,9 @@ class HybridAcousticConnectRN: HybridAcousticConnectRNSpec {
174
174
  bridgeLog.info("SDK initialised: appKey length=\(appKey.count), push=\(pushConfig.modeDescription, privacy: .public), isReactNative=true, wrapNavigationContainer=true")
175
175
  }
176
176
 
177
- /// Resolves `PushEnabled` + `iOSPushMode` + `iOSAppGroupIdentifier` from the
178
- /// bundled config into a `ConnectPushConfig`. Validation errors are logged
179
- /// at `.error`; soft mismatches at `.warning`. The SDK always falls back to
180
- /// a safe default (`.off`) so an invalid config never crashes the app.
181
- @MainActor
182
- private func resolvePushConfig(from connectData: [String: Any]) -> ConnectPushConfig {
183
- let pushEnabled = self.parsePushEnabled(connectData["PushEnabled"])
184
- let iOSPushModeRaw = connectData["iOSPushMode"] as? String
185
- let groupId = connectData["iOSAppGroupIdentifier"] as? String
186
-
187
- configLog.info("PushEnabled: \(pushEnabled)")
188
-
189
- // Inconsistency: iOSPushMode is set but PushEnabled is false. The
190
- // mode value would never take effect; warn the developer so they
191
- // know to fix one or the other.
192
- if !pushEnabled, let raw = iOSPushModeRaw, !raw.isEmpty {
193
- configLog.error("iOSPushMode \"\(raw, privacy: .public)\" is set but PushEnabled is false. Ignoring iOSPushMode; SDK will run with push disabled.")
194
- return .off
195
- }
196
-
197
- guard pushEnabled else {
198
- configLog.info("iOSPushMode: (not used, PushEnabled is false)")
199
- configLog.info("iOSAppGroupIdentifier: (not used, PushEnabled is false)")
200
- return .off
201
- }
202
-
203
- let mode = self.parseIOSPushMode(iOSPushModeRaw)
204
- configLog.info("iOSPushMode: \(mode.descriptor, privacy: .public)")
205
- if let groupId = groupId, !groupId.isEmpty {
206
- configLog.info("iOSAppGroupIdentifier: \(groupId, privacy: .public)")
207
- } else {
208
- configLog.warning("iOSAppGroupIdentifier is not set. Required if your app uses a Notification Service or Notification Content extension to render rich push payloads.")
209
- }
210
-
211
- switch mode {
212
- case .automatic:
213
- return ConnectPushConfig(mode: .automatic, appGroupIdentifier: groupId)
214
- case .manual:
215
- return ConnectPushConfig(mode: .manual, appGroupIdentifier: groupId)
216
- }
217
- }
218
-
219
- /// Lenient `PushEnabled` parser — accepts a `Bool` directly, or a string
220
- /// like `"true"`/`"false"`/`"yes"`/`"no"`. Anything else falls back to
221
- /// `false` with a warning so a typo in the JSON doesn't silently enable
222
- /// push when the developer thought they'd turned it off.
223
- private func parsePushEnabled(_ raw: Any?) -> Bool {
224
- if let b = raw as? Bool { return b }
225
- if let n = raw as? NSNumber { return n.boolValue }
226
- if let s = raw as? String {
227
- switch s.lowercased() {
228
- case "true", "yes", "1": return true
229
- case "false", "no", "0", "": return false
230
- default:
231
- configLog.warning("PushEnabled string \"\(s, privacy: .public)\" not recognised. Falling back to false.")
232
- return false
233
- }
234
- }
235
- if raw != nil {
236
- configLog.warning("PushEnabled is set but is neither a Bool nor a recognised string. Falling back to false.")
237
- }
238
- return false
239
- }
240
-
241
- private enum IOSPushMode { case automatic, manual
242
- var descriptor: String { self == .automatic ? "automatic" : "manual" } }
243
-
244
- private func parseIOSPushMode(_ raw: String?) -> IOSPushMode {
245
- switch raw?.lowercased() {
246
- case "automatic", "auto", nil, "":
247
- return .automatic
248
- case "manual":
249
- return .manual
250
- default:
251
- configLog.error("iOSPushMode \"\(raw ?? "", privacy: .public)\" not recognised. Allowed values: \"automatic\", \"manual\". Falling back to \"automatic\".")
252
- return .automatic
253
- }
254
- }
177
+ // Push-config resolution and the PushEnabled / iOSPushMode parsers moved
178
+ // to ConnectRNParsing (behaviour-preserving extraction) so
179
+ // they are unit-testable without constructing this hybrid.
255
180
 
256
181
  /// Reads and returns the `Connect` dictionary from the bundled
257
182
  /// `AcousticConnectRNConfig.json`. The bundle is populated at pod install
@@ -488,7 +413,10 @@ class HybridAcousticConnectRN: HybridAcousticConnectRNSpec {
488
413
  // MARK: - Push: helpers
489
414
 
490
415
  /// Builds an `NSError` from the structured bridge error object, shared by
491
- /// `pushDidFailToRegister` and `pushDidReceiveAuthorization`.
416
+ /// `pushDidFailToRegister` and `pushDidReceiveAuthorization`. Kept here
417
+ /// (not in ConnectRNParsing) because `PushErrorInfo` is a C++-backed
418
+ /// nitro type and ConnectRNParsing is also compiled into the UnitTests
419
+ /// bundle, which builds without C++ interop.
492
420
  private static func nsError(from info: PushErrorInfo) -> NSError {
493
421
  NSError(
494
422
  domain: info.domain ?? "ConnectRNBridge",
@@ -816,39 +744,18 @@ class HybridAcousticConnectRN: HybridAcousticConnectRNSpec {
816
744
  return true
817
745
  }
818
746
 
747
+ // The bodies below moved to ConnectRNParsing (behaviour-preserving
748
+ // extraction); these thin delegates keep the class API stable.
819
749
  func testModuleName(moduleName: String) throws -> String {
820
- if (moduleName.caseInsensitiveCompare("Connect") == .orderedSame) {
821
- return "TLFCoreModule"
822
- }
823
- return moduleName
750
+ return ConnectRNParsing.storeModuleName(for: moduleName)
824
751
  }
825
-
826
- func convertToAnyDictionary(input: [String: Variant_Bool_String_Double]) -> [String: Any] {
827
- var result: [String: Any] = [:]
828
-
829
- for (key, value) in input {
830
- switch value {
831
- case .first(let boolValue):
832
- result[key] = boolValue
833
- case .second(let stringValue):
834
- result[key] = stringValue
835
- case .third(let doubleValue):
836
- result[key] = doubleValue
837
- }
838
- }
839
752
 
840
- return result
753
+ func convertToAnyDictionary(input: [String: Variant_Bool_String_Double]) -> [String: Any] {
754
+ return ConnectRNParsing.convertToAnyDictionary(input: input)
841
755
  }
842
756
 
843
757
  func convertVariantToAny(_ variant: Variant_Bool_String_Double) -> Any {
844
- switch variant {
845
- case .first(let boolValue):
846
- return boolValue
847
- case .second(let stringValue):
848
- return stringValue
849
- case .third(let doubleValue):
850
- return doubleValue
851
- }
758
+ return ConnectRNParsing.convertVariantToAny(variant)
852
759
  }
853
760
 
854
761
  // func convertKeyValueMapToDictionary(_ keyValueMap: KeyValueMap) -> [AnyHashable: Any] {
@@ -863,14 +770,7 @@ class HybridAcousticConnectRN: HybridAcousticConnectRNSpec {
863
770
  // }
864
771
 
865
772
  func getLogLevelHelper(level: Double) throws -> kConnectMonitoringLevelType {
866
- let intValue: Int = Int(level)
867
- if intValue == 0 {
868
- return kConnectMonitoringLevelType.connectMonitoringLevelIgnore
869
- } else if intValue == 1 {
870
- return kConnectMonitoringLevelType.connectMonitoringLevelCellularAndWiFi
871
- } else {
872
- return kConnectMonitoringLevelType.connectMonitoringLevelWiFi
873
- }
773
+ return ConnectRNParsing.logLevel(from: level)
874
774
  }
875
775
 
876
776
  func getLogLevel(level: Double) throws -> kConnectMonitoringLevelType {
package/package.json CHANGED
@@ -84,12 +84,14 @@
84
84
  "ios/**/*.mm",
85
85
  "ios/**/*.cpp",
86
86
  "ios/**/*.swift",
87
+ "!ios/Tests/**",
87
88
  "ios/AcousticConnectRNConfig.json",
88
89
  "app.plugin.js",
89
90
  "plugin/build",
90
91
  "plugin/swift",
91
92
  "plugin/src",
92
93
  "cli",
94
+ "ConnectConfig.example.json",
93
95
  "*.podspec",
94
96
  "README.md",
95
97
  "CHANGELOG.md",
@@ -216,7 +218,8 @@
216
218
  "rebuild": "npm run codegen && npm run rebuildExample --prefix ./example",
217
219
  "release": "release-it",
218
220
  "test": "jest",
219
- "test:all": "npm test && npm run test:plugin",
221
+ "test:all": "npm test && npm run test:plugin && npm run test:cli",
222
+ "test:cli": "node --test cli/__tests__/",
220
223
  "test:plugin": "npm run build:plugin && node --test plugin/__tests__/",
221
224
  "test:watch": "jest --watch",
222
225
  "typecheck": "tsc --noEmit",
@@ -226,7 +229,7 @@
226
229
  "source": "src/index",
227
230
  "summary": "react-native ios android tealeaf connect cxa wxca er enhanced-replay",
228
231
  "types": "./lib/typescript/src/index.d.ts",
229
- "version": "19.0.6",
232
+ "version": "19.0.8",
230
233
  "workspaces": [
231
234
  "example",
232
235
  "Examples/bare-workflow"
@@ -12,8 +12,13 @@
12
12
  * Used review ConnectConfig.json is present ot will need to add one.
13
13
  */
14
14
  const fs = require('fs');
15
+ const path = require('path');
15
16
  const filePathBaseConnectConfig = "../../ConnectConfig.json"
16
- const filePathTemplateConnectConfig = "scripts/ConnectConfig.json"
17
+ // Single source of truth for the template — same file cli/doctor.mjs falls
18
+ // back to for bare/Expo consumers. Do not reintroduce a separate copy here.
19
+ // __dirname-relative (not CWD-relative) so this resolves correctly regardless
20
+ // of the working directory the caller runs this script from.
21
+ const filePathTemplateConnectConfig = path.resolve(__dirname, '..', 'ConnectConfig.example.json')
17
22
 
18
23
  console.log("Run reviewConnectConfig.js");
19
24
 
@@ -1,180 +0,0 @@
1
- {
2
- "Connect": {
3
- "AndroidVersion": "11.0.12-beta",
4
- "AppKey": "b6c3709b7a4c479bb4b5a9fb8fec324c",
5
- "KillSwitchUrl": "https://lib-us-2.brilliantcollector.com/collector/switch/b6c3709b7a4c479bb4b5a9fb8fec324c",
6
- "PostMessageUrl": "https://lib-us-2.brilliantcollector.com/collector/collectorPost",
7
- "iOSVersion": "",
8
- "layoutConfigAndroid": {
9
- "AppendMapIds": {
10
- "[w,9290],[v,0]": {
11
- "mid": "ASimpleUIView"
12
- },
13
- "idxPathValue": {
14
- "mid": "giveAdditionalId2"
15
- },
16
- "tag2999999": {
17
- "mid": "giveAdditionalId1"
18
- }
19
- },
20
- "AutoLayout": {
21
- "ExampleMaskingPage": {
22
- "CaptureLayoutDelay": 0,
23
- "CaptureLayoutOn": 0,
24
- "CaptureScreenVisits": false,
25
- "CaptureScreenshotOn": 0,
26
- "CaptureUserEvents": false,
27
- "DisplayName": "",
28
- "Masking": {
29
- "HasCustomMask": true,
30
- "HasMasking": true,
31
- "MaskAccessibilityIdList": [
32
-
33
- ],
34
- "MaskAccessibilityLabelList": [
35
-
36
- ],
37
- "MaskIdList": [
38
- "^9[0-9][0-9][0-9]$",
39
- "^\\[wvv,0\\],\\[dddv,0\\],\\[v,0\\],\\[v,0\\],\\[v,0\\],\\[b,0\\](.)*$"
40
- ],
41
- "MaskValueList": [
42
- "^4[0-9]{12}(?:[0-9]{3})?$",
43
- "^3[47][0-9]{13}$",
44
- "^65[4-9][0-9]{13}|64[4-9][0-9]{13}|6011[0-9]{12}|(622(?:12[6-9]|1[3-9][0-9]|[2-8][0-9][0-9]|9[01][0-9]|92[0-5])[0-9]{10})$",
45
- "^(5[1-5][0-9]{14}|2(22[1-9][0-9]{12}|2[3-9][0-9]{13}|[3-6][0-9]{14}|7[0-1][0-9]{13}|720[0-9]{12}))$"
46
- ],
47
- "Sensitive": {
48
- "capitalCaseAlphabet": "X",
49
- "number": "9",
50
- "smallCaseAlphabet": "x",
51
- "symbol": "#"
52
- }
53
- },
54
- "NumberOfWebViews": 0,
55
- "ScreenChange": false,
56
- "ScreenShot": false
57
- },
58
- "GlobalScreenSettings": {
59
- "CaptureLayoutDelay": 1,
60
- "CaptureLayoutOn": 2,
61
- "CaptureScreenVisits": false,
62
- "CaptureScreenshotOn": 2,
63
- "CaptureUserEvents": true,
64
- "DisplayName": "",
65
- "Masking": {
66
- "HasCustomMask": true,
67
- "HasMasking": true,
68
- "MaskAccessibilityIdList": [
69
-
70
- ],
71
- "MaskAccessibilityLabelList": [
72
-
73
- ],
74
- "MaskIdList": [
75
-
76
- ],
77
- "MaskValueList": [
78
-
79
- ],
80
- "Sensitive": {
81
- "capitalCaseAlphabet": "X",
82
- "number": "9",
83
- "smallCaseAlphabet": "x",
84
- "symbol": "#"
85
- }
86
- },
87
- "NumberOfWebViews": 0,
88
- "ScreenChange": true,
89
- "ScreenShot": true
90
- }
91
- }
92
- },
93
- "layoutConfigIos": {
94
- "AppendMapIds": {
95
- "[w,9290],[v,0]": {
96
- "mid": "ASimpleUIView"
97
- },
98
- "idxPathValue": {
99
- "mid": "giveAdditionalId2"
100
- },
101
- "tag2999999": {
102
- "mid": "giveAdditionalId1"
103
- }
104
- },
105
- "AutoLayout": {
106
- "ExampleMaskingPage": {
107
- "CaptureLayoutDelay": 0,
108
- "CaptureLayoutOn": 0,
109
- "CaptureScreenVisits": false,
110
- "CaptureScreenshotOn": 0,
111
- "CaptureUserEvents": false,
112
- "DisplayName": "",
113
- "Masking": {
114
- "HasCustomMask": true,
115
- "HasMasking": true,
116
- "MaskAccessibilityIdList": [
117
-
118
- ],
119
- "MaskAccessibilityLabelList": [
120
-
121
- ],
122
- "MaskIdList": [
123
- "^9[0-9][0-9][0-9]$",
124
- "^\\[wvv,0\\],\\[dddv,0\\],\\[v,0\\],\\[v,0\\],\\[v,0\\],\\[b,0\\](.)*$"
125
- ],
126
- "MaskValueList": [
127
- "^4[0-9]{12}(?:[0-9]{3})?$",
128
- "^3[47][0-9]{13}$",
129
- "^65[4-9][0-9]{13}|64[4-9][0-9]{13}|6011[0-9]{12}|(622(?:12[6-9]|1[3-9][0-9]|[2-8][0-9][0-9]|9[01][0-9]|92[0-5])[0-9]{10})$",
130
- "^(5[1-5][0-9]{14}|2(22[1-9][0-9]{12}|2[3-9][0-9]{13}|[3-6][0-9]{14}|7[0-1][0-9]{13}|720[0-9]{12}))$"
131
- ],
132
- "Sensitive": {
133
- "capitalCaseAlphabet": "X",
134
- "number": "9",
135
- "smallCaseAlphabet": "x",
136
- "symbol": "#"
137
- }
138
- },
139
- "NumberOfWebViews": 0,
140
- "ScreenChange": false,
141
- "ScreenShot": false
142
- },
143
- "GlobalScreenSettings": {
144
- "CaptureLayoutDelay": 1,
145
- "CaptureLayoutOn": 2,
146
- "CaptureScreenVisits": false,
147
- "CaptureScreenshotOn": 2,
148
- "CaptureUserEvents": true,
149
- "DisplayName": "",
150
- "Masking": {
151
- "HasCustomMask": true,
152
- "HasMasking": true,
153
- "MaskAccessibilityIdList": [
154
-
155
- ],
156
- "MaskAccessibilityLabelList": [
157
-
158
- ],
159
- "MaskIdList": [
160
-
161
- ],
162
- "MaskValueList": [
163
-
164
- ],
165
- "Sensitive": {
166
- "capitalCaseAlphabet": "X",
167
- "number": "9",
168
- "smallCaseAlphabet": "x",
169
- "symbol": "#"
170
- }
171
- },
172
- "NumberOfWebViews": 0,
173
- "ScreenChange": true,
174
- "ScreenShot": true
175
- }
176
- }
177
- },
178
- "useRelease": false
179
- }
180
- }