From 8fffd85b234b6db6313eaf832f2432d6a6cb3972 Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Mon, 28 Sep 2026 23:46:14 -0400 Subject: [PATCH 1/2] refactor(ios): move RoktContracts-typed code into a Swift layer Under Swift Package Manager, Objective-C++ files cannot reach another target's generated Swift header, so they cannot use the RoktContracts types. Event mapping (RNMPRoktEventMapper), config building (RNMPRoktConfigFactory) and the embedded view (RNMPRoktViews) are now Swift. The .mm adapters reach them through the hand-written RNMPRoktSwift.h, which uses only Foundation and UIKit types, and no .mm file imports RoktContracts. The podspec adds swift_version, DEFINES_MODULE and private headers, plus the Swift include path for packages in Swift Package Manager mode. Characterization tests for every Rokt event type and for the config mapping were written against the Objective-C first, and pass unchanged against the Swift. A drift test checks every selector the header declares. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/pull-request.yml | 3 + ios/RNMParticle.xcodeproj/project.pbxproj | 2 + ios/RNMParticle/RNMPRokt.mm | 62 ++---- ios/RNMParticle/RNMPRoktSwift.h | 30 +++ ios/RNMParticle/RNMPSDKImports.h | 5 + ios/RNMParticle/RoktEventManager.mm | 168 ++-------------- .../RoktNativeLayoutComponentView.h | 4 +- .../RoktNativeLayoutComponentView.mm | 5 +- .../Swift/RNMPRoktConfigFactory.swift | 34 ++++ .../Swift/RNMPRoktEventMapper.swift | 84 ++++++++ ios/RNMParticle/Swift/RNMPRoktViews.swift | 16 ++ react-native-mparticle.podspec | 14 +- .../MParticleSample.xcodeproj/project.pbxproj | 12 ++ .../RNMPRoktConfigFactoryTests.m | 63 ++++++ .../RNMPRoktEventMapperTests.m | 184 ++++++++++++++++++ .../MParticleSampleTests/RNMPRoktSwiftTests.m | 37 ++++ 16 files changed, 514 insertions(+), 209 deletions(-) create mode 100644 ios/RNMParticle/RNMPRoktSwift.h create mode 100644 ios/RNMParticle/Swift/RNMPRoktConfigFactory.swift create mode 100644 ios/RNMParticle/Swift/RNMPRoktEventMapper.swift create mode 100644 ios/RNMParticle/Swift/RNMPRoktViews.swift create mode 100644 sample/ios/MParticleSampleTests/RNMPRoktConfigFactoryTests.m create mode 100644 sample/ios/MParticleSampleTests/RNMPRoktEventMapperTests.m create mode 100644 sample/ios/MParticleSampleTests/RNMPRoktSwiftTests.m diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 2597c96..b54d41b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -187,6 +187,9 @@ jobs: -only-testing:MParticleSampleTests/RCTConvertCommerceMappingTests \ -only-testing:MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests \ -only-testing:MParticleSampleTests/RNMParticleDuplicateSDKTests \ + -only-testing:MParticleSampleTests/RNMPRoktEventMapperTests \ + -only-testing:MParticleSampleTests/RNMPRoktConfigFactoryTests \ + -only-testing:MParticleSampleTests/RNMPRoktSwiftTests \ | bundle exec xcpretty -k - name: Archive iOS sample app and check for a single copy of each SDK diff --git a/ios/RNMParticle.xcodeproj/project.pbxproj b/ios/RNMParticle.xcodeproj/project.pbxproj index 6e7405e..286fcd8 100644 --- a/ios/RNMParticle.xcodeproj/project.pbxproj +++ b/ios/RNMParticle.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ C4A1D2E32F6A000100ABCDEF /* RoktPlaceholderRegistry.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RoktPlaceholderRegistry.h; sourceTree = ""; }; C4A1D2E42F6A000100ABCDEF /* RoktPlaceholderRegistry.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RoktPlaceholderRegistry.m; sourceTree = ""; }; C4A1D2E62F6A000100ABCDEF /* RNMPSDKImports.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNMPSDKImports.h; sourceTree = ""; }; + C4A1D2E72F6A000100ABCDEF /* RNMPRoktSwift.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNMPRoktSwift.h; sourceTree = ""; }; B39BCD942E30562400FC90B8 /* RoktNativeLayoutComponentView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RoktNativeLayoutComponentView.h; sourceTree = ""; }; B39BCD952E30562400FC90B8 /* RoktNativeLayoutComponentView.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RoktNativeLayoutComponentView.mm; sourceTree = ""; }; DBDF24D71E007EB1000F3D73 /* libRNMParticle.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRNMParticle.a; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -81,6 +82,7 @@ C4A1D2E32F6A000100ABCDEF /* RoktPlaceholderRegistry.h */, C4A1D2E42F6A000100ABCDEF /* RoktPlaceholderRegistry.m */, C4A1D2E62F6A000100ABCDEF /* RNMPSDKImports.h */, + C4A1D2E72F6A000100ABCDEF /* RNMPRoktSwift.h */, B39BCD942E30562400FC90B8 /* RoktNativeLayoutComponentView.h */, B39BCD952E30562400FC90B8 /* RoktNativeLayoutComponentView.mm */, B39BCD892E2A06D700FC90B8 /* RNMPRokt.h */, diff --git a/ios/RNMParticle/RNMPRokt.mm b/ios/RNMParticle/RNMPRokt.mm index aa78901..64af67d 100644 --- a/ios/RNMParticle/RNMPRokt.mm +++ b/ios/RNMParticle/RNMPRokt.mm @@ -1,5 +1,6 @@ #import "RNMPRokt.h" #import "RNMPSDKImports.h" +#import "RNMPRoktSwift.h" #import #import #import @@ -119,14 +120,14 @@ - (void)selectPlacements:(NSString *)identifer NSMutableDictionary *finalAttributes = [self convertToMutableDictionaryOfStrings:attributes]; NSDictionary *roktConfigDict = safeExtractRoktConfigDict(roktConfig); - RoktConfig *config = [self buildRoktConfigFromDict:roktConfigDict]; + RoktConfig *config = [RNMPRoktConfigFactory configFromDictionary:roktConfigDict]; #else // Old Architecture Implementation — selectPlacements RCT_EXPORT_METHOD(selectPlacements:(NSString *) identifer attributes:(NSDictionary *)attributes placeholders:(NSDictionary * _Nullable)placeholders roktConfig:(NSDictionary * _Nullable)roktConfig fontFilesMap:(NSDictionary * _Nullable)fontFilesMap) { _rokt_log(@"[mParticle-Rokt] Old Architecture Implementation"); NSMutableDictionary *finalAttributes = [self convertToMutableDictionaryOfStrings:attributes]; - RoktConfig *config = [self buildRoktConfigFromDict:roktConfig]; + RoktConfig *config = [RNMPRoktConfigFactory configFromDictionary:roktConfig]; #endif _rokt_log(@"[mParticle-Rokt] selectPlacements called with identifier: %@, attributes count: %lu", identifer, (unsigned long)finalAttributes.count); @@ -171,7 +172,7 @@ - (void)selectPlacements:(NSString *)identifer // Replaced by a newer call with the same identifier, or cancelled by close(): the SDK // is never called, so report the failure the way the SDK reports a call it rejects. _rokt_log(@"[mParticle-Rokt] pending selectPlacements dropped for: %@", identifer); - [weakSelf.eventManager onRoktEvents:[[RoktPlacementFailure alloc] initWithIdentifier:nil] viewName:identifer]; + [weakSelf.eventManager onRoktEvents:[RNMPRoktEventMapper placementFailure] viewName:identifer]; }]; }); } @@ -185,14 +186,14 @@ - (void)selectShoppableAds:(NSString *)identifier _rokt_log(@"[mParticle-Rokt] selectShoppableAds New Architecture"); NSMutableDictionary *finalAttributes = [self convertToMutableDictionaryOfStrings:attributes]; NSDictionary *roktConfigDict = safeExtractRoktConfigDict(roktConfig); - RoktConfig *config = [self buildRoktConfigFromDict:roktConfigDict]; + RoktConfig *config = [RNMPRoktConfigFactory configFromDictionary:roktConfigDict]; #else // Old Architecture Implementation — selectShoppableAds RCT_EXPORT_METHOD(selectShoppableAds:(NSString *)identifier attributes:(NSDictionary *)attributes roktConfig:(NSDictionary * _Nullable)roktConfig) { _rokt_log(@"[mParticle-Rokt] selectShoppableAds Old Architecture"); NSMutableDictionary *finalAttributes = [self convertToMutableDictionaryOfStrings:attributes]; - RoktConfig *config = [self buildRoktConfigFromDict:roktConfig]; + RoktConfig *config = [RNMPRoktConfigFactory configFromDictionary:roktConfig]; #endif _rokt_log(@"[mParticle-Rokt] selectShoppableAds called with identifier: %@, attributes count: %lu", identifier, (unsigned long)finalAttributes.count); @@ -310,45 +311,6 @@ - (void)getSessionIdWithResolve:(RCTPromiseResolveBlock)resolve return finalAttributes; } -- (RoktConfig *)buildRoktConfigFromDict:(NSDictionary *)configMap { - _rokt_log(@"[mParticle-Rokt] buildRoktConfigFromDict: configMap %@", configMap == nil ? @"nil" : [NSString stringWithFormat:@"non-nil (%lu keys)", (unsigned long)configMap.count]); - if (configMap == nil || configMap.count == 0) { - _rokt_log(@"[mParticle-Rokt] buildRoktConfigFromDict: returning nil"); - return nil; - } - - RoktConfigBuilder *builder = [[RoktConfigBuilder alloc] init]; - BOOL isConfigEmpty = YES; - - NSString *colorModeString = configMap[@"colorMode"]; - if (colorModeString && [colorModeString isKindOfClass:[NSString class]]) { - isConfigEmpty = NO; - if ([colorModeString isEqualToString:@"dark"]) { - [builder colorMode:RoktColorModeDark]; - } else if ([colorModeString isEqualToString:@"light"]) { - [builder colorMode:RoktColorModeLight]; - } else { - [builder colorMode:RoktColorModeSystem]; - } - } - - NSDictionary *cacheConfigMap = configMap[@"cacheConfig"]; - if (cacheConfigMap && [cacheConfigMap isKindOfClass:[NSDictionary class]]) { - isConfigEmpty = NO; - NSNumber *cacheDuration = cacheConfigMap[@"cacheDurationInSeconds"]; - if (!cacheDuration) { - cacheDuration = @0; - } - NSDictionary *cacheAttributes = cacheConfigMap[@"cacheAttributes"]; - RoktCacheConfig *cacheConfig = [[RoktCacheConfig alloc] initWithCacheDuration:[cacheDuration longLongValue] - cacheAttributes:cacheAttributes ?: @{}]; - [builder cacheConfig:cacheConfig]; - } - - _rokt_log(@"[mParticle-Rokt] buildRoktConfigFromDict: returning %@", isConfigEmpty ? @"nil" : @"config"); - return isConfigEmpty ? nil : [builder build]; -} - // Main thread only — RCTViewRegistry and RoktPlaceholderRegistry read the mounted view hierarchy. // A positive numeric value is a legacy findNodeHandle react tag. Zero is the name-lookup // sentinel used by the JS wrapper; unresolved tags also fall back to placeholderName. @@ -359,15 +321,15 @@ - (NSMutableDictionary *)resolvePlaceholders:(NSDictionary *)placeholders for(id key in placeholders){ id reactTag = [placeholders objectForKey:key]; - RoktEmbeddedView *embeddedView = nil; + UIView *embeddedView = nil; if ([reactTag isKindOfClass:[NSNumber class]] && [reactTag integerValue] > 0) { embeddedView = [self embeddedViewForReactTag:reactTag]; } if (embeddedView == nil && [key isKindOfClass:[NSString class]]) { UIView *view = [RoktPlaceholderRegistry viewForName:key]; - // nil fails isKindOfClass:, covering both "not mounted" and "wrong class". - if ([view isKindOfClass:[RoktEmbeddedView class]]) { - embeddedView = (RoktEmbeddedView *)view; + // nil is not an embedded view, covering both "not mounted" and "wrong class". + if ([RNMPRoktViews isEmbeddedView:view]) { + embeddedView = view; } } if (embeddedView == nil) { @@ -396,7 +358,7 @@ - (NSMutableDictionary *)resolvePlaceholders:(NSDictionary *)placeholders return pending; } -- (nullable RoktEmbeddedView *)embeddedViewForReactTag:(NSNumber *)reactTag +- (nullable UIView *)embeddedViewForReactTag:(NSNumber *)reactTag { UIView *view = [_viewRegistry_DEPRECATED viewForReactTag:reactTag]; #ifdef RCT_NEW_ARCH_ENABLED @@ -404,7 +366,7 @@ - (nullable RoktEmbeddedView *)embeddedViewForReactTag:(NSNumber *)reactTag ? ((RoktNativeLayoutComponentView *)view).roktEmbeddedView : nil; #else - return [view isKindOfClass:[RoktEmbeddedView class]] ? (RoktEmbeddedView *)view : nil; + return [RNMPRoktViews isEmbeddedView:view] ? view : nil; #endif // RCT_NEW_ARCH_ENABLED } diff --git a/ios/RNMParticle/RNMPRoktSwift.h b/ios/RNMParticle/RNMPRoktSwift.h new file mode 100644 index 0000000..c1464ab --- /dev/null +++ b/ios/RNMParticle/RNMPRoktSwift.h @@ -0,0 +1,30 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +// Implemented in Swift (Swift/*.swift). Declared by hand instead of importing the generated +// -Swift.h header: under Swift Package Manager the Swift code is a separate target, and +// Objective-C++ files cannot reach another target's generated header without C++ modules. +// Only Foundation and UIKit types appear here, so Objective-C++ never needs the RoktContracts +// headers. Keep each declaration in sync with its @objc name; RNMPRoktSwiftTests checks them. + +@interface RNMPRoktEventMapper : NSObject +// Keys: "payload" (the RoktEvents body), optional "callback" (a RoktCallback value), and +// optional "height" and "placement" (LayoutHeightChanges). ++ (NSDictionary *)mapEvent:(id)event viewName:(nullable NSString *)viewName; +// A RoktPlacementFailure with no identifier. ++ (id)placementFailure; +@end + +@interface RNMPRoktConfigFactory : NSObject +// A RoktConfig, or nil when the dictionary has no usable keys. ++ (nullable id)configFromDictionary:(nullable NSDictionary *)dictionary; +@end + +@interface RNMPRoktViews : NSObject +// A RoktEmbeddedView. ++ (UIView *)makeEmbeddedViewWithFrame:(CGRect)frame; ++ (BOOL)isEmbeddedView:(nullable UIView *)view; +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/RNMParticle/RNMPSDKImports.h b/ios/RNMParticle/RNMPSDKImports.h index 48cc4bc..d32130a 100644 --- a/ios/RNMParticle/RNMPSDKImports.h +++ b/ios/RNMParticle/RNMPSDKImports.h @@ -17,6 +17,10 @@ @import mParticle_Apple_SDK_ObjC; #endif +// RoktContracts for Objective-C (.m) files only. Objective-C++ files use its types through the +// Swift layer (RNMPRoktSwift.h), because a Swift Package Manager Objective-C++ target cannot +// import them; the SDK headers above only forward-declare RoktEmbeddedView, RoktConfig and RoktEvent. +#ifndef __cplusplus #if __has_include() #import #elif __has_include() @@ -26,3 +30,4 @@ #else @import RoktContracts; #endif +#endif // __cplusplus diff --git a/ios/RNMParticle/RoktEventManager.mm b/ios/RNMParticle/RoktEventManager.mm index f8fc373..d30d299 100644 --- a/ios/RNMParticle/RoktEventManager.mm +++ b/ios/RNMParticle/RoktEventManager.mm @@ -1,5 +1,5 @@ #import "RoktEventManager.h" -#import "RNMPSDKImports.h" +#import "RNMPRoktSwift.h" #import static os_log_t _rokt_events_os_log(void) { @@ -80,157 +80,21 @@ - (void)onRoktCallbackReceived:(NSString*)eventValue - (void)onRoktEvents:(RoktEvent * _Nonnull)event viewName:(NSString * _Nullable)viewName { - NSString *eventClass = event ? NSStringFromClass([event class]) : @"nil"; - _rokt_events_log(@"[mParticle-Rokt] RoktEventManager onRoktEvents: %@ viewName: %@", eventClass, viewName ?: @"(nil)"); - if (hasListeners) { - NSString *placementId; - NSString *eventName = @""; - NSString *status; - NSString *url; - NSString *cartItemId; - NSString *catalogItemId; - NSString *currency; - NSString *itemDescription; - NSString *linkedProductId; - NSString *providerData; - NSDecimalNumber *quantity; - NSDecimalNumber *totalPrice; - NSDecimalNumber *unitPrice; - NSString *error; - NSString *paymentProvider; - - if ([event isKindOfClass:[RoktShowLoadingIndicator class]]) { - eventName = @"ShowLoadingIndicator"; - [self onRoktCallbackReceived:@"onShouldShowLoadingIndicator"]; - } else if ([event isKindOfClass:[RoktHideLoadingIndicator class]]) { - eventName = @"HideLoadingIndicator"; - [self onRoktCallbackReceived:@"onShouldHideLoadingIndicator"]; - } else if ([event isKindOfClass:[RoktPlacementInteractive class]]) { - placementId = ((RoktPlacementInteractive *)event).identifier; - eventName = @"PlacementInteractive"; - } else if ([event isKindOfClass:[RoktPlacementReady class]]) { - placementId = ((RoktPlacementReady *)event).identifier; - eventName = @"PlacementReady"; - [self onRoktCallbackReceived:@"onLoad"]; - } else if ([event isKindOfClass:[RoktOfferEngagement class]]) { - placementId = ((RoktOfferEngagement *)event).identifier; - eventName = @"OfferEngagement"; - } else if ([event isKindOfClass:[RoktPositiveEngagement class]]) { - placementId = ((RoktPositiveEngagement *)event).identifier; - eventName = @"PositiveEngagement"; - } else if ([event isKindOfClass:[RoktPlacementClosed class]]) { - placementId = ((RoktPlacementClosed *)event).identifier; - eventName = @"PlacementClosed"; - [self onRoktCallbackReceived:@"onUnLoad"]; - } else if ([event isKindOfClass:[RoktPlacementCompleted class]]) { - placementId = ((RoktPlacementCompleted *)event).identifier; - eventName = @"PlacementCompleted"; - } else if ([event isKindOfClass:[RoktPlacementFailure class]]) { - placementId = ((RoktPlacementFailure *)event).identifier; - eventName = @"PlacementFailure"; - } else if ([event isKindOfClass:[RoktFirstPositiveEngagement class]]) { - placementId = ((RoktFirstPositiveEngagement *)event).identifier; - eventName = @"FirstPositiveEngagement"; - } else if ([event isKindOfClass:[RoktInitComplete class]]) { - eventName = @"InitComplete"; - status = ((RoktInitComplete *)event).success ? @"true" : @"false"; - } else if ([event isKindOfClass:[RoktOpenUrl class]]) { - eventName = @"OpenUrl"; - placementId = ((RoktOpenUrl *)event).identifier; - url = ((RoktOpenUrl *)event).url; - } else if ([event isKindOfClass:[RoktEmbeddedSizeChanged class]]) { - RoktEmbeddedSizeChanged *sizeEvent = (RoktEmbeddedSizeChanged *)event; - placementId = sizeEvent.identifier; - eventName = @"EmbeddedSizeChanged"; - [self onWidgetHeightChanges:sizeEvent.updatedHeight placement:sizeEvent.identifier]; - } else if ([event isKindOfClass:[RoktCartItemInstantPurchase class]]) { - RoktCartItemInstantPurchase *cartEvent = (RoktCartItemInstantPurchase *)event; - eventName = @"CartItemInstantPurchase"; - placementId = cartEvent.identifier; - cartItemId = cartEvent.cartItemId; - catalogItemId = cartEvent.catalogItemId; - currency = cartEvent.currency; - providerData = cartEvent.providerData; - linkedProductId = cartEvent.linkedProductId; - itemDescription = cartEvent.description; - quantity = cartEvent.quantity; - totalPrice = cartEvent.totalPrice; - unitPrice = cartEvent.unitPrice; - } else if ([event isKindOfClass:[RoktCartItemInstantPurchaseInitiated class]]) { - RoktCartItemInstantPurchaseInitiated *initiatedEvent = (RoktCartItemInstantPurchaseInitiated *)event; - eventName = @"CartItemInstantPurchaseInitiated"; - placementId = initiatedEvent.identifier; - catalogItemId = initiatedEvent.catalogItemId; - cartItemId = initiatedEvent.cartItemId; - } else if ([event isKindOfClass:[RoktCartItemInstantPurchaseFailure class]]) { - RoktCartItemInstantPurchaseFailure *failureEvent = (RoktCartItemInstantPurchaseFailure *)event; - eventName = @"CartItemInstantPurchaseFailure"; - placementId = failureEvent.identifier; - catalogItemId = failureEvent.catalogItemId; - cartItemId = failureEvent.cartItemId; - error = failureEvent.error; - } else if ([event isKindOfClass:[RoktInstantPurchaseDismissal class]]) { - RoktInstantPurchaseDismissal *dismissalEvent = (RoktInstantPurchaseDismissal *)event; - eventName = @"InstantPurchaseDismissal"; - placementId = dismissalEvent.identifier; - } else if ([event isKindOfClass:[RoktCartItemDevicePay class]]) { - RoktCartItemDevicePay *devicePayEvent = (RoktCartItemDevicePay *)event; - eventName = @"CartItemDevicePay"; - placementId = devicePayEvent.identifier; - catalogItemId = devicePayEvent.catalogItemId; - cartItemId = devicePayEvent.cartItemId; - paymentProvider = devicePayEvent.paymentProvider; - } - - NSMutableDictionary *payload = [@{@"event": eventName} mutableCopy]; - if (viewName != nil) { - [payload setObject:viewName forKey:@"viewName"]; - } - if (placementId != nil) { - [payload setObject:placementId forKey:@"placementId"]; - } - if (status != nil) { - [payload setObject:status forKey:@"status"]; - } - if (url != nil) { - [payload setObject:url forKey:@"url"]; - } - if (cartItemId != nil) { - [payload setObject:cartItemId forKey:@"cartItemId"]; - } - if (catalogItemId != nil) { - [payload setObject:catalogItemId forKey:@"catalogItemId"]; - } - if (currency != nil) { - [payload setObject:currency forKey:@"currency"]; - } - if (itemDescription != nil) { - [payload setObject:itemDescription forKey:@"description"]; - } - if (linkedProductId != nil) { - [payload setObject:linkedProductId forKey:@"linkedProductId"]; - } - if (providerData != nil) { - [payload setObject:providerData forKey:@"providerData"]; - } - if (quantity != nil) { - [payload setObject:quantity forKey:@"quantity"]; - } - if (totalPrice != nil) { - [payload setObject:totalPrice forKey:@"totalPrice"]; - } - if (unitPrice != nil) { - [payload setObject:unitPrice forKey:@"unitPrice"]; - } - if (error != nil) { - [payload setObject:error forKey:@"error"]; - } - if (paymentProvider != nil) { - [payload setObject:paymentProvider forKey:@"paymentProvider"]; - } - - [self sendEventWithName:@"RoktEvents" body:payload]; - } + // RoktEvent is only forward-declared here, so message it as an NSObject. + NSString *eventClass = event ? NSStringFromClass([(NSObject *)event class]) : @"nil"; + _rokt_events_log(@"[mParticle-Rokt] RoktEventManager onRoktEvents: %@ viewName: %@", eventClass, viewName ?: @"(nil)"); + if (!hasListeners) { + return; + } + // The mapping lives in Swift (RNMPRoktEventMapper), which can see the Rokt event types. + NSDictionary *mapped = [RNMPRoktEventMapper mapEvent:event viewName:viewName]; + if (mapped[@"callback"] != nil) { + [self onRoktCallbackReceived:mapped[@"callback"]]; + } + if (mapped[@"height"] != nil) { + [self onWidgetHeightChanges:[mapped[@"height"] doubleValue] placement:mapped[@"placement"]]; + } + [self sendEventWithName:@"RoktEvents" body:mapped[@"payload"]]; } #ifdef RCT_NEW_ARCH_ENABLED diff --git a/ios/RNMParticle/RoktNativeLayoutComponentView.h b/ios/RNMParticle/RoktNativeLayoutComponentView.h index e19adb8..fbc9421 100644 --- a/ios/RNMParticle/RoktNativeLayoutComponentView.h +++ b/ios/RNMParticle/RoktNativeLayoutComponentView.h @@ -2,7 +2,6 @@ #import #import #import -#import "RNMPSDKImports.h" #ifndef RoktNativeLayoutComponentView_h #define RoktNativeLayoutComponentView_h @@ -10,7 +9,8 @@ NS_ASSUME_NONNULL_BEGIN @interface RoktNativeLayoutComponentView : RCTViewComponentView -@property (nonatomic, readonly) RoktEmbeddedView *roktEmbeddedView; +// The RoktEmbeddedView the SDK renders into (typed UIView so Objective-C++ needs no Rokt headers). +@property (nonatomic, readonly) UIView *roktEmbeddedView; @end NS_ASSUME_NONNULL_END diff --git a/ios/RNMParticle/RoktNativeLayoutComponentView.mm b/ios/RNMParticle/RoktNativeLayoutComponentView.mm index c79a32d..65480ec 100644 --- a/ios/RNMParticle/RoktNativeLayoutComponentView.mm +++ b/ios/RNMParticle/RoktNativeLayoutComponentView.mm @@ -2,6 +2,7 @@ #import #import "RoktNativeLayoutComponentView.h" #import "RoktPlaceholderRegistry.h" +#import "RNMPRoktSwift.h" #import #import @@ -11,7 +12,7 @@ using namespace facebook::react; @interface RoktNativeLayoutComponentView () -@property (nonatomic, nullable) RoktEmbeddedView *roktEmbeddedView; +@property (nonatomic, nullable) UIView *roktEmbeddedView; @property (nonatomic, nullable) NSString *placeholderName; @end @@ -27,7 +28,7 @@ + (ComponentDescriptorProvider)componentDescriptorProvider - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { - _roktEmbeddedView = [[RoktEmbeddedView alloc] initWithFrame:self.bounds]; + _roktEmbeddedView = [RNMPRoktViews makeEmbeddedViewWithFrame:self.bounds]; // Width only: the Rokt SDK owns the height (updateEmbeddedSize sets it before JS // resizes this view), so flexible height would re-apply that delta once React Native // caught up, doubling the embedded view and centring the layout below blank space. diff --git a/ios/RNMParticle/Swift/RNMPRoktConfigFactory.swift b/ios/RNMParticle/Swift/RNMPRoktConfigFactory.swift new file mode 100644 index 0000000..f1f86ba --- /dev/null +++ b/ios/RNMParticle/Swift/RNMPRoktConfigFactory.swift @@ -0,0 +1,34 @@ +import Foundation +import RoktContracts + +/// Builds the Rokt SDK config from the `roktConfig` object JavaScript passes. Returns nil when the +/// object has no usable keys, which the SDK treats as "no config". +@objc(RNMPRoktConfigFactory) +public final class RNMPRoktConfigFactory: NSObject { + @objc(configFromDictionary:) + public static func config(from map: [String: Any]?) -> RoktConfig? { + guard let map, !map.isEmpty else { return nil } + + let builder = RoktConfig.Builder() + var isEmpty = true + + if let colorMode = map["colorMode"] as? String { + isEmpty = false + switch colorMode { + case "dark": builder.colorMode(.dark) + case "light": builder.colorMode(.light) + default: builder.colorMode(.system) + } + } + + if let cache = map["cacheConfig"] as? [String: Any] { + isEmpty = false + // Whole seconds, as the Objective-C version read it (longLongValue). + let seconds = (cache["cacheDurationInSeconds"] as? NSNumber)?.int64Value ?? 0 + let attributes = cache["cacheAttributes"] as? [String: String] ?? [:] + builder.cacheConfig(RoktConfig.CacheConfig(cacheDuration: TimeInterval(seconds), cacheAttributes: attributes)) + } + + return isEmpty ? nil : builder.build() + } +} diff --git a/ios/RNMParticle/Swift/RNMPRoktEventMapper.swift b/ios/RNMParticle/Swift/RNMPRoktEventMapper.swift new file mode 100644 index 0000000..677e397 --- /dev/null +++ b/ios/RNMParticle/Swift/RNMPRoktEventMapper.swift @@ -0,0 +1,84 @@ +import Foundation +import RoktContracts + +/// Maps a Rokt SDK event to the `RoktEvents` payload sent to JavaScript, plus the side effects +/// RoktEventManager performs for it. Pure mapping only; React stays in RoktEventManager.mm. +@objc(RNMPRoktEventMapper) +public final class RNMPRoktEventMapper: NSObject { + /// Keys: "payload" (the JS body), optional "callback" (RoktCallback value), and optional + /// "height" + "placement" (LayoutHeightChanges). + @objc(mapEvent:viewName:) + public static func map(_ event: RoktEvent, viewName: String?) -> [String: Any] { + var name = "" + var fields: [String: Any?] = [:] + var callback: String? + var height: CGFloat? + var placement: String? + + switch event { + case is RoktEvent.ShowLoadingIndicator: + name = "ShowLoadingIndicator"; callback = "onShouldShowLoadingIndicator" + case is RoktEvent.HideLoadingIndicator: + name = "HideLoadingIndicator"; callback = "onShouldHideLoadingIndicator" + case let e as RoktEvent.PlacementInteractive: + name = "PlacementInteractive"; fields["placementId"] = e.identifier + case let e as RoktEvent.PlacementReady: + name = "PlacementReady"; fields["placementId"] = e.identifier; callback = "onLoad" + case let e as RoktEvent.OfferEngagement: + name = "OfferEngagement"; fields["placementId"] = e.identifier + case let e as RoktEvent.PositiveEngagement: + name = "PositiveEngagement"; fields["placementId"] = e.identifier + case let e as RoktEvent.PlacementClosed: + name = "PlacementClosed"; fields["placementId"] = e.identifier; callback = "onUnLoad" + case let e as RoktEvent.PlacementCompleted: + name = "PlacementCompleted"; fields["placementId"] = e.identifier + case let e as RoktEvent.PlacementFailure: + name = "PlacementFailure"; fields["placementId"] = e.identifier + case let e as RoktEvent.FirstPositiveEngagement: + name = "FirstPositiveEngagement"; fields["placementId"] = e.identifier + case let e as RoktEvent.InitComplete: + name = "InitComplete"; fields["status"] = e.success ? "true" : "false" + case let e as RoktEvent.OpenUrl: + name = "OpenUrl"; fields["placementId"] = e.identifier; fields["url"] = e.url + case let e as RoktEvent.EmbeddedSizeChanged: + name = "EmbeddedSizeChanged"; fields["placementId"] = e.identifier + height = e.updatedHeight; placement = e.identifier + case let e as RoktEvent.CartItemInstantPurchase: + name = "CartItemInstantPurchase" + fields = ["placementId": e.identifier, "cartItemId": e.cartItemId, "catalogItemId": e.catalogItemId, + "currency": e.currency, "providerData": e.providerData, "linkedProductId": e.linkedProductId, + "description": e.description, "quantity": e.quantity, "totalPrice": e.totalPrice, + "unitPrice": e.unitPrice] + case let e as RoktEvent.CartItemInstantPurchaseInitiated: + name = "CartItemInstantPurchaseInitiated" + fields = ["placementId": e.identifier, "catalogItemId": e.catalogItemId, "cartItemId": e.cartItemId] + case let e as RoktEvent.CartItemInstantPurchaseFailure: + name = "CartItemInstantPurchaseFailure" + fields = ["placementId": e.identifier, "catalogItemId": e.catalogItemId, "cartItemId": e.cartItemId, + "error": e.error] + case let e as RoktEvent.InstantPurchaseDismissal: + name = "InstantPurchaseDismissal"; fields["placementId"] = e.identifier + case let e as RoktEvent.CartItemDevicePay: + name = "CartItemDevicePay" + fields = ["placementId": e.identifier, "catalogItemId": e.catalogItemId, "cartItemId": e.cartItemId, + "paymentProvider": e.paymentProvider] + default: + break + } + + var payload: [String: Any] = fields.compactMapValues { $0 } + payload["event"] = name + if let viewName { payload["viewName"] = viewName } + + var result: [String: Any] = ["payload": payload] + if let callback { result["callback"] = callback } + if let height, let placement { result["height"] = Double(height); result["placement"] = placement } + return result + } + + /// The event the SDK sends for a call it rejects; also sent when a pending call is dropped. + @objc(placementFailure) + public static func placementFailure() -> RoktEvent { + RoktEvent.PlacementFailure(identifier: nil) + } +} diff --git a/ios/RNMParticle/Swift/RNMPRoktViews.swift b/ios/RNMParticle/Swift/RNMPRoktViews.swift new file mode 100644 index 0000000..43f044e --- /dev/null +++ b/ios/RNMParticle/Swift/RNMPRoktViews.swift @@ -0,0 +1,16 @@ +import RoktContracts +import UIKit + +/// The embedded view the Rokt SDK renders into, for Objective-C++ code that cannot see its type. +@objc(RNMPRoktViews) +public final class RNMPRoktViews: NSObject { + @objc(makeEmbeddedViewWithFrame:) + public static func makeEmbeddedView(frame: CGRect) -> UIView { + RoktEmbeddedView(frame: frame) + } + + @objc(isEmbeddedView:) + public static func isEmbeddedView(_ view: UIView?) -> Bool { + view is RoktEmbeddedView + } +} diff --git a/react-native-mparticle.podspec b/react-native-mparticle.podspec index 86ff8ba..90bfaa3 100644 --- a/react-native-mparticle.podspec +++ b/react-native-mparticle.podspec @@ -17,6 +17,12 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/mParticle/react-native-mparticle.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,swift}" + # The Rokt-typed code is Swift (ios/RNMParticle/Swift); Objective-C++ reaches it through the + # hand-written RNMPRoktSwift.h. Private headers keep C++ and React headers out of the module's + # umbrella header, which the Swift code's module would otherwise fail to build. + s.swift_version = '5.0' + s.private_header_files = 'ios/**/*.h' + xcconfig = { 'DEFINES_MODULE' => 'YES' } # Opt-in Swift Package Manager mode: set `$RNMParticleUseSPM = true` at the top of the Podfile and # call `mparticle_spm_post_install` (ios/mparticle_spm.rb) in post_install. When it is unset, this @@ -29,9 +35,9 @@ Pod::Spec.new do |s| # `use_frameworks! :linkage => :dynamic`. s.static_framework = true s.platforms = { :ios => ios_platform } # the Rokt kit Swift package is iOS-only - s.pod_target_xcconfig = { - 'HEADER_SEARCH_PATHS' => '"$(DERIVED_FILE_DIR)/mParticleSPMInclude" "$(OBJROOT)/GeneratedModuleMaps-$(PLATFORM_NAME)"' - } + xcconfig['HEADER_SEARCH_PATHS'] = '"$(DERIVED_FILE_DIR)/mParticleSPMInclude" "$(OBJROOT)/GeneratedModuleMaps-$(PLATFORM_NAME)"' + # Where Xcode puts the Swift packages' .swiftmodule files, for build and archive alike. + xcconfig['SWIFT_INCLUDE_PATHS'] = '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)"' # Xcode writes a module map holding the absolute checkout path of each package, for build and # archive alike, under OBJROOT. Linking that directory into this target's derived sources means no # DerivedData or -clonedSourcePackagesDirPath layout is assumed. Declaring RoktContracts-Swift.h @@ -56,6 +62,8 @@ Pod::Spec.new do |s| } end + s.pod_target_xcconfig = xcconfig + if respond_to?(:install_modules_dependencies, true) install_modules_dependencies(s) else diff --git a/sample/ios/MParticleSample.xcodeproj/project.pbxproj b/sample/ios/MParticleSample.xcodeproj/project.pbxproj index 127cf13..daeba0c 100644 --- a/sample/ios/MParticleSample.xcodeproj/project.pbxproj +++ b/sample/ios/MParticleSample.xcodeproj/project.pbxproj @@ -18,7 +18,10 @@ B7C10E932E50AA1100000004 /* RNMPRoktPlaceholderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */; }; B7C10E952E50AA1100000006 /* RoktNativeLayoutComponentViewSizingTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E942E50AA1100000005 /* RoktNativeLayoutComponentViewSizingTests.mm */; }; B7C10E972E50AA1100000008 /* RNMParticleDuplicateSDKTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E962E50AA1100000007 /* RNMParticleDuplicateSDKTests.m */; }; + B7C10E992E50AA110000000A /* RNMPRoktEventMapperTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E982E50AA1100000009 /* RNMPRoktEventMapperTests.m */; }; B7C10E992F6B000000000009 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E982F6B000000000008 /* SceneDelegate.swift */; }; + B7C10E9B2E50AA110000000C /* RNMPRoktConfigFactoryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E9A2E50AA110000000B /* RNMPRoktConfigFactoryTests.m */; }; + B7C10E9D2E50AA110000000E /* RNMPRoktSwiftTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E9C2E50AA110000000D /* RNMPRoktSwiftTests.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -51,7 +54,10 @@ B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktPlaceholderTests.m; sourceTree = ""; }; B7C10E942E50AA1100000005 /* RoktNativeLayoutComponentViewSizingTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RoktNativeLayoutComponentViewSizingTests.mm; sourceTree = ""; }; B7C10E962E50AA1100000007 /* RNMParticleDuplicateSDKTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMParticleDuplicateSDKTests.m; sourceTree = ""; }; + B7C10E982E50AA1100000009 /* RNMPRoktEventMapperTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktEventMapperTests.m; sourceTree = ""; }; B7C10E982F6B000000000008 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SceneDelegate.swift; path = MParticleSample/SceneDelegate.swift; sourceTree = ""; }; + B7C10E9A2E50AA110000000B /* RNMPRoktConfigFactoryTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktConfigFactoryTests.m; sourceTree = ""; }; + B7C10E9C2E50AA110000000D /* RNMPRoktSwiftTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktSwiftTests.m; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -83,6 +89,9 @@ B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */, B7C10E942E50AA1100000005 /* RoktNativeLayoutComponentViewSizingTests.mm */, B7C10E962E50AA1100000007 /* RNMParticleDuplicateSDKTests.m */, + B7C10E982E50AA1100000009 /* RNMPRoktEventMapperTests.m */, + B7C10E9A2E50AA110000000B /* RNMPRoktConfigFactoryTests.m */, + B7C10E9C2E50AA110000000D /* RNMPRoktSwiftTests.m */, 00E356F01AD99517003FC87E /* Supporting Files */, ); path = MParticleSampleTests; @@ -403,6 +412,9 @@ B7C10E932E50AA1100000004 /* RNMPRoktPlaceholderTests.m in Sources */, B7C10E952E50AA1100000006 /* RoktNativeLayoutComponentViewSizingTests.mm in Sources */, B7C10E972E50AA1100000008 /* RNMParticleDuplicateSDKTests.m in Sources */, + B7C10E992E50AA110000000A /* RNMPRoktEventMapperTests.m in Sources */, + B7C10E9B2E50AA110000000C /* RNMPRoktConfigFactoryTests.m in Sources */, + B7C10E9D2E50AA110000000E /* RNMPRoktSwiftTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/sample/ios/MParticleSampleTests/RNMPRoktConfigFactoryTests.m b/sample/ios/MParticleSampleTests/RNMPRoktConfigFactoryTests.m new file mode 100644 index 0000000..a63f62c --- /dev/null +++ b/sample/ios/MParticleSampleTests/RNMPRoktConfigFactoryTests.m @@ -0,0 +1,63 @@ +#import +#import "../../../ios/RNMParticle/RNMPSDKImports.h" +#import "../../../ios/RNMParticle/RNMPRoktSwift.h" + +/** + * Pins how the `roktConfig` object from JavaScript becomes a RoktConfig, including which inputs + * mean "no config" (nil). Written against the original Objective-C (`-[RNMPRokt + * buildRoktConfigFromDict:]`), which RNMPRoktConfigFactory replaced unchanged. + */ +@interface RNMPRoktConfigFactoryTests : XCTestCase +@end + +@implementation RNMPRoktConfigFactoryTests + +- (RoktConfig *)configFrom:(NSDictionary *)dict +{ + return [RNMPRoktConfigFactory configFromDictionary:dict]; +} + +- (void)testNoUsableKeysMeansNoConfig +{ + XCTAssertNil([self configFrom:nil]); + XCTAssertNil([self configFrom:@{}]); + XCTAssertNil([self configFrom:@{@"unknown" : @"x"}]); + XCTAssertNil([self configFrom:@{@"colorMode" : @1}]); + XCTAssertNil([self configFrom:@{@"cacheConfig" : @"not a dictionary"}]); +} + +- (void)testColorMode +{ + XCTAssertEqual([self configFrom:@{@"colorMode" : @"dark"}].colorMode, RoktColorModeDark); + XCTAssertEqual([self configFrom:@{@"colorMode" : @"light"}].colorMode, RoktColorModeLight); + XCTAssertEqual([self configFrom:@{@"colorMode" : @"system"}].colorMode, RoktColorModeSystem); + XCTAssertEqual([self configFrom:@{@"colorMode" : @"sepia"}].colorMode, RoktColorModeSystem); +} + +- (void)testCacheConfig +{ + RoktConfig *config = [self configFrom:@{@"cacheConfig" : @{@"cacheDurationInSeconds" : @120, @"cacheAttributes" : @{@"email" : @"a@b.c"}}}]; + XCTAssertEqual(config.cacheConfig.cacheDuration, 120); + XCTAssertEqualObjects(config.cacheConfig.cacheAttributes, (@{@"email" : @"a@b.c"})); +} + +- (void)testCacheConfigDefaultsAndTruncation +{ + RoktConfig *empty = [self configFrom:@{@"cacheConfig" : @{}}]; + XCTAssertNotNil(empty); + // A missing duration is sent as 0, which the SDK raises to its maximum. + XCTAssertEqual(empty.cacheConfig.cacheDuration, RoktCacheConfig.maxCacheDuration); + XCTAssertEqualObjects(empty.cacheConfig.cacheAttributes, @{}); + + // Whole seconds only: the duration was always read with longLongValue. + XCTAssertEqual([self configFrom:@{@"cacheConfig" : @{@"cacheDurationInSeconds" : @90.7}}].cacheConfig.cacheDuration, 90); +} + +- (void)testColorModeAndCacheTogether +{ + RoktConfig *config = [self configFrom:@{@"colorMode" : @"dark", @"cacheConfig" : @{@"cacheDurationInSeconds" : @60}}]; + XCTAssertEqual(config.colorMode, RoktColorModeDark); + XCTAssertEqual(config.cacheConfig.cacheDuration, 60); +} + +@end diff --git a/sample/ios/MParticleSampleTests/RNMPRoktEventMapperTests.m b/sample/ios/MParticleSampleTests/RNMPRoktEventMapperTests.m new file mode 100644 index 0000000..4773284 --- /dev/null +++ b/sample/ios/MParticleSampleTests/RNMPRoktEventMapperTests.m @@ -0,0 +1,184 @@ +#import +#import +#import "../../../ios/RNMParticle/RNMPSDKImports.h" +#import "../../../ios/RNMParticle/RoktEventManager.h" + +/** + * Pins what RoktEventManager sends to JavaScript for every Rokt event: the `RoktEvents` payload + * and the `RoktCallback` / `LayoutHeightChanges` events some of them also trigger, in order. + * Written against the original Objective-C mapping, which RNMPRoktEventMapper replaced unchanged. + */ +static NSMutableArray *RNMPSentEvents; + +// Records instead of sending. Adds no ivars, so an existing manager can be switched to it. +@interface RNMPRecordingEventManager : RoktEventManager +@end + +@implementation RNMPRecordingEventManager +- (void)sendEventWithName:(NSString *)name body:(id)body +{ + [RNMPSentEvents addObject:@[ name, body ]]; +} +@end + +@interface RNMPRoktEventMapperTests : XCTestCase +@end + +@implementation RNMPRoktEventMapperTests { + RoktEventManager *_manager; + Class _originalClass; +} + +- (void)setUp +{ + [super setUp]; + RNMPSentEvents = [NSMutableArray array]; + _manager = [RoktEventManager allocWithZone:nil]; + _originalClass = object_getClass(_manager); + object_setClass(_manager, [RNMPRecordingEventManager class]); + [_manager startObserving]; +} + +- (void)tearDown +{ + [_manager stopObserving]; + object_setClass(_manager, _originalClass); + [super tearDown]; +} + +- (NSArray *)send:(RoktEvent *)event +{ + [RNMPSentEvents removeAllObjects]; + [_manager onRoktEvents:event viewName:@"checkout"]; + return [RNMPSentEvents copy]; +} + +- (NSArray *)roktEvents:(NSDictionary *)fields +{ + NSMutableDictionary *payload = [fields mutableCopy]; + payload[@"viewName"] = @"checkout"; + return @[ @"RoktEvents", payload ]; +} + +- (NSArray *)callback:(NSString *)value +{ + return @[ @"RoktCallback", @{@"callbackValue" : value} ]; +} + +- (void)testLoadingIndicatorsAlsoSendCallbacks +{ + XCTAssertEqualObjects([self send:[[RoktShowLoadingIndicator alloc] init]], + (@[ [self callback:@"onShouldShowLoadingIndicator"], [self roktEvents:@{@"event" : @"ShowLoadingIndicator"}] ])); + XCTAssertEqualObjects([self send:[[RoktHideLoadingIndicator alloc] init]], + (@[ [self callback:@"onShouldHideLoadingIndicator"], [self roktEvents:@{@"event" : @"HideLoadingIndicator"}] ])); +} + +- (void)testPlacementLifecycleEvents +{ + XCTAssertEqualObjects([self send:[[RoktPlacementReady alloc] initWithIdentifier:@"p1"]], + (@[ [self callback:@"onLoad"], [self roktEvents:@{@"event" : @"PlacementReady", @"placementId" : @"p1"}] ])); + XCTAssertEqualObjects([self send:[[RoktPlacementClosed alloc] initWithIdentifier:@"p1"]], + (@[ [self callback:@"onUnLoad"], [self roktEvents:@{@"event" : @"PlacementClosed", @"placementId" : @"p1"}] ])); + + NSDictionary *plain = @{ + @"PlacementInteractive" : [[RoktPlacementInteractive alloc] initWithIdentifier:@"p1"], + @"OfferEngagement" : [[RoktOfferEngagement alloc] initWithIdentifier:@"p1"], + @"PositiveEngagement" : [[RoktPositiveEngagement alloc] initWithIdentifier:@"p1"], + @"PlacementCompleted" : [[RoktPlacementCompleted alloc] initWithIdentifier:@"p1"], + @"PlacementFailure" : [[RoktPlacementFailure alloc] initWithIdentifier:@"p1"], + @"FirstPositiveEngagement" : [[RoktFirstPositiveEngagement alloc] initWithIdentifier:@"p1" setFulfillmentAttributes:nil], + @"InstantPurchaseDismissal" : [[RoktInstantPurchaseDismissal alloc] initWithIdentifier:@"p1"], + }; + [plain enumerateKeysAndObjectsUsingBlock:^(NSString *name, RoktEvent *event, BOOL *stop) { + XCTAssertEqualObjects([self send:event], (@[ [self roktEvents:@{@"event" : name, @"placementId" : @"p1"}] ]), @"%@", name); + }]; +} + +- (void)testMissingIdentifierIsOmitted +{ + XCTAssertEqualObjects([self send:[[RoktPlacementFailure alloc] initWithIdentifier:nil]], + (@[ [self roktEvents:@{@"event" : @"PlacementFailure"}] ])); +} + +- (void)testInitCompleteAndOpenUrl +{ + XCTAssertEqualObjects([self send:[[RoktInitComplete alloc] initWithSuccess:YES]], + (@[ [self roktEvents:@{@"event" : @"InitComplete", @"status" : @"true"}] ])); + XCTAssertEqualObjects([self send:[[RoktInitComplete alloc] initWithSuccess:NO]], + (@[ [self roktEvents:@{@"event" : @"InitComplete", @"status" : @"false"}] ])); + XCTAssertEqualObjects([self send:[[RoktOpenUrl alloc] initWithIdentifier:@"p1" url:@"https://example.com"]], + (@[ [self roktEvents:@{@"event" : @"OpenUrl", @"placementId" : @"p1", @"url" : @"https://example.com"}] ])); +} + +- (void)testEmbeddedSizeChangedAlsoSendsTheHeight +{ + XCTAssertEqualObjects([self send:[[RoktEmbeddedSizeChanged alloc] initWithIdentifier:@"p1" updatedHeight:412.5]], + (@[ @[ @"LayoutHeightChanges", @{@"height" : @412.5, @"selectedPlacement" : @"p1"} ], + [self roktEvents:@{@"event" : @"EmbeddedSizeChanged", @"placementId" : @"p1"}] ])); +} + +- (void)testInstantPurchaseEvents +{ + RoktEvent *purchase = [[RoktCartItemInstantPurchase alloc] initWithIdentifier:@"p1" + name:@"ignored" + cartItemId:@"cart" + catalogItemId:@"catalog" + currency:@"USD" + description:@"A thing" + linkedProductId:@"linked" + providerData:@"data" + quantity:[NSDecimalNumber decimalNumberWithString:@"2"] + totalPrice:[NSDecimalNumber decimalNumberWithString:@"19.98"] + unitPrice:[NSDecimalNumber decimalNumberWithString:@"9.99"]]; + XCTAssertEqualObjects([self send:purchase], (@[ [self roktEvents:@{ + @"event" : @"CartItemInstantPurchase", + @"placementId" : @"p1", + @"cartItemId" : @"cart", + @"catalogItemId" : @"catalog", + @"currency" : @"USD", + @"description" : @"A thing", + @"linkedProductId" : @"linked", + @"providerData" : @"data", + @"quantity" : [NSDecimalNumber decimalNumberWithString:@"2"], + @"totalPrice" : [NSDecimalNumber decimalNumberWithString:@"19.98"], + @"unitPrice" : [NSDecimalNumber decimalNumberWithString:@"9.99"], + }] ])); + + RoktEvent *sparse = [[RoktCartItemInstantPurchase alloc] initWithIdentifier:@"p1" name:nil cartItemId:@"cart" catalogItemId:@"catalog" currency:@"USD" description:@"A thing" linkedProductId:nil providerData:@"data" quantity:nil totalPrice:nil unitPrice:nil]; + XCTAssertEqualObjects([self send:sparse], (@[ [self roktEvents:@{ + @"event" : @"CartItemInstantPurchase", + @"placementId" : @"p1", + @"cartItemId" : @"cart", + @"catalogItemId" : @"catalog", + @"currency" : @"USD", + @"description" : @"A thing", + @"providerData" : @"data", + }] ])); + + XCTAssertEqualObjects([self send:[[RoktCartItemInstantPurchaseInitiated alloc] initWithIdentifier:@"p1" catalogItemId:@"catalog" cartItemId:@"cart"]], + (@[ [self roktEvents:@{@"event" : @"CartItemInstantPurchaseInitiated", @"placementId" : @"p1", @"catalogItemId" : @"catalog", @"cartItemId" : @"cart"}] ])); + XCTAssertEqualObjects([self send:[[RoktCartItemInstantPurchaseFailure alloc] initWithIdentifier:@"p1" catalogItemId:@"catalog" cartItemId:@"cart" error:@"declined"]], + (@[ [self roktEvents:@{@"event" : @"CartItemInstantPurchaseFailure", @"placementId" : @"p1", @"catalogItemId" : @"catalog", @"cartItemId" : @"cart", @"error" : @"declined"}] ])); + XCTAssertEqualObjects([self send:[[RoktCartItemDevicePay alloc] initWithIdentifier:@"p1" catalogItemId:@"catalog" cartItemId:@"cart" paymentProvider:@"applePay"]], + (@[ [self roktEvents:@{@"event" : @"CartItemDevicePay", @"placementId" : @"p1", @"catalogItemId" : @"catalog", @"cartItemId" : @"cart", @"paymentProvider" : @"applePay"}] ])); +} + +- (void)testUnknownEventSendsAnEmptyName +{ + XCTAssertEqualObjects([self send:[[RoktEvent alloc] init]], (@[ [self roktEvents:@{@"event" : @""}] ])); +} + +- (void)testNilViewNameIsOmitted +{ + [RNMPSentEvents removeAllObjects]; + [_manager onRoktEvents:[[RoktPlacementReady alloc] initWithIdentifier:@"p1"] viewName:nil]; + XCTAssertEqualObjects(RNMPSentEvents.lastObject, (@[ @"RoktEvents", @{@"event" : @"PlacementReady", @"placementId" : @"p1"} ])); +} + +- (void)testNothingIsSentWithoutListeners +{ + [_manager stopObserving]; + XCTAssertEqualObjects([self send:[[RoktPlacementReady alloc] initWithIdentifier:@"p1"]], @[]); +} + +@end diff --git a/sample/ios/MParticleSampleTests/RNMPRoktSwiftTests.m b/sample/ios/MParticleSampleTests/RNMPRoktSwiftTests.m new file mode 100644 index 0000000..d64394e --- /dev/null +++ b/sample/ios/MParticleSampleTests/RNMPRoktSwiftTests.m @@ -0,0 +1,37 @@ +#import +#import "../../../ios/RNMParticle/RNMPSDKImports.h" +#import "../../../ios/RNMParticle/RNMPRoktSwift.h" + +/** + * RNMPRoktSwift.h declares the Swift classes by hand, so nothing checks it against the Swift code + * at compile time. A mismatch only fails at runtime with "unrecognized selector", so check every + * declared selector here. + */ +@interface RNMPRoktSwiftTests : XCTestCase +@end + +@implementation RNMPRoktSwiftTests + +- (void)testDeclaredSelectorsExist +{ + XCTAssertTrue([RNMPRoktEventMapper respondsToSelector:@selector(mapEvent:viewName:)]); + XCTAssertTrue([RNMPRoktEventMapper respondsToSelector:@selector(placementFailure)]); + XCTAssertTrue([RNMPRoktConfigFactory respondsToSelector:@selector(configFromDictionary:)]); + XCTAssertTrue([RNMPRoktViews respondsToSelector:@selector(makeEmbeddedViewWithFrame:)]); + XCTAssertTrue([RNMPRoktViews respondsToSelector:@selector(isEmbeddedView:)]); +} + +- (void)testDeclaredTypes +{ + XCTAssertTrue([[RNMPRoktEventMapper placementFailure] isKindOfClass:[RoktPlacementFailure class]]); + XCTAssertNil([(RoktPlacementFailure *)[RNMPRoktEventMapper placementFailure] identifier]); + + UIView *view = [RNMPRoktViews makeEmbeddedViewWithFrame:CGRectMake(0, 0, 10, 20)]; + XCTAssertTrue([view isKindOfClass:[RoktEmbeddedView class]]); + XCTAssertTrue(CGRectEqualToRect(view.frame, CGRectMake(0, 0, 10, 20))); + XCTAssertTrue([RNMPRoktViews isEmbeddedView:view]); + XCTAssertFalse([RNMPRoktViews isEmbeddedView:[[UIView alloc] init]]); + XCTAssertFalse([RNMPRoktViews isEmbeddedView:nil]); +} + +@end From 0a31cef6e41e35627d98df3375a20543cfe18a70 Mon Sep 17 00:00:00 2001 From: Thomson Thomas Date: Thu, 1 Oct 2026 14:26:59 -0400 Subject: [PATCH 2/2] fix(ios): give the Fabric Rokt layout view its own default props RCTViewComponentView's initWithFrame: leaves plain ViewProps in _props. In a debug build of React Native, the first updateProps asserts that a subclass has replaced them, so the app crashes with NSInternalInconsistencyException ("`RCTViewComponentView` subclasses (and `RoktNativeLayoutComponentView` particularly) must setup `_props` instance variable with a default value in the constructor.") as soon as a RoktLayoutView mounts. Release builds of React Native only log the failed assert, so release builds, and any app that links the release-flavoured prebuilt React Native frameworks, do not crash. Start from RoktNativeLayoutShadowNode::defaultSharedProps() in initWithFrame:, the same way the superclass starts from ViewShadowNode::defaultSharedProps(), and add a test that the view's initial props are RoktNativeLayoutProps. The test checks the props type directly, so it fails whichever React Native flavour the sample links. The omission predates this series: 3.4.0 has it too. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../RoktNativeLayoutComponentView.mm | 3 +++ ...oktNativeLayoutComponentViewSizingTests.mm | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/ios/RNMParticle/RoktNativeLayoutComponentView.mm b/ios/RNMParticle/RoktNativeLayoutComponentView.mm index 65480ec..aef0b2d 100644 --- a/ios/RNMParticle/RoktNativeLayoutComponentView.mm +++ b/ios/RNMParticle/RoktNativeLayoutComponentView.mm @@ -28,6 +28,9 @@ + (ComponentDescriptorProvider)componentDescriptorProvider - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { + // The superclass leaves plain ViewProps here, and debug builds of React Native assert on + // the first updateProps if a subclass keeps them, so start from this component's defaults. + _props = RoktNativeLayoutShadowNode::defaultSharedProps(); _roktEmbeddedView = [RNMPRoktViews makeEmbeddedViewWithFrame:self.bounds]; // Width only: the Rokt SDK owns the height (updateEmbeddedSize sets it before JS // resizes this view), so flexible height would re-apply that delta once React Native diff --git a/sample/ios/MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests.mm b/sample/ios/MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests.mm index 902769e..9c7c9da 100644 --- a/sample/ios/MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests.mm +++ b/sample/ios/MParticleSampleTests/RoktNativeLayoutComponentViewSizingTests.mm @@ -1,4 +1,5 @@ #import +#import #import "../../../ios/RNMParticle/RoktNativeLayoutComponentView.h" // Deliberately not wrapped in #ifdef RCT_NEW_ARCH_ENABLED: the header is, so a build @@ -72,3 +73,21 @@ - (void)testEmbeddedWidthFollowsComponentWidth } @end + +/** + * Debug builds of React Native crash on the first updateProps if the view kept the superclass's + * plain ViewProps; release builds only log it, so check the props type rather than the crash. + */ +@interface RoktNativeLayoutComponentViewPropsTests : XCTestCase +@end + +@implementation RoktNativeLayoutComponentViewPropsTests + +- (void)testStartsWithRoktNativeLayoutDefaultProps +{ + RoktNativeLayoutComponentView *componentView = [[RoktNativeLayoutComponentView alloc] initWithFrame:CGRectZero]; + + XCTAssertTrue(std::dynamic_pointer_cast([componentView props]) != nullptr); +} + +@end