@amplytools/react-native-amply-sdk 0.8.0 → 0.9.1

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.
@@ -22,7 +22,7 @@ Pod::Spec.new do |s|
22
22
  s.dependency 'React-RCTFabric'
23
23
 
24
24
  # Amply KMP SDK from CocoaPods
25
- s.dependency 'AmplySDK', '~> 0.8.0'
25
+ s.dependency 'AmplySDK', '~> 0.9.1'
26
26
 
27
27
  s.libraries = 'c++'
28
28
  s.pod_target_xcconfig = {
@@ -15,7 +15,7 @@ buildscript {
15
15
  apply plugin: 'com.android.library'
16
16
  apply plugin: 'org.jetbrains.kotlin.android'
17
17
 
18
- def amplySdkVersion = project.findProperty('amplySdkVersion') ?: System.getenv('AMPLY_SDK_VERSION') ?: '0.8.0'
18
+ def amplySdkVersion = project.findProperty('amplySdkVersion') ?: System.getenv('AMPLY_SDK_VERSION') ?: '0.9.1'
19
19
  group = 'tools.amply'
20
20
  version = amplySdkVersion
21
21
 
@@ -37,6 +37,7 @@ import tools.amply.sdk.config.amplyConfig
37
37
  import tools.amply.sdk.core.AmplySDKInterface
38
38
  import tools.amply.sdk.events.EventInterface
39
39
  import tools.amply.sdk.events.SystemEventsListener
40
+ import tools.amply.sdk.core.ListenerToken
40
41
  import tools.amply.sdk.logging.LogEntry
41
42
  import tools.amply.sdk.logging.LogListener
42
43
 
@@ -61,6 +62,16 @@ class DefaultAmplyClient(
61
62
  private val sessionPrimed = AtomicBoolean(false)
62
63
  private val mainHandler = Handler(Looper.getMainLooper())
63
64
 
65
+ // The tokens the SDK hands back at registration. These are the ONLY things that can withdraw
66
+ // each seam. shutdown() used to withdraw NOTHING at all: the log and system-event slots
67
+ // happened to be overwritten by the next initialize(), but the deeplink list is append-only, so
68
+ // it grew by one dead listener per reload for the life of the process and every deeplink was
69
+ // fanned out to all of them. Gates had no withdrawal in the SDK to call.
70
+ private val logListenerToken = AtomicReference<ListenerToken?>(null)
71
+ private val systemEventsToken = AtomicReference<ListenerToken?>(null)
72
+ private val deepLinkToken = AtomicReference<ListenerToken?>(null)
73
+ private val gateTokens = java.util.concurrent.CopyOnWriteArrayList<ListenerToken>()
74
+
64
75
  private val _deepLinkEvents = MutableSharedFlow<DeepLinkPayload>(
65
76
  replay = 1,
66
77
  extraBufferCapacity = 16,
@@ -94,13 +105,13 @@ class DefaultAmplyClient(
94
105
  "Initializing Amply with appId=${options.appId} apiKeyPublic=${options.apiKeyPublic.takeIf { it.isNotEmpty() } ?: "<empty>"}"
95
106
  )
96
107
 
97
- // Set log level and listener BEFORE creating instance so initialization logs are captured
108
+ // The LISTENER goes in before the LEVEL. Logger.setLevel emits a log entry
109
+ // synchronously, so setting the level first hands that entry to whatever listener the
110
+ // process had before — on a reload, the client of the host that is going away.
98
111
  val effectiveLogLevel = options.getEffectiveLogLevel()
99
- tools.amply.sdk.logging.Logger.setLevel(effectiveLogLevel.toString())
100
- android.util.Log.i("AmplyReactNative", "Pre-init log level set to: $effectiveLogLevel")
101
112
 
102
113
  // Set up log listener to forward logs to JS
103
- tools.amply.sdk.logging.Logger.setListener(object : LogListener {
114
+ logListenerToken.set(tools.amply.sdk.logging.Logger.setListener(object : LogListener {
104
115
  override fun onLog(entry: LogEntry) {
105
116
  val envelope = EventEnvelope(
106
117
  id = null,
@@ -116,7 +127,10 @@ class DefaultAmplyClient(
116
127
  )
117
128
  _logEvents.tryEmit(envelope)
118
129
  }
119
- })
130
+ }))
131
+
132
+ tools.amply.sdk.logging.Logger.setLevel(effectiveLogLevel.toString())
133
+ android.util.Log.i("AmplyReactNative", "Pre-init log level set to: $effectiveLogLevel")
120
134
 
121
135
  val instance = withContext(Dispatchers.Default) {
122
136
  Amply(config, application)
@@ -204,11 +218,13 @@ class DefaultAmplyClient(
204
218
  // gets its OWN presenter instance owning its OWN "current presentation" slot, so a
205
219
  // dismiss() delivered to this gate's presenter can only abandon THIS gate's live
206
220
  // presentation — never one belonging to a different url-pattern's gate.
207
- instance.registerGate(
208
- baseUrl = baseUrl,
209
- presenter = GatePresenter(baseUrl),
210
- onAbort = policy,
211
- timeoutMs = if (timeoutMs > 0L) timeoutMs else DEFAULT_GATE_TIMEOUT_MS,
221
+ gateTokens.add(
222
+ instance.registerGate(
223
+ baseUrl = baseUrl,
224
+ presenter = GatePresenter(baseUrl),
225
+ onAbort = policy,
226
+ timeoutMs = if (timeoutMs > 0L) timeoutMs else DEFAULT_GATE_TIMEOUT_MS,
227
+ )
212
228
  )
213
229
  }
214
230
 
@@ -289,7 +305,7 @@ class DefaultAmplyClient(
289
305
  }
290
306
  android.util.Log.i("AmplyReactNative", "Registering deep link listener")
291
307
 
292
- instance.registerDeepLinkListener(object : DeepLinkListener {
308
+ deepLinkToken.set(instance.registerDeepLinkListener(object : DeepLinkListener {
293
309
  override fun onDeepLink(url: String, info: Map<String, Any>): Boolean {
294
310
  android.util.Log.i(
295
311
  "AmplyReactNative",
@@ -309,7 +325,7 @@ class DefaultAmplyClient(
309
325
  }
310
326
  return false
311
327
  }
312
- })
328
+ }))
313
329
  }
314
330
 
315
331
  override fun registerSystemEventListener() {
@@ -407,6 +423,25 @@ class DefaultAmplyClient(
407
423
  }
408
424
 
409
425
  override fun shutdown() {
426
+ // Withdraw every seam FIRST, while the instance is still reachable.
427
+ //
428
+ // This used to withdraw nothing at all. The log and system-event slots happened to be
429
+ // overwritten by the next initialize(), which hid the omission; the deeplink list is
430
+ // append-only, so it grew by one dead listener per reload for the life of the process and
431
+ // every deeplink was fanned out to all of them. Gates had no withdrawal in the SDK to call,
432
+ // so a gate registered by a host that went away parked the next gated call on that URL for
433
+ // the whole fail-open timeout.
434
+ //
435
+ // Ordered before `amplyInstance = null` because three of the four withdrawals go through it.
436
+ val instance = amplyInstance
437
+ logListenerToken.getAndSet(null)?.let { tools.amply.sdk.logging.Logger.clearListener(it) }
438
+ if (instance != null) {
439
+ systemEventsToken.getAndSet(null)?.let { instance.clearSystemEventsListener(it) }
440
+ deepLinkToken.getAndSet(null)?.let { instance.removeDeepLinkListener(it) }
441
+ gateTokens.forEach { instance.unregisterGate(it) }
442
+ }
443
+ gateTokens.clear()
444
+
410
445
  runBlocking {
411
446
  mutex.withLock {
412
447
  amplyInstance = null
@@ -513,7 +548,7 @@ class DefaultAmplyClient(
513
548
  )
514
549
  return
515
550
  }
516
- instance.setSystemEventsListener(object : SystemEventsListener {
551
+ systemEventsToken.set(instance.setSystemEventsListener(object : SystemEventsListener {
517
552
  override fun onEvent(event: EventInterface) {
518
553
  android.util.Log.i(
519
554
  "AmplyReactNative",
@@ -527,7 +562,7 @@ class DefaultAmplyClient(
527
562
  )
528
563
  }
529
564
  }
530
- })
565
+ }))
531
566
  }
532
567
 
533
568
  private fun maybePrimeSessionTracker() {
@@ -1,5 +1,6 @@
1
1
  #import <Foundation/Foundation.h>
2
2
  #import <UIKit/UIKit.h>
3
+ #import <os/lock.h>
3
4
  #import <React/RCTBridgeModule.h>
4
5
  #import <React/RCTConvert.h>
5
6
  // Not pulled in by RCTBridgeModule.h — the module conforms to RCTInvalidating so it can withdraw
@@ -75,6 +76,15 @@ static ASDKCampaignResult* AmplyCampaignResultFromString(NSString *result) {
75
76
  @property (nonatomic, assign) BOOL systemEventsListenerRegistered;
76
77
  @property (nonatomic, assign) BOOL lifecycleObserversRegistered;
77
78
  @property (nonatomic, assign) BOOL logListenerRegistered;
79
+ // The tokens the SDK handed us at registration. These are the ONLY things that can withdraw
80
+ // each seam: the SDK compares the token, not the listener object, because an ObjC object is
81
+ // adapted afresh on every crossing into Kotlin and so its identity does not survive the trip.
82
+ // Dropping one of these on the floor compiles cleanly and silently makes that seam
83
+ // un-withdrawable, which is what scripts/check-seam-tokens.sh exists to catch.
84
+ @property (nonatomic, strong) ASDKListenerToken *logListenerToken;
85
+ @property (nonatomic, strong) ASDKListenerToken *systemEventsListenerToken;
86
+ @property (nonatomic, strong) ASDKListenerToken *deepLinkListenerToken;
87
+ @property (nonatomic, strong) NSMutableArray<ASDKListenerToken *> *gateTokens;
78
88
  @property (nonatomic, assign) AmplyLogLevel currentLogLevel;
79
89
  @property (nonatomic, strong) NSMutableArray<void (^)(ASDKAmply *)> *pendingPropertyOps;
80
90
  // Global routing table: mediationId -> the KMP CampaignResolution token awaiting a JS reply.
@@ -115,10 +125,31 @@ static ASDKCampaignResult* AmplyCampaignResultFromString(NSString *result) {
115
125
  @property (nonatomic, copy) NSString *activeMediationId;
116
126
  @end
117
127
 
118
- @implementation Amply
128
+ @implementation Amply {
129
+ // Held across every emit into the JS bridge, and taken by -invalidate.
130
+ //
131
+ // Withdrawal alone is not enough. It is a STORE: it stops the SDK acquiring this module for
132
+ // any FUTURE callback, but a Kotlin coroutine thread may already be past its own load and
133
+ // inside onLog/onEvent/onDeepLink right now. RN destroys the JSI object as soon as
134
+ // -invalidate returns, and the emitter callback captured `this` by reference, so a call still
135
+ // in flight then writes through a dangling pointer. Taking this lock in -invalidate IS the
136
+ // join: on return, no emit is running and none can start.
137
+ os_unfair_lock _emitLock;
138
+ BOOL _withdrawn;
139
+ }
119
140
 
120
141
  RCT_EXPORT_MODULE()
121
142
 
143
+ - (instancetype)init
144
+ {
145
+ if (self = [super init]) {
146
+ _emitLock = OS_UNFAIR_LOCK_INIT;
147
+ _withdrawn = NO;
148
+ _gateTokens = [NSMutableArray new];
149
+ }
150
+ return self;
151
+ }
152
+
122
153
  - (void)initialize:(JS::NativeAmplyModule::AmplyInitializationConfig &)config
123
154
  resolve:(RCTPromiseResolveBlock)resolve
124
155
  reject:(RCTPromiseRejectBlock)reject
@@ -204,15 +235,19 @@ RCT_EXPORT_MODULE()
204
235
  // Set log level and listener BEFORE creating Amply instance
205
236
  // so initialization logs are captured and forwarded to JS
206
237
  if (self.currentLogLevel != AmplyLogLevelNone) {
207
- RCTLogInfo(@"[AmplyReactNative] Setting log level to: %@ BEFORE instance creation", AmplyLogLevelToString(self.currentLogLevel));
208
- [[ASDKLogger shared] setLevelLevel:AmplyLogLevelToString(self.currentLogLevel)];
209
-
210
- // Set log listener to forward logs to JS
238
+ // Registration comes FIRST. Logger.setLevel emits a log entry synchronously, so setting the
239
+ // level before installing ourselves hands that entry to whatever listener the process had
240
+ // before — on a reload, the module of the host that is going away. That is not the cause of
241
+ // the crash this change fixes (a stale registration was reachable regardless), but it is
242
+ // what made it reproduce on every single reload, and there is no reason to keep the inversion.
211
243
  if (!self.logListenerRegistered) {
212
- [[ASDKLogger shared] setListenerListener:self];
244
+ self.logListenerToken = [[ASDKLogger shared] setListenerListener:self];
213
245
  self.logListenerRegistered = YES;
214
246
  RCTLogInfo(@"[AmplyReactNative] Log listener registered");
215
247
  }
248
+
249
+ RCTLogInfo(@"[AmplyReactNative] Setting log level to: %@ BEFORE instance creation", AmplyLogLevelToString(self.currentLogLevel));
250
+ [[ASDKLogger shared] setLevelLevel:AmplyLogLevelToString(self.currentLogLevel)];
216
251
  }
217
252
 
218
253
  // Create Amply instance (this triggers initialization and config fetch)
@@ -422,8 +457,19 @@ static NSDictionary *AmplyGateDecisionToMap(ASDKGateDecision *decision) {
422
457
  /** Surfaces a gate present dispatch to JS. Emitter delivery is async; hop to main to match deeplink. */
423
458
  - (void)emitGatePresent:(NSDictionary *)payload
424
459
  {
460
+ // The withdrawn check is INSIDE the block on purpose. This hop is asynchronous, so the block
461
+ // runs after -invalidate may already have returned and RN may already have destroyed the JSI
462
+ // object — a check at the enqueue site would pass and then emit into freed memory. Weak self
463
+ // for the same reason: nothing guarantees the module outlives the hop once the SDK has let go.
464
+ __weak __typeof(self) weakSelf = self;
425
465
  dispatch_async(dispatch_get_main_queue(), ^{
426
- [self emitOnCampaignPresent:payload];
466
+ __typeof(self) strongSelf = weakSelf;
467
+ if (!strongSelf) return;
468
+ os_unfair_lock_lock(&strongSelf->_emitLock);
469
+ if (!strongSelf->_withdrawn) {
470
+ [strongSelf emitOnCampaignPresent:payload];
471
+ }
472
+ os_unfair_lock_unlock(&strongSelf->_emitLock);
427
473
  });
428
474
  }
429
475
 
@@ -486,10 +532,14 @@ static NSDictionary *AmplyGateDecisionToMap(ASDKGateDecision *decision) {
486
532
  [self.gatePresenters addObject:presenter];
487
533
 
488
534
  RCTLogInfo(@"[AmplyReactNative] Registering gate baseUrl=%@ onAbort=%@ timeoutMs=%f", baseUrl, onAbort, timeoutMs);
489
- [self.amplyInstance registerGateBaseUrl:baseUrl
490
- presenter:presenter
491
- onAbort:policy
492
- timeoutMs:(int64_t)timeoutMs];
535
+ ASDKListenerToken *gateToken = [self.amplyInstance registerGateBaseUrl:baseUrl
536
+ presenter:presenter
537
+ onAbort:policy
538
+ timeoutMs:(int64_t)timeoutMs];
539
+ // Kept so -invalidate can withdraw it. Without this the gate outlives its host: the registry
540
+ // is process-scoped, so the next gated call on this URL would reach a presenter whose module
541
+ // is gone, resolve nothing, and park the caller for the whole fail-open timeout.
542
+ [self.gateTokens addObject:gateToken];
493
543
  self.gateRegistered = YES;
494
544
  }
495
545
 
@@ -715,7 +765,7 @@ static ASDKDataSetType *AmplyDataSetTypeFromDictionary(NSDictionary *type, NSStr
715
765
  }
716
766
 
717
767
  RCTLogInfo(@"[AmplyReactNative] Registering deep link listener");
718
- [self.amplyInstance registerDeepLinkListenerListener:self];
768
+ self.deepLinkListenerToken = [self.amplyInstance registerDeepLinkListenerListener:self];
719
769
  self.deepLinkListenerRegistered = YES;
720
770
  }
721
771
 
@@ -747,7 +797,11 @@ static ASDKDataSetType *AmplyDataSetTypeFromDictionary(NSDictionary *type, NSStr
747
797
  @"consumed": @NO
748
798
  };
749
799
 
750
- [self emitOnDeepLink:payload];
800
+ os_unfair_lock_lock(&_emitLock);
801
+ if (!_withdrawn) {
802
+ [self emitOnDeepLink:payload];
803
+ }
804
+ os_unfair_lock_unlock(&_emitLock);
751
805
 
752
806
  // For custom scheme URLs (e.g., amply://), let JS handle navigation via the event above.
753
807
  // For external URLs (http/https), open via system.
@@ -879,14 +933,15 @@ static ASDKDataSetType *AmplyDataSetTypeFromDictionary(NSDictionary *type, NSStr
879
933
  {
880
934
  self.currentLogLevel = AmplyLogLevelFromString(level);
881
935
  RCTLogInfo(@"[AmplyReactNative] Log level set to: %@", level);
882
- [[ASDKLogger shared] setLevelLevel:level];
883
936
 
884
- // Register log listener if not already registered and level is not none
937
+ // Register BEFORE setting the level, for the reason spelled out in -initialize:.
885
938
  if (self.currentLogLevel != AmplyLogLevelNone && !self.logListenerRegistered) {
886
- [[ASDKLogger shared] setListenerListener:self];
939
+ self.logListenerToken = [[ASDKLogger shared] setListenerListener:self];
887
940
  self.logListenerRegistered = YES;
888
941
  RCTLogInfo(@"[AmplyReactNative] Log listener registered via setLogLevel");
889
942
  }
943
+
944
+ [[ASDKLogger shared] setLevelLevel:level];
890
945
  }
891
946
 
892
947
  - (NSString *)getLogLevel
@@ -904,7 +959,7 @@ static ASDKDataSetType *AmplyDataSetTypeFromDictionary(NSDictionary *type, NSStr
904
959
  if (!self.amplyInstance) {
905
960
  return;
906
961
  }
907
- [self.amplyInstance setSystemEventsListenerListener:self];
962
+ self.systemEventsListenerToken = [self.amplyInstance setSystemEventsListenerListener:self];
908
963
  self.systemEventsListenerRegistered = YES;
909
964
  RCTLogInfo(@"[AmplyReactNative] System events listener registered");
910
965
  }
@@ -964,35 +1019,69 @@ static ASDKDataSetType *AmplyDataSetTypeFromDictionary(NSDictionary *type, NSStr
964
1019
  {
965
1020
  // The bridge is going away, and with it the JS emitter every callback below writes into. The
966
1021
  // SDK, however, is process-scoped and keeps running — its background coroutines (the events
967
- // ticker, the campaign loader) go on logging. If we are still installed as the log listener at
968
- // that point, each of those calls reaches a module whose emitter is dead, and the exception
969
- // that raises crosses back into Kotlin/Native, which terminates the process rather than
970
- // unwinding. That is the "crashes about every other launch" on a JS reload.
1022
+ // ticker, the campaign loader) go on logging. If we are still installed at that point, each of
1023
+ // those calls reaches a module whose emitter has been destroyed, and the fault is a memory
1024
+ // access, not an exception: no @try and no Kotlin try can intercept it.
971
1025
  //
972
- // clearListener COMPARES before clearing, so a replacement module that has already installed
973
- // itself is not silenced by this one's teardown.
974
- [[ASDKLogger shared] clearListenerListener:self];
1026
+ // Withdrawal is by TOKEN. It used to pass `self`, and that never matched: an ObjC object is
1027
+ // adapted afresh on every crossing into Kotlin, so the SDK's identity compare found nothing to
1028
+ // remove and the withdrawal silently did nothing at all. Measured, not deduced — the retain
1029
+ // count did not fall across the call.
1030
+ if (self.logListenerToken) {
1031
+ [[ASDKLogger shared] clearListenerToken:self.logListenerToken];
1032
+ self.logListenerToken = nil;
1033
+ }
975
1034
 
976
- // All three seams, not just the logger. The deeplink registration APPENDS, so leaving it behind
977
- // does not merely leak this module — every reload adds another dead listener and each future
978
- // deeplink is fanned out to all of them.
979
- [self.amplyInstance clearSystemEventsListenerListener:self];
980
- [self.amplyInstance removeDeepLinkListenerListener:self];
1035
+ // Every seam, not just the logger. The deeplink registration APPENDS, so leaving it behind does
1036
+ // not merely leak this module — every reload adds another dead listener and each future deeplink
1037
+ // is fanned out to all of them.
1038
+ if (self.systemEventsListenerToken) {
1039
+ [self.amplyInstance clearSystemEventsListenerToken:self.systemEventsListenerToken];
1040
+ self.systemEventsListenerToken = nil;
1041
+ }
1042
+ if (self.deepLinkListenerToken) {
1043
+ [self.amplyInstance removeDeepLinkListenerToken:self.deepLinkListenerToken];
1044
+ self.deepLinkListenerToken = nil;
1045
+ }
1046
+
1047
+ // Gates too, which had no withdrawal at all before. The registry is process-scoped: a gate left
1048
+ // registered by a module that is going away parks the next gated call on that URL for the whole
1049
+ // fail-open timeout, silently.
1050
+ for (ASDKListenerToken *gateToken in self.gateTokens) {
1051
+ [self.amplyInstance unregisterGateToken:gateToken];
1052
+ }
1053
+ [self.gateTokens removeAllObjects];
981
1054
 
982
1055
  [[NSNotificationCenter defaultCenter] removeObserver:self];
983
1056
  self.logListenerRegistered = NO;
984
1057
  self.deepLinkListenerRegistered = NO;
1058
+ self.systemEventsListenerRegistered = NO;
1059
+ self.gateRegistered = NO;
1060
+
1061
+ // Settle anything a gate left in flight, HERE rather than in -dealloc. -dealloc could not do
1062
+ // this job: the SDK held this module strongly precisely because withdrawal never worked, so it
1063
+ // never ran. Android has always settled from its own invalidate path; this brings iOS level.
1064
+ [self settlePendingCompletionsFailingOpen];
1065
+
1066
+ // THE JOIN, and it must come last. Everything above is a store — it stops the SDK acquiring
1067
+ // this module for anything new. It cannot stop a Kotlin coroutine thread that is already past
1068
+ // its own load and inside an emit right now. Taking the lock waits for that call to finish;
1069
+ // setting the flag under it stops any later one from starting. RN destroys the JSI object as
1070
+ // soon as this method returns, so without this there is still a window where a call in flight
1071
+ // writes through the emitter's dangling captured pointer.
1072
+ os_unfair_lock_lock(&_emitLock);
1073
+ _withdrawn = YES;
1074
+ os_unfair_lock_unlock(&_emitLock);
985
1075
  }
986
1076
 
987
- - (void)dealloc
1077
+ /**
1078
+ * Fails open every gate presentation still awaiting a JS reply, so no caller is left hanging.
1079
+ *
1080
+ * Idempotent: pendingCompletions is emptied under @synchronized, so the second pass from -dealloc
1081
+ * finds nothing to do.
1082
+ */
1083
+ - (void)settlePendingCompletionsFailingOpen
988
1084
  {
989
- // Belt and braces. In practice this rarely runs while the SDK holds us — see -invalidate.
990
- [[ASDKLogger shared] clearListenerListener:self];
991
- [[NSNotificationCenter defaultCenter] removeObserver:self];
992
-
993
- // Fail open any in-flight gate presentations so no caller is left hanging. Each per-gate
994
- // presenter clears its own current-presentation slot when its token is taken below; on dealloc
995
- // the presenters themselves are released with the module.
996
1085
  NSArray<id<ASDKCampaignResolution>> *inFlight = nil;
997
1086
  @synchronized (self) {
998
1087
  inFlight = self.pendingCompletions.allValues;
@@ -1007,6 +1096,17 @@ static ASDKDataSetType *AmplyDataSetTypeFromDictionary(NSDictionary *type, NSStr
1007
1096
  }
1008
1097
  }
1009
1098
 
1099
+ - (void)dealloc
1100
+ {
1101
+ // Belt and braces. Unlike before, this CAN now run: once the token withdrawal above actually
1102
+ // removes us, the SDK stops holding this module and it is released on schedule.
1103
+ if (self.logListenerToken) {
1104
+ [[ASDKLogger shared] clearListenerToken:self.logListenerToken];
1105
+ }
1106
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
1107
+ [self settlePendingCompletionsFailingOpen];
1108
+ }
1109
+
1010
1110
  #pragma mark - ASDKSystemEventsListener
1011
1111
 
1012
1112
  - (void)onEventEvent:(id<ASDKEventInterface>)event
@@ -1023,11 +1123,15 @@ static ASDKDataSetType *AmplyDataSetTypeFromDictionary(NSDictionary *type, NSStr
1023
1123
  // Same boundary, same hazard as -onLogEntry:. The system-events channel is also called from
1024
1124
  // Kotlin background coroutines and also writes into the JS emitter, so it can outlive the
1025
1125
  // bridge in exactly the same way. Guarded here rather than waiting for the second crash report.
1026
- @try {
1027
- [self emitOnSystemEvent:payload];
1028
- } @catch (NSException *exception) {
1029
- RCTLogWarn(@"[AmplyReactNative] Dropped a system event: %@", exception.reason);
1126
+ os_unfair_lock_lock(&_emitLock);
1127
+ if (!_withdrawn) {
1128
+ @try {
1129
+ [self emitOnSystemEvent:payload];
1130
+ } @catch (NSException *exception) {
1131
+ RCTLogWarn(@"[AmplyReactNative] Dropped a system event: %@", exception.reason);
1132
+ }
1030
1133
  }
1134
+ os_unfair_lock_unlock(&_emitLock);
1031
1135
  }
1032
1136
 
1033
1137
  #pragma mark - ASDKLogListener
@@ -1064,12 +1168,16 @@ static ASDKDataSetType *AmplyDataSetTypeFromDictionary(NSDictionary *type, NSStr
1064
1168
  // here does not unwind back into Kotlin — the runtime's terminate handler kills the process,
1065
1169
  // which is why a dead JS emitter shows up as std::terminate with no app frame in the stack.
1066
1170
  // A Kotlin-side try/catch cannot catch this; it has to be stopped on this side.
1067
- @try {
1068
- [self emitOnSystemEvent:payload];
1069
- } @catch (NSException *exception) {
1070
- // Deliberately not routed back through the SDK logger: that would re-enter this method.
1071
- RCTLogWarn(@"[AmplyReactNative] Dropped a log entry: %@", exception.reason);
1171
+ os_unfair_lock_lock(&_emitLock);
1172
+ if (!_withdrawn) {
1173
+ @try {
1174
+ [self emitOnSystemEvent:payload];
1175
+ } @catch (NSException *exception) {
1176
+ // Deliberately not routed back through the SDK logger: that would re-enter this method.
1177
+ RCTLogWarn(@"[AmplyReactNative] Dropped a log entry: %@", exception.reason);
1178
+ }
1072
1179
  }
1180
+ os_unfair_lock_unlock(&_emitLock);
1073
1181
  }
1074
1182
 
1075
1183
  - (std::shared_ptr<TurboModule>)getTurboModule:(const ObjCTurboModule::InitParams &)params
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amplytools/react-native-amply-sdk",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "React Native SDK for Amply: Mobile SDK to orchestrate in-app experiences and campaigns remotely, without app releases",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,11 +33,12 @@
33
33
  "build": "expo-module build",
34
34
  "build:plugin": "expo-module build plugin",
35
35
  "clean": "expo-module clean",
36
- "test": "node scripts/codegen.js --check && expo-module test",
36
+ "test": "node scripts/codegen.js --check && expo-module test && yarn check:seam-tokens",
37
37
  "prepare": "expo-module prepare",
38
38
  "prepublishOnly": "expo-module prepublishOnly",
39
39
  "codegen": "node scripts/codegen.js",
40
- "codegen:check": "node scripts/codegen.js --check"
40
+ "codegen:check": "node scripts/codegen.js --check",
41
+ "check:seam-tokens": "bash scripts/check-seam-tokens.sh"
41
42
  },
42
43
  "peerDependencies": {
43
44
  "expo": ">=54.0.0",
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Each host-callback seam must STORE the token the SDK hands back at registration, because that
4
+ # token is the only thing that can withdraw it.
5
+ #
6
+ # Why a grep and not a compiler check: changing a registration from returning Unit to returning
7
+ # ListenerToken does NOT change the Objective-C selector, and Kotlin lets a return value be
8
+ # discarded. So a bridge that calls the new API and throws the token away compiles cleanly, passes
9
+ # `clang -fsyntax-only`, and is silently unable to ever withdraw that seam. Nothing in either type
10
+ # system catches it. This does.
11
+ #
12
+ # Exits non-zero and names the seam if one is missing.
13
+ set -euo pipefail
14
+ cd "$(dirname "$0")/.."
15
+
16
+ IOS_BRIDGE="ios/Sources/AmplyReactNative/AmplyModule.mm"
17
+ ANDROID_BRIDGE="android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt"
18
+
19
+ failed=0
20
+
21
+ require_token() {
22
+ local file="$1" prop="$2" seam="$3"
23
+ if ! grep -q "$prop" "$file"; then
24
+ echo "FAIL: $file does not store a token for the $seam seam (expected '$prop')" >&2
25
+ failed=1
26
+ fi
27
+ }
28
+
29
+ require_withdrawal() {
30
+ local file="$1" call="$2" seam="$3"
31
+ if ! grep -q "$call" "$file"; then
32
+ echo "FAIL: $file never withdraws the $seam seam (expected a call to '$call')" >&2
33
+ failed=1
34
+ fi
35
+ }
36
+
37
+ # --- iOS ---
38
+ require_token "$IOS_BRIDGE" "logListenerToken" "log listener"
39
+ require_token "$IOS_BRIDGE" "systemEventsListenerToken" "system events"
40
+ require_token "$IOS_BRIDGE" "deepLinkListenerToken" "deeplink"
41
+ require_token "$IOS_BRIDGE" "gateTokens" "gates"
42
+ require_withdrawal "$IOS_BRIDGE" "clearListenerToken:" "log listener"
43
+ require_withdrawal "$IOS_BRIDGE" "clearSystemEventsListenerToken:" "system events"
44
+ require_withdrawal "$IOS_BRIDGE" "removeDeepLinkListenerToken:" "deeplink"
45
+ require_withdrawal "$IOS_BRIDGE" "unregisterGateToken:" "gates"
46
+
47
+ # The join. Withdrawal alone leaves a call already in flight writing into a destroyed JSI object,
48
+ # so -invalidate must take the same lock every emit holds.
49
+ require_withdrawal "$IOS_BRIDGE" "os_unfair_lock_lock" "emit barrier"
50
+
51
+ # --- Android ---
52
+ require_token "$ANDROID_BRIDGE" "logListenerToken" "log listener"
53
+ require_token "$ANDROID_BRIDGE" "systemEventsToken" "system events"
54
+ require_token "$ANDROID_BRIDGE" "deepLinkToken" "deeplink"
55
+ require_token "$ANDROID_BRIDGE" "gateTokens" "gates"
56
+ require_withdrawal "$ANDROID_BRIDGE" "Logger.clearListener" "log listener"
57
+ require_withdrawal "$ANDROID_BRIDGE" "clearSystemEventsListener" "system events"
58
+ require_withdrawal "$ANDROID_BRIDGE" "removeDeepLinkListener" "deeplink"
59
+ require_withdrawal "$ANDROID_BRIDGE" "unregisterGate" "gates"
60
+
61
+ if [ "$failed" -eq 0 ]; then
62
+ echo "check-seam-tokens: OK — every seam stores a token and is withdrawn on both bridges."
63
+ fi
64
+ exit "$failed"