@amplytools/react-native-amply-sdk 0.8.0 → 0.9.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/AmplyReactNative.podspec
CHANGED
package/android/build.gradle
CHANGED
|
@@ -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.
|
|
18
|
+
def amplySdkVersion = project.findProperty('amplySdkVersion') ?: System.getenv('AMPLY_SDK_VERSION') ?: '0.9.0'
|
|
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
|
-
//
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
968
|
-
//
|
|
969
|
-
//
|
|
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
|
-
//
|
|
973
|
-
//
|
|
974
|
-
|
|
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
|
-
//
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
|
|
980
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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.
|
|
3
|
+
"version": "0.9.0",
|
|
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"
|