react-native-acoustic-connect-beta 18.0.22 → 18.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/AcousticConnectRN.podspec +24 -4
  2. package/README.md +237 -67
  3. package/android/build.gradle +47 -2
  4. package/android/gradle.properties +3 -3
  5. package/android/src/main/assets/ConnectAdvancedConfig.json +1 -1
  6. package/android/src/main/assets/ConnectBasicConfig.properties +1 -1
  7. package/android/src/main/assets/TealeafAdvancedConfig.json +1 -1
  8. package/android/src/main/java/com/acousticconnectrn/AcousticConnectRNPackage.java +9 -32
  9. package/android/src/main/java/com/acousticconnectrn/HybridAcousticConnectRN.kt +258 -87
  10. package/ios/HybridAcousticConnectRN.swift +181 -73
  11. package/lib/commonjs/TLTRN.js +18 -12
  12. package/lib/commonjs/TLTRN.js.map +1 -1
  13. package/lib/commonjs/components/Connect.js +3 -0
  14. package/lib/commonjs/components/Connect.js.map +1 -1
  15. package/lib/module/TLTRN.js +18 -12
  16. package/lib/module/TLTRN.js.map +1 -1
  17. package/lib/module/components/Connect.js +3 -0
  18. package/lib/module/components/Connect.js.map +1 -1
  19. package/lib/typescript/src/TLTRN.d.ts.map +1 -1
  20. package/lib/typescript/src/components/Connect.d.ts.map +1 -1
  21. package/lib/typescript/src/specs/react-native-acoustic-connect.nitro.d.ts +62 -0
  22. package/lib/typescript/src/specs/react-native-acoustic-connect.nitro.d.ts.map +1 -1
  23. package/nitrogen/generated/android/c++/JHybridAcousticConnectRNSpec.cpp +10 -0
  24. package/nitrogen/generated/android/c++/JHybridAcousticConnectRNSpec.hpp +2 -0
  25. package/nitrogen/generated/android/kotlin/com/margelo/nitro/acousticconnectrn/HybridAcousticConnectRNSpec.kt +8 -0
  26. package/nitrogen/generated/ios/c++/HybridAcousticConnectRNSpecSwift.hpp +16 -0
  27. package/nitrogen/generated/ios/swift/HybridAcousticConnectRNSpec.swift +2 -0
  28. package/nitrogen/generated/ios/swift/HybridAcousticConnectRNSpec_cxx.swift +24 -0
  29. package/nitrogen/generated/shared/c++/HybridAcousticConnectRNSpec.cpp +2 -0
  30. package/nitrogen/generated/shared/c++/HybridAcousticConnectRNSpec.hpp +2 -0
  31. package/package.json +7 -4
  32. package/scripts/ConnectConfig.json +1 -1
  33. package/src/TLTRN.ts +23 -16
  34. package/src/components/Connect.tsx +3 -0
  35. package/src/specs/react-native-acoustic-connect.nitro.ts +63 -0
@@ -49,8 +49,7 @@ import com.acoustic.connect.android.connectmod.Connect.registerFormField
49
49
  import com.acoustic.connect.android.connectmod.Connect.resumeConnect
50
50
  import com.facebook.react.bridge.LifecycleEventListener
51
51
  import com.facebook.react.bridge.ReactApplicationContext
52
- import com.facebook.react.uimanager.NativeViewHierarchyManager
53
- import com.facebook.react.uimanager.UIManagerModule
52
+ import com.facebook.react.uimanager.UIManagerHelper
54
53
  import com.ibm.eo.EOCore
55
54
  import com.ibm.eo.model.EOMonitoringLevel
56
55
  import com.margelo.nitro.NitroModules.Companion.applicationContext
@@ -65,22 +64,201 @@ import com.tl.uic.util.keyboardview.KeyboardView
65
64
  import java.util.Objects
66
65
 
67
66
 
