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..aef0b2d 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,10 @@ + (ComponentDescriptorProvider)componentDescriptorProvider - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { - _roktEmbeddedView = [[RoktEmbeddedView alloc] initWithFrame:self.bounds]; + // 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 // 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 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