@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,979 @@
1
+ import Foundation
2
+ import UserNotifications
3
+ // RCTEventEmitter, RCTPromiseResolveBlock and friends live in React-Core. The
4
+ // podspec already depends on it, but a Swift file still has to import the
5
+ // module - without this the whole bridge fails to compile with "cannot find
6
+ // type 'RCTEventEmitter' in scope", which is what happened here: iOS had never
7
+ // been built for either demo app, so nothing had ever compiled this file.
8
+ import React
9
+ import Joryio
10
+
11
+ /// React Native bridge for the Joryio iOS SDK.
12
+ /// Each method delegates to the native `Joryio.shared` singleton using its
13
+ /// real public API (see sdk-ios `JoryioSDK.swift`).
14
+ @objc(JoryioModule)
15
+ class JoryioModule: RCTEventEmitter {
16
+
17
+ /// The prefix the native SDK requires of an iOS SDK key (it refuses any
18
+ /// other). Checked here so a wrong key rejects initialize() instead of
19
+ /// resolving into an SDK that sends nothing.
20
+ private static let sdkKeyPrefix = "jry_sdk_ios_"
21
+
22
+ private var hasListeners = false
23
+ /// True while in-app messages are handed to JS instead of drawn natively.
24
+ private var inAppHandedToJS = false
25
+
26
+ /// The module whose callbacks the native SDK currently holds (contract item
27
+ /// 18). Only that module may release them: on a reload the new module can
28
+ /// install its own before the old one is torn down, and a blind clear would
29
+ /// take the NEW one away. An ObjectIdentifier rather than a weak reference,
30
+ /// because a weak reference already reads nil inside `deinit`.
31
+ private enum NativeCallback { case inApp, auth }
32
+ private static var owners: [NativeCallback: ObjectIdentifier] = [:]
33
+ private static let ownerLock = NSLock()
34
+
35
+ /// Clears `callback`'s owner when this module holds it; true if it did.
36
+ private func releaseOwnership(_ callback: NativeCallback) -> Bool {
37
+ Self.ownerLock.lock()
38
+ defer { Self.ownerLock.unlock() }
39
+ guard Self.owners[callback] == ObjectIdentifier(self) else { return false }
40
+ Self.owners[callback] = nil
41
+ return true
42
+ }
43
+
44
+ private func takeOwnership(_ callback: NativeCallback) {
45
+ Self.ownerLock.lock()
46
+ Self.owners[callback] = ObjectIdentifier(self)
47
+ Self.ownerLock.unlock()
48
+ }
49
+
50
+ /// Hand the native callbacks back when this module goes away - a reload, a
51
+ /// dev-menu refresh, React Native tearing down. The callbacks capture `self`
52
+ /// weakly, so once the module is gone they silently RETURN: every in-app
53
+ /// message was then "handed to JS" and dropped, with no native rendering
54
+ /// either, until the next process start.
55
+ private func releaseNativeCallbacks() {
56
+ if releaseOwnership(.inApp) {
57
+ inAppHandedToJS = false
58
+ Joryio.shared.clearInAppMessageCallback()
59
+ }
60
+ if releaseOwnership(.auth) {
61
+ Joryio.shared.clearSdkAuthenticationErrorHandler()
62
+ }
63
+ }
64
+
65
+ override func invalidate() {
66
+ releaseNativeCallbacks()
67
+ super.invalidate()
68
+ }
69
+
70
+ deinit {
71
+ releaseNativeCallbacks()
72
+ }
73
+
74
+ // `requiresMainQueueSetup` is declared in JoryioModule.m (the RCT_EXTERN
75
+ // category) to avoid a duplicate Objective-C selector definition.
76
+
77
+ override func supportedEvents() -> [String]! {
78
+ return ["JoryioInAppMessage", "JoryioSdkAuthError"]
79
+ }
80
+
81
+ override func startObserving() {
82
+ hasListeners = true
83
+ }
84
+
85
+ override func stopObserving() {
86
+ hasListeners = false
87
+ // Every JS listener is gone, so nobody can draw a handed-off message: give
88
+ // rendering back to the native SDK. The JS layer asks for this itself when
89
+ // its last onInAppMessage listener unsubscribes; this covers a listener
90
+ // removed some other way (removeAllListeners, a reload).
91
+ if inAppHandedToJS, releaseOwnership(.inApp) {
92
+ inAppHandedToJS = false
93
+ Joryio.shared.clearInAppMessageCallback()
94
+ }
95
+ }
96
+
97
+ // MARK: - Initialization
98
+
99
+ // Every String / NSDictionary parameter is OPTIONAL (contract item 19): a JS
100
+ // null arrives as nil, and a nil in a non-optional parameter is undefined
101
+ // behaviour in Swift - at best a crash. The JS layer checks first; this is
102
+ // the second line of defence. A null CONFIG VALUE arrives as NSNull, which
103
+ // every `as?` below reads as "not set", so the native default applies.
104
+ @objc func initialize(
105
+ _ sdkKey: String?,
106
+ apiHost: String?,
107
+ config: NSDictionary?,
108
+ resolver resolve: @escaping RCTPromiseResolveBlock,
109
+ rejecter reject: @escaping RCTPromiseRejectBlock
110
+ ) {
111
+ guard let sdkKey, !sdkKey.isEmpty, let apiHost, !apiHost.isEmpty else {
112
+ reject("INIT_ERROR", "initialize() needs a non-empty sdkKey and apiHost", nil)
113
+ return
114
+ }
115
+ // A key the native SDK would refuse is refused HERE, with the prefix the
116
+ // native SDK checks (the Android bridge does the same). This is the ONLY
117
+ // refusal: anything else resolves - see the comment at the native call.
118
+ guard sdkKey.hasPrefix(Self.sdkKeyPrefix) else {
119
+ reject(
120
+ "INIT_ERROR",
121
+ "initialize() was refused: iOS SDK keys start with '\(Self.sdkKeyPrefix)'. "
122
+ + "Copy your SDK key from the Joryio dashboard.",
123
+ nil
124
+ )
125
+ return
126
+ }
127
+ let config: NSDictionary = config ?? [:]
128
+ // JoryioConfig is an immutable struct — build it via its initializer
129
+ // rather than mutating fields (which are `let`).
130
+ let enableDebug = config["enableDebug"] as? Bool ?? false
131
+
132
+ let logLevel: JoryioConfig.LogLevel = {
133
+ // Lower-cased: 'Warn' matched no case here while Android upper-cases.
134
+ if let raw = config["logLevel"] as? String,
135
+ let level = JoryioConfig.LogLevel(rawValue: raw.lowercased()) {
136
+ return level
137
+ }
138
+ return enableDebug ? .debug : .error
139
+ }()
140
+
141
+ let batchSize = config["batchSize"] as? Int ?? ConfigDefaults.batchSize
142
+ // JS sends milliseconds; the iOS SDK expects seconds.
143
+ let flushInterval = (config["flushInterval"] as? Double).map { $0 / 1000.0 }
144
+ ?? ConfigDefaults.flushInterval
145
+ let sessionTimeout = (config["sessionTimeout"] as? Double).map { $0 / 1000.0 }
146
+ ?? ConfigDefaults.sessionTimeout
147
+ let trackSessionStart = config["trackSessionStart"] as? Bool ?? true
148
+ // Opt-in auto-prompt; see JoryioConfig.requestPushPermissionAtLaunch.
149
+ let requestPushPermissionAtLaunch = config["requestPushPermissionAtLaunch"] as? Bool ?? false
150
+ let userId = config["userId"] as? String
151
+
152
+ // Four options the native SDKs honour that this bridge dropped on the
153
+ // floor (audit 2026-09-26, D26): a JS app setting any of them got the
154
+ // native default and no error. Same names as native; `requestTimeout`
155
+ // follows flushInterval - JS sends milliseconds, iOS wants seconds.
156
+ let anonymousId = config["anonymousId"] as? String
157
+ let sendImmediately = config["sendImmediately"] as? Bool ?? false
158
+ let maxQueueSize = config["maxQueueSize"] as? Int ?? ConfigDefaults.maxQueueSize
159
+ let requestTimeout = (config["requestTimeout"] as? Double).map { $0 / 1000.0 }
160
+ ?? ConfigDefaults.requestTimeout
161
+
162
+ // Optional SDK authentication (opt-in). Mirrors the sdk-ios
163
+ // JoryioConfig fields; both default off/nil so behaviour is unchanged.
164
+ let enableSdkAuthentication = config["enableSdkAuthentication"] as? Bool ?? false
165
+ let sdkAuthenticationToken = config["sdkAuthenticationToken"] as? String
166
+
167
+ // In-app: HTML messages render in a web view that can execute script (the
168
+ // SDK's own bridge runs there), so this is opt-in and OFF by default. The
169
+ // server strips <script> and on* handlers, so author JS does not run in
170
+ // normal operation - the residual risk is a sanitiser bypass. The Android bridge already forwarded
171
+ // it; without this arm the same JS config silently did nothing on iOS and
172
+ // an app that opted in still had every HTML campaign skipped.
173
+ let allowHtmlJsInAppMessages = config["allowHtmlJsInAppMessages"] as? Bool ?? false
174
+
175
+ // Consent and opt-out at start-up, the same options every Joryio SDK takes.
176
+ // Missing here, so an RN app could only opt out AFTER initialize() - by
177
+ // which time the session start had been sent.
178
+ let optOut = config["optOut"] as? Bool ?? false
179
+ // UNSET unless JS set it (consent model A, contract item 24): an unset
180
+ // consent keeps a denial stored by an earlier launch, while an explicit
181
+ // `.granted` lifts it. This used to fill in `.granted` for every app that
182
+ // passed nothing, which made the default config an explicit grant.
183
+ var trackingConsent: JoryioConfig.TrackingConsent?
184
+ if let raw = config["trackingConsent"] as? String {
185
+ if let parsed = JoryioConfig.TrackingConsent(rawValue: raw) {
186
+ trackingConsent = parsed
187
+ } else {
188
+ NSLog("[Joryio] Unknown trackingConsent '\(raw)' - ignored")
189
+ }
190
+ }
191
+
192
+ // Two forms, so an unset consent is left OUT and the native SDK's own
193
+ // "unset" default applies rather than a value chosen here.
194
+ let joryioConfig: JoryioConfig
195
+ if let trackingConsent {
196
+ joryioConfig = JoryioConfig(
197
+ userId: userId,
198
+ anonymousId: anonymousId,
199
+ batchSize: batchSize,
200
+ flushInterval: flushInterval,
201
+ sendImmediately: sendImmediately,
202
+ maxQueueSize: maxQueueSize,
203
+ sessionTimeout: sessionTimeout,
204
+ trackSessionStart: trackSessionStart,
205
+ requestPushPermissionAtLaunch: requestPushPermissionAtLaunch,
206
+ inApp: InAppConfig(allowHtmlJsInAppMessages: allowHtmlJsInAppMessages),
207
+ requestTimeout: requestTimeout,
208
+ enableSdkAuthentication: enableSdkAuthentication,
209
+ sdkAuthenticationToken: sdkAuthenticationToken,
210
+ optOut: optOut,
211
+ trackingConsent: trackingConsent,
212
+ enableDebug: enableDebug,
213
+ logLevel: logLevel
214
+ )
215
+ } else {
216
+ joryioConfig = JoryioConfig(
217
+ userId: userId,
218
+ anonymousId: anonymousId,
219
+ batchSize: batchSize,
220
+ flushInterval: flushInterval,
221
+ sendImmediately: sendImmediately,
222
+ maxQueueSize: maxQueueSize,
223
+ sessionTimeout: sessionTimeout,
224
+ trackSessionStart: trackSessionStart,
225
+ requestPushPermissionAtLaunch: requestPushPermissionAtLaunch,
226
+ inApp: InAppConfig(allowHtmlJsInAppMessages: allowHtmlJsInAppMessages),
227
+ requestTimeout: requestTimeout,
228
+ enableSdkAuthentication: enableSdkAuthentication,
229
+ sdkAuthenticationToken: sdkAuthenticationToken,
230
+ optOut: optOut,
231
+ enableDebug: enableDebug,
232
+ logLevel: logLevel
233
+ )
234
+ }
235
+
236
+ DispatchQueue.main.async {
237
+ // Forward SDK-authentication rejections to JS via the event emitter so the
238
+ // RN layer can mint a fresh token and call setSdkAuthenticationToken.
239
+ // Harmless when SDK auth is disabled (the handler simply never fires).
240
+ //
241
+ // Installed BEFORE initialize(), through the native SDK's pre-init path:
242
+ // the native SDK may DEFER its initialization (a launch before the first
243
+ // unlock since boot) and run it later, when protected data becomes
244
+ // available. A handler held for init is applied then; one installed
245
+ // after a deferred initialize() would never have been registered.
246
+ self.takeOwnership(.auth)
247
+ Joryio.shared.setSdkAuthenticationErrorHandler { [weak self] authError in
248
+ self?.emitSdkAuthError(authError)
249
+ }
250
+
251
+ Joryio.shared.initialize(sdkKey: sdkKey, apiHost: apiHost, config: joryioConfig)
252
+
253
+ // Resolved even when the native SDK has not started YET. Before the
254
+ // first unlock since boot it defers its initialization
255
+ // (`isInitializationPending`) and finishes it on unlock, holding every
256
+ // call made meanwhile - so this is not a failure (contract v4 47). This
257
+ // used to reject whenever `currentApiEndpoint` was nil, which a deferred
258
+ // init also is: JS then marked the SDK failed and dropped every call for
259
+ // the whole process, although native would have started a moment later.
260
+ // The real refusals (no key, no host, a key without the iOS prefix) are
261
+ // rejected above, before anything starts.
262
+ if Joryio.shared.isInitializationPending {
263
+ NSLog("[Joryio] initialize(): the device has not been unlocked since it started; the SDK starts at the first unlock and holds every call until then")
264
+ }
265
+ resolve(nil)
266
+ }
267
+ }
268
+
269
+ // MARK: - Event Tracking
270
+
271
+ /// Logs and answers false for a missing required string argument.
272
+ private func present(_ caller: String, _ name: String, _ value: String?) -> Bool {
273
+ if let value, !value.isEmpty { return true }
274
+ NSLog("[Joryio] \(caller)() needs a non-empty \(name) - ignored")
275
+ return false
276
+ }
277
+
278
+ @objc func track(_ eventName: String?, properties: NSDictionary?) {
279
+ guard present("track", "eventName", eventName), let eventName else { return }
280
+ let props = properties as? [String: Any] ?? [:]
281
+ Joryio.shared.track(eventName, properties: props)
282
+ }
283
+
284
+ @objc func trackScreen(_ screenName: String?, properties: NSDictionary?) {
285
+ guard present("trackScreen", "screenName", screenName), let screenName else { return }
286
+ let props = properties as? [String: Any] ?? [:]
287
+ Joryio.shared.trackScreen(screenName, properties: props)
288
+ }
289
+
290
+ // MARK: - Identity
291
+
292
+ @objc func identify(_ userId: String?) {
293
+ guard present("identify", "userId", userId), let userId else { return }
294
+ Joryio.shared.identify(userId)
295
+ }
296
+
297
+ /// A labelled alias (`crm_id` -> `C-1042`). Works before identify(): the
298
+ /// native SDK sends it with the anonymous id. The unlabelled alias(userId) is
299
+ /// gone from every SDK - identify() is how you name the current person.
300
+ @objc func addAlias(_ aliasLabel: String?, aliasName: String?) {
301
+ guard present("addAlias", "aliasLabel", aliasLabel), let aliasLabel,
302
+ present("addAlias", "aliasName", aliasName), let aliasName else { return }
303
+ Joryio.shared.addAlias(aliasLabel, name: aliasName)
304
+ }
305
+
306
+ @objc func reset() {
307
+ Joryio.shared.reset()
308
+ }
309
+
310
+ // MARK: - Tracking consent
311
+ //
312
+ // Tracking consent, NOT marketing subscription. optOut stops the SDK
313
+ // collecting and sending; it does not unsubscribe anyone from email.
314
+ //
315
+ // Bridged because it was missing: web, Android, iOS and Unity all had these
316
+ // and React Native had no consent surface at all. The native call already
317
+ // records `tracking_opted_out` on the profile, so bridging is the whole fix.
318
+
319
+ @objc func optOut() {
320
+ Joryio.shared.optOut()
321
+ }
322
+
323
+ @objc func optIn() {
324
+ Joryio.shared.optIn()
325
+ }
326
+
327
+ /// Erase this device's data for the current person and unregister its push
328
+ /// token on the server; the opt-out is kept (same on every Joryio SDK).
329
+ @objc func wipeData() {
330
+ Joryio.shared.wipeData()
331
+ }
332
+
333
+ @objc func isUserOptedOut(_ resolve: @escaping RCTPromiseResolveBlock,
334
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
335
+ resolve(Joryio.shared.isUserOptedOut())
336
+ }
337
+
338
+ /// The current tracking consent ('granted' | 'denied' | 'pending'), separate
339
+ /// from the opt-out that isUserOptedOut reports. Safe before initialize().
340
+ @objc func getTrackingConsent(_ resolve: @escaping RCTPromiseResolveBlock,
341
+ rejecter reject: @escaping RCTPromiseRejectBlock) {
342
+ resolve(Joryio.shared.getTrackingConsent().rawValue)
343
+ }
344
+
345
+ /// 'granted' releases what was held while consent was pending; 'denied'
346
+ /// discards it; 'pending' holds for this launch. Made before initialize(),
347
+ /// the native SDK records it and applies it inside init.
348
+ @objc func updateConsent(_ consent: String?) {
349
+ guard let consent, let parsed = JoryioConfig.TrackingConsent(rawValue: consent) else {
350
+ NSLog("[Joryio] updateConsent: unknown value '\(consent ?? "null")' - ignored")
351
+ return
352
+ }
353
+ Joryio.shared.updateConsent(parsed)
354
+ }
355
+
356
+ // MARK: - Marketing subscription
357
+ //
358
+ // A DIFFERENT consent from optIn/optOut above: that is whether we may
359
+ // OBSERVE, this is whether we may MESSAGE.
360
+
361
+ @objc func setSubscription(_ channel: String?, status: String?) {
362
+ guard present("setSubscription", "channel", channel), let channel,
363
+ present("setSubscription", "status", status), let status else { return }
364
+ Joryio.shared.setSubscription(channel: channel, status: status)
365
+ }
366
+
367
+ @objc func addToSubscriptionGroup(_ groupId: String?, channel: String?) {
368
+ guard present("addToSubscriptionGroup", "groupId", groupId), let groupId,
369
+ present("addToSubscriptionGroup", "channel", channel), let channel else { return }
370
+ Joryio.shared.addToSubscriptionGroup(groupId, channel: channel)
371
+ }
372
+
373
+ @objc func removeFromSubscriptionGroup(_ groupId: String?, channel: String?) {
374
+ guard present("removeFromSubscriptionGroup", "groupId", groupId), let groupId,
375
+ present("removeFromSubscriptionGroup", "channel", channel), let channel else { return }
376
+ Joryio.shared.removeFromSubscriptionGroup(groupId, channel: channel)
377
+ }
378
+
379
+ // MARK: - Attributes
380
+
381
+ /// A null value UNSETS that attribute, the same as on Android. The JS layer
382
+ /// already splits nulls out; this is the guard for anything that reaches the
383
+ /// bridge directly. It used to forward NSNull as if it were a value.
384
+ @objc func setAttributes(_ attributes: NSDictionary?) {
385
+ guard let attrs = attributes as? [String: Any] else {
386
+ NSLog("[Joryio] setAttributes() needs a map of attributes - ignored")
387
+ return
388
+ }
389
+ var values: [String: Any] = [:]
390
+ var unset: [String] = []
391
+ for (key, value) in attrs {
392
+ if value is NSNull { unset.append(key) } else { values[key] = value }
393
+ }
394
+ if !values.isEmpty { Joryio.shared.setAttributes(values) }
395
+ unset.forEach { Joryio.shared.unsetAttribute($0) }
396
+ }
397
+
398
+ @objc func setAttribute(_ key: String?, value: Any?) {
399
+ guard present("setAttribute", "key", key), let key else { return }
400
+ guard let value, !(value is NSNull) else {
401
+ Joryio.shared.unsetAttribute(key)
402
+ return
403
+ }
404
+ Joryio.shared.setAttribute(key, value: value)
405
+ }
406
+
407
+ /// `by` is untyped so a JS null reaches us at all - a `nonnull NSNumber`
408
+ /// made React Native refuse the call. Null means the default step, 1.
409
+ @objc func incrementAttribute(_ key: String?, by: Any?) {
410
+ guard present("incrementAttribute", "key", key), let key else { return }
411
+ let step: Double
412
+ switch by {
413
+ case nil, is NSNull: step = 1
414
+ case let n as NSNumber: step = n.doubleValue
415
+ default:
416
+ NSLog("[Joryio] incrementAttribute('\(key)') needs a number to add - ignored")
417
+ return
418
+ }
419
+ Joryio.shared.incrementAttribute(key, by: step)
420
+ }
421
+
422
+ @objc func unsetAttribute(_ key: String?) {
423
+ guard present("unsetAttribute", "key", key), let key else { return }
424
+ Joryio.shared.unsetAttribute(key)
425
+ }
426
+
427
+ // MARK: - Push
428
+
429
+ @objc func reportPushDelivered(_ trackingId: String?) {
430
+ guard present("reportPushDelivered", "trackingId", trackingId), let trackingId else { return }
431
+ Joryio.shared.reportPushDelivered(trackingId)
432
+ }
433
+
434
+ /// Resolves with the native PushRegistrationResult. The single-arg form
435
+ /// dropped it, so from JS a failed registration was indistinguishable from a
436
+ /// successful one (Teamder, 2026-09-29).
437
+ @objc func registerPushToken(
438
+ _ token: String?,
439
+ resolver resolve: @escaping RCTPromiseResolveBlock,
440
+ rejecter reject: @escaping RCTPromiseRejectBlock
441
+ ) {
442
+ guard let token, !token.isEmpty else {
443
+ resolve([
444
+ "success": false,
445
+ "message": "A push token is required",
446
+ "isUnauthorized": false,
447
+ "isRetryable": false,
448
+ ])
449
+ return
450
+ }
451
+ Joryio.shared.registerPushToken(token) { r in
452
+ resolve([
453
+ "success": r.success,
454
+ "message": r.message.map { $0 as Any } ?? NSNull(),
455
+ "isUnauthorized": r.isUnauthorized,
456
+ "isRetryable": r.isRetryable,
457
+ ])
458
+ }
459
+ }
460
+
461
+ @objc func unregisterPush() {
462
+ Joryio.shared.unregisterPush()
463
+ }
464
+
465
+ /// Show the system notification prompt, resolving the resulting status.
466
+ ///
467
+ /// Resolves a status string rather than a bare boolean so JS can tell "denied"
468
+ /// from "we could not ask" - that difference decides whether showing a primer
469
+ /// again is worth anything. Vocabulary matches the Android bridge.
470
+ ///
471
+ /// Opted out of tracking: the prompt is STILL shown, as on Android - whether
472
+ /// the app may notify is the user's choice in the OS, not a tracking decision.
473
+ /// It is asked for directly, so nothing registers or reports to Joryio. The
474
+ /// native call skips the prompt while opted out, which left a JS primer
475
+ /// reading `notDetermined` and asking again forever.
476
+ @objc func requestPushPermission(
477
+ _ resolve: @escaping RCTPromiseResolveBlock,
478
+ rejecter reject: @escaping RCTPromiseRejectBlock
479
+ ) {
480
+ Task {
481
+ if Joryio.shared.isUserOptedOut() {
482
+ _ = try? await UNUserNotificationCenter.current()
483
+ .requestAuthorization(options: [.alert, .badge, .sound])
484
+ } else {
485
+ _ = await Joryio.shared.requestPushPermission()
486
+ }
487
+ resolve(await Joryio.shared.getPushPermissionStatus())
488
+ }
489
+ }
490
+
491
+ @objc func getPushPermissionStatus(
492
+ _ resolve: @escaping RCTPromiseResolveBlock,
493
+ rejecter reject: @escaping RCTPromiseRejectBlock
494
+ ) {
495
+ Task { resolve(await Joryio.shared.getPushPermissionStatus()) }
496
+ }
497
+
498
+ @objc func isPushEnabled(
499
+ _ resolve: @escaping RCTPromiseResolveBlock,
500
+ rejecter reject: @escaping RCTPromiseRejectBlock
501
+ ) {
502
+ // isPushEnabled() is async on the iOS SDK — await it before resolving.
503
+ Task {
504
+ let enabled = await Joryio.shared.isPushEnabled()
505
+ resolve(enabled)
506
+ }
507
+ }
508
+
509
+ @objc func trackPushClick(_ trackingId: String?) {
510
+ guard present("trackPushClick", "trackingId", trackingId), let trackingId else { return }
511
+ Joryio.shared.trackPushClick(trackingId: trackingId)
512
+ }
513
+
514
+ // MARK: - E-commerce
515
+
516
+ /// ONE bridge method, not nineteen - see the Android module for the reasoning.
517
+ /// The canonical event names and purchase property keys live in the native
518
+ /// tracker; rebuilding them in JS would be a fifth copy of a vocabulary that
519
+ /// has no server-side alias layer, so a near-miss is stored verbatim and
520
+ /// matches no report.
521
+ ///
522
+ /// The case list here MUST match the Android `when` and the JS surface. A spec
523
+ /// compares all three, because nothing else can: three languages, none
524
+ /// importing another.
525
+ @objc func ecommerce(_ method: String?, payload: NSDictionary?) {
526
+ guard present("ecommerce", "method", method), let method else { return }
527
+ let p = payload as? [String: Any] ?? [:]
528
+
529
+ // Every required argument is checked before anything is sent (contract
530
+ // item 19). A missing one used to become a default - an empty orderId, a
531
+ // zero total - so `purchase(null)` tracked an Order Completed worth
532
+ // nothing instead of being refused. The JS layer checks first.
533
+ func need(_ name: String, _ value: Any?) -> String? {
534
+ if let text = value as? String, !text.isEmpty { return text }
535
+ NSLog("[Joryio] ecommerce.\(method)() needs a non-empty \(name) - event NOT sent")
536
+ return nil
537
+ }
538
+ func amount(_ name: String, _ value: Any?) -> Double? {
539
+ if let n = value as? NSNumber, n.doubleValue.isFinite { return n.doubleValue }
540
+ NSLog("[Joryio] ecommerce.\(method)() needs a number \(name) - event NOT sent")
541
+ return nil
542
+ }
543
+ func product(_ key: String) -> EcommerceProduct? {
544
+ guard let m = p[key] as? [String: Any], let id = m["productId"] as? String, !id.isEmpty else {
545
+ NSLog("[Joryio] ecommerce.\(method)() needs a \(key) with a productId - event NOT sent")
546
+ return nil
547
+ }
548
+ return Self.product(m)
549
+ }
550
+ /// Contract item 42: an order without an items array is not sent either,
551
+ /// rather than recorded as an empty order.
552
+ func orderItems(_ value: Any?) -> Bool {
553
+ if value is [Any] { return true }
554
+ NSLog("[Joryio] ecommerce.\(method)() needs an order with an items array - event NOT sent")
555
+ return false
556
+ }
557
+ func items() -> [EcommerceCartItem]? {
558
+ guard let raw = p["items"] as? [Any] else {
559
+ NSLog("[Joryio] ecommerce.\(method)() needs an array of items - event NOT sent")
560
+ return nil
561
+ }
562
+ return Self.cartItems(raw)
563
+ }
564
+
565
+ let tracker = Joryio.shared.ecommerce()
566
+
567
+ switch method {
568
+ case "viewProduct":
569
+ guard let product = product("product") else { return }
570
+ tracker.viewProduct(product)
571
+ case "viewCategory":
572
+ guard let id = need("categoryId", p["categoryId"]),
573
+ let name = need("categoryName", p["categoryName"]) else { return }
574
+ tracker.viewCategory(categoryId: id, categoryName: name)
575
+ case "search":
576
+ guard let query = need("query", p["query"]) else { return }
577
+ tracker.search(query: query, resultCount: p["resultCount"] as? Int)
578
+ case "addToCart":
579
+ guard product("item") != nil else { return }
580
+ tracker.addToCart(item: Self.cartItem(p["item"]), cartValue: p["cartValue"] as? Double)
581
+ case "removeFromCart":
582
+ guard let product = product("product") else { return }
583
+ tracker.removeFromCart(product: product, quantity: p["quantity"] as? Int ?? 1)
584
+ case "cartViewed":
585
+ guard let items = items(), let value = amount("cartValue", p["cartValue"]) else { return }
586
+ tracker.cartViewed(items: items, cartValue: value)
587
+ case "updateCart":
588
+ guard let items = items(), let value = amount("cartValue", p["cartValue"]) else { return }
589
+ tracker.updateCart(items: items, cartValue: value)
590
+ case "startCheckout":
591
+ guard let items = items(), let value = amount("cartValue", p["cartValue"]) else { return }
592
+ tracker.startCheckout(items: items, cartValue: value)
593
+ case "addPaymentInfo":
594
+ guard let paymentMethod = need("paymentMethod", p["paymentMethod"]) else { return }
595
+ tracker.addPaymentInfo(paymentMethod: paymentMethod)
596
+ case "purchase":
597
+ guard let order = p["order"] as? [String: Any],
598
+ need("order.orderId", order["orderId"]) != nil,
599
+ orderItems(order["items"]),
600
+ amount("order.value", order["value"]) != nil else { return }
601
+ tracker.purchase(order: Self.order(order))
602
+ case "orderFulfilled":
603
+ guard let orderId = need("orderId", p["orderId"]) else { return }
604
+ tracker.orderFulfilled(
605
+ orderId: orderId,
606
+ trackingNumber: p["trackingNumber"] as? String,
607
+ carrier: p["carrier"] as? String)
608
+ case "orderDelivered":
609
+ guard let orderId = need("orderId", p["orderId"]) else { return }
610
+ tracker.orderDelivered(orderId: orderId)
611
+ case "orderCancelled":
612
+ guard let orderId = need("orderId", p["orderId"]) else { return }
613
+ tracker.orderCancelled(orderId: orderId, reason: p["reason"] as? String)
614
+ case "orderRefunded":
615
+ guard let orderId = need("orderId", p["orderId"]) else { return }
616
+ tracker.orderRefunded(
617
+ orderId: orderId,
618
+ refundAmount: p["refundAmount"] as? Double,
619
+ reason: p["reason"] as? String)
620
+ case "addToWishlist":
621
+ guard let product = product("product") else { return }
622
+ tracker.addToWishlist(product: product)
623
+ case "removeFromWishlist":
624
+ guard let product = product("product") else { return }
625
+ tracker.removeFromWishlist(product: product)
626
+ case "shareProduct":
627
+ guard let product = product("product"),
628
+ let shareMethod = need("shareMethod", p["shareMethod"]) else { return }
629
+ tracker.shareProduct(product: product, shareMethod: shareMethod)
630
+ case "applyCoupon":
631
+ guard let couponCode = need("couponCode", p["couponCode"]) else { return }
632
+ tracker.applyCoupon(
633
+ couponCode: couponCode,
634
+ discountAmount: p["discountAmount"] as? Double,
635
+ discountType: p["discountType"] as? String)
636
+ case "removeCoupon":
637
+ guard let couponCode = need("couponCode", p["couponCode"]) else { return }
638
+ tracker.removeCoupon(couponCode: couponCode)
639
+ case "submitReview":
640
+ // Read as a Double on BOTH bridges, then converted the same way: `as?
641
+ // Int` read 4.5 as 0 here (it fails on a fraction) while Android gave 4.
642
+ // The native trackers take an Int rating, so a fraction is truncated
643
+ // identically on both until they take a Double like the web SDK.
644
+ guard let productId = need("productId", p["productId"]),
645
+ let rating = amount("rating", p["rating"]) else { return }
646
+ // Int(Double) TRAPS past Int.max - a crash of the host app from
647
+ // `submitReview('p', Number(userInput))`. Refused instead, at the same
648
+ // bound as Android (a 32-bit Int); the JS layer refuses it first.
649
+ guard abs(rating) <= Double(Int32.max) else {
650
+ NSLog("[Joryio] ecommerce.submitReview() needs a rating an Int can hold - event NOT sent")
651
+ return
652
+ }
653
+ tracker.submitReview(
654
+ productId: productId,
655
+ rating: Int(rating),
656
+ reviewText: p["reviewText"] as? String)
657
+ default:
658
+ // Loudly, not silently: a typo'd method name would otherwise be a no-op
659
+ // that looks exactly like the SDK working.
660
+ NSLog("[Joryio] Unknown ecommerce method '\(method)' - event NOT sent")
661
+ }
662
+ }
663
+
664
+ private static func product(_ raw: Any?) -> EcommerceProduct {
665
+ let m = raw as? [String: Any] ?? [:]
666
+ return EcommerceProduct(
667
+ productId: m["productId"] as? String ?? "",
668
+ name: m["name"] as? String ?? "",
669
+ price: m["price"] as? Double ?? 0,
670
+ quantity: m["quantity"] as? Int,
671
+ category: m["category"] as? String,
672
+ brand: m["brand"] as? String,
673
+ variant: m["variant"] as? String,
674
+ variantId: m["variantId"] as? String,
675
+ sku: m["sku"] as? String,
676
+ imageUrl: m["imageUrl"] as? String,
677
+ url: m["url"] as? String)
678
+ }
679
+
680
+ private static func cartItem(_ raw: Any?) -> EcommerceCartItem {
681
+ let m = raw as? [String: Any] ?? [:]
682
+ return EcommerceCartItem(
683
+ productId: m["productId"] as? String ?? "",
684
+ name: m["name"] as? String ?? "",
685
+ price: m["price"] as? Double ?? 0,
686
+ // Defaults to 1, not 0: a cart line with zero quantity has an item total
687
+ // of zero, which is a silently wrong revenue number rather than an
688
+ // obviously wrong one. Matches Android.
689
+ quantity: m["quantity"] as? Int ?? 1,
690
+ category: m["category"] as? String,
691
+ brand: m["brand"] as? String,
692
+ variant: m["variant"] as? String,
693
+ variantId: m["variantId"] as? String,
694
+ sku: m["sku"] as? String,
695
+ imageUrl: m["imageUrl"] as? String,
696
+ url: m["url"] as? String)
697
+ }
698
+
699
+ private static func cartItems(_ raw: Any?) -> [EcommerceCartItem] {
700
+ guard let arr = raw as? [Any] else { return [] }
701
+ // A null (NSNull) or non-object entry is SKIPPED, as the JS layer skips
702
+ // it. Mapped to a line it had no productId, and the native tracker
703
+ // refused the whole order because of it.
704
+ return arr.compactMap { $0 as? [String: Any] }.map { cartItem($0) }
705
+ }
706
+
707
+ private static func order(_ raw: Any?) -> EcommerceOrder {
708
+ let m = raw as? [String: Any] ?? [:]
709
+ return EcommerceOrder(
710
+ orderId: m["orderId"] as? String ?? "",
711
+ items: cartItems(m["items"]),
712
+ value: m["value"] as? Double ?? 0,
713
+ currency: m["currency"] as? String,
714
+ shipping: m["shipping"] as? Double,
715
+ tax: m["tax"] as? Double,
716
+ discount: m["discount"] as? Double,
717
+ coupon: m["coupon"] as? String)
718
+ }
719
+
720
+ // MARK: - In-App Messaging
721
+
722
+ /// Hand in-app messages to JS, exactly as the Android module does.
723
+ ///
724
+ /// This was a no-op, on a premise that had expired: the comment said the iOS
725
+ /// SDK "renders natively, so there is no callback to forward", which was true
726
+ /// when written. iOS later gained setInAppMessageCallback and the bridge was
727
+ /// never updated - so onInAppMessage fired on Android and never on iOS, and
728
+ /// one React Native app showed its own branded UI on Android and a
729
+ /// native-styled dialog on iOS. The docs promised the first for both.
730
+ ///
731
+ /// THE PAYLOAD MUST MATCH ANDROID FIELD FOR FIELD, because the same
732
+ /// JavaScript renders both. Two places where reflection would have been
733
+ /// wrong, and silently:
734
+ ///
735
+ /// - `deepLink` in Swift is `DEEP_LINK` in Kotlin, whose lowercased name is
736
+ /// `deep_link`. String(describing:) yields "deepLink", which no JS
737
+ /// renderer branches on - every deep-link button would quietly do nothing
738
+ /// on iOS only.
739
+ /// - MessageType is already a String enum with the right raw values, so it
740
+ /// uses rawValue rather than a second spelling of the same list.
741
+ ///
742
+ /// So the mapping is written out explicitly. It is longer, and it cannot
743
+ /// drift into a wrong string without someone editing that string.
744
+ @objc func enableInAppMessages(_ capabilities: NSArray?) {
745
+ // What THIS app can render, declared from JS - see the Android module.
746
+ // Null keeps the previous behaviour rather than asserting anything, and an
747
+ // EMPTY list means the same as null (not declared), as on Android. It used
748
+ // to reach the server as "this app can draw nothing".
749
+ let filtered = (capabilities as? [String])?.filter { !$0.isEmpty } ?? []
750
+ let declared: [String]? = filtered.isEmpty ? nil : filtered
751
+ inAppHandedToJS = true
752
+ takeOwnership(.inApp)
753
+
754
+ Joryio.shared.setInAppMessageCallback({ [weak self] campaign in
755
+ guard let self else { return }
756
+
757
+ // RCTEventEmitter requires an active listener; matches emitSdkAuthError.
758
+ // Deliberately checked BEFORE the content guard so the two reasons a
759
+ // message is dropped stay distinguishable in the log.
760
+ guard self.hasListeners else {
761
+ NSLog("[Joryio] No JS listener for JoryioInAppMessage; dropping \(campaign.id)")
762
+ return
763
+ }
764
+
765
+ guard let content = campaign.content else {
766
+ // A campaign with no renderable document is DROPPED rather than emitted
767
+ // with empty strings - JS would otherwise render a blank container the
768
+ // user has to dismiss, and count an impression for it. Matches Android.
769
+ NSLog("[Joryio] Campaign \(campaign.id) has no content; not emitting")
770
+ return
771
+ }
772
+
773
+ var params: [String: Any] = [
774
+ "id": campaign.id,
775
+ "name": campaign.name,
776
+ // Wire value, not the case name: JS types this as
777
+ // 'modal' | 'banner' | 'slideup' | 'fullscreen' | 'custom'.
778
+ "type": campaign.type.rawValue,
779
+ "priority": campaign.priority,
780
+ ]
781
+
782
+ switch content {
783
+ case let .html(html, css):
784
+ params["kind"] = "html"
785
+ params["html"] = html
786
+ params["css"] = css
787
+
788
+ case let .native(native):
789
+ params["kind"] = "native"
790
+ if let title = native.title { params["title"] = title }
791
+ params["body"] = native.body
792
+ if let imageUrl = native.imageUrl { params["imageUrl"] = imageUrl }
793
+ params["closeButton"] = native.closeButton
794
+ params["backdropDismissible"] = native.backdropDismissible
795
+
796
+ // Author style overrides. Forwarded rather than applied: the HOST draws
797
+ // the message, so resolving these here would compute the campaign's
798
+ // colours and then throw them away.
799
+ if let st = native.style {
800
+ var style: [String: Any] = [:]
801
+ if let v = st.backgroundColor { style["backgroundColor"] = v }
802
+ if let v = st.textColor { style["textColor"] = v }
803
+ if let v = st.primaryButtonColor { style["primaryButtonColor"] = v }
804
+ if let v = st.primaryButtonTextColor { style["primaryButtonTextColor"] = v }
805
+ if let v = st.cornerRadius { style["cornerRadius"] = v }
806
+ if let v = st.fontSize { style["fontSize"] = v }
807
+ if let v = st.titleWeight { style["titleWeight"] = v }
808
+ if let v = st.textAlign { style["textAlign"] = v }
809
+ if let v = st.fontFamily { style["fontFamily"] = v }
810
+ params["style"] = style
811
+ }
812
+
813
+ params["buttons"] = native.buttons.map { b -> [String: Any] in
814
+ var button: [String: Any] = [
815
+ "id": b.id,
816
+ "text": b.text,
817
+ "action": Self.buttonActionWireValue(b.action),
818
+ ]
819
+ if let url = b.url { button["url"] = url }
820
+ return button
821
+ }
822
+ }
823
+
824
+ self.sendEvent(withName: "JoryioInAppMessage", body: params)
825
+ }, capabilities: declared)
826
+ }
827
+
828
+ /// Hand in-app rendering back to the native SDK. The JS layer calls this
829
+ /// when its last onInAppMessage listener unsubscribes; before it existed,
830
+ /// messages kept being handed to JS and dropped for want of a listener, for
831
+ /// the rest of the session.
832
+ @objc func disableInAppMessages() {
833
+ inAppHandedToJS = false
834
+ _ = releaseOwnership(.inApp)
835
+ Joryio.shared.clearInAppMessageCallback()
836
+ }
837
+
838
+ /// The wire spelling Android sends, written out rather than derived.
839
+ ///
840
+ /// Kotlin's DEEP_LINK.lowercase() is "deep_link"; Swift's `.deepLink`
841
+ /// described is "deepLink". Deriving either from the case name produces a
842
+ /// value the JS renderer does not branch on, on one platform only.
843
+ private static func buttonActionWireValue(_ action: ButtonAction) -> String {
844
+ switch action {
845
+ case .dismiss: return "dismiss"
846
+ case .url: return "url"
847
+ case .deepLink: return "deep_link"
848
+ case .requestPushPermission: return "request_push_permission"
849
+ }
850
+ }
851
+
852
+ /// Report an impression for a message JS rendered.
853
+ ///
854
+ /// Also previously a no-op, and it could not stay one once the callback above
855
+ /// works: the SDK cannot see a message it did not draw. Reporting
856
+ /// `displayed` is what emits in_app.displayed AND spends the campaign's
857
+ /// frequency budget (contract item 17); an unreported campaign reads as
858
+ /// delivered to nobody and breaks revenue attribution.
859
+ @objc func trackInAppImpression(_ campaignId: String?, action: String?, elementId: String?) {
860
+ guard present("trackInAppImpression", "campaignId", campaignId), let campaignId,
861
+ present("trackInAppImpression", "action", action), let action else { return }
862
+ let element = (elementId?.isEmpty ?? true) ? nil : elementId
863
+ Joryio.shared.trackInAppImpression(campaignId: campaignId, action: action, elementId: element)
864
+ }
865
+
866
+ /// A form inside an HTML message the app rendered itself. Resolves with the
867
+ /// server's verdict ({ ok, message?, redirect?, errors? }) - never rejects,
868
+ /// a failure is `ok: false`. Mirrors sdk-ios `Joryio.submitInAppForm`.
869
+ @objc func submitInAppForm(_ campaignId: String?, values: NSDictionary?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
870
+ guard present("submitInAppForm", "campaignId", campaignId), let campaignId else {
871
+ resolve(["ok": false, "message": "Something went wrong. Please try again."])
872
+ return
873
+ }
874
+ let dict = (values as? [String: Any]) ?? [:]
875
+ Task {
876
+ let r = await Joryio.shared.submitInAppForm(campaignId: campaignId, values: dict)
877
+ var out: [String: Any] = ["ok": r.ok]
878
+ if let m = r.message { out["message"] = m }
879
+ if let u = r.redirect { out["redirect"] = u }
880
+ if let e = r.errors { out["errors"] = e }
881
+ resolve(out)
882
+ }
883
+ }
884
+
885
+ // MARK: - SDK Authentication
886
+
887
+ /// Supply/replace the customer-minted SDK-authentication JWT.
888
+ /// Mirrors sdk-ios `Joryio.setSdkAuthenticationToken(_:)`.
889
+ @objc func setSdkAuthenticationToken(_ token: String?) {
890
+ guard present("setSdkAuthenticationToken", "token", token), let token else { return }
891
+ Joryio.shared.setSdkAuthenticationToken(token)
892
+ }
893
+
894
+ /// Forward an SDK-authentication rejection to JS. Only emits when the JS side
895
+ /// has an active listener (RCTEventEmitter requirement).
896
+ private func emitSdkAuthError(_ authError: SdkAuthError) {
897
+ guard hasListeners else { return }
898
+ // All four fields, on both platforms. A JS handler is written once; it used
899
+ // to receive {reason, endpoint, refreshExhausted} here and
900
+ // {reason, rawReason} on Android, so half of any handler was silently dead
901
+ // on whichever platform the developer had not tested.
902
+ sendEvent(withName: "JoryioSdkAuthError", body: [
903
+ "reason": authError.reason.rawValue,
904
+ "rawReason": authError.rawReason as Any,
905
+ "endpoint": authError.endpoint,
906
+ "refreshExhausted": authError.refreshExhausted
907
+ ])
908
+ }
909
+
910
+ // MARK: - Utilities
911
+
912
+ @objc func flush() {
913
+ Joryio.shared.flush()
914
+ }
915
+
916
+ @objc func getAnonymousId(
917
+ _ resolve: @escaping RCTPromiseResolveBlock,
918
+ rejecter reject: @escaping RCTPromiseRejectBlock
919
+ ) {
920
+ resolve(Joryio.shared.getAnonymousId())
921
+ }
922
+
923
+ /// Fetch in-app campaigns now, and display any that are eligible.
924
+ ///
925
+ /// The SDK syncs on session start and on foreground; this is for the moments
926
+ /// in between - after a reset, or after the user does something that should
927
+ /// make them newly eligible. The RN bridge had no sync at all, so a React
928
+ /// Native app could only wait for the next lifecycle event.
929
+ @objc func syncInAppCampaigns() {
930
+ Task { await Joryio.shared.syncInAppCampaigns() }
931
+ }
932
+
933
+ /// Forget which campaigns have already been shown on this device.
934
+ ///
935
+ /// The SDK filters out any campaign in `displayedCampaigns`, and that set is
936
+ /// PERSISTED - so a message shows once per install and then never again.
937
+ /// Native apps could already call this; React Native could not, which made a
938
+ /// campaign untestable after its first display without deleting the app.
939
+ @objc func resetDisplayedCampaigns() {
940
+ Joryio.shared.resetDisplayedCampaigns()
941
+ }
942
+
943
+ /// What the SDK is ACTUALLY doing, as opposed to what the app configured.
944
+ ///
945
+ /// A host app can only display the config it set, which stays cheerfully
946
+ /// green while every request is rejected. Returns the live endpoint and the
947
+ /// last transport failure so a status view can tell the truth.
948
+ @objc func getDiagnostics(
949
+ _ resolve: @escaping RCTPromiseResolveBlock,
950
+ rejecter reject: @escaping RCTPromiseRejectBlock
951
+ ) {
952
+ var result: [String: Any] = [
953
+ "apiEndpoint": Joryio.shared.currentApiEndpoint ?? NSNull(),
954
+ ]
955
+ if let err = Joryio.shared.lastTransportError {
956
+ result["lastError"] = [
957
+ "message": err.message,
958
+ "isUnauthorized": err.isUnauthorized,
959
+ "isRetryable": err.isRetryable,
960
+ "at": err.at.timeIntervalSince1970 * 1000,
961
+ ]
962
+ }
963
+ resolve(result)
964
+ }
965
+
966
+ @objc func getUserId(
967
+ _ resolve: @escaping RCTPromiseResolveBlock,
968
+ rejecter reject: @escaping RCTPromiseRejectBlock
969
+ ) {
970
+ resolve(Joryio.shared.getUserId())
971
+ }
972
+
973
+ @objc func getSessionId(
974
+ _ resolve: @escaping RCTPromiseResolveBlock,
975
+ rejecter reject: @escaping RCTPromiseRejectBlock
976
+ ) {
977
+ resolve(Joryio.shared.getSessionId())
978
+ }
979
+ }