@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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +54 -0
- package/README.md +385 -2
- package/android/build.gradle +90 -0
- package/android/src/main/java/io/joryio/reactnative/JoryioModule.kt +1188 -0
- package/android/src/main/java/io/joryio/reactnative/JoryioPackage.kt +27 -0
- package/ios/JoryioModule.m +113 -0
- package/ios/JoryioModule.swift +979 -0
- package/joryio-react-native.podspec +36 -0
- package/lib/index.d.ts +1098 -0
- package/lib/index.js +1928 -0
- package/lib/index.js.map +1 -0
- package/package.json +62 -4
- package/react-native.config.js +25 -0
- package/src/index.ts +2355 -0
|
@@ -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
|
+
}
|