@joryio/react-native-sdk 0.0.0-stage → 1.3.0

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.
@@ -0,0 +1,1188 @@
1
+ package io.joryio.reactnative
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.*
5
+ import com.facebook.react.modules.core.DeviceEventManagerModule
6
+ import android.os.Handler
7
+ import android.os.Looper
8
+ import android.util.Log
9
+ import io.joryio.sdk.Joryio
10
+ import io.joryio.sdk.TrackingConsent
11
+ import java.util.concurrent.atomic.AtomicBoolean
12
+ import java.util.concurrent.atomic.AtomicReference
13
+ import kotlin.math.abs
14
+ import io.joryio.sdk.ecommerce.EcommerceCartItem
15
+ import io.joryio.sdk.ecommerce.EcommerceOrder
16
+ import io.joryio.sdk.ecommerce.EcommerceProduct
17
+ import io.joryio.sdk.ecommerce.EcommerceTracker
18
+ import io.joryio.sdk.models.InAppContent
19
+ import io.joryio.sdk.JoryioConfig
20
+
21
+ /**
22
+ * React Native bridge for the Joryio Android SDK.
23
+ * Each method delegates to the native Joryio singleton.
24
+ */
25
+ class JoryioModule(private val reactContext: ReactApplicationContext) :
26
+ ReactContextBaseJavaModule(reactContext) {
27
+
28
+ override fun getName(): String = "JoryioModule"
29
+
30
+ // ─── Initialization ────────────────────────────────────────────────────
31
+
32
+ @ReactMethod
33
+ fun initialize(sdkKey: String?, apiHost: String?, config: ReadableMap?, promise: Promise) {
34
+ // Every parameter nullable (contract item 19): a JS null reaching a
35
+ // non-null Kotlin parameter is a NullPointerException on the native
36
+ // modules thread, which is a crash in a release build. The JS layer
37
+ // checks first; this is the second line of defence.
38
+ if (sdkKey.isNullOrEmpty() || apiHost.isNullOrEmpty()) {
39
+ promise.reject("INIT_ERROR", "initialize() needs a non-empty sdkKey and apiHost")
40
+ return
41
+ }
42
+ // A key the native SDK would refuse is refused HERE, before any native
43
+ // instance exists. Handed to Joryio.initialize(), it built an instance
44
+ // in no-send mode and resolved: JS reported success, nothing was ever
45
+ // sent, and a corrected initialize() got the broken instance back, so
46
+ // only an app restart recovered. Rejected here, a retry with the right
47
+ // key works. Same prefix the native SDK checks.
48
+ if (!sdkKey.startsWith(SDK_KEY_PREFIX)) {
49
+ promise.reject(
50
+ "INIT_ERROR",
51
+ "initialize() was refused: Android SDK keys start with '$SDK_KEY_PREFIX'. Copy your SDK key from the Joryio dashboard.",
52
+ )
53
+ return
54
+ }
55
+ try {
56
+ // Only override keys the JS caller explicitly set; leave everything else
57
+ // to the native SDK's own defaults. Injecting JS-side defaults here would
58
+ // silently diverge RN-Android from native + web (e.g. batchSize/flush/
59
+ // sessionTimeout). This mirrors how the iOS bridge behaves.
60
+ //
61
+ // Every read goes through opt*(): a key that is ABSENT, NULL or of
62
+ // the wrong type means "use the native default". `getInt` on a
63
+ // present-but-null key threw, initialize() rejected, and one
64
+ // `batchSize: null` switched the SDK off for the whole session.
65
+ var joryioConfig = JoryioConfig(userId = config.optString("userId"))
66
+ // Off unless the app opts in - see JoryioConfig.
67
+ config.optBoolean("allowHtmlJsInAppMessages")?.let {
68
+ joryioConfig = joryioConfig.copy(allowHtmlJsInAppMessages = it)
69
+ }
70
+ config.optBoolean("requestPushPermissionAtLaunch")?.let {
71
+ joryioConfig = joryioConfig.copy(requestPushPermissionAtLaunch = it)
72
+ }
73
+ config.optBoolean("enableDebug")?.let { joryioConfig = joryioConfig.copy(enableDebug = it) }
74
+ // Explicit level, same key the iOS bridge accepts. Left unset, the
75
+ // level follows enableDebug (JoryioConfig.effectiveLogLevel).
76
+ config.optString("logLevel")?.uppercase()?.let { raw ->
77
+ io.joryio.sdk.LogLevel.values().firstOrNull { it.name == raw }
78
+ }?.let { joryioConfig = joryioConfig.copy(logLevel = it) }
79
+ config.optInt("batchSize")?.let { joryioConfig = joryioConfig.copy(batchSize = it) }
80
+ config.optLong("flushInterval")?.let { joryioConfig = joryioConfig.copy(flushInterval = it) }
81
+ config.optLong("sessionTimeout")?.let { joryioConfig = joryioConfig.copy(sessionTimeout = it) }
82
+ config.optBoolean("trackSessionStart")?.let {
83
+ joryioConfig = joryioConfig.copy(trackSessionStart = it)
84
+ }
85
+ // Four options the native SDKs honour that this bridge dropped on
86
+ // the floor (audit 2026-09-26, D26): a JS app setting any of them
87
+ // got the native default and no error. Same names as native;
88
+ // `requestTimeout` is milliseconds on both sides here.
89
+ config.optString("anonymousId")?.let { joryioConfig = joryioConfig.copy(anonymousId = it) }
90
+ config.optBoolean("sendImmediately")?.let {
91
+ joryioConfig = joryioConfig.copy(sendImmediately = it)
92
+ }
93
+ config.optInt("maxQueueSize")?.let { joryioConfig = joryioConfig.copy(maxQueueSize = it) }
94
+ config.optLong("requestTimeout")?.let { joryioConfig = joryioConfig.copy(requestTimeout = it) }
95
+ config.optBoolean("enableSdkAuthentication")?.let {
96
+ joryioConfig = joryioConfig.copy(enableSdkAuthentication = it)
97
+ }
98
+ config.optString("sdkAuthenticationToken")?.let {
99
+ joryioConfig = joryioConfig.copy(sdkAuthenticationToken = it)
100
+ }
101
+ // The channel name people see in system Settings, localized by the
102
+ // app. Missing from this bridge, so an RN app's channel always read
103
+ // the English "Notifications" while native Android and Unity apps
104
+ // could pass their own (scan 6, S4). Blank means the default.
105
+ config.optString("notificationChannelName")?.takeIf { it.isNotBlank() }?.let {
106
+ joryioConfig = joryioConfig.copy(notificationChannelName = it)
107
+ }
108
+ // Consent and opt-out at start-up, the same options every Joryio SDK
109
+ // takes. They were missing here, so an RN app could only opt out
110
+ // AFTER initialize() - by which time the session start had gone.
111
+ config.optBoolean("optOut")?.let { joryioConfig = joryioConfig.copy(optOut = it) }
112
+ // UNSET unless JS set it (consent model A, contract item 24): an
113
+ // unset consent keeps a denial stored by an earlier launch, while an
114
+ // explicit GRANTED lifts it. Filling in GRANTED here would turn
115
+ // every app's default config into an explicit grant.
116
+ joryioConfig = joryioConfig.copy(trackingConsent = null)
117
+ config.optString("trackingConsent")?.let { raw ->
118
+ val parsed = parseConsent(raw)
119
+ if (parsed != null) {
120
+ joryioConfig = joryioConfig.copy(trackingConsent = parsed)
121
+ } else {
122
+ Log.e(TAG, "Unknown trackingConsent '$raw' - ignored")
123
+ }
124
+ }
125
+
126
+ // The Activity, when there is one, rather than only the application
127
+ // context. The native SDK learns the foreground Activity from
128
+ // lifecycle callbacks it registers inside initialize() - and React
129
+ // Native runs initialize() AFTER the first Activity resumed, so that
130
+ // callback never came for it. Without this the first session had no
131
+ // Activity: in-app messages could not draw and
132
+ // requestPushPermissionAtLaunch could not prompt until the user left
133
+ // the app and came back. The SDK keeps only the application context
134
+ // and a weak reference to the Activity.
135
+ Joryio.initialize(
136
+ context = reactApplicationContext.currentActivity ?: reactContext.applicationContext,
137
+ sdkKey = sdkKey,
138
+ apiHost = apiHost,
139
+ config = joryioConfig,
140
+ )
141
+
142
+ promise.resolve(null)
143
+ } catch (e: Exception) {
144
+ promise.reject("INIT_ERROR", e.message, e)
145
+ }
146
+ }
147
+
148
+ // ─── Null-safe reads (contract item 19) ────────────────────────────────
149
+ //
150
+ // A key that is absent, JS `null`, or of the wrong type reads as null, so
151
+ // the caller falls back to its default. ReadableMap.getInt/getDouble/
152
+ // getBoolean THROW on a null value, and a throw here either failed
153
+ // initialize() or dropped the call.
154
+
155
+ private inline fun <T> ReadableMap?.opt(
156
+ key: String,
157
+ type: ReadableType,
158
+ read: ReadableMap.(String) -> T?,
159
+ ): T? {
160
+ val map = this ?: return null
161
+ if (!map.hasKey(key) || map.isNull(key)) return null
162
+ val actual = map.getType(key)
163
+ if (actual != type) {
164
+ Log.w(TAG, "'$key' should be ${type.name.lowercase()}, not ${actual.name.lowercase()} - ignored")
165
+ return null
166
+ }
167
+ return map.read(key)
168
+ }
169
+
170
+ private fun ReadableMap?.optString(key: String): String? =
171
+ opt(key, ReadableType.String) { getString(it) }
172
+
173
+ private fun ReadableMap?.optBoolean(key: String): Boolean? =
174
+ opt(key, ReadableType.Boolean) { getBoolean(it) }
175
+
176
+ private fun ReadableMap?.optDouble(key: String): Double? =
177
+ opt(key, ReadableType.Number) { getDouble(it) }
178
+
179
+ private fun ReadableMap?.optInt(key: String): Int? = optDouble(key)?.toInt()
180
+
181
+ private fun ReadableMap?.optLong(key: String): Long? = optDouble(key)?.toLong()
182
+
183
+ private fun ReadableMap?.optMap(key: String): ReadableMap? =
184
+ opt(key, ReadableType.Map) { getMap(it) }
185
+
186
+ private fun ReadableMap?.optArray(key: String): ReadableArray? =
187
+ opt(key, ReadableType.Array) { getArray(it) }
188
+
189
+ /** A required string argument: a warning and false when it is missing. */
190
+ private fun present(caller: String, name: String, value: String?): Boolean {
191
+ if (!value.isNullOrEmpty()) return true
192
+ Log.w(TAG, "$caller() needs a non-empty $name - ignored")
193
+ return false
194
+ }
195
+
196
+ /** Event properties; a null map means none. */
197
+ private fun ReadableMap?.toProps(): Map<String, Any?> =
198
+ this?.toHashMap()?.mapValues { it.value as Any? } ?: emptyMap()
199
+
200
+ // ─── Event Tracking ────────────────────────────────────────────────────
201
+
202
+ @ReactMethod
203
+ fun track(eventName: String?, properties: ReadableMap?) {
204
+ if (!present("track", "eventName", eventName)) return
205
+ Joryio.track(eventName, properties.toProps())
206
+ }
207
+
208
+ @ReactMethod
209
+ fun trackScreen(screenName: String?, properties: ReadableMap?) {
210
+ if (!present("trackScreen", "screenName", screenName)) return
211
+ Joryio.trackScreen(screenName, properties.toProps())
212
+ }
213
+
214
+ // ─── Identity ──────────────────────────────────────────────────────────
215
+
216
+ @ReactMethod
217
+ fun identify(userId: String?) {
218
+ if (!present("identify", "userId", userId)) return
219
+ Joryio.identify(userId)
220
+ }
221
+
222
+ /**
223
+ * A labelled alias (`crm_id` -> `C-1042`). Works before identify(): the
224
+ * native SDK sends it with the anonymous id. The unlabelled alias(userId)
225
+ * is gone from every SDK - identify() is how you name the current person.
226
+ */
227
+ @ReactMethod
228
+ fun addAlias(aliasLabel: String?, aliasName: String?) {
229
+ if (!present("addAlias", "aliasLabel", aliasLabel)) return
230
+ if (!present("addAlias", "aliasName", aliasName)) return
231
+ Joryio.whenReady { it.addAlias(aliasLabel, aliasName) }
232
+ }
233
+
234
+ @ReactMethod
235
+ fun reset() {
236
+ Joryio.reset()
237
+ }
238
+
239
+ // ─── Tracking consent ──────────────────────────────────────────────────
240
+ //
241
+ // Tracking consent, NOT marketing subscription. optOut stops the SDK
242
+ // collecting and sending; it does not unsubscribe anyone from email.
243
+ //
244
+ // Bridged because it was missing: web, Android, iOS and Unity all had these
245
+ // and React Native had no consent surface at all, so an RN app could not
246
+ // honour a "do not track" choice through the SDK. The native call already
247
+ // records `tracking_opted_out` on the profile, so bridging is the whole fix
248
+ // here - no wire behaviour is defined in this layer.
249
+
250
+ @ReactMethod
251
+ fun optOut() {
252
+ Joryio.optOut()
253
+ }
254
+
255
+ @ReactMethod
256
+ fun optIn() {
257
+ Joryio.optIn()
258
+ }
259
+
260
+ @ReactMethod
261
+ fun isUserOptedOut(promise: Promise) {
262
+ // Settled on every path, like getTrackingConsent: before initialize()
263
+ // the answer comes from a store read that can throw, and a throw here
264
+ // left the JS promise pending for good.
265
+ try {
266
+ promise.resolve(Joryio.isUserOptedOut())
267
+ } catch (e: Exception) {
268
+ Log.e(TAG, "isUserOptedOut failed: ${e.message}")
269
+ promise.resolve(null)
270
+ }
271
+ }
272
+
273
+ /** 'granted' releases what was held while consent was pending; 'denied' discards it. */
274
+ @ReactMethod
275
+ fun updateConsent(consent: String?) {
276
+ val parsed = parseConsent(consent)
277
+ if (parsed == null) {
278
+ Log.e(TAG, "updateConsent: unknown value '$consent' - ignored")
279
+ return
280
+ }
281
+ // The static form records a decision made before initialize() and
282
+ // applies it inside init, so nothing is sent first (whenReady would
283
+ // replay it only after init's own sync).
284
+ Joryio.updateConsent(parsed)
285
+ }
286
+
287
+ /**
288
+ * Erase everything this device holds for the current person (identity,
289
+ * queue, pending writes, in-app state, session, device id, push token) and
290
+ * unregister the token on the server. The opt-out is kept.
291
+ */
292
+ @ReactMethod
293
+ fun wipeData() {
294
+ // The static form records a wipe made before initialize() and runs it
295
+ // inside native init, ahead of the Session Start, the in-app sync and
296
+ // the push re-registration (contract item 25), as iOS does.
297
+ Joryio.wipeData()
298
+ }
299
+
300
+ /**
301
+ * The current tracking consent ('granted' | 'denied' | 'pending'),
302
+ * separate from the opt-out that isUserOptedOut() reports. Before
303
+ * initialize() it is what native reads from storage - 'granted' when
304
+ * nothing is decided or stored, the same as iOS - passed through as it is.
305
+ */
306
+ @ReactMethod
307
+ fun getTrackingConsent(promise: Promise) {
308
+ try {
309
+ promise.resolve(Joryio.getTrackingConsent()?.name?.lowercase())
310
+ } catch (e: Exception) {
311
+ promise.resolve(null)
312
+ }
313
+ }
314
+
315
+ private fun parseConsent(raw: String?): TrackingConsent? =
316
+ TrackingConsent.values().firstOrNull { it.name.equals(raw, ignoreCase = true) }
317
+
318
+ // ─── Marketing subscription ────────────────────────────────────────────
319
+ //
320
+ // A DIFFERENT consent from optIn/optOut above: that is whether we may
321
+ // OBSERVE, this is whether we may MESSAGE. Existed on web only, so no
322
+ // mobile app could offer an in-app preference centre.
323
+
324
+ @ReactMethod
325
+ fun setSubscription(channel: String?, status: String?) {
326
+ if (!present("setSubscription", "channel", channel)) return
327
+ if (!present("setSubscription", "status", status)) return
328
+ Joryio.setSubscription(channel, status)
329
+ }
330
+
331
+ @ReactMethod
332
+ fun addToSubscriptionGroup(groupId: String?, channel: String?) {
333
+ if (!present("addToSubscriptionGroup", "groupId", groupId)) return
334
+ if (!present("addToSubscriptionGroup", "channel", channel)) return
335
+ Joryio.addToSubscriptionGroup(groupId, channel)
336
+ }
337
+
338
+ @ReactMethod
339
+ fun removeFromSubscriptionGroup(groupId: String?, channel: String?) {
340
+ if (!present("removeFromSubscriptionGroup", "groupId", groupId)) return
341
+ if (!present("removeFromSubscriptionGroup", "channel", channel)) return
342
+ Joryio.removeFromSubscriptionGroup(groupId, channel)
343
+ }
344
+
345
+ // ─── Attributes ────────────────────────────────────────────────────────
346
+
347
+ /**
348
+ * A null value UNSETS that attribute, the same as on iOS. The JS layer
349
+ * already splits nulls out; this is the guard for anything that reaches the
350
+ * bridge directly. It used to be `mapValues { it.value as Any }`, which
351
+ * threw a NullPointerException on the first null and took the app down.
352
+ */
353
+ @ReactMethod
354
+ fun setAttributes(attributes: ReadableMap?) {
355
+ if (attributes == null) {
356
+ Log.w(TAG, "setAttributes() needs a map of attributes - ignored")
357
+ return
358
+ }
359
+ val values = HashMap<String, Any>()
360
+ val unset = ArrayList<String>()
361
+ for ((key, value) in attributes.toHashMap()) {
362
+ if (value == null) unset.add(key) else values[key] = value
363
+ }
364
+ Joryio.whenReady { sdk ->
365
+ if (values.isNotEmpty()) sdk.setAttributes(values)
366
+ unset.forEach { sdk.unsetAttribute(it) }
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Every ReadableType handled explicitly. `Dynamic.asString()` returns
372
+ * `String?` from React Native 0.81, so the old `else -> value.asString()`
373
+ * arm no longer compiled into an `Any`; and a JS null reached it at all.
374
+ */
375
+ @ReactMethod
376
+ fun setAttribute(key: String?, value: Dynamic?) {
377
+ if (!present("setAttribute", "key", key)) return
378
+ when (value?.type ?: ReadableType.Null) {
379
+ ReadableType.Null -> Joryio.whenReady { it.unsetAttribute(key) }
380
+ ReadableType.Boolean -> {
381
+ val v = value!!.asBoolean()
382
+ Joryio.whenReady { it.setAttribute(key, v) }
383
+ }
384
+ ReadableType.Number -> {
385
+ val v = value!!.asDouble()
386
+ Joryio.whenReady { it.setAttribute(key, v) }
387
+ }
388
+ ReadableType.String -> {
389
+ val v: String? = value!!.asString()
390
+ if (v == null) {
391
+ Joryio.whenReady { it.unsetAttribute(key) }
392
+ } else {
393
+ Joryio.whenReady { it.setAttribute(key, v) }
394
+ }
395
+ }
396
+ else -> Log.e(TAG, "setAttribute('$key'): ${value?.type} values are not supported - ignored")
397
+ }
398
+ }
399
+
400
+ @ReactMethod
401
+ /**
402
+ * `by` is a Dynamic, not a Double: React Native reads a primitive argument
403
+ * with getDouble(), which throws on a JS null before this method runs. A
404
+ * null step means the default, 1.
405
+ */
406
+ fun incrementAttribute(key: String?, by: Dynamic?) {
407
+ if (!present("incrementAttribute", "key", key)) return
408
+ val step = when (by?.type ?: ReadableType.Null) {
409
+ ReadableType.Null -> 1.0
410
+ ReadableType.Number -> by!!.asDouble()
411
+ else -> {
412
+ Log.w(TAG, "incrementAttribute('$key') needs a number to add - ignored")
413
+ return
414
+ }
415
+ }
416
+ Joryio.whenReady { it.incrementAttribute(key, step) }
417
+ }
418
+
419
+ @ReactMethod
420
+ fun unsetAttribute(key: String?) {
421
+ if (!present("unsetAttribute", "key", key)) return
422
+ Joryio.whenReady { it.unsetAttribute(key) }
423
+ }
424
+
425
+ // ─── Push ──────────────────────────────────────────────────────────────
426
+
427
+ @ReactMethod
428
+ fun reportPushDelivered(trackingId: String?) {
429
+ if (!present("reportPushDelivered", "trackingId", trackingId)) return
430
+ Joryio.whenReady { it.reportPushDelivered(trackingId) }
431
+ }
432
+
433
+ /**
434
+ * Resolves with the native PushRegistrationResult. The single-arg form
435
+ * dropped it, so from JS a failed registration was indistinguishable from
436
+ * a successful one (Teamder, 2026-09-29).
437
+ */
438
+ @ReactMethod
439
+ fun registerPushToken(token: String?, promise: Promise) {
440
+ if (token.isNullOrEmpty()) {
441
+ val map = Arguments.createMap()
442
+ map.putBoolean("success", false)
443
+ map.putString("message", "A push token is required")
444
+ map.putBoolean("isUnauthorized", false)
445
+ map.putBoolean("isRetryable", false)
446
+ promise.resolve(map)
447
+ return
448
+ }
449
+ Joryio.whenReady { sdk ->
450
+ sdk.registerPushToken(token) { r ->
451
+ val map = Arguments.createMap()
452
+ map.putBoolean("success", r.success)
453
+ // Local copy: a smart cast on a property from another module is not allowed.
454
+ val message = r.message
455
+ if (message != null) map.putString("message", message) else map.putNull("message")
456
+ map.putBoolean("isUnauthorized", r.isUnauthorized)
457
+ map.putBoolean("isRetryable", r.isRetryable)
458
+ promise.resolve(map)
459
+ }
460
+ }
461
+ }
462
+
463
+ @ReactMethod
464
+ fun unregisterPush() {
465
+ // The static form buffers it as a withdrawal before init: kept by the
466
+ // pre-init cap and by a pre-init wipe, and run under the identity it
467
+ // was made for (contract v5 51; scan 5, N4).
468
+ Joryio.unregisterPush()
469
+ }
470
+
471
+ /**
472
+ * Show the system notification prompt (Android 13+ POST_NOTIFICATIONS).
473
+ *
474
+ * Resolves the resulting status rather than a bare boolean, so JS can tell
475
+ * "denied" from "we could not ask" - the difference decides whether a primer
476
+ * is worth showing again.
477
+ */
478
+ @ReactMethod
479
+ fun requestPushPermission(promise: Promise) {
480
+ val activity = reactApplicationContext.currentActivity
481
+ if (activity == null) {
482
+ // No Activity = nothing to attach a dialog to. Report it instead of
483
+ // throwing: this is a normal race if JS asks during a background tick.
484
+ promise.resolve("unavailable")
485
+ return
486
+ }
487
+ if (!Joryio.isInitialized()) {
488
+ promise.resolve("unavailable")
489
+ return
490
+ }
491
+ PermissionRequest(promise, activity)
492
+ }
493
+
494
+ /**
495
+ * One prompt, settled exactly once - and ALWAYS settled.
496
+ *
497
+ * The prompt's answer is delivered to the Activity that showed it. If
498
+ * Android recreates that Activity while the prompt is up (rotation, a
499
+ * dark-mode switch, any config change) the answer goes nowhere, and the JS
500
+ * promise used to hang forever. So when the app returns to the foreground
501
+ * after the prompt, the real answer gets a moment to arrive, and otherwise
502
+ * the promise resolves with the status as it now stands.
503
+ */
504
+ private inner class PermissionRequest(
505
+ private val promise: Promise,
506
+ activity: android.app.Activity,
507
+ ) : LifecycleEventListener {
508
+ private val settled = AtomicBoolean(false)
509
+ @Volatile private var leftForeground = false
510
+
511
+ init {
512
+ start(activity)
513
+ }
514
+
515
+ // `private`, like everything here that is not a @ReactMethod: the
516
+ // binding-parity tripwire reads every bare `fun` in this file as one.
517
+ private fun start(activity: android.app.Activity) {
518
+ reactApplicationContext.addLifecycleEventListener(this)
519
+ if (!Joryio.isInitialized()) {
520
+ settle("unavailable")
521
+ return
522
+ }
523
+ Joryio.getInstance().requestPushPermission(activity) { status ->
524
+ settle(status.publicName)
525
+ }
526
+ }
527
+
528
+ private fun settle(status: String) {
529
+ if (!settled.compareAndSet(false, true)) return
530
+ reactApplicationContext.removeLifecycleEventListener(this)
531
+ promise.resolve(status)
532
+ }
533
+
534
+ override fun onHostPause() {
535
+ leftForeground = true
536
+ }
537
+
538
+ override fun onHostResume() {
539
+ if (!leftForeground) return
540
+ Handler(Looper.getMainLooper()).postDelayed(
541
+ { settle(currentPushPermissionStatus()) },
542
+ PERMISSION_ANSWER_GRACE_MS,
543
+ )
544
+ }
545
+
546
+ override fun onHostDestroy() {}
547
+ }
548
+
549
+ private fun currentPushPermissionStatus(): String =
550
+ if (Joryio.isInitialized()) {
551
+ Joryio.getInstance().getPushPermissionStatus().publicName
552
+ } else {
553
+ "unavailable"
554
+ }
555
+
556
+ @ReactMethod
557
+ fun getPushPermissionStatus(promise: Promise) {
558
+ promise.resolve(currentPushPermissionStatus())
559
+ }
560
+
561
+ @ReactMethod
562
+ fun isPushEnabled(promise: Promise) {
563
+ // Answer NOW, even if the SDK is not ready. getInstance() throws when it
564
+ // is not, and a throw inside a @ReactMethod is a red box in development
565
+ // and an unhandled JS error in production. Deferring instead would be
566
+ // worse: the promise would simply never settle and the caller would hang.
567
+ promise.resolve(if (Joryio.isInitialized()) Joryio.getInstance().isPushEnabled() else false)
568
+ }
569
+
570
+ @ReactMethod
571
+ fun trackPushClick(trackingId: String?) {
572
+ if (!present("trackPushClick", "trackingId", trackingId)) return
573
+ Joryio.whenReady { it.trackPushClick(trackingId) }
574
+ }
575
+
576
+ // ─── In-App Messaging ──────────────────────────────────────────────────
577
+
578
+ /**
579
+ * Forward in-app campaigns to JS.
580
+ *
581
+ * This used to read `message.html` / `message.css` / a String `message.type`
582
+ * off the native model, and did not compile: the native model described a
583
+ * structured message (title/body/imageUrl/buttons) the server has never
584
+ * sent. The model now matches the wire contract, so the bridge reads the
585
+ * resolved content.
586
+ *
587
+ * A campaign with no renderable document is DROPPED rather than emitted
588
+ * with empty strings - JS would otherwise render a blank container the user
589
+ * has to dismiss, and count an impression for it.
590
+ */
591
+ // ── E-commerce ───────────────────────────────────────────────────────────
592
+
593
+ /**
594
+ * ONE bridge method, not nineteen.
595
+ *
596
+ * EcommerceTracker exposes 19 calls. Forwarding each as its own
597
+ * @ReactMethod would mean 19 signatures here, 19 more in the iOS module and
598
+ * 19 in JS, all of which have to agree - and this repo's recurring failure
599
+ * is exactly that: a name one side emits and the other never branches on.
600
+ * A single entry point with an explicit `when` puts the whole contract in
601
+ * one readable place per platform, and a spec can compare the three lists.
602
+ *
603
+ * WHY FORWARD AT ALL rather than build the events in JS. The canonical
604
+ * event names ("Product Added", not "Added to Cart") and the purchase
605
+ * property keys live in the native tracker. Rebuilding them in JS would be
606
+ * a FIFTH copy of the vocabulary, and there is deliberately no alias layer
607
+ * server-side: a near-miss is stored verbatim and matches no report. Our own
608
+ * demo app shipped "Placed Order" and recorded zero revenue while looking
609
+ * completely healthy.
610
+ *
611
+ * An unknown method logs loudly rather than failing silently, because the
612
+ * failure it guards against - a typo'd method name - would otherwise be a
613
+ * no-op that looks like the SDK working.
614
+ */
615
+ @ReactMethod
616
+ fun ecommerce(method: String?, payload: ReadableMap?) {
617
+ if (!present("ecommerce", "method", method)) return
618
+ // whenReady, not getInstance(): getInstance() THROWS before initialize()
619
+ // and this is reachable from a module field or a first render, which is
620
+ // earlier than initialize() resolves. The repo's own bridge-safety guard
621
+ // caught this - the first version called getInstance() directly.
622
+ //
623
+ // Queuing is also the better behaviour: an e-commerce event fired during
624
+ // startup is a real event, and dropping it would understate revenue
625
+ // rather than fail visibly.
626
+ // Every required argument is checked HERE, before anything is queued
627
+ // (contract item 19). A missing one used to become a default - an
628
+ // empty orderId, a zero total - so `purchase(null)` tracked an Order
629
+ // Completed worth nothing instead of being refused. The JS layer checks
630
+ // first; this is the second line of defence.
631
+ val call: ((EcommerceTracker) -> Unit)? = when (method) {
632
+ "viewProduct" -> product(method, payload.optMap("product"))?.let { p -> ecommerceCall { t -> t.viewProduct(p) } }
633
+ "viewCategory" -> {
634
+ val id = need(method, "categoryId", payload.optString("categoryId"))
635
+ val name = need(method, "categoryName", payload.optString("categoryName"))
636
+ if (id != null && name != null) ecommerceCall { t -> t.viewCategory(id, name) } else null
637
+ }
638
+ "search" -> need(method, "query", payload.optString("query"))?.let { q ->
639
+ val count = payload.optInt("resultCount")
640
+ ecommerceCall { t -> t.search(q, count) }
641
+ }
642
+ "addToCart" -> cartItem(method, payload.optMap("item"))?.let { item ->
643
+ val cartValue = payload.optDouble("cartValue")
644
+ ecommerceCall { t -> t.addToCart(item, cartValue) }
645
+ }
646
+ "removeFromCart" -> product(method, payload.optMap("product"))?.let { p ->
647
+ val quantity = payload.optInt("quantity") ?: 1
648
+ ecommerceCall { t -> t.removeFromCart(p, quantity) }
649
+ }
650
+ "cartViewed" -> cart(method, payload) { t, items, value -> t.cartViewed(items, value) }
651
+ "updateCart" -> cart(method, payload) { t, items, value -> t.updateCart(items, value) }
652
+ "startCheckout" -> cart(method, payload) { t, items, value -> t.startCheckout(items, value) }
653
+ "addPaymentInfo" -> need(method, "paymentMethod", payload.optString("paymentMethod"))
654
+ ?.let { pm -> ecommerceCall { t -> t.addPaymentInfo(pm) } }
655
+ "purchase" -> order(method, payload.optMap("order"))?.let { o -> ecommerceCall { t -> t.purchase(o) } }
656
+ "orderFulfilled" -> need(method, "orderId", payload.optString("orderId"))?.let { id ->
657
+ val trackingNumber = payload.optString("trackingNumber")
658
+ val carrier = payload.optString("carrier")
659
+ ecommerceCall { t -> t.orderFulfilled(id, trackingNumber, carrier) }
660
+ }
661
+ "orderDelivered" -> need(method, "orderId", payload.optString("orderId"))
662
+ ?.let { id -> ecommerceCall { t -> t.orderDelivered(id) } }
663
+ "orderCancelled" -> need(method, "orderId", payload.optString("orderId"))?.let { id ->
664
+ val reason = payload.optString("reason")
665
+ ecommerceCall { t -> t.orderCancelled(id, reason) }
666
+ }
667
+ "orderRefunded" -> need(method, "orderId", payload.optString("orderId"))?.let { id ->
668
+ val refundAmount = payload.optDouble("refundAmount")
669
+ val reason = payload.optString("reason")
670
+ ecommerceCall { t -> t.orderRefunded(id, refundAmount, reason) }
671
+ }
672
+ "addToWishlist" -> product(method, payload.optMap("product"))?.let { p -> ecommerceCall { t -> t.addToWishlist(p) } }
673
+ "removeFromWishlist" -> product(method, payload.optMap("product"))
674
+ ?.let { p -> ecommerceCall { t -> t.removeFromWishlist(p) } }
675
+ "shareProduct" -> {
676
+ val p = product(method, payload.optMap("product"))
677
+ val shareMethod = need(method, "shareMethod", payload.optString("shareMethod"))
678
+ if (p != null && shareMethod != null) ecommerceCall { t -> t.shareProduct(p, shareMethod) } else null
679
+ }
680
+ "applyCoupon" -> need(method, "couponCode", payload.optString("couponCode"))?.let { code ->
681
+ val discountAmount = payload.optDouble("discountAmount")
682
+ val discountType = payload.optString("discountType")
683
+ ecommerceCall { t -> t.applyCoupon(code, discountAmount, discountType) }
684
+ }
685
+ "removeCoupon" -> need(method, "couponCode", payload.optString("couponCode"))
686
+ ?.let { code -> ecommerceCall { t -> t.removeCoupon(code) } }
687
+ // Read as a Double on BOTH bridges, then converted the same way:
688
+ // iOS read 4.5 as 0 (`as? Int` fails on a fraction) while this
689
+ // side gave 4. The native trackers take an Int rating, so a
690
+ // fraction is truncated identically on both until they take a
691
+ // Double like the web SDK.
692
+ "submitReview" -> {
693
+ val productId = need(method, "productId", payload.optString("productId"))
694
+ val rating = amount(method, "rating", payload.optDouble("rating"))?.let { r ->
695
+ // An Int must be able to hold it: toInt() saturated here,
696
+ // while Swift's Int(Double) traps. Refused on both.
697
+ if (abs(r) <= Int.MAX_VALUE.toDouble()) {
698
+ r
699
+ } else {
700
+ Log.w(TAG, "ecommerce.$method() needs a rating an Int can hold - event NOT sent")
701
+ null
702
+ }
703
+ }
704
+ val reviewText = payload.optString("reviewText")
705
+ if (productId != null && rating != null) {
706
+ ecommerceCall { t -> t.submitReview(productId, rating.toInt(), reviewText) }
707
+ } else {
708
+ null
709
+ }
710
+ }
711
+ else -> {
712
+ Log.e(TAG, "Unknown ecommerce method '$method' - event NOT sent")
713
+ null
714
+ }
715
+ }
716
+ // whenReady, not getInstance(): getInstance() THROWS before initialize()
717
+ // and this is reachable from a module field or a first render, which is
718
+ // earlier than initialize() resolves. The repo's own bridge-safety guard
719
+ // caught this - the first version called getInstance() directly.
720
+ //
721
+ // Queuing is also the better behaviour: an e-commerce event fired during
722
+ // startup is a real event, and dropping it would understate revenue
723
+ // rather than fail visibly.
724
+ if (call != null) Joryio.whenReady { sdk -> call(sdk.ecommerce()) }
725
+ }
726
+
727
+ /**
728
+ * One e-commerce call, validated and ready to run against the tracker.
729
+ * Gives each lambda in the `when` its type.
730
+ */
731
+ private fun ecommerceCall(block: (EcommerceTracker) -> Unit): (EcommerceTracker) -> Unit = block
732
+
733
+ /** The three cart calls: an items array and a cartValue, both required. */
734
+ private fun cart(
735
+ method: String,
736
+ payload: ReadableMap?,
737
+ send: (EcommerceTracker, List<EcommerceCartItem>, Double) -> Unit,
738
+ ): ((EcommerceTracker) -> Unit)? {
739
+ val items = cartItems(method, payload.optArray("items"))
740
+ val cartValue = amount(method, "cartValue", payload.optDouble("cartValue"))
741
+ if (items == null || cartValue == null) return null
742
+ return ecommerceCall { t -> send(t, items, cartValue) }
743
+ }
744
+
745
+ /** A required string, or a warning naming the call and null. */
746
+ private fun need(method: String, name: String, value: String?): String? {
747
+ if (!value.isNullOrEmpty()) return value
748
+ Log.w(TAG, "ecommerce.$method() needs a non-empty $name - event NOT sent")
749
+ return null
750
+ }
751
+
752
+ /** A required finite number, or a warning and null. */
753
+ private fun amount(method: String, name: String, value: Double?): Double? {
754
+ if (value != null && value.isFinite()) return value
755
+ Log.w(TAG, "ecommerce.$method() needs a number $name - event NOT sent")
756
+ return null
757
+ }
758
+
759
+ private fun product(method: String, m: ReadableMap?): EcommerceProduct? {
760
+ if (m.optString("productId").isNullOrEmpty()) {
761
+ Log.w(TAG, "ecommerce.$method() needs a product with a productId - event NOT sent")
762
+ return null
763
+ }
764
+ return product(m)
765
+ }
766
+
767
+ private fun cartItem(method: String, m: ReadableMap?): EcommerceCartItem? {
768
+ if (m.optString("productId").isNullOrEmpty()) {
769
+ Log.w(TAG, "ecommerce.$method() needs an item with a productId - event NOT sent")
770
+ return null
771
+ }
772
+ return cartItem(m)
773
+ }
774
+
775
+ private fun cartItems(method: String, arr: ReadableArray?): List<EcommerceCartItem>? {
776
+ if (arr == null) {
777
+ Log.w(TAG, "ecommerce.$method() needs an array of items - event NOT sent")
778
+ return null
779
+ }
780
+ return cartItems(arr)
781
+ }
782
+
783
+ private fun order(method: String, m: ReadableMap?): EcommerceOrder? {
784
+ if (m.optString("orderId").isNullOrEmpty()) {
785
+ Log.w(TAG, "ecommerce.$method() needs an order with an orderId - event NOT sent")
786
+ return null
787
+ }
788
+ // Contract item 42: an order without an items array is not sent
789
+ // either, rather than recorded as an empty order.
790
+ if (m.optArray("items") == null) {
791
+ Log.w(TAG, "ecommerce.$method() needs an order with an items array - event NOT sent")
792
+ return null
793
+ }
794
+ if (amount(method, "order.value", m.optDouble("value")) == null) return null
795
+ return order(m)
796
+ }
797
+
798
+ // `m` may be null (a JS null product): every opt*() then reads null and
799
+ // the defaults below apply, instead of a NullPointerException.
800
+ private fun product(m: ReadableMap?): EcommerceProduct {
801
+ return EcommerceProduct(
802
+ productId = m.optString("productId") ?: "",
803
+ name = m.optString("name") ?: "",
804
+ price = m.optDouble("price") ?: 0.0,
805
+ quantity = m.optInt("quantity"),
806
+ category = m.optString("category"),
807
+ brand = m.optString("brand"),
808
+ variant = m.optString("variant"),
809
+ variantId = m.optString("variantId"),
810
+ sku = m.optString("sku"),
811
+ imageUrl = m.optString("imageUrl"),
812
+ url = m.optString("url"),
813
+ )
814
+ }
815
+
816
+ private fun cartItem(m: ReadableMap?): EcommerceCartItem {
817
+ return EcommerceCartItem(
818
+ productId = m.optString("productId") ?: "",
819
+ name = m.optString("name") ?: "",
820
+ price = m.optDouble("price") ?: 0.0,
821
+ // Quantity defaults to 1, not 0: a cart line with zero quantity has
822
+ // an item total of zero, which is a silently wrong revenue number
823
+ // rather than an obviously wrong one.
824
+ quantity = m.optInt("quantity") ?: 1,
825
+ category = m.optString("category"),
826
+ brand = m.optString("brand"),
827
+ variant = m.optString("variant"),
828
+ variantId = m.optString("variantId"),
829
+ sku = m.optString("sku"),
830
+ imageUrl = m.optString("imageUrl"),
831
+ url = m.optString("url"),
832
+ )
833
+ }
834
+
835
+ private fun cartItems(arr: ReadableArray?): List<EcommerceCartItem> {
836
+ val a = arr ?: return emptyList()
837
+ // A null (or any non-object) entry is SKIPPED, as the JS layer skips
838
+ // it. Mapped to cartItem(null) it became a line with no productId, and
839
+ // the native tracker refused the whole order because of it.
840
+ return (0 until a.size())
841
+ .filter { i -> a.getType(i) == ReadableType.Map }
842
+ .map { i -> cartItem(a.getMap(i)) }
843
+ }
844
+
845
+ private fun order(m: ReadableMap?): EcommerceOrder {
846
+ return EcommerceOrder(
847
+ orderId = m.optString("orderId") ?: "",
848
+ items = cartItems(m.optArray("items")),
849
+ value = m.optDouble("value") ?: 0.0,
850
+ currency = m.optString("currency"),
851
+ shipping = m.optDouble("shipping"),
852
+ tax = m.optDouble("tax"),
853
+ discount = m.optDouble("discount"),
854
+ coupon = m.optString("coupon"),
855
+ )
856
+ }
857
+
858
+ /**
859
+ * Ask the SDK to fetch in-app campaigns now.
860
+ *
861
+ * Bound here because it was iOS-only, so `Joryio.syncInAppCampaigns()` from
862
+ * JS was `undefined` on Android and _dispatch's catch swallowed it - a
863
+ * cross-platform API that silently did nothing on half its platforms. The
864
+ * native method has existed all along (Joryio.kt:1053); only the binding
865
+ * was missing.
866
+ */
867
+ @ReactMethod
868
+ fun syncInAppCampaigns() {
869
+ Joryio.syncInAppCampaigns()
870
+ }
871
+
872
+ /**
873
+ * Forget local frequency state so a campaign can be re-tested.
874
+ *
875
+ * Also previously iOS-only, and the JS doc said Android had "no
876
+ * already-displayed filter at all - nothing to reset". That is out of date:
877
+ * Joryio.kt:1102 clears the per-campaign impression history and the
878
+ * cross-campaign gap, which is exactly the state that suppresses a repeat
879
+ * on Android. It is debug-gated natively, so a release build is unaffected.
880
+ */
881
+ @ReactMethod
882
+ fun resetDisplayedCampaigns() {
883
+ Joryio.resetDisplayedCampaigns()
884
+ }
885
+
886
+ @ReactMethod
887
+ fun enableInAppMessages(capabilities: ReadableArray?) {
888
+ // This module now owns the native callback; see invalidate().
889
+ inAppOwner.set(ownerToken)
890
+ // What THIS app can render, declared from JS.
891
+ //
892
+ // The SDK's own capability list describes the SDK's own views, and those
893
+ // views stop running the moment this callback is set - here they never
894
+ // run at all, because React Native always renders. Worse, `content.html`
895
+ // was derived from a NATIVE config flag describing a native renderer:
896
+ // an RN app with react-native-webview can draw HTML and may have been
897
+ // reporting that it cannot, while one without it may have been
898
+ // reporting that it can. Both directions are wrong, and the server reads
899
+ // an absent capability as "cannot" rather than "unknown".
900
+ //
901
+ // Null keeps the previous behaviour, so an app that does not declare is
902
+ // unchanged.
903
+ // Empty means NOT DECLARED (null), never "can draw nothing" - the same
904
+ // reading as the iOS bridge and the JS layer.
905
+ val declared = capabilities?.let { arr ->
906
+ (0 until arr.size()).mapNotNull { i ->
907
+ if (arr.getType(i) == ReadableType.String) arr.getString(i) else null
908
+ }
909
+ .filter { it.isNotEmpty() }
910
+ .takeIf { it.isNotEmpty() }
911
+ }
912
+ Joryio.whenReady { sdk -> sdk.setInAppMessageCallback({ campaign ->
913
+ val content = campaign.content
914
+ if (content == null) {
915
+ Log.w(TAG, "Campaign ${campaign.id} has no content; not emitting")
916
+ return@setInAppMessageCallback
917
+ }
918
+
919
+ val params = Arguments.createMap().apply {
920
+ putString("id", campaign.id)
921
+ putString("name", campaign.name)
922
+ // Wire value, not the enum name: JS types this as
923
+ // 'modal' | 'banner' | 'slideup' | 'fullscreen' | 'custom'.
924
+ putString("type", campaign.type.name.lowercase())
925
+ putInt("priority", campaign.priority)
926
+
927
+ // Both content kinds reach JS. A React Native app renders its
928
+ // own UI, so dropping native here would mean native campaigns
929
+ // never display in RN apps at all - while showing up as
930
+ // delivered server-side.
931
+ when (content) {
932
+ is InAppContent.Html -> {
933
+ putString("kind", "html")
934
+ putString("html", content.html)
935
+ putString("css", content.css)
936
+ }
937
+ is InAppContent.Native -> {
938
+ putString("kind", "native")
939
+ content.title?.let { putString("title", it) }
940
+ putString("body", content.body)
941
+ content.imageUrl?.let { putString("imageUrl", it) }
942
+ putBoolean("closeButton", content.closeButton)
943
+ putBoolean("backdropDismissible", content.backdropDismissible)
944
+ // Author style overrides. Forwarded rather than applied:
945
+ // in RN the HOST draws the message, so keeping these on
946
+ // the native side would resolve them and then throw them
947
+ // away - the campaign's colours would simply not happen.
948
+ content.style?.let { st ->
949
+ putMap(
950
+ "style",
951
+ Arguments.createMap().apply {
952
+ st.backgroundColor?.let { putString("backgroundColor", it) }
953
+ st.textColor?.let { putString("textColor", it) }
954
+ st.primaryButtonColor?.let { putString("primaryButtonColor", it) }
955
+ st.primaryButtonTextColor?.let {
956
+ putString("primaryButtonTextColor", it)
957
+ }
958
+ st.cornerRadius?.let { putDouble("cornerRadius", it.toDouble()) }
959
+ st.fontSize?.let { putDouble("fontSize", it.toDouble()) }
960
+ st.titleWeight?.let { putString("titleWeight", it) }
961
+ st.textAlign?.let { putString("textAlign", it) }
962
+ st.fontFamily?.let { putString("fontFamily", it) }
963
+ },
964
+ )
965
+ }
966
+ putArray(
967
+ "buttons",
968
+ Arguments.createArray().apply {
969
+ content.buttons.forEach { b ->
970
+ pushMap(
971
+ Arguments.createMap().apply {
972
+ putString("id", b.id)
973
+ putString("text", b.text)
974
+ putString("action", b.action.name.lowercase())
975
+ b.url?.let { putString("url", it) }
976
+ },
977
+ )
978
+ }
979
+ },
980
+ )
981
+ }
982
+ }
983
+ }
984
+
985
+ reactContext
986
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
987
+ .emit("JoryioInAppMessage", params)
988
+ }, declared) }
989
+ }
990
+
991
+ /**
992
+ * Hand in-app rendering back to the native SDK. The JS layer calls this
993
+ * when its last onInAppMessage listener unsubscribes; before it existed,
994
+ * messages kept being handed to JS after that and nobody drew them, for the
995
+ * rest of the session.
996
+ */
997
+ @ReactMethod
998
+ fun disableInAppMessages() {
999
+ inAppOwner.compareAndSet(ownerToken, null)
1000
+ Joryio.whenReady { it.clearInAppMessageCallback() }
1001
+ }
1002
+
1003
+ @ReactMethod
1004
+ fun trackInAppImpression(campaignId: String?, action: String?, elementId: String?) {
1005
+ if (!present("trackInAppImpression", "campaignId", campaignId)) return
1006
+ if (!present("trackInAppImpression", "action", action)) return
1007
+ val element = elementId?.takeIf { it.isNotEmpty() }
1008
+ Joryio.whenReady { it.trackInAppImpression(campaignId, action, element) }
1009
+ }
1010
+
1011
+ /**
1012
+ * A form inside an HTML message the app rendered itself. Resolves with the
1013
+ * server's verdict as a JSON string ({ ok, message?, redirect?, errors? });
1014
+ * never rejects. Mirrors sdk-android `Joryio.submitInAppForm`.
1015
+ */
1016
+ @ReactMethod
1017
+ fun submitInAppForm(campaignId: String?, values: ReadableMap?, promise: Promise) {
1018
+ if (!present("submitInAppForm", "campaignId", campaignId)) {
1019
+ promise.resolve("{\"ok\":false,\"message\":\"Something went wrong. Please try again.\"}")
1020
+ return
1021
+ }
1022
+ Joryio.whenReady { sdk ->
1023
+ sdk.submitInAppForm(campaignId, values.toProps()) { json -> promise.resolve(json) }
1024
+ }
1025
+ }
1026
+
1027
+ // ─── SDK Authentication ────────────────────────────────────────────────
1028
+
1029
+ /**
1030
+ * Through the companion setter, not whenReady (contract v8 84). Before
1031
+ * initialize() it records the token for the constructor, which applies it
1032
+ * ahead of its first requests (Session Start, in-app sync, outbox drain),
1033
+ * and keeps it through a pre-init wipeData(), which retires it to the
1034
+ * erased person's own withdrawals. Via whenReady it was an ordinary
1035
+ * buffered action: applied only after those first requests, and deleted
1036
+ * by a pre-init wipe, so the erased person's unsubscribe went out with no
1037
+ * token and was lost under SDK auth.
1038
+ */
1039
+ @ReactMethod
1040
+ fun setSdkAuthenticationToken(token: String?) {
1041
+ if (!present("setSdkAuthenticationToken", "token", token)) return
1042
+ Joryio.setSdkAuthenticationToken(token)
1043
+ }
1044
+
1045
+ /**
1046
+ * Through the companion setter for the same reasons as the token: set
1047
+ * before initialize(), the handler is installed by the constructor and
1048
+ * hears a 401 on the first requests, and a pre-init wipe keeps it.
1049
+ */
1050
+ @ReactMethod
1051
+ fun enableSdkAuthenticationErrors() {
1052
+ authOwner.set(ownerToken)
1053
+ Joryio.setSdkAuthenticationErrorHandler { error ->
1054
+ val params = Arguments.createMap().apply {
1055
+ // enum name (e.g. SUB_MISMATCH) → wire reason ("sub_mismatch"),
1056
+ // matching the JS SdkAuthError.reason union.
1057
+ putString("reason", error.reason.name.lowercase())
1058
+ error.rawReason?.let { putString("rawReason", it) }
1059
+ // All four fields, on both platforms - see the iOS emitter. A JS
1060
+ // handler that signs the user out on refreshExhausted used to be
1061
+ // dead code on Android, because the field only existed on iOS.
1062
+ error.endpoint?.let { putString("endpoint", it) }
1063
+ putBoolean("refreshExhausted", error.refreshExhausted)
1064
+ }
1065
+
1066
+ reactContext
1067
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
1068
+ .emit("JoryioSdkAuthError", params)
1069
+ }
1070
+ }
1071
+
1072
+ // ─── Utilities ─────────────────────────────────────────────────────────
1073
+
1074
+ @ReactMethod
1075
+ fun flush() {
1076
+ Joryio.flush()
1077
+ }
1078
+
1079
+ @ReactMethod
1080
+ fun getAnonymousId(promise: Promise) {
1081
+ promise.resolve(if (Joryio.isInitialized()) Joryio.getInstance().getAnonymousId() else null)
1082
+ }
1083
+
1084
+ /**
1085
+ * Diagnostics: which server the SDK is talking to, and what went wrong last.
1086
+ *
1087
+ * Ported from iOS so the method exists on BOTH platforms. It was iOS-only,
1088
+ * which meant the one call you reach for when an integration is misbehaving
1089
+ * was missing on the platform more likely to be misbehaving.
1090
+ *
1091
+ * Returns `{ apiEndpoint, lastError? }`, matching the iOS payload exactly so
1092
+ * the JS type covers both.
1093
+ */
1094
+ @ReactMethod
1095
+ fun getDiagnostics(promise: Promise) {
1096
+ if (!Joryio.isInitialized()) {
1097
+ // Diagnostics is what an integrator calls when things look wrong,
1098
+ // which is exactly when the SDK may not be initialized. Report that
1099
+ // fact instead of throwing at them.
1100
+ val notReady = Arguments.createMap()
1101
+ notReady.putNull("apiEndpoint")
1102
+ promise.resolve(notReady)
1103
+ return
1104
+ }
1105
+ val sdk = Joryio.getInstance()
1106
+ val result = Arguments.createMap()
1107
+ result.putString("apiEndpoint", sdk.currentApiEndpoint)
1108
+ sdk.lastTransportError?.let { err ->
1109
+ val lastError = Arguments.createMap()
1110
+ lastError.putString("message", err.message)
1111
+ lastError.putBoolean("isUnauthorized", err.isUnauthorized)
1112
+ lastError.putBoolean("isRetryable", err.isRetryable)
1113
+ // Double, not Int: an epoch-ms timestamp overflows a 32-bit Int.
1114
+ lastError.putDouble("at", err.at.toDouble())
1115
+ result.putMap("lastError", lastError)
1116
+ }
1117
+ promise.resolve(result)
1118
+ }
1119
+
1120
+ @ReactMethod
1121
+ fun getUserId(promise: Promise) {
1122
+ promise.resolve(if (Joryio.isInitialized()) Joryio.getInstance().getUserId() else null)
1123
+ }
1124
+
1125
+ @ReactMethod
1126
+ fun getSessionId(promise: Promise) {
1127
+ promise.resolve(if (Joryio.isInitialized()) Joryio.getInstance().getSessionId() else null)
1128
+ }
1129
+
1130
+ // ─── Teardown (contract item 18) ───────────────────────────────────────
1131
+
1132
+ /**
1133
+ * The JS context is going away - a reload, a dev-menu refresh, the app
1134
+ * tearing React Native down. Hand the native callbacks back so they do not
1135
+ * outlive it.
1136
+ *
1137
+ * Without this, a reload left the native SDK holding the in-app callback of
1138
+ * a dead module: every message was "handed to JS", emitted into a context
1139
+ * nobody listens to, and lost - no native rendering and no JS rendering
1140
+ * until the next process start. The auth-error handler likewise kept a dead
1141
+ * context alive.
1142
+ *
1143
+ * Only the module that installed a callback releases it. On a reload the
1144
+ * new module can install its own before the old one is invalidated, and a
1145
+ * blind clear would then take the NEW one away.
1146
+ */
1147
+ override fun invalidate() {
1148
+ if (inAppOwner.compareAndSet(ownerToken, null)) {
1149
+ Joryio.clearInAppMessageCallback()
1150
+ }
1151
+ if (authOwner.compareAndSet(ownerToken, null)) {
1152
+ Joryio.setSdkAuthenticationErrorHandler(null)
1153
+ }
1154
+ super.invalidate()
1155
+ }
1156
+
1157
+ /** Identity of this module instance for the owner checks above. Holds nothing. */
1158
+ private val ownerToken = Any()
1159
+
1160
+ // Required for NativeEventEmitter
1161
+ @ReactMethod
1162
+ fun addListener(eventName: String?) {}
1163
+
1164
+ @ReactMethod
1165
+ fun removeListeners(count: Int) {}
1166
+
1167
+ private companion object {
1168
+ const val TAG = "JoryioModule"
1169
+
1170
+ /**
1171
+ * Android keys only (contract v5 60). The backend issues one key per
1172
+ * app as `jry_sdk_<platform>_<random>`, so an iOS or web key here
1173
+ * would file this device's data under the other app and send its push
1174
+ * with that app's credentials. `jry_sdk_` alone let them through.
1175
+ */
1176
+ const val SDK_KEY_PREFIX = "jry_sdk_android_"
1177
+
1178
+ /** The module whose callback the native SDK currently holds; see invalidate(). */
1179
+ val inAppOwner = AtomicReference<Any?>(null)
1180
+ val authOwner = AtomicReference<Any?>(null)
1181
+
1182
+ /**
1183
+ * How long after returning to the foreground a permission answer may
1184
+ * take to arrive before the current status is reported instead.
1185
+ */
1186
+ const val PERMISSION_ANSWER_GRACE_MS = 1000L
1187
+ }
1188
+ }