68
- class HybridAcousticConnectRN(private val reactContext: ReactApplicationContext) : HybridAcousticConnectRNSpec(),
67
+ class HybridAcousticConnectRN : HybridAcousticConnectRNSpec(),
69
68
  LifecycleEventListener {
70
69
 
70
+ // The Nitro C++ factory (`AcousticConnectRNOnLoad.cpp`) instantiates this
71
+ // class with `getConstructor<()>()` — i.e. it expects a zero-arg
72
+ // constructor. The React context can therefore not be passed in at
73
+ // construction time; we resolve it lazily via `NitroModules.applicationContext`
74
+ // (which is nullable until the React context attaches), guard every
75
+ // access, and retry the lifecycle-listener registration on each
76
+ // `enable()` call until it succeeds.
77
+
78
+ // Touched only on the main looper. All read/write goes through
79
+ // `runOnMain { ... }` (called from `init`, `enable()`, etc.), so the
80
+ // unsynchronised access pattern is safe — the field's mutating thread
81
+ // and reading thread are the same.
82
+ private var lifecycleListenerRegistered = false
83
+
71
84
  init {
72
- // Add your listener logic here
73
- Log.v(TAG, "HybridAcousticConnectRNSpec has been initialized")
85
+ Log.v(TAG, "[bridge] HybridAcousticConnectRN constructed")
86
+ runOnMain {
87
+ tryRegisterLifecycleListenerOnMain()
88
+ // Cold-start auto-init: mirrors the iOS bridge's behaviour, where
89
+ // `load()` runs inside the constructor's `Task { @MainActor in … }`
90
+ // and initialises the SDK without waiting for an explicit JS
91
+ // call. Without this, an Android cold start where the first
92
+ // activity `onResume` fires before the lifecycle listener was
93
+ // registered (slow JS bundle, RAM-bundle apps, dev hot reload)
94
+ // would leave the SDK uninitialised until the next resume.
95
+ maybeAutoInitOnMain()
96
+ }
97
+ }
74
98
 
75
- Handler(Looper.getMainLooper()).post {
76
- if (reactContext.hasActiveCatalystInstance()) {
77
- reactContext.addLifecycleEventListener(this)
78
- } else {
79
- Log.v(TAG, "ReactContext is not ready. LifecycleEventListener not registered.")
99
+ /**
100
+ * Posts to the main looper. Used by every operation that touches
101
+ * `lifecycleListenerRegistered`, registers a `LifecycleEventListener`,
102
+ * or calls into `Connect.init`/`Connect.enable`/`Connect.disable` — so
103
+ * all of those operations execute single-threaded on main, eliminating
104
+ * read-then-write races on the registration flag.
105
+ */
106
+ private fun runOnMain(block: () -> Unit) {
107
+ Handler(Looper.getMainLooper()).post(block)
108
+ }
109
+
110
+ /**
111
+ * Idempotent: registers this hybrid as a React lifecycle listener the
112
+ * first time `NitroModules.applicationContext` is non-null, then no-ops.
113
+ *
114
+ * MUST be called on the main looper. Callers funnel through
115
+ * [runOnMain]; the read-then-write of `lifecycleListenerRegistered` is
116
+ * therefore single-threaded by construction.
117
+ */
118
+ private fun tryRegisterLifecycleListenerOnMain() {
119
+ if (lifecycleListenerRegistered) return
120
+ val ctx = applicationContext
121
+ if (ctx == null) {
122
+ Log.v(TAG, "[bridge] React context not ready; lifecycle listener registration deferred")
123
+ return
124
+ }
125
+ ctx.addLifecycleEventListener(this)
126
+ lifecycleListenerRegistered = true
127
+ Log.v(TAG, "[bridge] Lifecycle listener registered")
128
+ }
129
+
130
+ /**
131
+ * Cold-start auto-init helper. Calls `Connect.init` + `Connect.enable`
132
+ * if the SDK isn't already running and the React context is attached.
133
+ * MUST be called on the main looper.
134
+ *
135
+ * Idempotent at every level:
136
+ * - If the SDK is already enabled, returns immediately.
137
+ * - If the context isn't ready, returns without acting (the subsequent
138
+ * `onHostResume` lifecycle callback or an explicit `enable()` from JS
139
+ * will retry).
140
+ * - `Connect.init` and `Connect.enable` are themselves idempotent in
141
+ * the native SDK, so this co-existing with a later JS-driven
142
+ * `enable()` is safe.
143
+ */
144
+ private fun maybeAutoInitOnMain() {
145
+ if (Connect.isEnabled()) return
146
+ val app = resolveApplication() ?: return
147
+ if (Connect.getApplication() == null) {
148
+ Connect.init(app)
149
+ }
150
+ Connect.enable()
151
+ Log.i(TAG, "[bridge] SDK auto-initialised at bridge construction")
152
+ }
153
+
154
+ /**
155
+ * Resolves the host app's `Application` from
156
+ * `NitroModules.applicationContext`. Returns `null` (with a logged
157
+ * warning) when the React context isn't attached yet — callers must
158
+ * bail rather than NPE. Replaces the previous direct deref through a
159
+ * constructor-injected `ReactApplicationContext` field, which was unsafe
160
+ * because Nitro's factory passes a null JNI handle through Kotlin's
161
+ * non-null platform type.
162
+ */
163
+ private fun resolveApplication(): Application? {
164
+ val app = applicationContext?.applicationContext as? Application
165
+ if (app == null) {
166
+ Log.w(TAG, "[bridge] Application not yet available (NitroModules.applicationContext is null or its applicationContext is not an Application)")
167
+ }
168
+ return app
169
+ }
170
+
171
+ // region Gate-keeper API (CA-137696)
172
+
173
+ /**
174
+ * Re-enables the Connect SDK after a prior `disable()`. All configuration
175
+ * comes from `ConnectConfig.json` at the consumer's project root, which
176
+ * `config.gradle` bakes into `ConnectBasicConfig.properties` /
177
+ * `TealeafBasicConfig.properties` at build time.
178
+ *
179
+ * Idempotency is owned by the native SDK. `Connect.init` is safe to call
180
+ * multiple times, and `Connect.enable` short-circuits once the SDK is
181
+ * running. The bridge does not track its own enable signature — subsequent
182
+ * JS calls just post another runnable that the native side will treat as
183
+ * a no-op.
184
+ *
185
+ * Push wiring on Android is gated at build time by `Connect.PushEnabled`
186
+ * in `ConnectConfig.json`, which `android/build.gradle` consults to
187
+ * include the `connect-push-fcm` artifact. Token forwarding to Connect
188
+ * lives in CA-137698 and runs through the host app's
189
+ * `FirebaseMessagingService`.
190
+ *
191
+ * @return true on accepted dispatch, false if no application context.
192
+ */
193
+ override fun enable(): Boolean {
194
+ Log.i(TAG, "[bridge] enable() called from JS")
195
+ logResolvedPushAvailability()
196
+
197
+ // All work happens on the main looper so the lifecycle-listener
198
+ // registration flag is touched single-threaded and we don't race
199
+ // with the `init { runOnMain { … } }` registration path. Returning
200
+ // `true` reflects "accepted dispatch", not "succeeded"; the actual
201
+ // work logs its own success or failure on main.
202
+ runOnMain {
203
+ tryRegisterLifecycleListenerOnMain()
204
+ val application = resolveApplication() ?: run {
205
+ Log.w(TAG, "[bridge] enable() bailed — Application still null on main thread")
206
+ return@runOnMain
80
207
  }
208
+ Connect.init(application)
209
+ Connect.enable()
210
+ Log.i(TAG, "[bridge] SDK initialised")
211
+ }
212
+ return true
213
+ }
214
+
215
+ /**
216
+ * Disables the Connect SDK. Idempotent — the underlying `Connect.disable()`
217
+ * is safe to call repeatedly on the native side; this override always
218
+ * returns `true` for an accepted dispatch.
219
+ */
220
+ override fun disable(): Boolean {
221
+ Log.i(TAG, "[bridge] disable() called from JS")
222
+ runOnMain {
223
+ Connect.disable()
224
+ Log.i(TAG, "[bridge] SDK disabled")
81
225
  }
226
+ return true
82
227
  }
83
228
 
229
+ /**
230
+ * Checks whether the `connect-push-fcm` artifact is on the classpath and
231
+ * logs the result. The artifact is gated by `Connect.PushEnabled` in
232
+ * `ConnectConfig.json` via `android/build.gradle`'s conditional
233
+ * `implementation` clause. A missing artifact when `PushEnabled` was set
234
+ * to true points at a build-pipeline issue (didn't run `config.gradle`
235
+ * or the conditional didn't fire). Once CA-137698 lands the actual
236
+ * Connect push API on Android, this method also gates the wire-up.
237
+ */
238
+ private fun logResolvedPushAvailability() {
239
+ val pushAvailable = isConnectPushFcmAvailable()
240
+ Log.i(TAG, "[config] connect-push-fcm on classpath: $pushAvailable")
241
+ if (!pushAvailable) {
242
+ Log.i(TAG, "[config] Push is not active on Android in this build. To enable, set Connect.PushEnabled=true in ConnectConfig.json and re-sync Gradle to include connect-push-fcm. Token forwarding wires in CA-137698.")
243
+ }
244
+ }
245
+
246
+ private fun isConnectPushFcmAvailable(): Boolean {
247
+ // Probe a known class shipped by the connect-push-fcm artifact. The
248
+ // exact class lives in the Connect Android SDK's push module and is
249
+ // finalised under CA-137698; this Class.forName probe is robust to
250
+ // package-name changes because it falls through silently when the
251
+ // class is missing (which is the default in a no-push build).
252
+ return try {
253
+ Class.forName(CONNECT_PUSH_FCM_PROBE_CLASS)
254
+ true
255
+ } catch (e: ClassNotFoundException) {
256
+ false
257
+ }
258
+ }
259
+
260
+ // endregion
261
+
84
262
  /**
85
263
  * Sets the module's boolean configuration item from AdvancedConfig.json or BasicConfig.properties that matches the specified key.
86
264
  *
@@ -282,35 +460,28 @@ class HybridAcousticConnectRN(private val reactContext: ReactApplicationContext)
282
460
  * @return True if the operation was successful, false otherwise.
283
461
  */
284
462
  override fun logClickEvent(target: Double, controlId: String): Boolean {
285
- var result = false
286
- try {
287
- val context: ReactApplicationContext = reactContext
288
- // Add UI-block so we can get a valid reference to the map-view
289
- val uiManager = context.getNativeModule(UIManagerModule::class.java)
290
-
291
- Objects.requireNonNull<UIManagerModule?>(uiManager)
292
- .addUIBlock { nvhm: NativeViewHierarchyManager ->
293
- val view = nvhm.resolveView(target.toInt())
294
- if (view == null) {
295
- result = false
296
- } else {
297
- if (view is EditText) {
298
- addFocusAndRegister(view, null, getCurrentActivity()!!)
299
- } else {
300
- if (!TextUtils.isEmpty(controlId)) {
301
- logEvent(view, "click", controlId)
302
- } else {
303
- logEvent(view, "click")
304
- }
305
- }
306
- result = true
307
- }
463
+ val viewTag = target.toInt()
464
+ return try {
465
+ val ctx = applicationContext ?: return false
466
+ val uiManager = UIManagerHelper.getUIManagerForReactTag(ctx, viewTag)
467
+ ?: return false
468
+ val view = uiManager.resolveView(viewTag) ?: return false
469
+
470
+ Handler(Looper.getMainLooper()).post {
471
+ val activity = getCurrentActivity() ?: return@post
472
+ if (view is EditText) {
473
+ addFocusAndRegister(view, null, activity)
474
+ } else if (TextUtils.isEmpty(controlId)) {
475
+ logEvent(view, "click")
476
+ } else {
477
+ logEvent(view, "click", controlId)
308
478
  }
309
- } catch (e: java.lang.Exception) {
310
- // Handle the exception
311
- result = false
479
+ }
480
+ true
481
+ } catch (e: Exception) {
482
+ Log.v(TAG, "logClickEvent error: ${e.message}", e)
483
+ false
312
484
  }
313
- return result
314
485
  }
315
486
 
316
487
  /**
@@ -320,31 +491,26 @@ class HybridAcousticConnectRN(private val reactContext: ReactApplicationContext)
320
491
  * @return True if the operation was successful, false otherwise.
321
492
  */
322
493
  fun logClickEvent(target: Double): Boolean {
323
- var result = false
324
- try {
325
- val context: ReactApplicationContext = reactContext
326
- // Add UI-block so we can get a valid reference to the map-view
327
- val uiManager = context.getNativeModule(UIManagerModule::class.java)
328
-
329
- Objects.requireNonNull<UIManagerModule?>(uiManager)
330
- .addUIBlock { nvhm: NativeViewHierarchyManager ->
331
- val view = nvhm.resolveView(target.toInt())
332
- if (view == null) {
333
- result = false
334
- } else {
335
- if (view is EditText) {
336
- addFocusAndRegister(view as EditText, null, getCurrentActivity()!!)
337
- } else {
338
- logEvent(view, "click")
339
- }
340
- result = true
341
- }
494
+ val viewTag = target.toInt()
495
+ return try {
496
+ val ctx = applicationContext ?: return false
497
+ val uiManager = UIManagerHelper.getUIManagerForReactTag(ctx, viewTag)
498
+ ?: return false
499
+ val view = uiManager.resolveView(viewTag) ?: return false
500
+
501
+ Handler(Looper.getMainLooper()).post {
502
+ val activity = getCurrentActivity() ?: return@post
503
+ if (view is EditText) {
504
+ addFocusAndRegister(view, null, activity)
505
+ } else {
506
+ logEvent(view, "click")
342
507
  }
343
- } catch (e: java.lang.Exception) {
344
- // Handle the exception
345
- result = false
508
+ }
509
+ true
510
+ } catch (e: Exception) {
511
+ Log.v(TAG, "logClickEvent error: ${e.message}")
512
+ false
346
513
  }
347
- return result
348
514
  }
349
515
 
350
516
  /**
@@ -356,31 +522,25 @@ class HybridAcousticConnectRN(private val reactContext: ReactApplicationContext)
356
522
  * @return True if the operation was successful, false otherwise.
357
523
  */
358
524
  override fun logTextChangeEvent(target: Double, controlId: String, text: Variant_NullType_String?): Boolean {
359
- var result = false
360
- try {
361
- val context: ReactApplicationContext = reactContext
362
- // Add UI-block so we can get a valid reference to the map-view
363
- val uiManager = context.getNativeModule(UIManagerModule::class.java)
364
-
365
- Objects.requireNonNull<UIManagerModule?>(uiManager)
366
- .addUIBlock { nvhm: NativeViewHierarchyManager ->
367
- val view = nvhm.resolveView(target.toInt())
368
- if (view == null) {
369
- result = false
370
- } else {
371
- if (view is EditText && (view as EditText).onFocusChangeListener == null) {
372
- // First time, logEvent and subsequent calls will be handled in change listener
373
- logEvent(view, TLF_ON_FOCUS_CHANGE_IN, controlId)
374
- addFocusAndRegister(view as EditText, controlId, getCurrentActivity()!!)
375
- }
376
- result = true
377
- }
525
+ val viewTag = target.toInt()
526
+ return try {
527
+ val ctx = applicationContext ?: return false
528
+ val uiManager = UIManagerHelper.getUIManagerForReactTag(ctx, viewTag)
529
+ ?: return false
530
+ val view = uiManager.resolveView(viewTag) ?: return false
531
+
532
+ Handler(Looper.getMainLooper()).post {
533
+ val activity = getCurrentActivity() ?: return@post
534
+ if (view is EditText && view.onFocusChangeListener == null) {
535
+ logEvent(view, TLF_ON_FOCUS_CHANGE_IN, controlId)
536
+ addFocusAndRegister(view, controlId, activity)
378
537
  }
379
- } catch (e: java.lang.Exception) {
380
- // Handle the exception
381
- result = false
538
+ }
539
+ true
540
+ } catch (e: Exception) {
541
+ Log.v(TAG, "logTextChangeEvent error: ${e.message}")
542
+ false
382
543
  }
383
- return result
384
544
  }
385
545
 
386
546
  /**
@@ -832,7 +992,7 @@ class HybridAcousticConnectRN(private val reactContext: ReactApplicationContext)
832
992
  // }
833
993
 
834
994
  fun onWindowFocusChanged(hasFocus: Boolean) {
835
- if (!reactContext.hasActiveCatalystInstance()) {
995
+ if (applicationContext == null) {
836
996
  // logEvent("WindowFocus", "Context is not ready. Skipping onWindowFocusChanged.")
837
997
  return
838
998
  }
@@ -850,7 +1010,7 @@ class HybridAcousticConnectRN(private val reactContext: ReactApplicationContext)
850
1010
  */
851
1011
  override fun onHostResume() {
852
1012
  // Initialize Connect library, and hook into activity lifecycle events to help detect if app is in background
853
- if (!reactContext.hasActiveCatalystInstance()) {
1013
+ if (applicationContext == null) {
854
1014
  // logEvent("Lifecycle", "onHostResume skipped: ReactContext is not ready")
855
1015
  return
856
1016
  }
@@ -863,9 +1023,13 @@ class HybridAcousticConnectRN(private val reactContext: ReactApplicationContext)
863
1023
 
864
1024
  if (!isEnabled()) {
865
1025
  if (getApplication() == null) {
866
- init((reactContext.applicationContext as Application))
1026
+ val app = resolveApplication() ?: return
1027
+ init(app)
867
1028
  }
868
- enable()
1029
+ // Qualified to bypass the `enable()` override on this class —
1030
+ // call the native SDK's `Connect.enable()` directly so the
1031
+ // log line "called from JS" doesn't fire on lifecycle wake-ups.
1032
+ Connect.enable()
869
1033
  }
870
1034
  onResume(activity, null)
871
1035
  }
@@ -901,5 +1065,12 @@ class HybridAcousticConnectRN(private val reactContext: ReactApplicationContext)
901
1065
  companion object {
902
1066
  const val TAG = "AcousticConnectRN"
903
1067
  const val DIALOG_CAPTURE_DELAY_MS = 500L // Configurable delay for dialog screenshot capture
1068
+
1069
+ // Class probed at runtime to detect whether the connect-push-fcm
1070
+ // artifact was included in the build. Conservative placeholder — the
1071
+ // actual class name is finalised under CA-137698; if that ticket lands
1072
+ // a different fully-qualified name, update this constant.
1073
+ private const val CONNECT_PUSH_FCM_PROBE_CLASS =
1074
+ "com.acoustic.connect.android.push.fcm.ConnectPushFcm"
904
1075
  }
905
1076
  }