diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index 3edb1d2bb..72c855e0a 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -14,6 +14,7 @@ env: jobs: native-unit-tests: strategy: + fail-fast: false max-parallel: 4 matrix: platform: [iOS, tvOS] @@ -26,10 +27,23 @@ jobs: runs-on: macOS-15 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + + - name: Setup specified simulator + uses: futureware-tech/simulator-action@v4 + id: simulator + with: + model: ${{ matrix.device }} + os: ${{ matrix.platform }} + os_version: '>=18.0' + erase_before_boot: true + wait_for_boot: true + shutdown_after_job: true - name: Run unit tests - run: xcodebuild -project mParticle-Apple-SDK.xcodeproj -scheme ${{ matrix.scheme }} -destination 'platform=${{ matrix.platform }} Simulator,name=${{ matrix.device }},OS=latest' test + run: xcodebuild -project mParticle-Apple-SDK.xcodeproj -scheme ${{ matrix.scheme }} -destination 'id=${{ steps.simulator.outputs.UDID }}' test \ No newline at end of file diff --git a/UnitTests/ObjCTests/MPRoktTests.m b/UnitTests/ObjCTests/MPRoktTests.m index f54cd7b4f..39021dac4 100644 --- a/UnitTests/ObjCTests/MPRoktTests.m +++ b/UnitTests/ObjCTests/MPRoktTests.m @@ -293,17 +293,19 @@ - (void)testGetRoktPlacementAttributesMapping { self.mockInstance = OCMPartialMock(instance); self.mockContainer = OCMClassMock([MPKitContainer_PRIVATE class]); NSArray *kitConfig = @[@{ - @"AllowJavaScriptResponse": @"True", - @"accountId": @12345, - @"onboardingExpProvider": @"None", - kMPPlacementAttributesMapping: @"[{\"jsmap\":null,\"map\":\"f.name\",\"maptype\":\"UserAttributeClass.Name\",\"value\":\"firstname\"},{\"jsmap\":null,\"map\":\"zip\",\"maptype\":\"UserAttributeClass.Name\",\"value\":\"billingzipcode\"},{\"jsmap\":null,\"map\":\"l.name\",\"maptype\":\"UserAttributeClass.Name\",\"value\":\"lastname\"}]", - @"sandboxMode": @"True", - @"eau": @0, - @"hs": @{ - @"pur": @{}, - @"reg": @{} - }, - @"id": @181 + @"id": @181, + kMPRemoteConfigKitConfigurationKey: @{ + @"AllowJavaScriptResponse": @"True", + @"accountId": @12345, + @"onboardingExpProvider": @"None", + kMPPlacementAttributesMapping: @"[{\"jsmap\":null,\"map\":\"f.name\",\"maptype\":\"UserAttributeClass.Name\",\"value\":\"firstname\"},{\"jsmap\":null,\"map\":\"zip\",\"maptype\":\"UserAttributeClass.Name\",\"value\":\"billingzipcode\"},{\"jsmap\":null,\"map\":\"l.name\",\"maptype\":\"UserAttributeClass.Name\",\"value\":\"lastname\"}]", + @"sandboxMode": @"True", + @"eau": @0, + @"hs": @{ + @"pur": @{}, + @"reg": @{} + } + } }]; [[[self.mockContainer stub] andReturn:kitConfig] originalConfig]; [[[self.mockInstance stub] andReturn:self.mockContainer] kitContainer_PRIVATE]; @@ -315,6 +317,49 @@ - (void)testGetRoktPlacementAttributesMapping { XCTAssertEqualObjects(testResult, expectedResult, @"Mapping does not match ."); } +- (void)testGetRoktHashedEmailUserIdentityType { + MParticle *instance = [MParticle sharedInstance]; + self.mockInstance = OCMPartialMock(instance); + self.mockContainer = OCMClassMock([MPKitContainer_PRIVATE class]); + NSArray *kitConfig = @[@{ + @"id": @181, + kMPRemoteConfigKitConfigurationKey: @{ + @"AllowJavaScriptResponse": @"True", + @"accountId": @12345, + kMPHashedEmailUserIdentityType: @"other3", + @"sandboxMode": @"True" + } + }]; + [[[self.mockContainer stub] andReturn:kitConfig] originalConfig]; + [[[self.mockInstance stub] andReturn:self.mockContainer] kitContainer_PRIVATE]; + [[[self.mockInstance stub] andReturn:self.mockInstance] sharedInstance]; + + NSNumber *testResult = [self.rokt getRoktHashedEmailUserIdentityType]; + + XCTAssertEqualObjects(testResult, @(MPIdentityOther3), @"Hashed email identity type does not match."); +} + +- (void)testGetRoktHashedEmailUserIdentityTypeReturnsNilWhenNotConfigured { + MParticle *instance = [MParticle sharedInstance]; + self.mockInstance = OCMPartialMock(instance); + self.mockContainer = OCMClassMock([MPKitContainer_PRIVATE class]); + NSArray *kitConfig = @[@{ + @"id": @181, + kMPRemoteConfigKitConfigurationKey: @{ + @"AllowJavaScriptResponse": @"True", + @"accountId": @12345, + @"sandboxMode": @"True" + } + }]; + [[[self.mockContainer stub] andReturn:kitConfig] originalConfig]; + [[[self.mockInstance stub] andReturn:self.mockContainer] kitContainer_PRIVATE]; + [[[self.mockInstance stub] andReturn:self.mockInstance] sharedInstance]; + + NSNumber *testResult = [self.rokt getRoktHashedEmailUserIdentityType]; + + XCTAssertNil(testResult, @"Hashed email identity type should be nil when not configured."); +} + - (void)testSelectPlacementsIdentifyUser { [[[self.mockRokt stub] andReturn:@[]] getRoktPlacementAttributesMapping]; MParticle *instance = [MParticle sharedInstance]; diff --git a/mParticle-Apple-SDK/Identity/FilteredMParticleUser.m b/mParticle-Apple-SDK/Identity/FilteredMParticleUser.m index 4e5b93200..5028fa44a 100644 --- a/mParticle-Apple-SDK/Identity/FilteredMParticleUser.m +++ b/mParticle-Apple-SDK/Identity/FilteredMParticleUser.m @@ -1,6 +1,7 @@ #import "FilteredMParticleUser.h" #import "mParticle.h" #import "MParticleUser.h" +#import "MPStateMachine.h" #import "MPKitConfiguration.h" #import "MPDataPlanFilter.h" #import "MParticleSwift.h" @@ -8,6 +9,7 @@ @interface MParticle () @property (nonatomic, strong) id dataPlanFilter; +@property (nonatomic, strong, readonly) MPStateMachine_PRIVATE *stateMachine; @end @@ -39,6 +41,18 @@ -(BOOL)isLoggedIn { return self.user.isLoggedIn; } +-(NSString *)idfa { + NSNumber *currentStatus = [MParticle sharedInstance].stateMachine.attAuthorizationStatus; + if (currentStatus != nil && currentStatus.integerValue == MPATTAuthorizationStatusAuthorized) { + return self.user.identities[@(MPIdentityIOSAdvertiserId)]; + } + return nil; +} + +-(NSString *)idfv { + return self.user.identities[@(MPIdentityIOSVendorId)]; +} + -(NSDictionary *) userIdentities { NSDictionary *unfilteredUserIdentities = self.user.identities; NSMutableDictionary *userIdentities = [NSMutableDictionary dictionary]; diff --git a/mParticle-Apple-SDK/Include/FilteredMParticleUser.h b/mParticle-Apple-SDK/Include/FilteredMParticleUser.h index 15199bcea..e510ebb0f 100644 --- a/mParticle-Apple-SDK/Include/FilteredMParticleUser.h +++ b/mParticle-Apple-SDK/Include/FilteredMParticleUser.h @@ -23,6 +23,20 @@ */ @property (readonly, strong, nonnull) NSDictionary *userIdentities; +/** + Gets current user's IDFA (readonly) + @returns A string that represents a unique, resettable device identifier that allows developers to track user activity for personalized ads. This will always be nil if ATTStatus is not set to authorized. + @see MPIdentityIOSAdvertiserId + */ +@property (readonly, strong, nullable) NSString *idfa; + +/** + Gets current user's IDFV (readonly) + @returns A string that represents a unique, non-resettable alphanumeric code Apple assigns to a specific device, identical for all apps from the same developer on that device. + @see MPIdentityIOSVendorId + */ +@property (readonly, strong, nullable) NSString *idfv; + /** Gets all user attributes. @returns A dictionary containing the collection of user attributes. diff --git a/mParticle-Apple-SDK/MPIConstants.h b/mParticle-Apple-SDK/MPIConstants.h index 7df0ea653..6a3a55167 100644 --- a/mParticle-Apple-SDK/MPIConstants.h +++ b/mParticle-Apple-SDK/MPIConstants.h @@ -241,6 +241,7 @@ extern NSString * _Nonnull const kMPRemoteConfigAppDefined; extern NSString * _Nonnull const kMPRemoteConfigForceTrue; extern NSString * _Nonnull const kMPRemoteConfigForceFalse; extern NSString * _Nonnull const kMPRemoteConfigKitsKey; +extern NSString * _Nonnull const kMPRemoteConfigKitConfigurationKey; extern NSString * _Nonnull const kMPRemoteConfigKitHashesKey; extern NSString * _Nonnull const kMPRemoteConfigConsumerInfoKey; extern NSString * _Nonnull const kMPRemoteConfigCookiesKey; diff --git a/mParticle-Apple-SDK/MPIConstants.m b/mParticle-Apple-SDK/MPIConstants.m index 70084dfc6..aef0794b3 100644 --- a/mParticle-Apple-SDK/MPIConstants.m +++ b/mParticle-Apple-SDK/MPIConstants.m @@ -194,6 +194,7 @@ NSString *const kMPRemoteConfigForceTrue = @"forcetrue"; NSString *const kMPRemoteConfigForceFalse = @"forcefalse"; NSString *const kMPRemoteConfigKitsKey = @"eks"; +NSString *const kMPRemoteConfigKitConfigurationKey = @"as"; NSString *const kMPRemoteConfigKitHashesKey = @"hs"; NSString *const kMPRemoteConfigConsumerInfoKey = @"ci"; NSString *const kMPRemoteConfigCookiesKey = @"ck"; diff --git a/mParticle-Apple-SDK/MPRokt.m b/mParticle-Apple-SDK/MPRokt.m index 6f4e40ec0..6b5c1270a 100644 --- a/mParticle-Apple-SDK/MPRokt.m +++ b/mParticle-Apple-SDK/MPRokt.m @@ -12,6 +12,17 @@ #import "MPIConstants.h" #import "MPIdentityDTO.h" +// Constants for kit configuration keys +static NSString * const kMPKitConfigurationIdKey = @"id"; +static NSString * const kMPAttributeMappingSourceKey = @"map"; +static NSString * const kMPAttributeMappingDestinationKey = @"value"; + +// Rokt attribute keys +static NSString * const kMPRoktAttributeKeySandbox = @"sandbox"; + +// Rokt kit identifier +static const NSInteger kMPRoktKitId = 181; + @interface MParticle () + (dispatch_queue_t)messageQueue; @@ -29,11 +40,25 @@ @implementation MPRoktConfig @implementation MPRokt +/// Displays a Rokt ad placement with the specified identifier and user attributes. +/// This is a convenience method that calls the full selectPlacements method with nil for optional parameters. +/// - Parameters: +/// - identifier: The Rokt placement identifier configured in the Rokt dashboard (e.g., "checkout_confirmation") +/// - attributes: Optional dictionary of user attributes to pass to Rokt (e.g., email, firstName, etc.) - (void)selectPlacements:(NSString *)identifier attributes:(NSDictionary * _Nullable)attributes { [self selectPlacements:identifier attributes:attributes embeddedViews:nil config:nil callbacks:nil]; } +/// Displays a Rokt ad placement with full configuration options. +/// This method handles user identity synchronization, attribute mapping, and forwards the request to the Rokt Kit. +/// Device identifiers (IDFA/IDFV) are automatically added if available. +/// - Parameters: +/// - identifier: The Rokt placement identifier configured in the Rokt dashboard +/// - attributes: Optional dictionary of user attributes (email, firstName, etc.). Attributes will be mapped according to dashboard configuration. +/// - embeddedViews: Optional dictionary mapping placement identifiers to embedded view containers for inline placements +/// - config: Optional Rokt configuration object (e.g., for dark mode or custom styling) +/// - callbacks: Optional callback handlers for Rokt events (selection, display, completion, etc.) - (void)selectPlacements:(NSString *)identifier attributes:(NSDictionary * _Nullable)attributes embeddedViews:(NSDictionary * _Nullable)embeddedViews @@ -49,8 +74,8 @@ - (void)selectPlacements:(NSString *)identifier if (attributeMap) { NSMutableDictionary *mappedAttributes = attributes.mutableCopy; for (NSDictionary *map in attributeMap) { - NSString *mapFrom = map[@"map"]; - NSString *mapTo = map[@"value"]; + NSString *mapFrom = map[kMPAttributeMappingSourceKey]; + NSString *mapTo = map[kMPAttributeMappingDestinationKey]; if (mappedAttributes[mapFrom]) { NSString * value = mappedAttributes[mapFrom]; [mappedAttributes removeObjectForKey:mapFrom]; @@ -58,7 +83,7 @@ - (void)selectPlacements:(NSString *)identifier } } for (NSString *key in mappedAttributes) { - if (![key isEqual:@"sandbox"]) { + if (![key isEqual:kMPRoktAttributeKeySandbox]) { [resolvedUser setUserAttribute:key value:mappedAttributes[key]]; } } @@ -86,6 +111,12 @@ - (void)selectPlacements:(NSString *)identifier }]; } +/// Notifies Rokt that a purchase from a placement offer has been finalized. +/// Call this method to inform Rokt about the completion status of an offer purchase initiated from a placement. +/// - Parameters: +/// - placementId: The identifier of the placement where the offer was displayed +/// - catalogItemId: The identifier of the catalog item that was purchased +/// - success: Whether the purchase was successful (YES) or failed (NO) - (void)purchaseFinalized:(NSString * _Nonnull)placementId catalogItemId:(NSString * _Nonnull)catalogItemId success:(BOOL)success { dispatch_async(dispatch_get_main_queue(), ^{ // Forwarding call to kits @@ -103,6 +134,11 @@ - (void)purchaseFinalized:(NSString * _Nonnull)placementId catalogItemId:(NSStri }); } +/// Registers a callback to receive events from a specific Rokt placement. +/// Use this to listen for events like placement shown, offer selected, placement closed, etc. +/// - Parameters: +/// - identifier: The Rokt placement identifier to listen for events from +/// - onEvent: Callback block that receives MPRoktEvent objects when placement events occur - (void)events:(NSString * _Nonnull)identifier onEvent:(void (^ _Nullable)(MPRoktEvent * _Nonnull))onEvent { dispatch_async(dispatch_get_main_queue(), ^{ // Forwarding call to kits @@ -120,6 +156,8 @@ - (void)events:(NSString * _Nonnull)identifier onEvent:(void (^ _Nullable)(MPRok }); } +/// Closes any currently displayed Rokt placement. +/// Call this method to programmatically dismiss an active Rokt overlay or embedded placement. - (void)close { dispatch_async(dispatch_get_main_queue(), ^{ // Forwarding call to kits @@ -132,17 +170,28 @@ - (void)close { }); } -- (NSArray *> *)getRoktPlacementAttributesMapping { - NSArray *> *attributeMap = nil; - - // Get the kit configuration +#pragma mark - Private Helper Methods + +/// Retrieves the Rokt Kit configuration from the kit container. +/// @return The Rokt Kit configuration dictionary, or nil if Rokt Kit is not configured. +- (NSDictionary * _Nullable)getRoktKitConfiguration { NSArray *kitConfigs = [MParticle sharedInstance].kitContainer_PRIVATE.originalConfig.copy; - NSDictionary *roktKitConfig; for (NSDictionary *kitConfig in kitConfigs) { - if (kitConfig[@"id"] != nil && [kitConfig[@"id"] integerValue] == 181) { - roktKitConfig = kitConfig; + if ([kitConfig[kMPKitConfigurationIdKey] integerValue] == kMPRoktKitId) { + return kitConfig; } } + return nil; +} + +/// Retrieves the attribute mapping configuration for the Rokt Kit from the mParticle dashboard settings. +/// The mapping defines how attribute keys should be renamed before being sent to Rokt (e.g., "userEmail" → "email"). +/// @return An array of mapping dictionaries with "map" (source key) and "value" (destination key), or nil if Rokt Kit is not configured. +- (NSArray *> *)getRoktPlacementAttributesMapping { + NSArray *> *attributeMap = nil; + + // Get the kit configuration + NSDictionary *roktKitConfig = [self getRoktKitConfiguration]; // Return nil if no Rokt Kit configuration found if (!roktKitConfig) { @@ -155,8 +204,9 @@ - (void)close { NSData *dataAttributeMap; // Rokt Kit is available though there may not be an attribute map attributeMap = @[]; - if (roktKitConfig[kMPPlacementAttributesMapping] != [NSNull null]) { - strAttributeMap = [roktKitConfig[kMPPlacementAttributesMapping] stringByRemovingPercentEncoding]; + id configJSONString = roktKitConfig[kMPRemoteConfigKitConfigurationKey][kMPPlacementAttributesMapping]; + if (configJSONString != nil && configJSONString != [NSNull null]) { + strAttributeMap = [configJSONString stringByRemovingPercentEncoding]; dataAttributeMap = [strAttributeMap dataUsingEncoding:NSUTF8StringEncoding]; } @@ -167,54 +217,63 @@ - (void)close { @try { attributeMap = [NSJSONSerialization JSONObjectWithData:dataAttributeMap options:kNilOptions error:&error]; } @catch (NSException *exception) { + MPILogVerbose(@"Exception parsing placement attribute map: %@", exception); } if (attributeMap && !error) { - NSLog(@"%@", attributeMap); + MPILogVerbose(@"Successfully parsed placement attribute map with %lu entries", (unsigned long)attributeMap.count); } else { - NSLog(@"%@", error); + MPILogVerbose(@"Failed to parse placement attribute map: %@", error); } } return attributeMap; } +/// Retrieves the configured identity type to use for hashed email from the Rokt Kit configuration. +/// The hashed email identity type is determined by dashboard settings and may vary (e.g., CustomerId, Other, etc.). +/// @return The NSNumber representing the MPIdentity type for hashed email, or nil if not configured. - (NSNumber *)getRoktHashedEmailUserIdentityType { // Get the kit configuration - NSArray *kitConfigs = [MParticle sharedInstance].kitContainer_PRIVATE.originalConfig.copy; - NSDictionary *roktKitConfig; - for (NSDictionary *kitConfig in kitConfigs) { - if (kitConfig[@"id"] != nil && [kitConfig[@"id"] integerValue] == 181) { - roktKitConfig = kitConfig; - } - } + NSDictionary *roktKitConfig = [self getRoktKitConfiguration]; // Get the string representing which identity to use and convert it to the key (NSNumber) - NSString *hashedIdentityTypeString = roktKitConfig[kMPHashedEmailUserIdentityType]; + NSString *hashedIdentityTypeString = roktKitConfig[kMPRemoteConfigKitConfigurationKey][kMPHashedEmailUserIdentityType]; NSNumber *hashedIdentityTypeNumber = [MPIdentityHTTPIdentities identityTypeForString:hashedIdentityTypeString.lowercaseString]; return hashedIdentityTypeNumber; } +/// Ensures the "sandbox" attribute is present in the attributes dictionary. +/// If not already set by the caller, the sandbox value is automatically determined based on the current mParticle environment +/// (MPEnvironmentDevelopment → "true", production → "false"). This tells Rokt whether to show test or production ads. +/// - Parameter attributes: The input attributes dictionary to validate +/// @return A dictionary with the sandbox attribute guaranteed to be present - (NSDictionary *)confirmSandboxAttribute:(NSDictionary * _Nullable)attributes { NSMutableDictionary *finalAttributes = attributes.mutableCopy; - NSString *sandboxKey = @"sandbox"; // Determine the value of the sandbox attribute based off the current environment NSString *sandboxValue = ([[MParticle sharedInstance] environment] == MPEnvironmentDevelopment) ? @"true" : @"false"; if (finalAttributes != nil) { // Only set sandbox if it`s not set by the client - if (![finalAttributes.allKeys containsObject:sandboxKey]) { - finalAttributes[sandboxKey] = sandboxValue; + if (![finalAttributes.allKeys containsObject:kMPRoktAttributeKeySandbox]) { + finalAttributes[kMPRoktAttributeKeySandbox] = sandboxValue; } } else { - finalAttributes = [[NSMutableDictionary alloc] initWithDictionary:@{sandboxKey: sandboxValue}]; + finalAttributes = [[NSMutableDictionary alloc] initWithDictionary:@{kMPRoktAttributeKeySandbox: sandboxValue}]; } return finalAttributes; } +/// Synchronizes user identity with mParticle if email or hashed email is provided in attributes. +/// If the email or hashed email in attributes differs from the current user's identity, this method performs +/// an identity API call to update the user before proceeding. This ensures Rokt has the most current user identity. +/// - Parameters: +/// - attributes: Dictionary that may contain "email" or "emailsha256" keys +/// - user: The current mParticle user +/// - completion: Completion handler called with the resolved (possibly updated) user - (void)confirmUser:(NSDictionary * _Nullable)attributes user:(MParticleUser * _Nullable)user completion:(void (^)(MParticleUser *_Nullable))completion { NSString *email = attributes[@"email"]; NSString *hashedEmail = attributes[@"emailsha256"]; @@ -233,19 +292,19 @@ - (void)confirmUser:(NSDictionary * _Nullable)attributes [[[MParticle sharedInstance] identity] identify:identityRequest completion:^(MPIdentityApiResult *_Nullable apiResult, NSError *_Nullable error) { if (error) { - NSLog(@"Failed to sync email from selectPlacement to user: %@", error); + MPILogVerbose(@"Failed to sync email from selectPlacement to user: %@", error); completion(user); } else { - NSLog(@"Updated user identity based off selectPlacement's attributes: %@", apiResult.user.identities); + MPILogVerbose(@"Updated user identity based off selectPlacement's attributes: %@", apiResult.user.identities); completion(apiResult.user); } }]; // Warn the customer if we had to identify and therefore delay their Rokt placement. if (shouldIdentifyFromEmail) { - NSLog(@"The existing email on the user (%@) does not match the email passed in to `selectPlacements:` (%@). Please remember to sync the email identity to mParticle as soon as you receive it. We will now identify the user before continuing to `selectPlacements:`", user.identities[@(MPIdentityEmail)], email); + MPILogVerbose(@"The existing email on the user (%@) does not match the email passed in to `selectPlacements:` (%@). Please remember to sync the email identity to mParticle as soon as you receive it. We will now identify the user before continuing to `selectPlacements:`", user.identities[@(MPIdentityEmail)], email); } else if (shouldIdentifyFromHash) { - NSLog(@"The existing hashed email on the user (%@) does not match the email passed in to `selectPlacements:` (%@). Please remember to sync the email identity to mParticle as soon as you receive it. We will now identify the user before continuing to `selectPlacements:`", user.identities[hashedEmailIdentity], hashedEmail); + MPILogVerbose(@"The existing hashed email on the user (%@) does not match the email passed in to `selectPlacements:` (%@). Please remember to sync the email identity to mParticle as soon as you receive it. We will now identify the user before continuing to `selectPlacements:`", user.identities[hashedEmailIdentity], hashedEmail); } } else { completion(user);