diff --git a/README.md b/README.md index b93340640..0ff065bd3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ This document will help you: - Install the mParticle SDK using [CocoaPods](https://cocoapods.org/?q=mparticle) or [Carthage](https://github.com/Carthage/Carthage) - Add any desired [kits](#currently-supported-kits) +- Control kit startup with [consent](#consent) - Initialize the mParticle SDK ## Get the SDK @@ -137,6 +138,56 @@ Several integrations require additional client-side add-on libraries called "kit | [UserLeap](https://github.com/UserLeap/userleap-mparticle-ios-kit) | ✓ | ✓ | | [Wootric](https://github.com/mparticle-integrations/mparticle-apple-integration-wootric) | ✓ | | +## Consent + +The mParticle Apple SDK supports gating **integration kit** initialization until the host app has collected user consent. + +- `hasConsent` defaults to `false` +- When `hasConsent` is `false`, **kits will not initialize/start** +- The core mParticle SDK continues to initialize and function normally + +### Swift + +Start kits immediately: + +```swift +let options = MParticleOptions(key: "<<>>", secret: "<<>>") +options.hasConsent = true +MParticle.sharedInstance().start(with: options) +``` + +Defer kit startup until consent is collected: + +```swift +let options = MParticleOptions(key: "<<>>", secret: "<<>>") +// options.hasConsent is false by default +MParticle.sharedInstance().start(with: options) + +// Later, when the user has granted consent: +MParticle.sharedInstance().hasConsent = true +``` + +### Objective-C + +Start kits immediately: + +```objective-c +MParticleOptions *options = [MParticleOptions optionsWithKey:@"<<>>" secret:@"<<>>"]; +options.hasConsent = YES; +[[MParticle sharedInstance] startWithOptions:options]; +``` + +Defer kit startup until consent is collected: + +```objective-c +MParticleOptions *options = [MParticleOptions optionsWithKey:@"<<>>" secret:@"<<>>"]; +// options.hasConsent is NO by default +[[MParticle sharedInstance] startWithOptions:options]; + +// Later, when the user has granted consent: +[MParticle sharedInstance].hasConsent = YES; +``` + ## Initialize the SDK The mParticle SDK is initialized by calling the `startWithOptions` method within the `application:didFinishLaunchingWithOptions:` delegate call. Preferably the location of the initialization method call should be one of the last statements in the `application:didFinishLaunchingWithOptions:`. The `startWithOptions` method requires an options argument containing your key and secret and an initial Identity request. diff --git a/UnitTests/ObjCTests/MPBaseTestCase.m b/UnitTests/ObjCTests/MPBaseTestCase.m index a7d7a18be..1534bc4c5 100644 --- a/UnitTests/ObjCTests/MPBaseTestCase.m +++ b/UnitTests/ObjCTests/MPBaseTestCase.m @@ -44,6 +44,9 @@ - (void)setUpWithCompletionHandler:(void (^)(NSError * _Nullable))completion { [instance reset:^{ MPNetworkCommunication_PRIVATE.connectorFactory = [[MPTestConnectorFactory alloc] init]; + // Most unit tests assume legacy behavior where kits are allowed to initialize immediately. + // Tests that need consent-gated behavior can override this in their own setup. + [MParticle sharedInstance].hasConsent = YES; completion(nil); }]; } diff --git a/UnitTests/ObjCTests/MPKitConsentGateTests.m b/UnitTests/ObjCTests/MPKitConsentGateTests.m new file mode 100644 index 000000000..d6c1b40d2 --- /dev/null +++ b/UnitTests/ObjCTests/MPKitConsentGateTests.m @@ -0,0 +1,116 @@ +#import +#import + +#import "mParticle.h" +#import "MPKitContainer.h" +#import "MPIConstants.h" + +#import "MParticle+PrivateMethods.h" + +@interface MPKitConsentGateTests : XCTestCase +@end + +@implementation MPKitConsentGateTests + +- (void)setUp { + [super setUp]; + + // Ensure we control the shared instance for these tests. + MParticle *instance = [[MParticle alloc] init]; + [MParticle setSharedInstance:instance]; +} + +- (void)tearDown { + [MParticle setSharedInstance:nil]; + [super tearDown]; +} + +- (void)testConfigureKitsIsDeferredUntilHasConsent { + MParticle *mp = [MParticle sharedInstance]; + mp.hasConsent = NO; + + MPKitContainer_PRIVATE *kitContainer = [[MPKitContainer_PRIVATE alloc] init]; + mp.kitContainer_PRIVATE = kitContainer; + + NSArray *kitConfig = @[ + @{@"id": @42, @"as": @{}} + ]; + + [kitContainer configureKits:kitConfig]; + + XCTAssertEqualObjects(mp.deferredKitConfiguration_PRIVATE, kitConfig); + XCTAssertFalse(kitContainer.kitsInitialized); +} + +- (void)testInitializeKitsIsDeferredUntilHasConsent { + MParticle *mp = [MParticle sharedInstance]; + mp.hasConsent = NO; + + MPKitContainer_PRIVATE *kitContainer = [[MPKitContainer_PRIVATE alloc] init]; + mp.kitContainer_PRIVATE = kitContainer; + + [kitContainer initializeKits]; + + XCTAssertFalse(kitContainer.kitsInitialized); +} + +- (void)testSettingHasConsentProcessesDeferredKitConfigurationWithoutConsentFilters { + MParticle *mp = [MParticle sharedInstance]; + mp.hasConsent = NO; + + NSArray *deferredConfig = @[ + @{@"id": @42, @"as": @{}} + ]; + mp.deferredKitConfiguration_PRIVATE = deferredConfig; + + id kitContainerMock = OCMProtocolMock(@protocol(MPKitContainerProtocol)); + XCTestExpectation *expectation = [self expectationWithDescription:@"configureKits called"]; + + OCMExpect([kitContainerMock configureKits:deferredConfig]).andDo(^(NSInvocation *invocation) { + [expectation fulfill]; + }); + + [mp setKitContainer:kitContainerMock]; + + mp.hasConsent = YES; + + [self waitForExpectationsWithTimeout:2 handler:nil]; + OCMVerifyAll(kitContainerMock); + XCTAssertNil(mp.deferredKitConfiguration_PRIVATE); +} + +- (void)testSettingHasConsentDoesNotProcessDeferredConfigurationRequiringIdentity { + MParticle *mp = [MParticle sharedInstance]; + mp.hasConsent = NO; + + // Force a "no initial identity" state (MPID == 0) to exercise the deferral path. + [mp.identity.currentUser setValue:@0 forKey:@"userId"]; + + NSArray *deferredConfig = @[ + @{ + @"id": @42, + @"as": @{}, + // Include a consent filter marker so the SDK continues to defer until identity is resolved. + kMPConsentKitFilter: @{ @"i": @YES } + } + ]; + mp.deferredKitConfiguration_PRIVATE = deferredConfig; + + id kitContainerMock = OCMProtocolMock(@protocol(MPKitContainerProtocol)); + XCTestExpectation *expectation = [self expectationWithDescription:@"configureKits not called"]; + expectation.inverted = YES; + + OCMStub([kitContainerMock configureKits:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + [expectation fulfill]; + }); + + [mp setKitContainer:kitContainerMock]; + + mp.hasConsent = YES; + + [self waitForExpectationsWithTimeout:0.5 handler:nil]; + XCTAssertEqualObjects(mp.deferredKitConfiguration_PRIVATE, deferredConfig); +} + +@end + diff --git a/UnitTests/ObjCTests/MPURLRequestBuilderTests.m b/UnitTests/ObjCTests/MPURLRequestBuilderTests.m index 9eeaac82a..24eda5cea 100644 --- a/UnitTests/ObjCTests/MPURLRequestBuilderTests.m +++ b/UnitTests/ObjCTests/MPURLRequestBuilderTests.m @@ -99,6 +99,7 @@ - (void)tearDown { - (void)testCustomUserAgent { MParticleOptions *options = [MParticleOptions optionsWithKey:@"testKey" secret:@"testSecret"]; + options.hasConsent = YES; options.customUserAgent = @"Test User Agent"; [[MParticle sharedInstance] startWithOptions:options]; @@ -125,6 +126,7 @@ - (void)testCustomUserAgent { - (void)testDisableCollectUserAgent { MParticleOptions *options = [MParticleOptions optionsWithKey:@"testKey" secret:@"testSecret"]; + options.hasConsent = YES; options.customUserAgent = nil; options.collectUserAgent = NO; [[MParticle sharedInstance] startWithOptions:options]; diff --git a/UnitTests/ObjCTests/MParticleTests.m b/UnitTests/ObjCTests/MParticleTests.m index 12877fb4b..dc323f519 100644 --- a/UnitTests/ObjCTests/MParticleTests.m +++ b/UnitTests/ObjCTests/MParticleTests.m @@ -115,7 +115,9 @@ - (void)testOptOutEndsSession { - (void)testNonOptOutHasSession { XCTestExpectation *expectation = [self expectationWithDescription:@"async work"]; MParticle *instance = [MParticle sharedInstance]; - [instance startWithOptions:[MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]]; + MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; + [instance startWithOptions:options]; dispatch_async([MParticle messageQueue], ^{ MParticleSession *session = instance.currentSession; XCTAssertNotNil(session, "Not Opted Out but nil current session"); @@ -126,7 +128,9 @@ - (void)testNonOptOutHasSession { - (void)testInitStartsSessionSync { MParticle *instance = [MParticle sharedInstance]; - [instance startWithOptions:[MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]]; + MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; + [instance startWithOptions:options]; MParticleSession *session = instance.currentSession; XCTAssertNotNil(session, "Nil current session immediately after SDK init"); } @@ -134,6 +138,7 @@ - (void)testInitStartsSessionSync { - (void)testInitStartsSessionSyncDisable { MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.shouldBeginSession = NO; [instance startWithOptions:options]; MParticleSession *session = instance.currentSession; @@ -143,6 +148,7 @@ - (void)testInitStartsSessionSyncDisable { - (void)testInitStartsSessionSyncDisableAll { MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.shouldBeginSession = NO; options.automaticSessionTracking = NO; [instance startWithOptions:options]; @@ -154,6 +160,7 @@ - (void)testNoAutoTrackingHasNoSession { XCTestExpectation *expectation = [self expectationWithDescription:@"async work"]; MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.automaticSessionTracking = NO; options.shouldBeginSession = NO; [instance startWithOptions:options]; @@ -169,6 +176,7 @@ - (void)testNoAutoTrackingManualSession { XCTestExpectation *expectation = [self expectationWithDescription:@"async work"]; MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.automaticSessionTracking = NO; options.shouldBeginSession = NO; [instance startWithOptions:options]; @@ -185,6 +193,7 @@ - (void)testNoAutoTrackingManualEndSession { XCTestExpectation *expectation = [self expectationWithDescription:@"async work"]; MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.automaticSessionTracking = NO; options.shouldBeginSession = NO; [instance startWithOptions:options]; @@ -208,6 +217,7 @@ - (void)testAutoTrackingContentAvail { XCTestExpectation *expectation = [self expectationWithDescription:@"async work"]; MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.automaticSessionTracking = YES; options.proxyAppDelegate = NO; [instance startWithOptions:options]; @@ -226,6 +236,7 @@ - (void)testEventStartSession { XCTestExpectation *expectation = [self expectationWithDescription:@"async work"]; MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.automaticSessionTracking = YES; options.proxyAppDelegate = NO; [instance startWithOptions:options]; @@ -246,6 +257,7 @@ - (void)testEventNoStartSession { XCTestExpectation *expectation = [self expectationWithDescription:@"async work"]; MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.automaticSessionTracking = YES; options.proxyAppDelegate = NO; [instance startWithOptions:options]; @@ -267,6 +279,7 @@ - (void)testEventStartSessionManual { XCTestExpectation *expectation = [self expectationWithDescription:@"async work"]; MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.automaticSessionTracking = NO; options.proxyAppDelegate = NO; [instance startWithOptions:options]; @@ -287,6 +300,7 @@ - (void)testEventNoStartSessionManual { XCTestExpectation *expectation = [self expectationWithDescription:@"async work"]; MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.automaticSessionTracking = NO; options.proxyAppDelegate = NO; [instance startWithOptions:options]; @@ -309,7 +323,9 @@ - (void)testEventNoStartSessionManual { - (void)testNormalSessionContents { XCTestExpectation *expectation = [self expectationWithDescription:@"async work"]; MParticle *instance = [MParticle sharedInstance]; - [instance startWithOptions:[MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]]; + MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; + [instance startWithOptions:options]; dispatch_async([MParticle messageQueue], ^{ MParticle.sharedInstance.stateMachine.currentSession.uuid = @"76F1ABB9-7A9A-4D4E-AB4D-56C8FF79CAD1"; MParticleSession *session = instance.currentSession; @@ -327,6 +343,7 @@ - (void)testOptionsConsentStateInitialNil { MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; MPCCPAConsent *ccpaConsent = [[MPCCPAConsent alloc] init]; ccpaConsent.consented = YES; ccpaConsent.document = @"ccpa_consent_agreement_v3"; @@ -365,6 +382,7 @@ - (void)testOptionsConsentStateInitialSet { MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; MPCCPAConsent *newCCPAState = [[MPCCPAConsent alloc] init]; newCCPAState.consented = YES; newCCPAState.document = @"ccpa_consent_agreement_v3"; @@ -626,6 +644,7 @@ - (void)testTrackNotificationsDefault { [[[mockInstance stub] andReturn:mockBackend] backendController]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; [mockInstance startWithOptions:options]; XCTAssertTrue(instance.trackNotifications, "By Default Track Notifications should be set to true"); @@ -639,6 +658,7 @@ - (void)testTrackNotificationsOff { [[[mockInstance stub] andReturn:mockBackend] backendController]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.trackNotifications = NO; [mockInstance startWithOptions:options]; @@ -653,6 +673,7 @@ - (void)testTrackNotificationsOn { [[[mockInstance stub] andReturn:mockBackend] backendController]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.trackNotifications = YES; [mockInstance startWithOptions:options]; @@ -674,7 +695,9 @@ - (void)testSessionStartNotification { }; testNotificationHandler = block; MParticle *instance = [MParticle sharedInstance]; - [instance startWithOptions:[MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]]; + MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; + [instance startWithOptions:options]; [self waitForExpectationsWithTimeout:DEFAULT_TIMEOUT handler:nil]; testNotificationHandler = nil; } @@ -694,7 +717,9 @@ - (void)testSessionEndNotification { }; testNotificationHandler = block; MParticle *instance = [MParticle sharedInstance]; - [instance startWithOptions:[MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]]; + MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; + [instance startWithOptions:options]; dispatch_async([MParticle messageQueue], ^{ [[MParticle sharedInstance].backendController endSession]; }); @@ -724,6 +749,7 @@ - (void)testLogNotificationWithUserInfo { - (void)testATTAuthorizationStatusNotDetermined { MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.attStatus = @(MPATTAuthorizationStatusNotDetermined); [instance startWithOptions:options]; MPStateMachine_PRIVATE *stateMachine = instance.stateMachine; @@ -734,6 +760,7 @@ - (void)testATTAuthorizationStatusNotDetermined { - (void)testATTAuthorizationStatusRestricted { MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.attStatus = @(MPATTAuthorizationStatusRestricted); [instance startWithOptions:options]; MPStateMachine_PRIVATE *stateMachine = instance.stateMachine; @@ -744,6 +771,7 @@ - (void)testATTAuthorizationStatusRestricted { - (void)testATTAuthorizationStatusDenied { MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.attStatus = @(MPATTAuthorizationStatusDenied); [instance startWithOptions:options]; MPStateMachine_PRIVATE *stateMachine = instance.stateMachine; @@ -754,6 +782,7 @@ - (void)testATTAuthorizationStatusDenied { - (void)testATTAuthorizationStatusAuthorized { MParticle *instance = [MParticle sharedInstance]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.attStatus = @(MPATTAuthorizationStatusAuthorized); [instance startWithOptions:options]; MPStateMachine_PRIVATE *stateMachine = instance.stateMachine; @@ -765,6 +794,7 @@ - (void)testattAuthorizationStatusWithTimestamp { MParticle *instance = [MParticle sharedInstance]; NSNumber *testTimestamp = @([[NSDate date] timeIntervalSince1970] - 400); MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; options.attStatus = @(MPATTAuthorizationStatusRestricted); options.attStatusTimestampMillis = testTimestamp; [instance startWithOptions:options]; @@ -1105,6 +1135,7 @@ - (void)testSwitchWorkspaceOptions { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, WORKSPACE_SWITCHING_DELAY), dispatch_get_main_queue(), ^{ MParticleOptions *options1 = [MParticleOptions optionsWithKey:@"unit-test-key1" secret:@"unit-test-secret1"]; + options1.hasConsent = YES; [instance startWithOptions:options1]; XCTAssertNotNil(instance.options); XCTAssertEqualObjects(instance.options.apiKey, @"unit-test-key1"); @@ -1112,6 +1143,7 @@ - (void)testSwitchWorkspaceOptions { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, WORKSPACE_SWITCHING_DELAY), dispatch_get_main_queue(), ^{ MParticleOptions *options2 = [MParticleOptions optionsWithKey:@"unit-test-key2" secret:@"unit-test-secret2"]; + options2.hasConsent = YES; [instance switchWorkspaceWithOptions:options2]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, WORKSPACE_SWITCHING_DELAY), dispatch_get_main_queue(), ^{ @@ -1140,6 +1172,7 @@ - (void)testSwitchWorkspaceSideloadedKits { // Start with a sideloaded kit MParticleOptions *options1 = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options1.hasConsent = YES; MPKitTestClassSideloaded *kitTestSideloaded1 = [[MPKitTestClassSideloaded alloc] init]; options1.sideloadedKits = @[[[MPSideloadedKit alloc] initWithKitInstance:kitTestSideloaded1]]; @@ -1151,6 +1184,7 @@ - (void)testSwitchWorkspaceSideloadedKits { // Switch workspace with a new sideloaded kit MParticleOptions *options2 = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options2.hasConsent = YES; MPKitTestClassSideloaded *kitTestSideloaded2 = [[MPKitTestClassSideloaded alloc] init]; options2.sideloadedKits = @[[[MPSideloadedKit alloc] initWithKitInstance:kitTestSideloaded2]]; @@ -1162,6 +1196,7 @@ - (void)testSwitchWorkspaceSideloadedKits { // Switch workspace with no sideloaded kits MParticleOptions *options3 = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options3.hasConsent = YES; [[MParticle sharedInstance] switchWorkspaceWithOptions:options3]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, WORKSPACE_SWITCHING_DELAY), dispatch_get_main_queue(), ^{ @@ -1185,6 +1220,7 @@ - (void)testSwitchWorkspaceKitsNoConfigurations { XCTAssertEqual(MPKitContainer_PRIVATE.registeredKits.count, 2); MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; [[MParticle sharedInstance] startWithOptions:options]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, WORKSPACE_SWITCHING_DELAY), dispatch_get_main_queue(), ^{ @@ -1209,6 +1245,7 @@ - (void)testSwitchWorkspaceKitsWithoutStop { [MParticle registerExtension:registerNoStop]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; [[MParticle sharedInstance] startWithOptions:options]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, WORKSPACE_SWITCHING_DELAY), dispatch_get_main_queue(), ^{ @@ -1237,6 +1274,7 @@ - (void)testSwitchWorkspaceKitsWithStop { [MParticle registerExtension:registerWithStop]; MParticleOptions *options = [MParticleOptions optionsWithKey:@"unit-test-key" secret:@"unit-test-secret"]; + options.hasConsent = YES; [[MParticle sharedInstance] startWithOptions:options]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, WORKSPACE_SWITCHING_DELAY), dispatch_get_main_queue(), ^{ diff --git a/UnitTests/SwiftTests/MParticle/MParticleTestBase.swift b/UnitTests/SwiftTests/MParticle/MParticleTestBase.swift index 7d967a3c1..3a5706afa 100644 --- a/UnitTests/SwiftTests/MParticle/MParticleTestBase.swift +++ b/UnitTests/SwiftTests/MParticle/MParticleTestBase.swift @@ -54,6 +54,7 @@ class MParticleTestBase: XCTestCase { super.setUp() mparticle = MParticle.sharedInstance() mparticle = MParticle() + mparticle.hasConsent = true mparticle.logLevel = .verbose mparticle.customLogger = customLogger listenerController = MPListenerControllerMock() diff --git a/mParticle-Apple-SDK/Include/mParticle.h b/mParticle-Apple-SDK/Include/mParticle.h index 74f7907de..86be8358a 100644 --- a/mParticle-Apple-SDK/Include/mParticle.h +++ b/mParticle-Apple-SDK/Include/mParticle.h @@ -357,6 +357,12 @@ Defaults to false. Prevents the eventsHost above from overwriting the alias endp */ @property (nonatomic, strong, nullable) MPConsentState *consentState; +/** + Whether the end user has granted consent required to initialize and start integration kits. + Defaults to NO. + */ +@property (nonatomic, readwrite) BOOL hasConsent; + /** Data Plan ID. @@ -669,6 +675,12 @@ Defaults to false. Prevents the eventsHost above from overwriting the alias endp */ @property (nonatomic, strong, nullable) NSArray *deferredKitConfiguration_PRIVATE; +/** + Whether the end user has granted consent required to initialize and start integration kits. + Defaults to NO. + */ +@property (nonatomic, readwrite) BOOL hasConsent; + #pragma mark - Initialization /** diff --git a/mParticle-Apple-SDK/Kits/MPKitContainer.mm b/mParticle-Apple-SDK/Kits/MPKitContainer.mm index 1c0a03d3b..93bc562ad 100644 --- a/mParticle-Apple-SDK/Kits/MPKitContainer.mm +++ b/mParticle-Apple-SDK/Kits/MPKitContainer.mm @@ -274,6 +274,11 @@ - (void)registerSideloadedKits { } - (void)initializeKits { + if (![MParticle sharedInstance].hasConsent) { + MPILogWarning(@"initializeKits - hasConsent is NO, deferring kit initialization until consent is granted"); + return; + } + if (self.kitsInitialized) { MPILogDebug(@"initializeKits - already initialized, skipping"); return; @@ -549,12 +554,34 @@ - (void)startKitRegister:(nonnull id)kitRegister configu if ([kitRegister.wrapperInstance respondsToSelector:@selector(didFinishLaunchingWithConfiguration:)]) { MPILogDebug(@"startKitRegister - launching kit %@ with configuration", kitRegister.code); - if ([NSThread isMainThread]) { + MPStateMachine_PRIVATE *stateMachine = [MParticle sharedInstance].stateMachine; + NSDictionary *launchOptions = stateMachine.launchOptions; + + void (^launchAndStartIfNeeded)(void) = ^{ [kitRegister.wrapperInstance didFinishLaunchingWithConfiguration:configuration]; + + // If the app has already finished launching, we won't see UIApplicationDidFinishLaunchingNotification again. + // Ensure any kits that rely on `start` still get started. + if (launchOptions != nil && ![kitRegister.wrapperInstance started]) { + if ([kitRegister.wrapperInstance respondsToSelector:@selector(setLaunchOptions:)]) { + [kitRegister.wrapperInstance performSelector:@selector(setLaunchOptions:) withObject:launchOptions]; + } + + if ([kitRegister.wrapperInstance respondsToSelector:@selector(start)]) { + @try { + [kitRegister.wrapperInstance start]; + } + @catch (NSException *exception) { + MPILogError(@"Exception thrown while starting kit (%@): %@", kitRegister.wrapperInstance, exception); + } + } + } + }; + + if ([NSThread isMainThread]) { + launchAndStartIfNeeded(); } else { - dispatch_async(dispatch_get_main_queue(), ^{ - [kitRegister.wrapperInstance didFinishLaunchingWithConfiguration:configuration]; - }); + dispatch_async(dispatch_get_main_queue(), launchAndStartIfNeeded); } } } @@ -1999,13 +2026,27 @@ - (void)configureKits:(NSArray *)kitConfigurations { MPILogDebug(@"configureKits - received %lu kit configuration(s) from server", (unsigned long)kitConfigurations.count); - if (MPIsNull(kitConfigurations) || stateMachine.optOut) { + if (stateMachine.optOut) { MPILogDebug(@"configureKits - null config or opted out, flushing kits"); [self flushSerializedKits]; self.kitsInitialized = YES; return; } + + if (![MParticle sharedInstance].hasConsent) { + MPILogWarning(@"configureKits - hasConsent is NO, deferring kit configuration until consent is granted"); + [MParticle sharedInstance].deferredKitConfiguration_PRIVATE = kitConfigurations; + return; + } + + if (MPIsNull(kitConfigurations)) { + MPILogDebug(@"configureKits - null config, flushing kits"); + [self flushSerializedKits]; + self.kitsInitialized = YES; + + return; + } dispatch_semaphore_wait(kitsSemaphore, DISPATCH_TIME_FOREVER); @@ -2059,6 +2100,22 @@ - (void)configureKits:(NSArray *)kitConfigurations { if ([kitInstance respondsToSelector:@selector(setConfiguration:)]) { [kitInstance setConfiguration:configuration]; } + + // If kit init happened after UIApplicationDidFinishLaunching, ensure start is called. + if (![kitInstance started] && stateMachine.launchOptions != nil) { + if ([kitInstance respondsToSelector:@selector(setLaunchOptions:)]) { + [kitInstance performSelector:@selector(setLaunchOptions:) withObject:stateMachine.launchOptions]; + } + + if ([kitInstance respondsToSelector:@selector(start)]) { + @try { + [kitInstance start]; + } + @catch (NSException *exception) { + MPILogError(@"Exception thrown while starting kit (%@): %@", kitInstance, exception); + } + } + } } } else { diff --git a/mParticle-Apple-SDK/MPBackendController.m b/mParticle-Apple-SDK/MPBackendController.m index e8bf6a10c..dd3b0a5a7 100644 --- a/mParticle-Apple-SDK/MPBackendController.m +++ b/mParticle-Apple-SDK/MPBackendController.m @@ -1495,13 +1495,15 @@ - (void)startWithKey:(NSString *)apiKey secret:(NSString *)secret networkOptions [MPPersistenceController_PRIVATE setConsentState:consentState forMpid:[MPPersistenceController_PRIVATE mpId]]; } - if (![MParticle sharedInstance].stateMachine.optOut) { + if ([MParticle sharedInstance].stateMachine.optOut) { + MPILogWarning(@"Skipping kit initialization - SDK is opted out"); + } else if (![MParticle sharedInstance].hasConsent) { + MPILogWarning(@"Skipping kit initialization - hasConsent is NO (kits will start once consent is granted)"); + } else { MPILogDebug(@"Dispatching kit initialization to message queue"); dispatch_async([MParticle messageQueue], ^{ [[MParticle sharedInstance].kitContainer_PRIVATE initializeKits]; }); - } else { - MPILogWarning(@"Skipping kit initialization - SDK is opted out"); } MPStateMachine_PRIVATE *stateMachine = [MParticle sharedInstance].stateMachine; diff --git a/mParticle-Apple-SDK/MParticleOptions+MParticlePrivate.m b/mParticle-Apple-SDK/MParticleOptions+MParticlePrivate.m index 7188a19a5..d780f84ef 100644 --- a/mParticle-Apple-SDK/MParticleOptions+MParticlePrivate.m +++ b/mParticle-Apple-SDK/MParticleOptions+MParticlePrivate.m @@ -20,6 +20,7 @@ - (instancetype)init _logLevel = MPILogLevelNone; _uploadInterval = 0.0; _sessionTimeout = DEFAULT_SESSION_TIMEOUT; + _hasConsent = NO; } return self; } diff --git a/mParticle-Apple-SDK/Utils/MPResponseConfig.swift b/mParticle-Apple-SDK/Utils/MPResponseConfig.swift index 9559e01cc..c62a30551 100644 --- a/mParticle-Apple-SDK/Utils/MPResponseConfig.swift +++ b/mParticle-Apple-SDK/Utils/MPResponseConfig.swift @@ -77,7 +77,8 @@ import Foundation hasInitialIdentity = true } - let shouldDefer = hasConsentFilters && !hasInitialIdentity + let hasConsent = MParticle.sharedInstance().hasConsent + let shouldDefer = (!hasConsent) || (hasConsentFilters && !hasInitialIdentity) if !shouldDefer { MPLog.debug("MPResponseConfig - dispatching configureKits to main queue (immediate)") DispatchQueue.main.async { @@ -85,10 +86,13 @@ import Foundation .configureKits(config[RemoteConfig.kMPRemoteConfigKitsKey] as? [[AnyHashable: Any]]) } } else { - MPLog - .debug( + if !hasConsent { + MPLog.debug("MPResponseConfig - deferring kit configuration (hasConsent: false)") + } else { + MPLog.debug( "MPResponseConfig - deferring kit configuration (hasConsentFilters: true, hasInitialIdentity: false)" ) + } MParticle.sharedInstance() .deferredKitConfiguration_PRIVATE = config[RemoteConfig.kMPRemoteConfigKitsKey] as? [[AnyHashable: Any]] } diff --git a/mParticle-Apple-SDK/mParticle.m b/mParticle-Apple-SDK/mParticle.m index 2a459b7c9..f65ef3038 100644 --- a/mParticle-Apple-SDK/mParticle.m +++ b/mParticle-Apple-SDK/mParticle.m @@ -34,6 +34,40 @@ static NSString *const kMPMethodName = @"$MethodName"; static NSString *const kMPStateKey = @"state"; +static BOOL MPKitConfigurationsHaveConsentFilters(NSArray *kitConfigurations) { + if (kitConfigurations == nil || ![kitConfigurations isKindOfClass:[NSArray class]]) { + return NO; + } + + for (id kitConfig in kitConfigurations) { + if (![kitConfig isKindOfClass:[NSDictionary class]]) { + continue; + } + + NSDictionary *kitDict = (NSDictionary *)kitConfig; + + NSDictionary *consentKitFilter = kitDict[kMPConsentKitFilter]; + if ([consentKitFilter isKindOfClass:[NSDictionary class]] && consentKitFilter.count > 0) { + return YES; + } + + NSDictionary *hashes = kitDict[kMPRemoteConfigKitHashesKey]; + if ([hashes isKindOfClass:[NSDictionary class]] && hashes.count > 0) { + NSDictionary *regulationFilters = hashes[kMPConsentRegulationFilters]; + if ([regulationFilters isKindOfClass:[NSDictionary class]] && regulationFilters.count > 0) { + return YES; + } + + NSDictionary *purposeFilters = hashes[kMPConsentPurposeFilters]; + if ([purposeFilters isKindOfClass:[NSDictionary class]] && purposeFilters.count > 0) { + return YES; + } + } + } + + return NO; +} + @interface MPIdentityApi () - (void)identifyNoDispatch:(MPIdentityApiRequest *)identifyRequest completion:(nullable MPIdentityApiResultCallback)completion; @end @@ -91,6 +125,7 @@ @implementation MParticle @synthesize identity = _identity; @synthesize rokt = _rokt; @synthesize optOut = _optOut; +@synthesize hasConsent = _hasConsent; @synthesize persistenceController = _persistenceController; @synthesize stateMachine = _stateMachine; @synthesize kitContainer_PRIVATE = _kitContainer_PRIVATE; @@ -357,15 +392,20 @@ - (void)identifyNoDispatchCallback:(MPIdentityApiResult * _Nullable)apiResult } NSArray *deferredKitConfiguration = self.deferredKitConfiguration_PRIVATE; - - if (deferredKitConfiguration != nil && [deferredKitConfiguration isKindOfClass:[NSArray class]]) { - MPILogDebug(@"Processing deferred kit configuration with %lu kit(s)", (unsigned long)deferredKitConfiguration.count); - [executor executeOnMain: ^{ - [self.kitContainer configureKits:deferredKitConfiguration]; - self.deferredKitConfiguration_PRIVATE = nil; - }]; + if (!self.hasConsent) { + if (deferredKitConfiguration != nil && [deferredKitConfiguration isKindOfClass:[NSArray class]]) { + MPILogDebug(@"Deferred kit configuration present but hasConsent is NO - leaving deferred config intact"); + } } else { - MPILogDebug(@"No deferred kit configuration to process"); + if (deferredKitConfiguration != nil && [deferredKitConfiguration isKindOfClass:[NSArray class]]) { + MPILogDebug(@"Processing deferred kit configuration with %lu kit(s)", (unsigned long)deferredKitConfiguration.count); + [executor executeOnMain: ^{ + [self.kitContainer configureKits:deferredKitConfiguration]; + self.deferredKitConfiguration_PRIVATE = nil; + }]; + } else { + MPILogDebug(@"No deferred kit configuration to process"); + } } if (options.onIdentifyComplete) { @@ -481,6 +521,8 @@ - (void)startWithOptions:(MParticleOptions *)options { NSAssert((NSNull *)apiKey != [NSNull null] && (NSNull *)secret != [NSNull null], @"mParticle SDK apiKey and secret cannot be null."); self.options = options; + // Must be set before backend controller startup so kit initialization/configuration can be deferred. + _hasConsent = options.hasConsent; self.dataPlanId = options.dataPlanId; if (self.dataPlanId != nil) { @@ -561,6 +603,56 @@ - (void)startWithOptions:(MParticleOptions *)options { }]; } +- (void)setHasConsent:(BOOL)hasConsent { + BOOL previous = _hasConsent; + _hasConsent = hasConsent; + + if (previous == hasConsent) { + return; + } + + if (hasConsent) { + MPILogDebug(@"hasConsent set to YES - resuming kit initialization/configuration"); + + // 1) (Re)run kit initialization. This registers sideloaded kits and starts kits from cached configuration. + if (self.kitContainer_PRIVATE != nil && !self.stateMachine.optOut) { + dispatch_async([MParticle messageQueue], ^{ + [[MParticle sharedInstance].kitContainer_PRIVATE initializeKits]; + }); + } + + // 2) Apply any deferred kit configuration or current cached kit configuration (ensures full config + start on main thread). + dispatch_async(dispatch_get_main_queue(), ^{ + NSArray *deferredKitConfiguration = self.deferredKitConfiguration_PRIVATE; + if (deferredKitConfiguration != nil && [deferredKitConfiguration isKindOfClass:[NSArray class]]) { + NSNumber *mpid = self.identity.currentUser.userId; + BOOL hasInitialIdentity = (mpid != nil && mpid.longLongValue != 0); + + // Preserve existing behavior: if the deferred config includes consent filters and we don't yet + // have an initial identity, keep deferring until identify completes. + if (!hasInitialIdentity && MPKitConfigurationsHaveConsentFilters(deferredKitConfiguration)) { + MPILogDebug(@"Deferred kit configuration requires initial identity; deferring until identity is available"); + } else { + [self.kitContainer configureKits:deferredKitConfiguration]; + self.deferredKitConfiguration_PRIVATE = nil; + } + return; + } + + MPUserDefaults *userDefaults = + [MPUserDefaults standardUserDefaultsWithStateMachine:self.stateMachine + backendController:self.backendController + identity:self.identity]; + NSArray *cachedKitConfigurations = [userDefaults getKitConfigurations]; + if (cachedKitConfigurations != nil && [cachedKitConfigurations isKindOfClass:[NSArray class]]) { + [self.kitContainer configureKits:(NSArray *)cachedKitConfigurations]; + } + }); + } else { + MPILogDebug(@"hasConsent set to NO - kit initialization/configuration will be deferred until consent is granted"); + } +} + - (MParticleSession *)currentSession { MParticleSession *session = self.backendController.tempSession; if (session != nil) {