From 7d807c7ef5536b0e62a2b424bf4f69e7483833e7 Mon Sep 17 00:00:00 2001 From: Brandon Stalnaker <33703490+BrandonStalnaker@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:44:32 -0500 Subject: [PATCH 01/19] fix: Use Defensive Copy for ActiveKitsRegistry (#571) (cherry picked from commit e5d5e27313bd6445ff491590168ab9718f05b86e) --- UnitTests/ObjCTests/MPKitContainerTests.m | 76 +++++++++++++++++++++++ mParticle-Apple-SDK/Kits/MPKitContainer.m | 6 +- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/UnitTests/ObjCTests/MPKitContainerTests.m b/UnitTests/ObjCTests/MPKitContainerTests.m index 4415810aa..9c52d01e4 100644 --- a/UnitTests/ObjCTests/MPKitContainerTests.m +++ b/UnitTests/ObjCTests/MPKitContainerTests.m @@ -3365,4 +3365,80 @@ - (void)testFilterCommerceEvent_TransactionAttributesForSideloadedKit { #endif +#pragma mark - Thread Safety Tests + +- (void)testActiveKitsRegistryThreadSafety { + // This stress test verifies that activeKitsRegistry doesn't crash when + // called concurrently with configureKits: modifications. + // Race conditions are non-deterministic, so this test increases the + // likelihood of catching issues but cannot guarantee detection. + + MPKitContainer_PRIVATE *kitContainer = [[MPKitContainer_PRIVATE alloc] init]; + + NSArray *configurations = @[ + @{ + @"id": @42, + @"as": @{ + @"appId": @"MyAppId" + } + }, + @{ + @"id": @314, + @"as": @{ + @"appId": @"MyAppId" + } + } + ]; + + // Initial configuration + [kitContainer configureKits:nil]; + [kitContainer configureKits:configurations]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Thread safety stress test"]; + + dispatch_group_t group = dispatch_group_create(); + dispatch_queue_t concurrentQueue = dispatch_queue_create("com.mparticle.test.concurrent", DISPATCH_QUEUE_CONCURRENT); + + NSInteger iterations = 100; + __block BOOL encounteredError = NO; + + // Multiple threads reading activeKitsRegistry + for (NSInteger i = 0; i < 3; i++) { + dispatch_group_async(group, concurrentQueue, ^{ + for (NSInteger j = 0; j < iterations && !encounteredError; j++) { + @try { + NSArray *activeKits = [kitContainer activeKitsRegistry]; + // Access the returned array to ensure objects are valid + for (id kit in activeKits) { + (void)kit.code; + } + } @catch (NSException *exception) { + encounteredError = YES; + XCTFail(@"Exception in activeKitsRegistry: %@", exception); + } + } + }); + } + + // Thread modifying kits configuration + dispatch_group_async(group, concurrentQueue, ^{ + for (NSInteger j = 0; j < iterations && !encounteredError; j++) { + @try { + [kitContainer configureKits:nil]; + [kitContainer configureKits:configurations]; + } @catch (NSException *exception) { + encounteredError = YES; + XCTFail(@"Exception in configureKits: %@", exception); + } + } + }); + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + XCTAssertFalse(encounteredError, @"Thread safety test should complete without errors"); + [expectation fulfill]; + }); + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + @end diff --git a/mParticle-Apple-SDK/Kits/MPKitContainer.m b/mParticle-Apple-SDK/Kits/MPKitContainer.m index 7148ed395..0d9fa2275 100644 --- a/mParticle-Apple-SDK/Kits/MPKitContainer.m +++ b/mParticle-Apple-SDK/Kits/MPKitContainer.m @@ -1984,9 +1984,11 @@ - (void)removeKitsFromRegistryInvalidForWorkspaceSwitch { return nil; } - NSMutableArray > *activeKitsRegistry = [[NSMutableArray alloc] initWithCapacity:kitsRegistry.count]; + // Copy the registry to avoid race conditions with concurrent modifications + NSSet *kitsRegistryCopy = [kitsRegistry copy]; + NSMutableArray > *activeKitsRegistry = [[NSMutableArray alloc] initWithCapacity:kitsRegistryCopy.count]; - for (idkitRegister in kitsRegistry) { + for (idkitRegister in kitsRegistryCopy) { if ([self isActiveAndNotDisabled:kitRegister]) { [activeKitsRegistry addObject:kitRegister]; } From dcabc28e5aac7a920a3cdaa4803f5ee173033baf Mon Sep 17 00:00:00 2001 From: Brandon Stalnaker <33703490+BrandonStalnaker@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:13:35 -0500 Subject: [PATCH 02/19] fix: Mitigate Thread-safety of DateFormatter (#574) (cherry picked from commit 7b36691d5fb50739da8e17b76cdba127caefaa19) --- .../Sources/Utils/MPDateFormatter.swift | 37 +++++--- .../Test/Utils/MPDateFormatterTests.swift | 94 +++++++++++++++++++ 2 files changed, 120 insertions(+), 11 deletions(-) diff --git a/mParticle-Apple-SDK-Swift/Sources/Utils/MPDateFormatter.swift b/mParticle-Apple-SDK-Swift/Sources/Utils/MPDateFormatter.swift index 9c9c9fcb6..1f8f4d5c3 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Utils/MPDateFormatter.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Utils/MPDateFormatter.swift @@ -1,6 +1,11 @@ import Foundation @objc public class MPDateFormatter: NSObject { + // MARK: - Serial queue for thread-safe access to DateFormatter instances + + // DateFormatter is NOT thread-safe, so we use a serial queue to synchronize access + private static let formatterQueue = DispatchQueue(label: "com.mparticle.dateformatter") + private static var dateFormatterRFC3339: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -30,15 +35,17 @@ import Foundation return nil } - if let date = dateFormatterRFC3339.date(from: dateString) { - return date - } + return formatterQueue.sync { + if let date = dateFormatterRFC3339.date(from: dateString) { + return date + } - if let date = dateFormatterRFC1123.date(from: dateString) { - return date - } + if let date = dateFormatterRFC1123.date(from: dateString) { + return date + } - return dateFormatterRFC850.date(from: dateString) + return dateFormatterRFC850.date(from: dateString) + } } @objc public static func date(fromStringRFC1123 dateString: String) -> Date? { @@ -46,7 +53,9 @@ import Foundation return nil } - return dateFormatterRFC1123.date(from: dateString) + return formatterQueue.sync { + dateFormatterRFC1123.date(from: dateString) + } } @objc public static func date(fromStringRFC3339 dateString: String) -> Date? { @@ -54,14 +63,20 @@ import Foundation return nil } - return dateFormatterRFC3339.date(from: dateString) + return formatterQueue.sync { + dateFormatterRFC3339.date(from: dateString) + } } @objc public static func string(fromDateRFC1123 date: Date) -> String? { - return dateFormatterRFC1123.string(from: date) + return formatterQueue.sync { + dateFormatterRFC1123.string(from: date) + } } @objc public static func string(fromDateRFC3339 date: Date) -> String? { - return dateFormatterRFC3339.string(from: date) + return formatterQueue.sync { + dateFormatterRFC3339.string(from: date) + } } } diff --git a/mParticle-Apple-SDK-Swift/Test/Utils/MPDateFormatterTests.swift b/mParticle-Apple-SDK-Swift/Test/Utils/MPDateFormatterTests.swift index 6565ebdb0..f44f9524e 100644 --- a/mParticle-Apple-SDK-Swift/Test/Utils/MPDateFormatterTests.swift +++ b/mParticle-Apple-SDK-Swift/Test/Utils/MPDateFormatterTests.swift @@ -87,4 +87,98 @@ final class MPDateFormatterTests: XCTestCase { ) } } + + // MARK: - Thread Safety Tests + + func testDateFormatterThreadSafety() { + // This stress test verifies that MPDateFormatter doesn't crash when + // called concurrently from multiple threads. DateFormatter is NOT + // thread-safe, so without synchronization this test would likely crash. + // Race conditions are non-deterministic, so this test increases the + // likelihood of catching issues but cannot guarantee detection. + + let expectation = self.expectation(description: "Thread safety stress test") + + let group = DispatchGroup() + let concurrentQueue = DispatchQueue(label: "com.mparticle.test.dateformatter", attributes: .concurrent) + + let iterations = 100 + var encounteredError = false + let errorLock = NSLock() + + let rfc3339Strings = [ + "2024-01-15T10:30:00+0000", + "2023-06-20T15:45:30-0500", + "1955-11-05T01:15:00-0800" + ] + + let rfc1123Strings = [ + "Mon, 15 Jan 2024 10:30:00 GMT", + "Tue, 20 Jun 2023 15:45:30 GMT", + "Sat, 05 Nov 1955 09:15:00 GMT" + ] + + // Multiple threads parsing RFC3339 dates + for _ in 0..<3 { + group.enter() + concurrentQueue.async { + for j in 0.. Date: Thu, 12 Feb 2026 16:23:11 -0500 Subject: [PATCH 03/19] fix: MPURLRequestBuilder build crash (#575) * fix: MPURLRequestBuilder build crash * Change signature to nullable (cherry picked from commit 604afeefdfe32e5c2785cc3404c64941fcfda847) --- .../ObjCTests/MPURLRequestBuilderTests.m | 152 ++++++++++++++++++ .../Network/MPURLRequestBuilder.h | 2 +- .../Network/MPURLRequestBuilder.m | 56 +++++-- 3 files changed, 194 insertions(+), 16 deletions(-) diff --git a/UnitTests/ObjCTests/MPURLRequestBuilderTests.m b/UnitTests/ObjCTests/MPURLRequestBuilderTests.m index 571fe61d3..a7971b4df 100644 --- a/UnitTests/ObjCTests/MPURLRequestBuilderTests.m +++ b/UnitTests/ObjCTests/MPURLRequestBuilderTests.m @@ -436,6 +436,158 @@ - (void)testEventRequest { } } +- (void)testBuildReturnsNilWhenURLPropertyIsNil { + NSURL *validURL = [NSURL URLWithString:@"https://config2.mparticle.com/v4/key/config?av=1.0&sv=1.0"]; + MPURL *mpURL = [[MPURL alloc] initWithURL:validURL defaultURL:validURL]; + MPURLRequestBuilder *builder = [MPURLRequestBuilder newBuilderWithURL:mpURL message:nil httpMethod:@"GET"]; + XCTAssertNotNil(builder); + + mpURL.url = (NSURL * _Nonnull)nil; + + NSMutableURLRequest *request; + XCTAssertNoThrow(request = [builder build], @"build should not throw when URL is nil"); + XCTAssertNil(request, @"build should return nil when URL is nil"); +} + +- (void)testBuildReturnsNilWhenDefaultURLIsNil { + NSURL *validURL = [NSURL URLWithString:@"https://config2.mparticle.com/v4/key/config?av=1.0&sv=1.0"]; + MPURL *mpURL = [[MPURL alloc] initWithURL:validURL defaultURL:validURL]; + MPURLRequestBuilder *builder = [MPURLRequestBuilder newBuilderWithURL:mpURL message:nil httpMethod:@"GET"]; + XCTAssertNotNil(builder); + + mpURL.defaultURL = (NSURL * _Nonnull)nil; + + NSMutableURLRequest *request; + XCTAssertNoThrow(request = [builder build], @"build should not throw when defaultURL is nil"); + XCTAssertNil(request, @"build should return nil when defaultURL is nil"); +} + +- (void)testBuildConfigRequestWithQuerylessURL { + NSURL *noQueryURL = [NSURL URLWithString:@"https://config2.mparticle.com/v4/key/config"]; + MPURL *mpURL = [[MPURL alloc] initWithURL:noQueryURL defaultURL:noQueryURL]; + MPURLRequestBuilder *builder = [MPURLRequestBuilder newBuilderWithURL:mpURL message:nil httpMethod:@"GET"]; + XCTAssertNotNil(builder); + + NSMutableURLRequest *request; + XCTAssertNoThrow(request = [builder build], @"build should not throw for a URL without query parameters"); + XCTAssertNotNil(request); + + NSString *signature = request.allHTTPHeaderFields[@"x-mp-signature"]; + XCTAssertNotNil(signature, @"Signature should still be generated for queryless URL"); + XCTAssertTrue(signature.length > 0); +} + +- (void)testDateHeaderIsValidRFC1123 { + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; + MPURLRequestBuilder *builder = [MPURLRequestBuilder newBuilderWithURL:[networkCommunication configURL] message:nil httpMethod:@"GET"]; + NSMutableURLRequest *request = [builder build]; + XCTAssertNotNil(request); + + NSString *dateHeader = request.allHTTPHeaderFields[@"Date"]; + XCTAssertNotNil(dateHeader, @"Date header should be present"); + XCTAssertTrue(dateHeader.length > 0, @"Date header should not be empty"); + + NSDate *parsedDate = [MPDateFormatter dateFromStringRFC1123:dateHeader]; + XCTAssertNotNil(parsedDate, @"Date header should be parseable as RFC1123 by MPDateFormatter"); +} + +- (void)testConfigSignatureOmitsQuestionMarkForQuerylessURL { + NSURL *noQueryURL = [NSURL URLWithString:@"https://config2.mparticle.com/v4/key/config"]; + MPURL *mpURL = [[MPURL alloc] initWithURL:noQueryURL defaultURL:noQueryURL]; + MPURLRequestBuilder *builder = [MPURLRequestBuilder newBuilderWithURL:mpURL message:nil httpMethod:@"GET"]; + XCTAssertNotNil(builder); + + __block NSString *capturedSignature = nil; + id partialMock = OCMPartialMock(builder); + OCMStub([partialMock hmacSha256Encode:[OCMArg any] key:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained NSString *sig; + [invocation getArgument:&sig atIndex:2]; + capturedSignature = sig; + }); + + [builder build]; + + XCTAssertNotNil(capturedSignature, @"Signature message should have been captured"); + XCTAssertTrue([capturedSignature rangeOfString:@"(null)"].location == NSNotFound, + @"Signature should not contain (null): %@", capturedSignature); + XCTAssertFalse([capturedSignature hasSuffix:@"?"], + @"Signature should not end with a trailing ? for queryless URL: %@", capturedSignature); +} + +- (void)testAudienceSignatureOmitsQuestionMarkForQuerylessURL { + NSURL *audienceURL = [NSURL URLWithString:@"https://nativesdks.mparticle.com/v2/audience"]; + audienceURL.accessibilityHint = @"audience"; + MPURL *mpURL = [[MPURL alloc] initWithURL:audienceURL defaultURL:audienceURL]; + MPURLRequestBuilder *builder = [MPURLRequestBuilder newBuilderWithURL:mpURL message:nil httpMethod:@"GET"]; + XCTAssertNotNil(builder); + + __block NSString *capturedSignature = nil; + id partialMock = OCMPartialMock(builder); + OCMStub([partialMock hmacSha256Encode:[OCMArg any] key:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained NSString *sig; + [invocation getArgument:&sig atIndex:2]; + capturedSignature = sig; + }); + + [builder build]; + + XCTAssertNotNil(capturedSignature, @"Signature message should have been captured"); + XCTAssertTrue([capturedSignature rangeOfString:@"(null)"].location == NSNotFound, + @"Signature should not contain (null): %@", capturedSignature); + XCTAssertFalse([capturedSignature hasSuffix:@"?"], + @"Signature should not end with a trailing ? for queryless URL: %@", capturedSignature); +} + +- (void)testBuildReturnsNilForIdentityRequestWithNilPostData { + NSURL *identityURL = [NSURL URLWithString:@"https://identity.mparticle.com/v1/identify"]; + identityURL.accessibilityHint = @"identity"; + MPURL *mpURL = [[MPURL alloc] initWithURL:identityURL defaultURL:identityURL]; + MPURLRequestBuilder *builder = [MPURLRequestBuilder newBuilderWithURL:mpURL message:nil httpMethod:@"POST"]; + XCTAssertNotNil(builder); + + NSMutableURLRequest *request; + XCTAssertNoThrow(request = [builder build], @"build should not throw for identity request with nil post data"); + XCTAssertNil(request, @"build should return nil for identity request with nil post data"); +} + +- (void)testSecondsFromGMTHeaderIsValidSignedInteger { + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; + MPURLRequestBuilder *builder = [MPURLRequestBuilder newBuilderWithURL:[networkCommunication configURL] message:nil httpMethod:@"GET"]; + NSMutableURLRequest *request = [builder build]; + XCTAssertNotNil(request); + + NSString *secondsHeader = request.allHTTPHeaderFields[@"secondsFromGMT"]; + XCTAssertNotNil(secondsHeader, @"secondsFromGMT header should be present"); + XCTAssertTrue(secondsHeader.length > 0, @"secondsFromGMT header should not be empty"); + + NSInteger parsedValue = [secondsHeader integerValue]; + NSString *reformatted = [NSString stringWithFormat:@"%ld", (long)parsedValue]; + XCTAssertEqualObjects(secondsHeader, reformatted, + @"secondsFromGMT should be a valid signed integer, got: %@", secondsHeader); + + XCTAssertTrue(parsedValue >= -43200 && parsedValue <= 50400, + @"secondsFromGMT should be within valid UTC offset range (-43200 to 50400), got: %ld", (long)parsedValue); +} + +- (void)testBuildWithNilSecretProducesRequestWithoutSignature { + NSURL *validURL = [NSURL URLWithString:@"https://config2.mparticle.com/v4/key/config?av=1.0&sv=1.0"]; + MPURL *mpURL = [[MPURL alloc] initWithURL:validURL defaultURL:validURL]; + MPURLRequestBuilder *builder = [MPURLRequestBuilder newBuilderWithURL:mpURL message:nil httpMethod:@"GET"]; + XCTAssertNotNil(builder); + + NSString *originalSecret = [MParticle sharedInstance].stateMachine.secret; + [MParticle sharedInstance].stateMachine.secret = nil; + + NSMutableURLRequest *request; + XCTAssertNoThrow(request = [builder build], @"build should not throw when secret is nil"); + XCTAssertNotNil(request, @"build should still return a request when secret is nil"); + + NSString *signature = request.allHTTPHeaderFields[@"x-mp-signature"]; + XCTAssertNil(signature, @"x-mp-signature should be absent when secret is nil"); + + [MParticle sharedInstance].stateMachine.secret = originalSecret; +} + - (void)testSignatureRelativePath { MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPNetworkOptions *networkOptions = [[MPNetworkOptions alloc] init]; diff --git a/mParticle-Apple-SDK/Network/MPURLRequestBuilder.h b/mParticle-Apple-SDK/Network/MPURLRequestBuilder.h index 884962d30..8542e54f5 100644 --- a/mParticle-Apple-SDK/Network/MPURLRequestBuilder.h +++ b/mParticle-Apple-SDK/Network/MPURLRequestBuilder.h @@ -16,6 +16,6 @@ - (nonnull MPURLRequestBuilder *)withHttpMethod:(nonnull NSString *)httpMethod; - (nonnull MPURLRequestBuilder *)withPostData:(nullable NSData *)postData; - (nonnull MPURLRequestBuilder *)withSecret:(nullable NSString *)secret; -- (nonnull NSMutableURLRequest *)build; +- (nullable NSMutableURLRequest *)build; @end diff --git a/mParticle-Apple-SDK/Network/MPURLRequestBuilder.m b/mParticle-Apple-SDK/Network/MPURLRequestBuilder.m index edfa04b7d..d437e6de0 100644 --- a/mParticle-Apple-SDK/Network/MPURLRequestBuilder.m +++ b/mParticle-Apple-SDK/Network/MPURLRequestBuilder.m @@ -11,8 +11,6 @@ #import "MPUserDefaultsConnector.h" @import mParticle_Apple_SDK_Swift; -static NSDateFormatter *RFC1123DateFormatter; - @interface MParticle () @property (nonatomic, strong, readonly) MPStateMachine_PRIVATE *stateMachine; @@ -34,15 +32,6 @@ @interface MPURLRequestBuilder() { @implementation MPURLRequestBuilder -+ (void)initialize { - if (self == [MPURLRequestBuilder class]) { - RFC1123DateFormatter = [[NSDateFormatter alloc] init]; - RFC1123DateFormatter.locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US"]; - RFC1123DateFormatter.timeZone = [NSTimeZone timeZoneWithAbbreviation:@"GMT"]; - RFC1123DateFormatter.dateFormat = @"EEE',' dd MMM yyyy HH':'mm':'ss 'GMT'"; - } -} - - (instancetype)initWithURL:(MPURL *)url { self = [super init]; if (!self || !url) { @@ -144,6 +133,16 @@ - (MPURLRequestBuilder *)withSecret:(nullable NSString *)secret { } - (NSMutableURLRequest *)build { + if (!_url.url) { + MPILogError(@"Cannot build URL request — URL is nil"); + return nil; + } + + if (!_url.defaultURL) { + MPILogError(@"Cannot build URL request — defaultURL is nil"); + return nil; + } + NSMutableURLRequest *urlRequest = [NSMutableURLRequest requestWithURL:_url.url]; [urlRequest setCachePolicy:NSURLRequestReloadIgnoringLocalCacheData]; [urlRequest setTimeoutInterval:[MPURLRequestBuilder requestTimeout]]; @@ -152,11 +151,22 @@ - (NSMutableURLRequest *)build { BOOL isIdentityRequest = [urlRequest.URL.accessibilityHint isEqualToString:@"identity"]; BOOL isAudienceRequest = [urlRequest.URL.accessibilityHint isEqualToString:@"audience"]; - NSString *date = [RFC1123DateFormatter stringFromDate:[NSDate date]]; + NSString *date = [MPDateFormatter stringFromDateRFC1123:[NSDate date]] ?: @""; NSString *secret = _secret ?: [MParticle sharedInstance].stateMachine.secret; if (isAudienceRequest) { - NSString *audienceSignature = [NSString stringWithFormat:@"%@\n%@\n%@?%@", _httpMethod, date, [urlRequest.URL relativePath], [urlRequest.URL query]]; + NSString *audienceRelativePath = [urlRequest.URL relativePath]; + if (!audienceRelativePath) { + MPILogError(@"Cannot build URL request — audience relative path is nil"); + return nil; + } + NSString *audienceQuery = [urlRequest.URL query]; + NSString *audienceSignature; + if (audienceQuery) { + audienceSignature = [NSString stringWithFormat:@"%@\n%@\n%@?%@", _httpMethod, date, audienceRelativePath, audienceQuery]; + } else { + audienceSignature = [NSString stringWithFormat:@"%@\n%@\n%@", _httpMethod, date, audienceRelativePath]; + } MPILogVerbose(@"Audience Signature:\n%@", audienceSignature); NSString *hmacSha256Encode = [self hmacSha256Encode:audienceSignature key:secret]; if (hmacSha256Encode) { @@ -175,16 +185,28 @@ - (NSMutableURLRequest *)build { NSString *contentType = nil; NSString *kits = nil; NSString *relativePath = [_url.defaultURL relativePath]; + if (!relativePath) { + MPILogError(@"Cannot build URL request — relative path is nil"); + return nil; + } NSString *signatureMessage; NSTimeZone *timeZone = [NSTimeZone defaultTimeZone]; - NSString *secondsFromGMT = [NSString stringWithFormat:@"%ld", (unsigned long)[timeZone secondsFromGMT]]; + NSString *secondsFromGMT = [NSString stringWithFormat:@"%ld", (long)[timeZone secondsFromGMT]]; NSRange range; BOOL containsMessage = _message != nil; if (isIdentityRequest) { // /identify, /login, /logout, //modify contentType = @"application/json"; [urlRequest setValue:[MParticle sharedInstance].stateMachine.apiKey forHTTPHeaderField:@"x-mp-key"]; + if (!_postData) { + MPILogError(@"Cannot build URL request — post data is nil for identity request"); + return nil; + } NSString *postDataString = [[NSString alloc] initWithData:_postData encoding:NSUTF8StringEncoding]; + if (!postDataString) { + MPILogError(@"Cannot build URL request — failed to encode post data as UTF-8"); + return nil; + } signatureMessage = [NSString stringWithFormat:@"%@\n%@\n%@%@", _httpMethod, date, relativePath, postDataString]; } else if (containsMessage) { // /events contentType = @"application/json"; @@ -221,7 +243,11 @@ - (NSMutableURLRequest *)build { } NSString *query = [_url.defaultURL query]; - signatureMessage = [NSString stringWithFormat:@"%@\n%@\n%@?%@", _httpMethod, date, relativePath, query]; + if (query) { + signatureMessage = [NSString stringWithFormat:@"%@\n%@\n%@?%@", _httpMethod, date, relativePath, query]; + } else { + signatureMessage = [NSString stringWithFormat:@"%@\n%@\n%@", _httpMethod, date, relativePath]; + } } NSString *hmacSha256Encode = [self hmacSha256Encode:signatureMessage key:secret]; From 1915591dc65367d9dff54993ad900a4c76b3d186 Mon Sep 17 00:00:00 2001 From: Thomson Thomas <125323226+thomson-t@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:43:03 -0500 Subject: [PATCH 04/19] fix: app crash from [MPUpload description] (#572) * fix: app crash from [MPUpload description] Stacktrace ``` 0 libsystem_malloc.dylib 0x30a0 _xzm_xzone_malloc_freelist_outlined + 864 1 Foundation 0x4804 -[NSString quotedStringRepresentation] + 132 2 Foundation 0x4724 -[NSString _stringRepresentation] + 360 3 CoreFoundation 0x14a580 -[NSDictionary descriptionWithLocale:indent:] + 1128 4 CoreFoundation 0x14a5ac -[NSDictionary descriptionWithLocale:indent:] + 1172 5 Foundation 0x36b4 _NSDescriptionWithLocaleFunc + 56 6 CoreFoundation 0x15cb0 __CFSTRING_IS_CALLING_OUT_TO_AN_OBJECT_FORMAT_ARGUMENT_WITH_LOCALE__ + 28 7 CoreFoundation 0x8c4c __CFStringAppendFormatCore + 9968 8 CoreFoundation 0x122e4 _CFStringCreateWithFormatAndArgumentsReturningMetadata + 184 9 CoreFoundation 0x12398 _CFStringCreateWithFormatAndArgumentsAux2 + 44 10 Foundation 0x9b2bb0 +[NSString stringWithFormat:] + 68 11 mParticle_Apple_SDK 0x3b7c8 -[MPUpload description] + 172 12 mParticle_Apple_SDK 0x232c4 -[MPPersistenceController_PRIVATE saveUpload:] + 1472 13 mParticle_Apple_SDK 0x94d1c -[MPUploadBuilder build:] + 3488 14 mParticle_Apple_SDK 0x6e30c __55-[MPBackendController_PRIVATE prepareBatchesForUpload:]_block_invoke_4 + 548 ``` * MPListenerController is not required in this scenario. Removing MPListenerController (cherry picked from commit 91c0c5d4880e064fcb07569283fddb886ce1eb33) From 335d2c33b97d7772780abefdbc0c5aa5dd81923a Mon Sep 17 00:00:00 2001 From: Thomson Thomas <125323226+thomson-t@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:40:15 -0500 Subject: [PATCH 05/19] fix: Thread-safe access to currentUser to prevent crash during kit replay (#576) The currentUser property on MPIdentityApi was nonatomic with no synchronization, but written on messageQueue (via identity responses) and read on the main queue (via replayQueuedItems -> isActiveAndNotDisabled). Stacktrace ``` 0 libobjc.A.dylib 0x144c objc_retain_x0 1 libobjc.A.dylib 0x144c objc_retain 2 mParticle_Apple_SDK 0x392f8 -[MPIdentityApi currentUser] + 32 3 mParticle_Apple_SDK 0x8a078 -[MPKitContainer_PRIVATE isActiveAndNotDisabled:] + 208 4 mParticle_Apple_SDK 0x89ed0 -[MPKitContainer_PRIVATE activeKitsRegistry] + 208 5 mParticle_Apple_SDK 0x8ccc0 -[MPKitContainer_PRIVATE forwardSDKCall:event:parameters:messageType:userInfo:] + 124 6 mParticle_Apple_SDK 0x7ed3c __43-[MPKitContainer_PRIVATE replayQueuedItems]_block_invoke_3 + 88 ``` (cherry picked from commit a3ba57e860c68552c2d6297f7b8deb04ac09fe95) --- UnitTests/ObjCTests/MPIdentityTests.m | 2 +- mParticle-Apple-SDK/Identity/MPIdentityApi.m | 22 +++++++++++++------- mParticle-Apple-SDK/Include/MPIdentityApi.h | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/UnitTests/ObjCTests/MPIdentityTests.m b/UnitTests/ObjCTests/MPIdentityTests.m index cfb8497ba..0ee6fd2d8 100644 --- a/UnitTests/ObjCTests/MPIdentityTests.m +++ b/UnitTests/ObjCTests/MPIdentityTests.m @@ -35,7 +35,7 @@ - (void)setUserId:(NSNumber *)userId; @interface MPIdentityApi () @property (nonatomic, strong) MPIdentityApiManager *apiManager; -@property(nonatomic, strong, readwrite, nonnull) MParticleUser *currentUser; +@property(strong, readwrite, nonnull) MParticleUser *currentUser; - (void)onIdentityRequestComplete:(MPIdentityApiRequest *)request identityRequestType:(MPIdentityRequestType)identityRequestType httpResponse:(MPIdentityHTTPSuccessResponse *) httpResponse completion:(MPIdentityApiResultCallback)completion error: (NSError *) error; - (void)onModifyRequestComplete:(MPIdentityApiRequest *)request httpResponse:(MPIdentityHTTPModifySuccessResponse *) httpResponse completion:(MPModifyApiResultCallback)completion error: (NSError *) error; diff --git a/mParticle-Apple-SDK/Identity/MPIdentityApi.m b/mParticle-Apple-SDK/Identity/MPIdentityApi.m index f720b8ccf..9f48ab42e 100644 --- a/mParticle-Apple-SDK/Identity/MPIdentityApi.m +++ b/mParticle-Apple-SDK/Identity/MPIdentityApi.m @@ -34,7 +34,7 @@ @interface MParticle () @interface MPIdentityApi () @property (nonatomic, strong) MPIdentityApiManager *apiManager; -@property(nonatomic, strong, readwrite, nonnull) MParticleUser *currentUser; +@property(strong, readwrite, nonnull) MParticleUser *currentUser; @end @@ -300,15 +300,23 @@ - (void)forwardCallToKits:(MPIdentityApiRequest *)request identityRequestType:(M } - (MParticleUser *)currentUser { - if (_currentUser) { + @synchronized(self) { + if (_currentUser) { + return _currentUser; + } + + NSNumber *mpid = [MPPersistenceController_PRIVATE mpId]; + MParticleUser *user = [[MParticleUser alloc] init]; + user.userId = mpid; + _currentUser = user; return _currentUser; } +} - NSNumber *mpid = [MPPersistenceController_PRIVATE mpId]; - MParticleUser *user = [[MParticleUser alloc] init]; - user.userId = mpid; - _currentUser = user; - return _currentUser; +- (void)setCurrentUser:(MParticleUser *)currentUser { + @synchronized(self) { + _currentUser = currentUser; + } } - (MParticleUser *)getUser:(NSNumber *)mpId { diff --git a/mParticle-Apple-SDK/Include/MPIdentityApi.h b/mParticle-Apple-SDK/Include/MPIdentityApi.h index 3d460eb9e..7effbc710 100644 --- a/mParticle-Apple-SDK/Include/MPIdentityApi.h +++ b/mParticle-Apple-SDK/Include/MPIdentityApi.h @@ -79,7 +79,7 @@ typedef void (^MPModifyApiResultCallback)(MPModifyApiResult *_Nullable apiResult /** The current user. All actions taken in the SDK will be associated with this user (e.g. logging events, setting attributes, etc.) */ -@property(nonatomic, strong, readonly, nullable) MParticleUser *currentUser; +@property(strong, readonly, nullable) MParticleUser *currentUser; /** The device application stamp. This is a random identifier associated with this particular app as installed on this particular device. From 7a89b79d1d52adb591e9bf08e2a8d162bb5b577f Mon Sep 17 00:00:00 2001 From: Brandon Stalnaker <33703490+BrandonStalnaker@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:20:42 -0500 Subject: [PATCH 06/19] fix: Add Brackets Thread Safety Tests (#573) (cherry picked from commit 0d831cc522c38834081483dce8428a1737ce8b1e) --- UnitTests/ObjCTests/MPKitContainerTests.m | 73 +++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/UnitTests/ObjCTests/MPKitContainerTests.m b/UnitTests/ObjCTests/MPKitContainerTests.m index 9c52d01e4..e19d39792 100644 --- a/UnitTests/ObjCTests/MPKitContainerTests.m +++ b/UnitTests/ObjCTests/MPKitContainerTests.m @@ -71,6 +71,8 @@ - (MPKitFilter *)filter:(id)kitRegister forUserAttribute - (MPKitFilter *)filter:(id)kitRegister forUserIdentityKey:(NSString *)key identityType:(MPUserIdentity)identityType; - (MPKitFilter *)filter:(id)kitRegister forCommerceEvent:(MPCommerceEvent *const)commerceEvent; - (void)attemptToLogEventToKit:(id)kitRegister kitFilter:(MPKitFilter *)kitFilter selector:(SEL)selector parameters:(nullable MPForwardQueueParameters *)parameters messageType:(MPMessageType)messageType userInfo:(NSDictionary *)userInfo; +- (id)bracketForKit:(NSNumber *)integrationId; +- (void)updateBracketsWithConfiguration:(NSDictionary *)configuration integrationId:(NSNumber *)integrationId; @end @@ -3441,4 +3443,75 @@ - (void)testActiveKitsRegistryThreadSafety { [self waitForExpectationsWithTimeout:30 handler:nil]; } +- (void)testBracketForKitThreadSafety { + // This stress test verifies that bracketForKit: doesn't crash when + // called concurrently with updateBracketsWithConfiguration: modifications. + // Race conditions are non-deterministic, so this test increases the + // likelihood of catching issues but cannot guarantee detection. + + MPKitContainer_PRIVATE *kitContainer = [[MPKitContainer_PRIVATE alloc] init]; + + NSArray *integrationIds = @[@42, @314, @123, @456]; + NSDictionary *bracketConfig = @{ + @"lo": @0, + @"hi": @100 + }; + + // Initialize some brackets + for (NSNumber *integrationId in integrationIds) { + [kitContainer updateBracketsWithConfiguration:bracketConfig integrationId:integrationId]; + } + + XCTestExpectation *expectation = [self expectationWithDescription:@"Bracket thread safety stress test"]; + + dispatch_group_t group = dispatch_group_create(); + dispatch_queue_t concurrentQueue = dispatch_queue_create("com.mparticle.test.brackets", DISPATCH_QUEUE_CONCURRENT); + + NSInteger iterations = 100; + __block BOOL encounteredError = NO; + + // Multiple threads reading brackets + for (NSInteger i = 0; i < 3; i++) { + dispatch_group_async(group, concurrentQueue, ^{ + for (NSInteger j = 0; j < iterations && !encounteredError; j++) { + @try { + for (NSNumber *integrationId in integrationIds) { + id bracket = [kitContainer bracketForKit:integrationId]; + (void)bracket; // Use the result to prevent optimization + } + } @catch (NSException *exception) { + encounteredError = YES; + XCTFail(@"Exception in bracketForKit: %@", exception); + } + } + }); + } + + // Thread modifying brackets + dispatch_group_async(group, concurrentQueue, ^{ + for (NSInteger j = 0; j < iterations && !encounteredError; j++) { + @try { + for (NSNumber *integrationId in integrationIds) { + // Alternate between adding and removing brackets + if (j % 2 == 0) { + [kitContainer updateBracketsWithConfiguration:bracketConfig integrationId:integrationId]; + } else { + [kitContainer updateBracketsWithConfiguration:nil integrationId:integrationId]; + } + } + } @catch (NSException *exception) { + encounteredError = YES; + XCTFail(@"Exception in updateBracketsWithConfiguration: %@", exception); + } + } + }); + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + XCTAssertFalse(encounteredError, @"Thread safety test should complete without errors"); + [expectation fulfill]; + }); + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + @end From 0e7ab34a14589e874954944020bc0be56e134f83 Mon Sep 17 00:00:00 2001 From: Nickolas Dimitrakas Date: Fri, 13 Feb 2026 10:09:57 -0500 Subject: [PATCH 07/19] fix: background expiration race (#577) * fix: background expiration race * edit tests * remove additional mock interface (cherry picked from commit 9d97bd32fee29e25cb748c1ade4af1c933fa949a) --- .../ObjCTests/MPBackendControllerTests.m | 130 ++++++++++++++++++ mParticle-Apple-SDK/MPBackendController.m | 12 +- 2 files changed, 138 insertions(+), 4 deletions(-) diff --git a/UnitTests/ObjCTests/MPBackendControllerTests.m b/UnitTests/ObjCTests/MPBackendControllerTests.m index 923bc445c..647d06a48 100644 --- a/UnitTests/ObjCTests/MPBackendControllerTests.m +++ b/UnitTests/ObjCTests/MPBackendControllerTests.m @@ -17,6 +17,7 @@ #import "MPKitConfiguration.h" #import "MPBaseTestCase.h" #import "MPUserDefaultsConnector.h" +#import "MPApplication.h" #if TARGET_OS_IOS == 1 #import @@ -94,9 +95,19 @@ - (void)uploadBatchesWithCompletionHandler:(void(^)(BOOL success))completionHand - (NSMutableArray *> *)userIdentitiesForUserId:(NSNumber *)userId; - (void)cleanUp:(NSTimeInterval)currentTime; - (void)processDidFinishLaunching:(NSNotification *)notification; +- (void)beginBackgroundTask; +- (void)endBackgroundTask; +- (void)beginBackgroundTimeCheckLoop; +- (void)cancelBackgroundTimeCheckLoop; +@property NSOperationQueue *backgroundCheckQueue; +@property UIBackgroundTaskIdentifier backendBackgroundTaskIdentifier; @end +@interface MPApplication_PRIVATE(Tests) ++ (void)setMockApplication:(id)mockApplication; +@end + #pragma mark - MPBackendControllerTests unit test class @interface MPBackendControllerTests : MPBaseTestCase { dispatch_queue_t messageQueue; @@ -2306,4 +2317,123 @@ - (void)testProcessDidFinishLaunchingWithWebpageURL { XCTAssertEqual(instance.stateMachine.launchInfo.url, testURL); } +#pragma mark - Background Time Check Loop Tests + +- (void)testExpirationHandlerCancelsBackgroundTimeCheckLoop { + // Verify that the expiration handler cancels the background check loop + // so that backgroundTimeRemaining is not called after the OS begins suspension. + + // 1. Set up a mock UIApplication that tracks backgroundTimeRemaining calls + __block NSInteger backgroundTimeRemainingCallCount = 0; + __block void (^capturedExpirationHandler)(void) = nil; + + id mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication applicationState]).andReturn(UIApplicationStateBackground); + OCMStub([mockApplication backgroundTimeRemaining]).andDo(^(NSInvocation *invocation) { + backgroundTimeRemainingCallCount++; + NSTimeInterval remaining = 25.0; + [invocation setReturnValue:&remaining]; + }); + OCMStub([mockApplication beginBackgroundTaskWithExpirationHandler:([OCMArg checkWithBlock:^BOOL(id obj) { + capturedExpirationHandler = [obj copy]; + return YES; + }])]).andReturn((UIBackgroundTaskIdentifier)42); + OCMStub([mockApplication endBackgroundTask:(UIBackgroundTaskIdentifier)42]); + + [MPApplication_PRIVATE setMockApplication:mockApplication]; + + // 2. Start the background task on the main queue (captures the expiration handler) + // beginBackgroundTask dispatches to main, so we use an expectation to wait for it + XCTestExpectation *taskStarted = [self expectationWithDescription:@"Background task started"]; + [self.backendController beginBackgroundTask]; + // Give the main queue time to process the dispatched block + dispatch_async(dispatch_get_main_queue(), ^{ + [taskStarted fulfill]; + }); + [self waitForExpectations:@[taskStarted] timeout:2.0]; + + XCTAssertNotNil(capturedExpirationHandler, @"Expiration handler should have been captured"); + + // 3. Start the background time check loop on the message queue + dispatch_async(messageQueue, ^{ + [self.backendController beginBackgroundTimeCheckLoop]; + }); + + // Let the loop run a few iterations. + // Use NSRunLoop instead of sleep so the main queue stays alive + // (the loop calls dispatch_sync to the main queue to check app state) + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:3.0]]; + NSInteger callsBeforeExpiration = backgroundTimeRemainingCallCount; + XCTAssertGreaterThan(callsBeforeExpiration, 0, @"Loop should have called backgroundTimeRemaining at least once"); + + // 4. Simulate the OS firing the expiration handler + capturedExpirationHandler(); + // Let the main queue process the cancelBackgroundTimeCheckLoop and endBackgroundTask dispatches + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.5]]; + + // 5. Record calls right after expiration and wait to see if any new calls arrive + NSInteger callsAtExpiration = backgroundTimeRemainingCallCount; + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:3.0]]; + NSInteger callsAfterExpiration = backgroundTimeRemainingCallCount; + + // 6. Assert: no new backgroundTimeRemaining calls after expiration + XCTAssertEqual(callsAfterExpiration, callsAtExpiration, + @"backgroundTimeRemaining should not be called after expiration handler fires. " + "Calls at expiration: %ld, calls after waiting: %ld", + (long)callsAtExpiration, (long)callsAfterExpiration); + + // 7. Verify the loop's operation queue is empty (loop exited) + XCTAssertEqual(self.backendController.backgroundCheckQueue.operationCount, 0, + @"Background check queue should have no running operations after expiration"); + + // Clean up + [self.backendController cancelBackgroundTimeCheckLoop]; + [MPApplication_PRIVATE setMockApplication:nil]; +} + +- (void)testBackgroundTimeCheckLoopStoresTimeRemainingInLocalVariable { + // Verify that backgroundTimeRemaining is called only once per loop iteration + // (not twice as in the original code that called it again for logging). + + __block NSInteger backgroundTimeRemainingCallCount = 0; + __block NSTimeInterval simulatedTime = 15.0; + + id mockApplication = OCMClassMock([UIApplication class]); + + // First call returns background, subsequent calls return foreground to exit loop after one iteration + __block NSInteger stateCallCount = 0; + OCMStub([mockApplication applicationState]).andDo(^(NSInvocation *invocation) { + UIApplicationState state = (stateCallCount == 0) + ? UIApplicationStateBackground + : UIApplicationStateActive; + stateCallCount++; + [invocation setReturnValue:&state]; + }); + + OCMStub([mockApplication backgroundTimeRemaining]).andDo(^(NSInvocation *invocation) { + backgroundTimeRemainingCallCount++; + [invocation setReturnValue:&simulatedTime]; + }); + OCMStub([mockApplication beginBackgroundTaskWithExpirationHandler:OCMOCK_ANY]).andReturn((UIBackgroundTaskIdentifier)42); + OCMStub([mockApplication endBackgroundTask:(UIBackgroundTaskIdentifier)42]); + + [MPApplication_PRIVATE setMockApplication:mockApplication]; + + dispatch_async(messageQueue, ^{ + [self.backendController beginBackgroundTimeCheckLoop]; + }); + + // Wait for the loop to run one iteration and exit (it exits because applicationState returns active on second call) + // Use NSRunLoop so the main queue stays alive for the loop's dispatch_sync calls + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:3.0]]; + + // Should be called exactly once per iteration (not twice like the old code) + XCTAssertEqual(backgroundTimeRemainingCallCount, 1, + @"backgroundTimeRemaining should be called exactly once per loop iteration, got %ld", + (long)backgroundTimeRemainingCallCount); + + [self.backendController cancelBackgroundTimeCheckLoop]; + [MPApplication_PRIVATE setMockApplication:nil]; +} + @end diff --git a/mParticle-Apple-SDK/MPBackendController.m b/mParticle-Apple-SDK/MPBackendController.m index 19a1064dd..d2c5e66af 100644 --- a/mParticle-Apple-SDK/MPBackendController.m +++ b/mParticle-Apple-SDK/MPBackendController.m @@ -1888,6 +1888,7 @@ - (void)beginBackgroundTask { if (self.backendBackgroundTaskIdentifier == UIBackgroundTaskInvalid) { self.backendBackgroundTaskIdentifier = [[MPApplication_PRIVATE sharedUIApplication] beginBackgroundTaskWithExpirationHandler:^{ MPILogDebug(@"SDK has ended background activity together with the app."); + [self cancelBackgroundTimeCheckLoop]; [self endBackgroundTask]; }]; } @@ -2036,14 +2037,17 @@ - (void)beginBackgroundTimeCheckLoop { // Loop to check the background state and time remaining to decide when to upload while (applicationState == UIApplicationStateBackground) { - // Handle edge case where app leaves and re-enters background during while the thread is asleep + [self endSessionIfTimedOut]; + + // Check cancellation immediately before accessing backgroundTimeRemaining + // to avoid calling it after the OS has begun tearing down XPC connections if (!weakBlockOperation || weakBlockOperation.isCancelled) { return; } - [self endSessionIfTimedOut]; + NSTimeInterval timeRemaining = sharedApplication.backgroundTimeRemaining; - if (sharedApplication.backgroundTimeRemaining <= kMPRemainingBackgroundTimeMinimumThreshold) { + if (timeRemaining <= kMPRemainingBackgroundTimeMinimumThreshold) { // Less than kMPRemainingBackgroundTimeMinimumThreshold seconds left in the background, upload the batch MPILogVerbose(@"Less than %f time remaining in background, uploading batch and ending background task", kMPRemainingBackgroundTimeMinimumThreshold); [MParticle executeOnMessage:^{ @@ -2055,7 +2059,7 @@ - (void)beginBackgroundTimeCheckLoop { }]; return; } - MPILogVerbose(@"Background time remaining %f", sharedApplication.backgroundTimeRemaining); + MPILogVerbose(@"Background time remaining %f", timeRemaining); // Short sleep to prevent burning CPU cycles [NSThread sleepForTimeInterval:1.0]; From 0ca87468894e86957f39c75458dc886b8e5c4b24 Mon Sep 17 00:00:00 2001 From: Brandon Stalnaker <33703490+BrandonStalnaker@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:20:41 -0500 Subject: [PATCH 08/19] fix: Guarantee UserDefaults Thread Safety (#580) fix: Guarantee UserDefsults Thread Safety (cherry picked from commit 7baa7b41a3d38b7bbc5afa4364b948b5de09f6f3) --- .../Sources/Utils/MPUserDefaults.swift | 38 +++++++----- .../Test/Utils/MPUserDefaultsTests.swift | 59 +++++++++++++++++++ 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/mParticle-Apple-SDK-Swift/Sources/Utils/MPUserDefaults.swift b/mParticle-Apple-SDK-Swift/Sources/Utils/MPUserDefaults.swift index 5e7bed505..c97793d51 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Utils/MPUserDefaults.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Utils/MPUserDefaults.swift @@ -2,6 +2,7 @@ import Foundation private var userDefaults: MPUserDefaults? private var sharedGroupID: String? +private let userDefaultsQueue = DispatchQueue(label: "com.mparticle.userdefaults") private let NSUserDefaultsPrefix = "mParticle::" private let userSpecificKeys = ["lud", /* kMPAppLastUseDateKey */ "lc", /* kMPAppLaunchCountKey */ @@ -33,11 +34,12 @@ public protocol MPUserDefaultsProtocol { } @objc public class func standardUserDefaults(connector: MPUserDefaultsConnectorProtocol) -> MPUserDefaults { - if userDefaults == nil { - userDefaults = MPUserDefaults(connector: connector) + return userDefaultsQueue.sync { + if userDefaults == nil { + userDefaults = MPUserDefaults(connector: connector) + } + return userDefaults! } - - return userDefaults! } @objc public func mpObject(forKey key: String, userId: NSNumber) -> Any? { @@ -263,7 +265,10 @@ public protocol MPUserDefaultsProtocol { for key in mParticleKeys { UserDefaults.standard.removeObject(forKey: key) } - userDefaults = nil + + userDefaultsQueue.sync { + userDefaults = nil + } UserDefaults.standard.synchronize() } @@ -335,9 +340,11 @@ public protocol MPUserDefaultsProtocol { @objc public class func isOlderThanConfigMaxAgeSeconds() -> Bool { var shouldConfigurationBeDeleted = false - if let userDefaults = userDefaults { - let configProvisioned = userDefaults[Miscellaneous.kMPConfigProvisionedTimestampKey] as? NSNumber - let maxAgeSeconds = userDefaults.connector.configMaxAgeSeconds() + let defaults = userDefaultsQueue.sync { userDefaults } + + if let defaults = defaults { + let configProvisioned = defaults[Miscellaneous.kMPConfigProvisionedTimestampKey] as? NSNumber + let maxAgeSeconds = defaults.connector.configMaxAgeSeconds() if let configProvisioned = configProvisioned, let maxAgeSeconds = maxAgeSeconds, maxAgeSeconds.doubleValue > 0 { let intervalConfigProvisioned: TimeInterval = configProvisioned.doubleValue @@ -346,7 +353,7 @@ public protocol MPUserDefaultsProtocol { } if shouldConfigurationBeDeleted { - userDefaults.deleteConfiguration() + defaults.deleteConfiguration() } } return shouldConfigurationBeDeleted @@ -359,11 +366,13 @@ public protocol MPUserDefaultsProtocol { } @objc public class func restore() -> MPResponseConfig? { - if let userDefaults = userDefaults { - if let configuration = userDefaults.getConfiguration(), userDefaults.connector.canCreateConfiguration() { + let defaults = userDefaultsQueue.sync { userDefaults } + + if let defaults = defaults { + if let configuration = defaults.getConfiguration(), defaults.connector.canCreateConfiguration() { let responseConfig = MPResponseConfig( configuration: configuration, - connector: userDefaults.connector + connector: defaults.connector ) return responseConfig @@ -374,9 +383,8 @@ public protocol MPUserDefaultsProtocol { } @objc public class func deleteConfig() { - if let userDefaults = userDefaults { - userDefaults.deleteConfiguration() - } + let defaults = userDefaultsQueue.sync { userDefaults } + defaults?.deleteConfiguration() } // Private Methods diff --git a/mParticle-Apple-SDK-Swift/Test/Utils/MPUserDefaultsTests.swift b/mParticle-Apple-SDK-Swift/Test/Utils/MPUserDefaultsTests.swift index 146ccc8f3..8a70250ca 100644 --- a/mParticle-Apple-SDK-Swift/Test/Utils/MPUserDefaultsTests.swift +++ b/mParticle-Apple-SDK-Swift/Test/Utils/MPUserDefaultsTests.swift @@ -482,4 +482,63 @@ class MPUserDefaultsTests: XCTestCase { configuration as NSDictionary ) } + + // MARK: - Thread Safety Tests + + func testUserDefaultsSingletonThreadSafety() { + // Stress test to verify the singleton accessor is thread-safe + // This tests the fix for the race condition in standardUserDefaults() + + let expectation = self.expectation(description: "Thread safety test completed") + + let group = DispatchGroup() + let concurrentQueue = DispatchQueue( + label: "com.mparticle.test.userdefaults.concurrent", + attributes: .concurrent + ) + + let iterations = 1000 + + for i in 0.. Date: Fri, 13 Feb 2026 12:43:50 -0500 Subject: [PATCH 09/19] fix: App crash when JSON serialization of upload dictionary (#579) * fix: App crash when JSON serialization of upload dictionary Prevent heap corruption from concurrent mutation of shared mutable objects during JSON serialization. Added a mechanism to create a deep immutable copy of JSON-compatible objects, ensuring thread safety and preventing heap corruption during serialization. Invalid entries are logged and dropped. Stacktrace ``` 0 libsystem_malloc.dylib 0x30a0 _xzm_xzone_malloc_freelist_outlined + 864 1 Foundation 0xa2e854 -[_NSJSONWriter resizeTemporaryBuffer:] + 104 2 Foundation 0x114f8 _convertJSONString + 148 3 Foundation 0x1138c _writeJSONString + 84 4 Foundation 0x94628 ___writeJSONObject_block_invoke + 412 5 CoreFoundation 0x12044 __NSDICTIONARY_IS_CALLING_OUT_TO_A_BLOCK__ + 24 6 CoreFoundation 0x19fb40 -[__NSDictionaryM enumerateKeysAndObjectsWithOptions:usingBlock:] + 288 7 Foundation 0x97c54 _writeJSONObject + 512 8 Foundation 0x94628 ___writeJSONObject_block_invoke + 412 9 CoreFoundation 0x12044 __NSDICTIONARY_IS_CALLING_OUT_TO_A_BLOCK__ + 24 10 CoreFoundation 0x19fb40 -[__NSDictionaryM enumerateKeysAndObjectsWithOptions:usingBlock:] + 288 11 Foundation 0x97c54 _writeJSONObject + 512 12 Foundation 0xa2e70c -[_NSJSONWriter dataWithRootObject:options:] + 104 13 Foundation 0xa2f6b8 +[NSJSONSerialization dataWithJSONObject:options:error:] + 112 14 mParticle_Apple_SDK 0x3b480 -[MPUpload initWithSessionId:uploadDictionary:dataPlanId:dataPlanVersion:uploadSettings:] + 140 15 mParticle_Apple_SDK 0x94cfc -[MPUploadBuilder build:] + 3456 16 mParticle_Apple_SDK 0x6e30c __55-[MPBackendController_PRIVATE prepareBatchesForUpload:]_block_invoke_4 + 548 ``` * refactor: rework the fix to cover SDKE-906 Stacktrace ``` 0 libsystem_malloc.dylib 0x35104 _xzm_xzone_malloc_from_tiny_chunk.cold.1 + 36 1 libsystem_malloc.dylib 0x1d28 _xzm_xzone_malloc_from_tiny_chunk + 612 2 libsystem_malloc.dylib 0x164c _xzm_xzone_find_and_malloc_from_tiny_chunk + 112 3 libsystem_malloc.dylib 0x1e84 _xzm_xzone_malloc_tiny_outlined + 312 4 CoreFoundation 0x3566c __CFBinaryPlistWriteOrPresize + 292 5 Foundation 0x5ed80 -[NSKeyedArchiver finishEncoding] + 640 6 Foundation 0x2c39e4 +[NSKeyedArchiver archivedDataWithRootObject:] + 112 7 mParticle_Apple_SDK 0x23234 -[MPPersistenceController_PRIVATE saveUpload:] + 1284 8 mParticle_Apple_SDK 0x95154 -[MPUploadBuilder build:] + 3488 9 mParticle_Apple_SDK 0x6e744 __55-[MPBackendController_PRIVATE prepareBatchesForUpload:]_block_invoke_4 + 548 10 CoreFoundation 0x1ce98 __NSDICTIONARY_IS_CALLING_OUT_TO_A_BLOCK__ + 24 11 CoreFoundation 0x1d078 -[__NSDictionaryM enumerateKeysAndObjectsWithOptions:usingBlock:] + 288 12 mParticle_Apple_SDK 0x6e4e0 __55-[MPBackendController_PRIVATE prepareBatchesForUpload:]_block_invoke_3 + 172 13 CoreFoundation 0x1ce98 __NSDICTIONARY_IS_CALLING_OUT_TO_A_BLOCK__ + 24 14 CoreFoundation 0x1d078 -[__NSDictionaryM enumerateKeysAndObjectsWithOptions:usingBlock:] + 288 15 mParticle_Apple_SDK 0x6e3fc __55-[MPBackendController_PRIVATE prepareBatchesForUpload:]_block_invoke_2 + 164 ``` (cherry picked from commit a5e196005cbcda92889f4f032498646a5108b58e) --- UnitTests/ObjCTests/MPDataModelTests.m | 81 +++++++++++++++++++++++ mParticle-Apple-SDK/Data Model/MPUpload.m | 55 ++++++++++++++- mParticle-Apple-SDK/MPBackendController.m | 6 +- 3 files changed, 137 insertions(+), 5 deletions(-) diff --git a/UnitTests/ObjCTests/MPDataModelTests.m b/UnitTests/ObjCTests/MPDataModelTests.m index d570a6f80..c2ddf5fdf 100644 --- a/UnitTests/ObjCTests/MPDataModelTests.m +++ b/UnitTests/ObjCTests/MPDataModelTests.m @@ -226,6 +226,87 @@ - (void)testUploadInstance { XCTAssertNotEqualObjects(uploadCopy, upload, @"Should not have been equal."); } +- (void)testUploadSerializationDeepCopiesValues { + // Verify that deep copy produces correct, independent data + NSMutableString *mutableString = [NSMutableString stringWithString:@"mutable"]; + NSMutableArray *mutableArray = [NSMutableArray arrayWithObjects:@"a", @"b", nil]; + NSMutableDictionary *mutableNested = [NSMutableDictionary dictionaryWithDictionary:@{@"key":@"value"}]; + + NSDictionary *uploadDictionary = @{ + kMPMessageIdKey:[[NSUUID UUID] UUIDString], + kMPTimestampKey:@(123456789), + @"string":mutableString, + @"array":mutableArray, + @"nested":mutableNested, + @"number":@(42), + @"null":[NSNull null], + kMPMessagesKey:@[] + }; + + MPUpload *upload = [[MPUpload alloc] initWithSessionId:@1 + uploadDictionary:uploadDictionary + dataPlanId:@"test" + dataPlanVersion:@(1) + uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; + XCTAssertNotNil(upload, @"Upload should succeed with valid JSON data."); + + // Mutate originals after creation -- upload should be unaffected + [mutableString appendString:@"_changed"]; + [mutableArray addObject:@"c"]; + mutableNested[@"key"] = @"changed"; + + NSDictionary *serialized = [upload dictionaryRepresentation]; + XCTAssertNotNil(serialized); + XCTAssertEqualObjects(serialized[@"string"], @"mutable"); + XCTAssertEqual([serialized[@"array"] count], 2); + XCTAssertEqualObjects(serialized[@"nested"][@"key"], @"value"); + XCTAssertEqualObjects(serialized[@"number"], @(42)); + XCTAssertEqualObjects(serialized[@"null"], [NSNull null]); +} + +- (void)testUploadSerializationDropsNonJSONTypes { + // Non-JSON-compatible types (NSDate, NSURL, etc.) are dropped by deep copy. + // If the remaining data is still valid JSON, the upload succeeds with those keys missing. + NSDictionary *uploadDictionary = @{ + kMPMessageIdKey:[[NSUUID UUID] UUIDString], + kMPTimestampKey:@(123456789), + @"valid":@"ok", + @"date":[NSDate date], + @"url":[NSURL URLWithString:@"https://example.com"], + kMPMessagesKey:@[] + }; + + MPUpload *upload = [[MPUpload alloc] initWithSessionId:@1 + uploadDictionary:uploadDictionary + dataPlanId:@"test" + dataPlanVersion:@(1) + uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; + XCTAssertNotNil(upload, @"Upload should succeed; non-JSON values are dropped."); + + NSDictionary *serialized = [upload dictionaryRepresentation]; + XCTAssertEqualObjects(serialized[@"valid"], @"ok"); + XCTAssertNil(serialized[@"date"]); + XCTAssertNil(serialized[@"url"]); +} + +- (void)testUploadSerializationReturnsNilForInvalidData { + // An upload dictionary that cannot produce valid JSON should return nil + double four = 4.0; + double zed = 0.0; + NSDictionary *uploadDictionary = @{ + kMPMessageIdKey:[[NSUUID UUID] UUIDString], + kMPTimestampKey:@(four/zed), // NaN timestamp makes the whole upload invalid + kMPMessagesKey:@[] + }; + + MPUpload *upload = [[MPUpload alloc] initWithSessionId:@1 + uploadDictionary:uploadDictionary + dataPlanId:@"test" + dataPlanVersion:@(1) + uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; + XCTAssertNil(upload, @"Upload should be nil when JSON serialization fails."); +} + - (void)testBreadcrumbInstance { MPSession *session = [[MPSession alloc] initWithStartTime:[[NSDate date] timeIntervalSince1970] userId:[MPPersistenceController_PRIVATE mpId]]; diff --git a/mParticle-Apple-SDK/Data Model/MPUpload.m b/mParticle-Apple-SDK/Data Model/MPUpload.m index bd2e8db70..48a8ffd45 100644 --- a/mParticle-Apple-SDK/Data Model/MPUpload.m +++ b/mParticle-Apple-SDK/Data Model/MPUpload.m @@ -2,20 +2,69 @@ #import "MPSession.h" #import "MPIConstants.h" #import "mParticle.h" +#import "MPILogger.h" @interface MParticle() @property (nonatomic, strong) MPStateMachine_PRIVATE *stateMachine; @end +// Recursively deep-copies a JSON-compatible object tree, producing new immutable +// containers at every level. Snapshots each collection before enumerating it, +// breaking shared references with mutable data that other threads may mutate. +// Non-JSON-compatible values are silently dropped. +static id MPDeepCopyJSONObject(id object) { + if (object == nil || object == [NSNull null]) return object; + if ([object isKindOfClass:[NSString class]]) return [object copy]; + if ([object isKindOfClass:[NSNumber class]]) return object; + + if ([object isKindOfClass:[NSDictionary class]]) { + NSDictionary *snapshot = [((NSDictionary *)object) copy]; + NSMutableDictionary *result = [[NSMutableDictionary alloc] initWithCapacity:snapshot.count]; + for (id key in snapshot) { + if (![key isKindOfClass:[NSString class]]) continue; + id value = MPDeepCopyJSONObject(snapshot[key]); + if (value) result[key] = value; + } + return [result copy]; + } + + if ([object isKindOfClass:[NSArray class]]) { + NSArray *snapshot = [((NSArray *)object) copy]; + NSMutableArray *result = [[NSMutableArray alloc] initWithCapacity:snapshot.count]; + for (id item in snapshot) { + id value = MPDeepCopyJSONObject(item); + if (value) [result addObject:value]; + } + return [result copy]; + } + + return nil; +} + @implementation MPUpload - (instancetype)initWithSessionId:(NSNumber *)sessionId uploadDictionary:(NSDictionary *)uploadDictionary dataPlanId:(nullable NSString *)dataPlanId dataPlanVersion:(nullable NSNumber *)dataPlanVersion uploadSettings:(nonnull MPUploadSettings *)uploadSettings { - NSData *uploadData = [NSJSONSerialization dataWithJSONObject:uploadDictionary options:0 error:nil]; + NSDictionary *safeDictionary = nil; + NSData *uploadData = nil; + + @try { + safeDictionary = (NSDictionary *)MPDeepCopyJSONObject(uploadDictionary); + if (safeDictionary) { + uploadData = [NSJSONSerialization dataWithJSONObject:safeDictionary options:0 error:nil]; + } + } @catch (NSException *exception) { + MPILogError(@"Exception serializing upload dictionary: %@", exception); + } + + if (!uploadData) { + return nil; + } + return [self initWithSessionId:sessionId uploadId:0 - UUID:uploadDictionary[kMPMessageIdKey] + UUID:safeDictionary[kMPMessageIdKey] uploadData:uploadData - timestamp:[uploadDictionary[kMPTimestampKey] doubleValue] + timestamp:[safeDictionary[kMPTimestampKey] doubleValue] uploadType:MPUploadTypeMessage dataPlanId:dataPlanId dataPlanVersion:dataPlanVersion diff --git a/mParticle-Apple-SDK/MPBackendController.m b/mParticle-Apple-SDK/MPBackendController.m index d2c5e66af..5d5710a36 100644 --- a/mParticle-Apple-SDK/MPBackendController.m +++ b/mParticle-Apple-SDK/MPBackendController.m @@ -598,8 +598,10 @@ - (void)prepareBatchesForUpload:(MPUploadSettings *)uploadSettings { [uploadBuilder withUserAttributes:[self userAttributesForUserId:mpid] deletedUserAttributes:self.deletedUserAttributes]; [uploadBuilder withUserIdentities:[self userIdentitiesForUserId:mpid]]; [uploadBuilder build:^(MPUpload *upload) { - //Save the Upload to the Database (3) - [persistence saveUpload:upload]; + if (upload) { + //Save the Upload to the Database (3) + [persistence saveUpload:upload]; + } }]; } From 47fbe335eef68de68097e234c1703e9ef42d6bc0 Mon Sep 17 00:00:00 2001 From: Brandon Stalnaker <33703490+BrandonStalnaker@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:48:25 -0500 Subject: [PATCH 10/19] fix: Add Try/Catch to File Write (#581) (cherry picked from commit 18045c9e32c1a01cb954e08dd9a5ee590e6eb096) --- mParticle-Apple-SDK/MPBackendController.m | 24 +++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/mParticle-Apple-SDK/MPBackendController.m b/mParticle-Apple-SDK/MPBackendController.m index 5d5710a36..666771adf 100644 --- a/mParticle-Apple-SDK/MPBackendController.m +++ b/mParticle-Apple-SDK/MPBackendController.m @@ -343,13 +343,25 @@ - (void)setPreviousSessionSuccessfullyClosed:(NSNumber *)previousSessionSuccessf NSString *previousSessionStateFile = [stateMachineDirectoryPath stringByAppendingPathComponent:kMPPreviousSessionStateFileName]; NSDictionary *previousSessionStateDictionary = @{kMPASTPreviousSessionSuccessfullyClosedKey:previousSessionSuccessfullyClosed}; - if (![fileManager fileExistsAtPath:stateMachineDirectoryPath]) { - [fileManager createDirectoryAtPath:stateMachineDirectoryPath withIntermediateDirectories:YES attributes:nil error:nil]; - } else if ([fileManager fileExistsAtPath:previousSessionStateFile]) { - [fileManager removeItemAtPath:previousSessionStateFile error:nil]; + @try { + if (![fileManager fileExistsAtPath:stateMachineDirectoryPath]) { + NSError *dirError = nil; + [fileManager createDirectoryAtPath:stateMachineDirectoryPath withIntermediateDirectories:YES attributes:nil error:&dirError]; + if (dirError) { + MPILogError(@"Failed to create state machine directory: %@", dirError); + return; + } + } else if ([fileManager fileExistsAtPath:previousSessionStateFile]) { + [fileManager removeItemAtPath:previousSessionStateFile error:nil]; + } + + BOOL success = [previousSessionStateDictionary writeToFile:previousSessionStateFile atomically:YES]; + if (!success) { + MPILogError(@"Failed to write previous session state to file"); + } + } @catch (NSException *exception) { + MPILogError(@"Exception writing previous session state: %@", exception); } - - [previousSessionStateDictionary writeToFile:previousSessionStateFile atomically:YES]; } - (void)processDidFinishLaunching:(NSNotification *)notification { From 1aa67cd7a58ed35d5d48b81975a071a1583ab39c Mon Sep 17 00:00:00 2001 From: Brandon Stalnaker <33703490+BrandonStalnaker@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:50:05 -0500 Subject: [PATCH 11/19] chore: Cleanup Release Actions (#570) (cherry picked from commit aee96c4e3059e5a759a7c7d4cc13c1ba925313fc) --- .github/workflows/sdk-release-manual.yml | 3 +-- .github/workflows/sdk-release.yml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sdk-release-manual.yml b/.github/workflows/sdk-release-manual.yml index 582dfacfc..09562f679 100644 --- a/.github/workflows/sdk-release-manual.yml +++ b/.github/workflows/sdk-release-manual.yml @@ -337,5 +337,4 @@ jobs: --base main \ --head chore/release-v${VERSION} \ --title "chore: Release v${VERSION}" \ - --body "$PR_BODY" \ - --reviewer mParticle/sdk-team + --body "$PR_BODY" diff --git a/.github/workflows/sdk-release.yml b/.github/workflows/sdk-release.yml index 253bdac17..6a36ac321 100644 --- a/.github/workflows/sdk-release.yml +++ b/.github/workflows/sdk-release.yml @@ -1,4 +1,4 @@ -name: iOS SDK Release +name: iOS SDK Release (Deprecated) on: workflow_dispatch: From 1a1b676c22d3743333290b2c46eac5200f705fa5 Mon Sep 17 00:00:00 2001 From: James Newman Date: Fri, 13 Feb 2026 14:15:52 -0500 Subject: [PATCH 12/19] fix: Potential MPURLRequestBuilder crash (#578) * fix: Potential crash in MPURLRequestBuilder * Add validation for API key and secret * Allow nil secrets and key There's logic allowing network requests without the header x-mp-signature (cherry picked from commit 70c0076c3e4d0f32a1ff4c0ad9c5518d8a1a5fb8) --- UnitTests/ObjCTests/MPStateMachineTests.m | 58 +++++++++++++++++++ .../ObjCTests/MPURLRequestBuilderTests.m | 10 +--- mParticle-Apple-SDK/Include/MPStateMachine.h | 8 +-- mParticle-Apple-SDK/Network/MPURL.h | 4 +- .../Network/MPURLRequestBuilder.m | 5 +- 5 files changed, 69 insertions(+), 16 deletions(-) diff --git a/UnitTests/ObjCTests/MPStateMachineTests.m b/UnitTests/ObjCTests/MPStateMachineTests.m index 00c20e2a5..2785bc21f 100644 --- a/UnitTests/ObjCTests/MPStateMachineTests.m +++ b/UnitTests/ObjCTests/MPStateMachineTests.m @@ -213,4 +213,62 @@ - (void)testRequestAttribution { } #endif +#pragma mark - Thread Safety Tests + +- (void)testApiKeySecretThreadSafety { + MPStateMachine_PRIVATE *stateMachine = [MParticle sharedInstance].stateMachine; + + NSString *originalApiKey = stateMachine.apiKey; + NSString *originalSecret = stateMachine.secret; + [self addTeardownBlock:^{ + stateMachine.apiKey = originalApiKey; + stateMachine.secret = originalSecret; + }]; + + stateMachine.apiKey = @"initial_api_key_value_that_is_long_enough_to_force_heap_allocation"; + stateMachine.secret = @"initial_secret_value_that_is_long_enough_to_force_heap_allocation"; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Thread safety stress test"]; + + dispatch_group_t group = dispatch_group_create(); + dispatch_queue_t concurrentQueue = dispatch_queue_create("com.mparticle.test.statemachine.concurrent", DISPATCH_QUEUE_CONCURRENT); + + NSInteger iterations = 10000; + __block BOOL encounteredError = NO; + + for (NSInteger i = 0; i < 4; i++) { + dispatch_group_async(group, concurrentQueue, ^{ + for (NSInteger j = 0; j < iterations && !encounteredError; j++) { + @try { + NSString *key = stateMachine.apiKey; + NSString *sec = stateMachine.secret; + (void)[key length]; + (void)[sec length]; + } @catch (NSException *exception) { + encounteredError = YES; + } + } + }); + } + + dispatch_group_async(group, concurrentQueue, ^{ + for (NSInteger j = 0; j < iterations && !encounteredError; j++) { + @try { + // Use long format strings to force heap-allocated NSString (not tagged pointers) + stateMachine.apiKey = [NSString stringWithFormat:@"api_key_value_for_thread_safety_test_iteration_%ld", (long)j]; + stateMachine.secret = [NSString stringWithFormat:@"secret_value_for_thread_safety_test_iteration_%ld", (long)j]; + } @catch (NSException *exception) { + encounteredError = YES; + } + } + }); + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + XCTAssertFalse(encounteredError, @"Thread safety test should complete without errors"); + [expectation fulfill]; + }); + + [self waitForExpectationsWithTimeout:30 handler:nil]; +} + @end diff --git a/UnitTests/ObjCTests/MPURLRequestBuilderTests.m b/UnitTests/ObjCTests/MPURLRequestBuilderTests.m index a7971b4df..8c2239d90 100644 --- a/UnitTests/ObjCTests/MPURLRequestBuilderTests.m +++ b/UnitTests/ObjCTests/MPURLRequestBuilderTests.m @@ -438,11 +438,8 @@ - (void)testEventRequest { - (void)testBuildReturnsNilWhenURLPropertyIsNil { NSURL *validURL = [NSURL URLWithString:@"https://config2.mparticle.com/v4/key/config?av=1.0&sv=1.0"]; - MPURL *mpURL = [[MPURL alloc] initWithURL:validURL defaultURL:validURL]; + MPURL *mpURL = [[MPURL alloc] initWithURL:(NSURL * _Nonnull)nil defaultURL:validURL]; MPURLRequestBuilder *builder = [MPURLRequestBuilder newBuilderWithURL:mpURL message:nil httpMethod:@"GET"]; - XCTAssertNotNil(builder); - - mpURL.url = (NSURL * _Nonnull)nil; NSMutableURLRequest *request; XCTAssertNoThrow(request = [builder build], @"build should not throw when URL is nil"); @@ -451,11 +448,8 @@ - (void)testBuildReturnsNilWhenURLPropertyIsNil { - (void)testBuildReturnsNilWhenDefaultURLIsNil { NSURL *validURL = [NSURL URLWithString:@"https://config2.mparticle.com/v4/key/config?av=1.0&sv=1.0"]; - MPURL *mpURL = [[MPURL alloc] initWithURL:validURL defaultURL:validURL]; + MPURL *mpURL = [[MPURL alloc] initWithURL:validURL defaultURL:(NSURL * _Nonnull)nil]; MPURLRequestBuilder *builder = [MPURLRequestBuilder newBuilderWithURL:mpURL message:nil httpMethod:@"GET"]; - XCTAssertNotNil(builder); - - mpURL.defaultURL = (NSURL * _Nonnull)nil; NSMutableURLRequest *request; XCTAssertNoThrow(request = [builder build], @"build should not throw when defaultURL is nil"); diff --git a/mParticle-Apple-SDK/Include/MPStateMachine.h b/mParticle-Apple-SDK/Include/MPStateMachine.h index 43c774bd3..3d4373903 100644 --- a/mParticle-Apple-SDK/Include/MPStateMachine.h +++ b/mParticle-Apple-SDK/Include/MPStateMachine.h @@ -15,14 +15,14 @@ @property (nonatomic, weak, nullable) MPSession *currentSession; @property (nonatomic) NSNumber * _Nullable attAuthorizationStatus; @property (nonatomic) NSNumber * _Nullable attAuthorizationTimestamp; -@property (nonatomic, strong, nonnull) NSString *apiKey __attribute__((const)); -@property (nonatomic, strong, nonnull) NSString *secret __attribute__((const)); +@property (atomic, strong, nonnull) NSString *apiKey; +@property (atomic, strong, nonnull) NSString *secret; @end @interface MPStateMachine_PRIVATE : NSObject -@property (nonatomic, strong, nonnull) NSString *apiKey __attribute__((const)); +@property (atomic, strong, nonnull) NSString *apiKey; @property (nonatomic, strong, nonnull) MPConsumerInfo *consumerInfo; @property (nonatomic, weak, nullable) MPSession *currentSession; @property (nonatomic, strong, nullable) NSArray *customModules; @@ -31,7 +31,7 @@ @property (nonatomic, strong, nullable) NSDictionary *launchOptions; @property (nonatomic, strong, nullable) NSString *networkPerformanceMeasuringMode; @property (nonatomic, strong, nullable) NSString *pushNotificationMode; -@property (nonatomic, strong, nonnull) NSString *secret __attribute__((const)); +@property (atomic, strong, nonnull) NSString *secret; @property (nonatomic, strong, nonnull) NSDate *startTime; @property (nonatomic, strong, nullable) MPLaunchInfo *launchInfo; @property (nonatomic, strong, readonly, nullable) NSString *deviceTokenType; diff --git a/mParticle-Apple-SDK/Network/MPURL.h b/mParticle-Apple-SDK/Network/MPURL.h index 36694cb14..5fd951cf6 100644 --- a/mParticle-Apple-SDK/Network/MPURL.h +++ b/mParticle-Apple-SDK/Network/MPURL.h @@ -4,8 +4,8 @@ NS_ASSUME_NONNULL_BEGIN @interface MPURL : NSObject -@property (nonatomic, strong, nonnull) NSURL *url; -@property (nonatomic, strong, nonnull) NSURL *defaultURL; +@property (nonatomic, strong, readonly, nonnull) NSURL *url; +@property (nonatomic, strong, readonly, nonnull) NSURL *defaultURL; - (nonnull instancetype)initWithURL:(nonnull NSURL *)url defaultURL:(nonnull NSURL *)defaultURL; diff --git a/mParticle-Apple-SDK/Network/MPURLRequestBuilder.m b/mParticle-Apple-SDK/Network/MPURLRequestBuilder.m index d437e6de0..06afeba78 100644 --- a/mParticle-Apple-SDK/Network/MPURLRequestBuilder.m +++ b/mParticle-Apple-SDK/Network/MPURLRequestBuilder.m @@ -153,6 +153,7 @@ - (NSMutableURLRequest *)build { NSString *date = [MPDateFormatter stringFromDateRFC1123:[NSDate date]] ?: @""; NSString *secret = _secret ?: [MParticle sharedInstance].stateMachine.secret; + NSString *apiKey = [MParticle sharedInstance].stateMachine.apiKey; if (isAudienceRequest) { NSString *audienceRelativePath = [urlRequest.URL relativePath]; @@ -173,7 +174,7 @@ - (NSMutableURLRequest *)build { [urlRequest setValue:hmacSha256Encode forHTTPHeaderField:@"x-mp-signature"]; } [urlRequest setValue:date forHTTPHeaderField:@"Date"]; - [urlRequest setValue:[MParticle sharedInstance].stateMachine.apiKey forHTTPHeaderField:@"x-mp-key"]; + [urlRequest setValue:apiKey forHTTPHeaderField:@"x-mp-key"]; NSString *userAgent = [self userAgent]; if (userAgent) { [urlRequest setValue:userAgent forHTTPHeaderField:@"User-Agent"]; @@ -197,7 +198,7 @@ - (NSMutableURLRequest *)build { if (isIdentityRequest) { // /identify, /login, /logout, //modify contentType = @"application/json"; - [urlRequest setValue:[MParticle sharedInstance].stateMachine.apiKey forHTTPHeaderField:@"x-mp-key"]; + [urlRequest setValue:apiKey forHTTPHeaderField:@"x-mp-key"]; if (!_postData) { MPILogError(@"Cannot build URL request — post data is nil for identity request"); return nil; From 65623b9a60bc12b9688349057d1f8b8d29005dce Mon Sep 17 00:00:00 2001 From: Nickolas Dimitrakas Date: Fri, 13 Feb 2026 15:12:37 -0500 Subject: [PATCH 13/19] fix: endSessionIfTimedOut race condition (#582) * fix: endSessionIfTimedOut race condition * add test (cherry picked from commit b2eb508c401c3b189f9f4abcb149066f5959cbea) --- .../ObjCTests/MPBackendControllerTests.m | 98 +++++++++++++++++++ mParticle-Apple-SDK/MPBackendController.m | 42 ++++---- 2 files changed, 119 insertions(+), 21 deletions(-) diff --git a/UnitTests/ObjCTests/MPBackendControllerTests.m b/UnitTests/ObjCTests/MPBackendControllerTests.m index 647d06a48..cb682994a 100644 --- a/UnitTests/ObjCTests/MPBackendControllerTests.m +++ b/UnitTests/ObjCTests/MPBackendControllerTests.m @@ -99,8 +99,11 @@ - (void)beginBackgroundTask; - (void)endBackgroundTask; - (void)beginBackgroundTimeCheckLoop; - (void)cancelBackgroundTimeCheckLoop; +- (void)endSessionIfTimedOut; @property NSOperationQueue *backgroundCheckQueue; @property UIBackgroundTaskIdentifier backendBackgroundTaskIdentifier; +@property NSTimeInterval timeOfLastEventInBackground; +@property NSTimeInterval timeAppWentToBackgroundInCurrentSession; @end @@ -2436,4 +2439,99 @@ - (void)testBackgroundTimeCheckLoopStoresTimeRemainingInLocalVariable { [MPApplication_PRIVATE setMockApplication:nil]; } +- (void)testEndSessionIfTimedOutDispatchesToMessageQueue { + // Verify that endSessionIfTimedOut called from a non-message-queue thread + // does not mutate session properties directly on that thread, but instead + // dispatches the work to the message queue. + + // 1. Set up a session and make it eligible for timeout + dispatch_sync(messageQueue, ^{ + [self.backendController beginSessionWithIsManual:NO date:[NSDate date]]; + }); + XCTAssertNotNil(self.backendController.session, @"Session should exist"); + + NSTimeInterval pastTime = [[NSDate date] timeIntervalSince1970] - 200; + dispatch_sync(messageQueue, ^{ + self.backendController.sessionTimeout = 30; + self.backendController.timeOfLastEventInBackground = pastTime; + self.backendController.timeAppWentToBackgroundInCurrentSession = pastTime; + }); + + // 2. Capture the session's endTime before the call + __block NSTimeInterval endTimeBefore; + dispatch_sync(messageQueue, ^{ + endTimeBefore = self.backendController.session.endTime; + }); + + // 3. Call endSessionIfTimedOut from a background thread (not the message queue) + XCTestExpectation *bgCallDone = [self expectationWithDescription:@"Background call completed"]; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [self.backendController endSessionIfTimedOut]; + [bgCallDone fulfill]; + }); + [self waitForExpectations:@[bgCallDone] timeout:2.0]; + + // 4. Wait for the message queue to drain and process the dispatched block + XCTestExpectation *mqDrained = [self expectationWithDescription:@"Message queue drained"]; + dispatch_async(messageQueue, ^{ + [mqDrained fulfill]; + }); + [self waitForExpectations:@[mqDrained] timeout:5.0]; + + // 5. Verify the session was ended (set to nil by processOpenSessionsEndingCurrent:YES) + __block MPSession *sessionAfter; + dispatch_sync(messageQueue, ^{ + sessionAfter = self.backendController.session; + }); + XCTAssertNil(sessionAfter, @"Session should be nil after endSessionIfTimedOut processes on the message queue"); +} + +- (void)testConcurrentEndSessionIfTimedOutDoesNotCrash { + // Verify that calling endSessionIfTimedOut simultaneously from a background + // thread and the message queue does not crash. + + // 1. Set up a session eligible for timeout + dispatch_sync(messageQueue, ^{ + [self.backendController beginSessionWithIsManual:NO date:[NSDate date]]; + }); + XCTAssertNotNil(self.backendController.session, @"Session should exist"); + + NSTimeInterval pastTime = [[NSDate date] timeIntervalSince1970] - 200; + dispatch_sync(messageQueue, ^{ + self.backendController.sessionTimeout = 30; + self.backendController.timeOfLastEventInBackground = pastTime; + self.backendController.timeAppWentToBackgroundInCurrentSession = pastTime; + }); + + // 2. Call from both a background thread and the message queue simultaneously + XCTestExpectation *bgDone = [self expectationWithDescription:@"Background thread done"]; + XCTestExpectation *mqDone = [self expectationWithDescription:@"Message queue done"]; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ + [self.backendController endSessionIfTimedOut]; + [bgDone fulfill]; + }); + + dispatch_async(messageQueue, ^{ + [self.backendController endSessionIfTimedOut]; + [mqDone fulfill]; + }); + + [self waitForExpectations:@[bgDone, mqDone] timeout:5.0]; + + // 3. Wait for the message queue to fully drain + XCTestExpectation *drained = [self expectationWithDescription:@"Queue drained"]; + dispatch_async(messageQueue, ^{ + [drained fulfill]; + }); + [self waitForExpectations:@[drained] timeout:5.0]; + + // 4. Verify session ended exactly once (session is nil, no crash) + __block MPSession *sessionAfter; + dispatch_sync(messageQueue, ^{ + sessionAfter = self.backendController.session; + }); + XCTAssertNil(sessionAfter, @"Session should be nil after concurrent endSessionIfTimedOut calls"); +} + @end diff --git a/mParticle-Apple-SDK/MPBackendController.m b/mParticle-Apple-SDK/MPBackendController.m index 666771adf..6d0c7ef6c 100644 --- a/mParticle-Apple-SDK/MPBackendController.m +++ b/mParticle-Apple-SDK/MPBackendController.m @@ -1949,31 +1949,31 @@ - (void)endSessionIfTimedOut { return; } - if (self.session != nil && [self shouldEndSession]) { - NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970]; - NSTimeInterval lastEventTime = self.timeOfLastEventInBackground; - self.session.endTime = lastEventTime; - - [self updateSessionBackgroundTime]; - - // Since we use the timeAppWentToBackground to calculate background time, but timeOfLastEventInBackground as the endTime, - // this can result in incorrectly calculated foreground time when ending a session in the background. So subtract the additional - // time since timeOfLastEventInBackground from the background time to correct this. - self.session.backgroundTime -= currentTime - self.timeOfLastEventInBackground; - - // Reset time of last event to reset the session timeout - self.timeOfLastEventInBackground = currentTime; - - // Reset the time app went to background so that it's correctly calculated in the new session - self.timeAppWentToBackgroundInCurrentSession = currentTime; - - [MParticle executeOnMessage:^{ + [MParticle executeOnMessage:^{ + if (self.session != nil && [self shouldEndSession]) { + NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970]; + NSTimeInterval lastEventTime = self.timeOfLastEventInBackground; + self.session.endTime = lastEventTime; + + [self updateSessionBackgroundTime]; + + // Since we use the timeAppWentToBackground to calculate background time, but timeOfLastEventInBackground as the endTime, + // this can result in incorrectly calculated foreground time when ending a session in the background. So subtract the additional + // time since timeOfLastEventInBackground from the background time to correct this. + self.session.backgroundTime -= currentTime - self.timeOfLastEventInBackground; + + // Reset time of last event to reset the session timeout + self.timeOfLastEventInBackground = currentTime; + + // Reset the time app went to background so that it's correctly calculated in the new session + self.timeAppWentToBackgroundInCurrentSession = currentTime; + [[MParticle sharedInstance].persistenceController updateSession:self.session]; [self processOpenSessionsEndingCurrent:YES completionHandler:^(void) { MPILogVerbose(@"Session ended in the background. New session will begin if an mParticle event is logged or app enters foreground."); }]; - }]; - } + } + }]; } #pragma mark Application Lifecycle From 181399b87fe71508e767c881c4e20e9228c8bed1 Mon Sep 17 00:00:00 2001 From: denischilik Date: Mon, 16 Feb 2026 10:16:48 -0500 Subject: [PATCH 14/19] fix: PreferredLanguages may be empty (#583) fix: array may be empty (cherry picked from commit 5a538c3b3fc67444b76b8e3e756ec3f520c1d23c) --- mParticle-Apple-SDK-Swift/Sources/Utils/MPDevice.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mParticle-Apple-SDK-Swift/Sources/Utils/MPDevice.swift b/mParticle-Apple-SDK-Swift/Sources/Utils/MPDevice.swift index 87a636fb4..ce48acd8a 100644 --- a/mParticle-Apple-SDK-Swift/Sources/Utils/MPDevice.swift +++ b/mParticle-Apple-SDK-Swift/Sources/Utils/MPDevice.swift @@ -145,7 +145,7 @@ public class MPDevice: NSObject, NSCopying { @objc public var language: String? { // Extra logic added to strip out the country code to stay consistent with earlier iOS releases - guard let subString = Locale.preferredLanguages[0].split(separator: "-").first else { + guard let subString = Locale.preferredLanguages.first?.split(separator: "-").first else { return nil } From 0eb5fb5c7262d775b925f0d7e617fcec2dea2a84 Mon Sep 17 00:00:00 2001 From: James Newman Date: Mon, 16 Feb 2026 10:35:40 -0500 Subject: [PATCH 15/19] fix: MPNetworkCommunication background task (#584) * fix: Dispatch MPNetworkCommunication main thread * Update MPBackendControllerTests.m (cherry picked from commit dfab795e2bd68b6eb51f5cb0e5bcf1f551ae1182) --- .../ObjCTests/MPBackendControllerTests.m | 5 +- .../ObjCTests/MPNetworkCommunicationTests.m | 498 ++++++++++++------ .../Network/MPNetworkCommunication.m | 378 ++++++------- 3 files changed, 524 insertions(+), 357 deletions(-) diff --git a/UnitTests/ObjCTests/MPBackendControllerTests.m b/UnitTests/ObjCTests/MPBackendControllerTests.m index cb682994a..c98ee9a6c 100644 --- a/UnitTests/ObjCTests/MPBackendControllerTests.m +++ b/UnitTests/ObjCTests/MPBackendControllerTests.m @@ -126,11 +126,14 @@ @implementation MPBackendControllerTests - (void)setUp { [super setUp]; - messageQueue = [MParticle messageQueue]; [MPPersistenceController_PRIVATE setMpid:@1]; [MParticle sharedInstance].persistenceController = [[MPPersistenceController_PRIVATE alloc] init]; + // Must read messageQueue AFTER [MParticle sharedInstance] triggers singleton + // recreation, otherwise we get the old executor's queue. + messageQueue = [MParticle messageQueue]; + [MParticle sharedInstance].stateMachine.apiKey = @"unit_test_app_key"; [MParticle sharedInstance].stateMachine.secret = @"unit_test_secret"; diff --git a/UnitTests/ObjCTests/MPNetworkCommunicationTests.m b/UnitTests/ObjCTests/MPNetworkCommunicationTests.m index d0ff043e4..cf64c6c1b 100644 --- a/UnitTests/ObjCTests/MPNetworkCommunicationTests.m +++ b/UnitTests/ObjCTests/MPNetworkCommunicationTests.m @@ -31,9 +31,16 @@ @interface MPNetworkCommunication_PRIVATE () - (NSNumber *)maxAgeForCache:(nonnull NSString *)cache; - (BOOL)performMessageUpload:(MPUpload *)upload; - (BOOL)performAliasUpload:(MPUpload *)upload; +- (UIBackgroundTaskIdentifier)beginSafeBackgroundTaskWithExpirationHandler:(void(^_Nullable)(void))handler; +- (void)endSafeBackgroundTask:(UIBackgroundTaskIdentifier)taskId; +@property (nonatomic) BOOL identifying; @end +@interface MPApplication_PRIVATE(Tests) ++ (void)setMockApplication:(id)mockApplication; +@end + @interface MPNetworkCommunicationTests : MPBaseTestCase @end @@ -44,10 +51,10 @@ @implementation MPNetworkCommunicationTests - (void)setUp { [super setUp]; - + [MParticle sharedInstance].stateMachine.apiKey = @"unit_test_app_key"; [MParticle sharedInstance].stateMachine.secret = @"unit_test_secret"; - + [MParticle sharedInstance].backendController = [[MPBackendController_PRIVATE alloc] initWithDelegate:(id)[MParticle sharedInstance]]; } @@ -71,23 +78,23 @@ - (NSDictionary *)infoDictionary { - (void)testAudienceURL { [self swizzleInstanceMethodForInstancesOfClass:[NSBundle class] selector:@selector(infoDictionary)]; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; NSURL *audienceURL = [networkCommunication audienceURL].url; - + [self deswizzle]; - + XCTAssert([audienceURL.absoluteString rangeOfString:@"/unit_test_app_key/audience?mpid=0"].location != NSNotFound); } - (void)testConfigURL { [self swizzleInstanceMethodForInstancesOfClass:[NSBundle class] selector:@selector(infoDictionary)]; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; NSURL *configURL = [networkCommunication configURL].url; - + [self deswizzle]; - + XCTAssert([configURL.absoluteString rangeOfString:@"/config?av=1.2.3.4.5678%20(bd12345ff)"].location != NSNotFound); XCTAssert(![configURL.accessibilityHint isEqualToString:@"identity"]); } @@ -99,9 +106,9 @@ - (void)testConfigURLWithOptions { [MParticle sharedInstance].networkOptions = options; MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; NSURL *configURL = [networkCommunication configURL].url; - + [self deswizzle]; - + XCTAssert([configURL.absoluteString rangeOfString:@"config.mpproxy.example.com/v4/"].location != NSNotFound); XCTAssert(![configURL.accessibilityHint isEqualToString:@"identity"]); } @@ -114,9 +121,9 @@ - (void)testConfigURLWithOptionsAndOverride { [MParticle sharedInstance].networkOptions = options; MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; NSURL *configURL = [networkCommunication configURL].url; - + [self deswizzle]; - + XCTAssert([configURL.absoluteString rangeOfString:@"config.mpproxy.example.com"].location != NSNotFound); XCTAssert([configURL.absoluteString rangeOfString:@"v4"].location == NSNotFound); XCTAssert(![configURL.accessibilityHint isEqualToString:@"identity"]); @@ -124,12 +131,12 @@ - (void)testConfigURLWithOptionsAndOverride { - (void)testModifyURL { [self swizzleInstanceMethodForInstancesOfClass:[NSBundle class] selector:@selector(infoDictionary)]; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; NSURL *modifyURL = [networkCommunication modifyURL].url; - + [self deswizzle]; - + XCTAssert([modifyURL.absoluteString rangeOfString:@"https://identity.us1.mparticle.com/v1/0/modify"].location != NSNotFound); XCTAssert([modifyURL.accessibilityHint isEqualToString:@"identity"]); } @@ -139,12 +146,12 @@ - (void)testModifyURLWithOptions { MPNetworkOptions *options = [[MPNetworkOptions alloc] init]; options.identityHost = @"identity.mpproxy.example.com"; [MParticle sharedInstance].networkOptions = options; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; NSURL *modifyURL = [networkCommunication modifyURL].url; - + [self deswizzle]; - + XCTAssert([modifyURL.absoluteString rangeOfString:@"https://identity.mpproxy.example.com/v1/0/modify"].location != NSNotFound); XCTAssert([modifyURL.accessibilityHint isEqualToString:@"identity"]); } @@ -153,17 +160,17 @@ - (void)testModifyURLWithOptionsAndTrackingOverride { [self swizzleInstanceMethodForInstancesOfClass:[NSBundle class] selector:@selector(infoDictionary)]; MPStateMachine_PRIVATE *stateMachine = [MParticle sharedInstance].stateMachine; stateMachine.attAuthorizationStatus = @(MPATTAuthorizationStatusAuthorized); - + MPNetworkOptions *options = [[MPNetworkOptions alloc] init]; options.identityHost = @"identity.mpproxy.example.com"; options.identityTrackingHost = @"identity-tracking.mpproxy.example.com"; [MParticle sharedInstance].networkOptions = options; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; NSURL *modifyURL = [networkCommunication modifyURL].url; - + [self deswizzle]; - + XCTAssert([modifyURL.absoluteString rangeOfString:@"https://identity-tracking.mpproxy.example.com/v1/0/modify"].location != NSNotFound); XCTAssert([modifyURL.accessibilityHint isEqualToString:@"identity"]); } @@ -172,19 +179,19 @@ - (void)testEventURLWithOptionsAndOverrideAndEventsOnlyAndTrackingHost { [self swizzleInstanceMethodForInstancesOfClass:[NSBundle class] selector:@selector(infoDictionary)]; MPStateMachine_PRIVATE *stateMachine = [MParticle sharedInstance].stateMachine; stateMachine.attAuthorizationStatus = @(MPATTAuthorizationStatusAuthorized); - + MPNetworkOptions *options = [[MPNetworkOptions alloc] init]; options.eventsHost = @"events.mpproxy.example.com"; options.eventsTrackingHost = @"events-tracking.mpproxy.example.com"; options.eventsOnly = true; [MParticle sharedInstance].networkOptions = options; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPUpload *upload = [[MPUpload alloc] initWithSessionId:nil uploadDictionary:@{} dataPlanId:nil dataPlanVersion:nil uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; NSURL *eventURL = [networkCommunication eventURLForUpload:upload].url; - + [self deswizzle]; - + XCTAssert([eventURL.absoluteString rangeOfString:@"https://events-tracking.mpproxy.example.com/"].location != NSNotFound); XCTAssert([eventURL.absoluteString rangeOfString:@"v1"].location == NSNotFound); XCTAssert([eventURL.absoluteString rangeOfString:@"identity"].location == NSNotFound); @@ -193,13 +200,13 @@ - (void)testEventURLWithOptionsAndOverrideAndEventsOnlyAndTrackingHost { - (void)testAliasURL { [self swizzleInstanceMethodForInstancesOfClass:[NSBundle class] selector:@selector(infoDictionary)]; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPUpload *upload = [[MPUpload alloc] initWithSessionId:nil uploadDictionary:@{} dataPlanId:nil dataPlanVersion:nil uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; NSURL *aliasURL = [networkCommunication aliasURLForUpload:upload].url; - + [self deswizzle]; - + XCTAssert([aliasURL.absoluteString rangeOfString:@"https://nativesdks.us1.mparticle.com/v1/identity/"].location != NSNotFound); XCTAssert([aliasURL.accessibilityHint isEqualToString:@"identity"]); } @@ -209,13 +216,13 @@ - (void)testAliasURLWithOptions { MPNetworkOptions *options = [[MPNetworkOptions alloc] init]; options.eventsHost = @"events.mpproxy.example.com"; [MParticle sharedInstance].networkOptions = options; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPUpload *upload = [[MPUpload alloc] initWithSessionId:nil uploadDictionary:@{} dataPlanId:nil dataPlanVersion:nil uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; NSURL *aliasURL = [networkCommunication aliasURLForUpload:upload].url; - + [self deswizzle]; - + XCTAssert([aliasURL.absoluteString rangeOfString:@"https://events.mpproxy.example.com/v1/identity/"].location != NSNotFound); XCTAssert([aliasURL.accessibilityHint isEqualToString:@"identity"]); } @@ -226,13 +233,13 @@ - (void)testAliasURLWithOptionsAndOverride { options.eventsHost = @"events.mpproxy.example.com"; options.overridesEventsSubdirectory = true; [MParticle sharedInstance].networkOptions = options; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPUpload *upload = [[MPUpload alloc] initWithSessionId:nil uploadDictionary:@{} dataPlanId:nil dataPlanVersion:nil uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; NSURL *aliasURL = [networkCommunication aliasURLForUpload:upload].url; - + [self deswizzle]; - + XCTAssert([aliasURL.absoluteString rangeOfString:@"https://events.mpproxy.example.com/"].location != NSNotFound); XCTAssert([aliasURL.absoluteString rangeOfString:@"v1"].location == NSNotFound); XCTAssert([aliasURL.absoluteString rangeOfString:@"identity"].location == NSNotFound); @@ -245,13 +252,13 @@ - (void)testAliasURLWithEventsOnly { options.eventsHost = @"events.mpproxy.example.com"; options.eventsOnly = true; [MParticle sharedInstance].networkOptions = options; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPUpload *upload = [[MPUpload alloc] initWithSessionId:nil uploadDictionary:@{} dataPlanId:nil dataPlanVersion:nil uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; NSURL *aliasURL = [networkCommunication aliasURLForUpload:upload].url; - + [self deswizzle]; - + XCTAssert([aliasURL.absoluteString rangeOfString:@"https://nativesdks.us1.mparticle.com/v1/identity/"].location != NSNotFound); XCTAssert([aliasURL.accessibilityHint isEqualToString:@"identity"]); } @@ -263,13 +270,13 @@ - (void)testAliasURLWithOptionsAndEventsOnly { options.aliasHost = @"alias.mpproxy.example.com"; options.eventsOnly = true; [MParticle sharedInstance].networkOptions = options; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPUpload *upload = [[MPUpload alloc] initWithSessionId:nil uploadDictionary:@{} dataPlanId:nil dataPlanVersion:nil uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; NSURL *aliasURL = [networkCommunication aliasURLForUpload:upload].url; - + [self deswizzle]; - + XCTAssert([aliasURL.absoluteString rangeOfString:@"https://alias.mpproxy.example.com/v1/identity/"].location != NSNotFound); XCTAssert([aliasURL.accessibilityHint isEqualToString:@"identity"]); } @@ -282,13 +289,13 @@ - (void)testAliasURLWithOptionsAndOverrideAndEventsOnly { options.overridesAliasSubdirectory = true; options.eventsOnly = true; [MParticle sharedInstance].networkOptions = options; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPUpload *upload = [[MPUpload alloc] initWithSessionId:nil uploadDictionary:@{} dataPlanId:nil dataPlanVersion:nil uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; NSURL *aliasURL = [networkCommunication aliasURLForUpload:upload].url; - + [self deswizzle]; - + XCTAssert([aliasURL.absoluteString rangeOfString:@"https://alias.mpproxy.example.com/"].location != NSNotFound); XCTAssert([aliasURL.absoluteString rangeOfString:@"v1"].location == NSNotFound); XCTAssert([aliasURL.absoluteString rangeOfString:@"identity"].location == NSNotFound); @@ -299,7 +306,7 @@ - (void)testAliasURLWithOptionsAndOverrideAndEventsOnlyAndTrackingHost { [self swizzleInstanceMethodForInstancesOfClass:[NSBundle class] selector:@selector(infoDictionary)]; MPStateMachine_PRIVATE *stateMachine = [MParticle sharedInstance].stateMachine; stateMachine.attAuthorizationStatus = @(MPATTAuthorizationStatusAuthorized); - + MPNetworkOptions *options = [[MPNetworkOptions alloc] init]; options.eventsHost = @"events.mpproxy.example.com"; options.eventsTrackingHost = @"events-tracking.mpproxy.example.com"; @@ -308,13 +315,13 @@ - (void)testAliasURLWithOptionsAndOverrideAndEventsOnlyAndTrackingHost { options.overridesAliasSubdirectory = true; options.eventsOnly = true; [MParticle sharedInstance].networkOptions = options; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPUpload *upload = [[MPUpload alloc] initWithSessionId:nil uploadDictionary:@{} dataPlanId:nil dataPlanVersion:nil uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; NSURL *aliasURL = [networkCommunication aliasURLForUpload:upload].url; - + [self deswizzle]; - + XCTAssert([aliasURL.absoluteString rangeOfString:@"https://alias-tracking.mpproxy.example.com/"].location != NSNotFound); XCTAssert([aliasURL.absoluteString rangeOfString:@"v1"].location == NSNotFound); XCTAssert([aliasURL.absoluteString rangeOfString:@"identity"].location == NSNotFound); @@ -346,7 +353,7 @@ - (void)testUploadsArrayZipFail { - (void)testUploadsArrayZipSucceedWithATTNotDetermined { [[MParticle sharedInstance] setATTStatus:MPATTAuthorizationStatusNotDetermined withATTStatusTimestampMillis:nil]; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPUpload *upload = [[MPUpload alloc] initWithSessionId:@1 uploadDictionary:@{kMPDeviceInformationKey: @{}} dataPlanId:@"test" dataPlanVersion:@(1) uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; NSArray *uploads = @[upload]; @@ -355,7 +362,7 @@ - (void)testUploadsArrayZipSucceedWithATTNotDetermined { NSMutableDictionary *uploadDict = [NSJSONSerialization JSONObjectWithData:value options:0 error:nil]; return ([uploadDict[kMPDeviceInformationKey][kMPATT] isEqual: @"not_determined"]); }]]; - + [networkCommunication upload:uploads completionHandler:^{ }]; [mockZip verifyWithDelay:2]; @@ -363,7 +370,7 @@ - (void)testUploadsArrayZipSucceedWithATTNotDetermined { - (void)testUploadsArrayZipSucceedWithATTRestricted { [[MParticle sharedInstance] setATTStatus:MPATTAuthorizationStatusRestricted withATTStatusTimestampMillis:nil]; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPUpload *upload = [[MPUpload alloc] initWithSessionId:@1 uploadDictionary:@{kMPDeviceInformationKey: @{}} dataPlanId:@"test" dataPlanVersion:@(1) uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; NSArray *uploads = @[upload]; @@ -372,7 +379,7 @@ - (void)testUploadsArrayZipSucceedWithATTRestricted { NSMutableDictionary *uploadDict = [NSJSONSerialization JSONObjectWithData:value options:0 error:nil]; return ([uploadDict[kMPDeviceInformationKey][kMPATT] isEqual: @"restricted"]); }]]; - + [networkCommunication upload:uploads completionHandler:^{ }]; [mockZip verifyWithDelay:2]; @@ -380,7 +387,7 @@ - (void)testUploadsArrayZipSucceedWithATTRestricted { - (void)testUploadsArrayZipSucceedWithATTDenied { [[MParticle sharedInstance] setATTStatus:MPATTAuthorizationStatusDenied withATTStatusTimestampMillis:nil]; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPUpload *upload = [[MPUpload alloc] initWithSessionId:@1 uploadDictionary:@{kMPDeviceInformationKey: @{}} dataPlanId:@"test" dataPlanVersion:@(1) uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; NSArray *uploads = @[upload]; @@ -389,7 +396,7 @@ - (void)testUploadsArrayZipSucceedWithATTDenied { NSMutableDictionary *uploadDict = [NSJSONSerialization JSONObjectWithData:value options:0 error:nil]; return ([uploadDict[kMPDeviceInformationKey][kMPATT] isEqual: @"denied"]); }]]; - + [networkCommunication upload:uploads completionHandler:^{ }]; [mockZip verifyWithDelay:2]; @@ -397,7 +404,7 @@ - (void)testUploadsArrayZipSucceedWithATTDenied { - (void)testUploadsArrayZipSucceedWithATTAuthorized { [[MParticle sharedInstance] setATTStatus:MPATTAuthorizationStatusAuthorized withATTStatusTimestampMillis:nil]; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPUpload *upload = [[MPUpload alloc] initWithSessionId:@1 uploadDictionary:@{kMPDeviceInformationKey: @{}} dataPlanId:@"test" dataPlanVersion:@(1) uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; NSArray *uploads = @[upload]; @@ -406,7 +413,7 @@ - (void)testUploadsArrayZipSucceedWithATTAuthorized { NSMutableDictionary *uploadDict = [NSJSONSerialization JSONObjectWithData:value options:0 error:nil]; return ([uploadDict[kMPDeviceInformationKey][kMPATT] isEqual: @"authorized"]); }]]; - + [networkCommunication upload:uploads completionHandler:^{ }]; [mockZip verifyWithDelay:2]; @@ -430,19 +437,19 @@ - (void)testShouldStopEvents { - (void)shouldStopEvents:(int)returnCode shouldStop:(BOOL)shouldStop { id urlResponseMock = OCMClassMock([NSHTTPURLResponse class]); [[[urlResponseMock stub] andReturnValue:OCMOCK_VALUE(returnCode)] statusCode]; - + MPConnectorResponse *response = [[MPConnectorResponse alloc] init]; response.httpResponse = urlResponseMock; - + id mockConnector = OCMClassMock([MPConnector class]); [[[mockConnector stub] andReturn:response] responseFromPostRequestToURL:OCMOCK_ANY message:OCMOCK_ANY serializedParams:OCMOCK_ANY secret:OCMOCK_ANY]; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; id mockNetworkCommunication = OCMPartialMock(networkCommunication); [[[mockNetworkCommunication stub] andReturn:mockConnector] makeConnector]; - + MPUpload *messageUpload = [[MPUpload alloc] initWithSessionId:@1 uploadDictionary:@{} dataPlanId:@"test" dataPlanVersion:@(1) uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; - + BOOL actualShouldStop = [networkCommunication performMessageUpload:messageUpload]; XCTAssertEqual(shouldStop, actualShouldStop, @"Return code assertion: %d", returnCode); } @@ -461,24 +468,24 @@ - (void)testShouldStopAlias { [self shouldStopAlias:500 shouldStop:YES]; [self shouldStopAlias:503 shouldStop:YES]; } - + - (void)shouldStopAlias:(int)returnCode shouldStop:(BOOL)shouldStop { id urlResponseMock = OCMClassMock([NSHTTPURLResponse class]); [[[urlResponseMock stub] andReturnValue:OCMOCK_VALUE(returnCode)] statusCode]; - + MPConnectorResponse *response = [[MPConnectorResponse alloc] init]; response.httpResponse = urlResponseMock; - + id mockConnector = OCMClassMock([MPConnector class]); [[[mockConnector stub] andReturn:response] responseFromPostRequestToURL:OCMOCK_ANY message:OCMOCK_ANY serializedParams:OCMOCK_ANY secret:OCMOCK_ANY]; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; id mockNetworkCommunication = OCMPartialMock(networkCommunication); [[[mockNetworkCommunication stub] andReturn:mockConnector] makeConnector]; - + MPUpload *aliasUpload = [[MPUpload alloc] initWithSessionId:@1 uploadDictionary:@{} dataPlanId:@"test" dataPlanVersion:@(1) uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; aliasUpload.uploadType = MPUploadTypeAlias; - + BOOL actualShouldStop = [networkCommunication performAliasUpload:aliasUpload]; XCTAssertEqual(shouldStop, actualShouldStop, @"Return code assertion: %d", returnCode); } @@ -486,27 +493,27 @@ - (void)shouldStopAlias:(int)returnCode shouldStop:(BOOL)shouldStop { - (void)testOfflineUpload { id urlResponseMock = OCMClassMock([NSHTTPURLResponse class]); [[[urlResponseMock stub] andReturnValue:OCMOCK_VALUE(0)] statusCode]; - + MPConnectorResponse *response = [[MPConnectorResponse alloc] init]; response.httpResponse = urlResponseMock; - + id mockConnector = OCMClassMock([MPConnector class]); [[[mockConnector stub] andReturn:response] responseFromPostRequestToURL:OCMOCK_ANY message:OCMOCK_ANY serializedParams:OCMOCK_ANY secret:OCMOCK_ANY]; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; id mockNetworkCommunication = OCMPartialMock(networkCommunication); [[[mockNetworkCommunication stub] andReturn:mockConnector] makeConnector]; - + id mockPersistenceController = OCMClassMock([MPPersistenceController_PRIVATE class]); [[mockPersistenceController reject] deleteUpload:OCMOCK_ANY]; - + MParticle *instance = [MParticle sharedInstance]; instance.persistenceController = mockPersistenceController; - + MPUpload *eventUpload = [[MPUpload alloc] initWithSessionId:@1 uploadDictionary:@{} dataPlanId:@"test" dataPlanVersion:@(1) uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; MPUpload *aliasUpload = [[MPUpload alloc] initWithSessionId:@1 uploadDictionary:@{} dataPlanId:@"test" dataPlanVersion:@(1) uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; aliasUpload.uploadType = MPUploadTypeAlias; - + NSArray *uploads = @[eventUpload, aliasUpload]; XCTestExpectation *expectation = [self expectationWithDescription:@"async work"]; [networkCommunication upload:uploads completionHandler:^{ @@ -518,26 +525,26 @@ - (void)testOfflineUpload { - (void)testUploadSuccessDeletion { id urlResponseMock = OCMClassMock([NSHTTPURLResponse class]); [[[urlResponseMock stub] andReturnValue:OCMOCK_VALUE(202)] statusCode]; - + MPConnectorResponse *response = [[MPConnectorResponse alloc] init]; response.httpResponse = urlResponseMock; - + id mockConnector = OCMClassMock([MPConnector class]); [[[mockConnector stub] andReturn:response] responseFromPostRequestToURL:OCMOCK_ANY message:OCMOCK_ANY serializedParams:OCMOCK_ANY secret:OCMOCK_ANY]; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; id mockNetworkCommunication = OCMPartialMock(networkCommunication); [[[mockNetworkCommunication stub] andReturn:mockConnector] makeConnector]; - + id mockPersistenceController = OCMClassMock([MPPersistenceController_PRIVATE class]); - + MPUpload *eventUpload = [[MPUpload alloc] initWithSessionId:@1 uploadDictionary:@{kMPDeviceInformationKey: @{}} dataPlanId:@"test" dataPlanVersion:@(1) uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; MPUpload *aliasUpload = [[MPUpload alloc] initWithSessionId:@1 uploadDictionary:@{} dataPlanId:@"test" dataPlanVersion:@(1) uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; aliasUpload.uploadType = MPUploadTypeAlias; - + [[mockPersistenceController expect] deleteUpload:eventUpload]; [[mockPersistenceController expect] deleteUpload:aliasUpload]; - + MParticle *instance = [MParticle sharedInstance]; id mockInstance = OCMPartialMock(instance); [(MParticle *)[mockInstance expect] logKitBatch:[OCMArg checkWithBlock:^BOOL(id obj) { @@ -547,7 +554,7 @@ - (void)testUploadSuccessDeletion { return NO; }]]; ((MParticle *)mockInstance).persistenceController = mockPersistenceController; - + NSArray *uploads = @[eventUpload, aliasUpload]; XCTestExpectation *expectation = [self expectationWithDescription:@"async work"]; [networkCommunication upload:uploads completionHandler:^{ @@ -560,33 +567,33 @@ - (void)testUploadSuccessDeletion { - (void)testUploadInvalidDeletion { id urlResponseMock = OCMClassMock([NSHTTPURLResponse class]); [[[urlResponseMock stub] andReturnValue:OCMOCK_VALUE(400)] statusCode]; - + MPConnectorResponse *response = [[MPConnectorResponse alloc] init]; response.httpResponse = urlResponseMock; - + id mockConnector = OCMClassMock([MPConnector class]); [[[mockConnector stub] andReturn:response] responseFromPostRequestToURL:OCMOCK_ANY message:OCMOCK_ANY serializedParams:OCMOCK_ANY secret:OCMOCK_ANY]; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; id mockNetworkCommunication = OCMPartialMock(networkCommunication); [[[mockNetworkCommunication stub] andReturn:mockConnector] makeConnector]; - + id mockPersistenceController = OCMClassMock([MPPersistenceController_PRIVATE class]); - + MPUpload *eventUpload = [[MPUpload alloc] initWithSessionId:@1 uploadDictionary:@{} dataPlanId:@"test" dataPlanVersion:@(1) uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; MPUpload *aliasUpload = [[MPUpload alloc] initWithSessionId:@1 uploadDictionary:@{} dataPlanId:@"test" dataPlanVersion:@(1) uploadSettings:[MPUploadSettings currentUploadSettingsWithStateMachine:[MParticle sharedInstance].stateMachine networkOptions:[MParticle sharedInstance].networkOptions]]; aliasUpload.uploadType = MPUploadTypeAlias; - + [[mockPersistenceController expect] deleteUpload:eventUpload]; [[mockPersistenceController expect] deleteUpload:aliasUpload]; - + MParticle *instance = [MParticle sharedInstance]; id mockInstance = OCMPartialMock(instance); [(MParticle *)[mockInstance expect] logKitBatch:[OCMArg checkWithBlock:^BOOL(id obj) { return NO; // reject }]]; ((MParticle *)mockInstance).persistenceController = mockPersistenceController; - + NSArray *uploads = @[eventUpload, aliasUpload]; XCTestExpectation *expectation = [self expectationWithDescription:@"async work"]; [networkCommunication upload:uploads completionHandler:^{ @@ -599,16 +606,16 @@ - (void)testRequestConfigWithDefaultMaxAge { MPUserDefaults *userDefaults = MPUserDefaultsConnector.userDefaults; NSNumber *configProvisioned = userDefaults[kMPConfigProvisionedTimestampKey]; NSNumber *maxAge = userDefaults[kMPConfigMaxAgeHeaderKey]; - + XCTAssertEqualObjects(configProvisioned, nil); XCTAssertEqualObjects(maxAge, nil); - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; NSURL *configURL = [networkCommunication configURL].url; - + MPConnector *connector = [[MPConnector alloc] init]; id mockConnector = OCMPartialMock(connector); - + NSDictionary *httpHeaders = @{@"Age": @"0", kMPHTTPETagHeaderKey: @"242f22f24c224" }; @@ -618,39 +625,39 @@ - (void)testRequestConfigWithDefaultMaxAge { @"appId":@"cool app key" } }; - + NSDictionary *configuration2 = @{ @"id":@312, @"as":@{ @"appId":@"cool app key 2" } }; - + NSArray *kitConfigs = @[configuration1, configuration2]; - + NSDictionary *responseConfiguration = @{kMPRemoteConfigKitsKey:kitConfigs, kMPMessageTypeKey:kMPMessageTypeConfig, kMPRemoteConfigRampKey:@100, kMPRemoteConfigExceptionHandlingModeKey:kMPRemoteConfigExceptionHandlingModeForce, kMPRemoteConfigSessionTimeoutKey:@112}; NSError *error; - + MPConnectorResponse *response = [[MPConnectorResponse alloc] init]; NSHTTPURLResponse *httpResponse = [[NSHTTPURLResponse alloc] initWithURL:configURL statusCode:HTTPStatusCodeSuccess HTTPVersion:@"" headerFields:httpHeaders]; response.httpResponse = httpResponse; response.data = [NSJSONSerialization dataWithJSONObject:responseConfiguration options:NSJSONWritingPrettyPrinted error:&error]; - + [[[mockConnector stub] andReturn:response] responseFromGetRequestToURL:[networkCommunication configURL]]; [networkCommunication requestConfig:mockConnector withCompletionHandler:^(BOOL success) { XCTAssert(success); }]; - + configProvisioned = userDefaults[kMPConfigProvisionedTimestampKey]; maxAge = userDefaults[kMPConfigMaxAgeHeaderKey]; - + XCTAssertNotNil(configProvisioned); XCTAssertNil(maxAge); } @@ -658,13 +665,13 @@ - (void)testRequestConfigWithDefaultMaxAge { - (void)testRequestConfigWithManualMaxAge { MPUserDefaults *userDefaults = MPUserDefaultsConnector.userDefaults; userDefaults[kMPConfigProvisionedTimestampKey] = @5555; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; NSURL *configURL = [networkCommunication configURL].url; MPConnector *connector = [[MPConnector alloc] init]; id mockConnector = OCMPartialMock(connector); - + NSDictionary *httpHeaders = @{@"Age": @"0", kMPHTTPETagHeaderKey: @"242f22f24c224", kMPHTTPCacheControlHeaderKey: @"max-age=43200" @@ -675,36 +682,36 @@ - (void)testRequestConfigWithManualMaxAge { @"appId":@"cool app key" } }; - + NSDictionary *configuration2 = @{ @"id":@312, @"as":@{ @"appId":@"cool app key 2" } }; - + NSArray *kitConfigs = @[configuration1, configuration2]; - + NSDictionary *responseConfiguration = @{kMPRemoteConfigKitsKey:kitConfigs, kMPMessageTypeKey:kMPMessageTypeConfig, kMPRemoteConfigRampKey:@100, kMPRemoteConfigExceptionHandlingModeKey:kMPRemoteConfigExceptionHandlingModeForce, kMPRemoteConfigSessionTimeoutKey:@112}; NSError *error; - + MPConnectorResponse *response = [[MPConnectorResponse alloc] init]; NSHTTPURLResponse *httpResponse = [[NSHTTPURLResponse alloc] initWithURL:configURL statusCode:HTTPStatusCodeSuccess HTTPVersion:@"" headerFields:httpHeaders]; response.httpResponse = httpResponse; response.data = [NSJSONSerialization dataWithJSONObject:responseConfiguration options:NSJSONWritingPrettyPrinted error:&error]; - + [[[mockConnector stub] andReturn:response] responseFromGetRequestToURL:[networkCommunication configURL]]; [networkCommunication requestConfig:mockConnector withCompletionHandler:^(BOOL success) { XCTAssert(success); }]; - + NSNumber *maxAge = userDefaults[kMPConfigMaxAgeHeaderKey]; XCTAssertEqualObjects(maxAge, @43200); @@ -713,39 +720,39 @@ - (void)testRequestConfigWithManualMaxAge { - (void)testRequestConfigWithManualMaxAgeAndInitialAge { MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; NSURL *configURL = [networkCommunication configURL].url; - + MPConnector *connector = [[MPConnector alloc] init]; id mockConnector = OCMPartialMock(connector); - + NSDictionary *httpHeaders = @{@"age": @"4000", kMPMessageTypeKey:kMPMessageTypeConfig, kMPHTTPETagHeaderKey: @"242f22f24c224", kMPHTTPCacheControlHeaderKey: @"max-age=43200" }; - + NSDictionary *configuration1 = @{ @"id":@42, @"as":@{ @"appId":@"cool app key" } }; - + NSDictionary *configuration2 = @{ @"id":@312, @"as":@{ @"appId":@"cool app key 2" } }; - + NSArray *kitConfigs = @[configuration1, configuration2]; - + NSDictionary *responseConfiguration = @{kMPRemoteConfigKitsKey:kitConfigs, kMPRemoteConfigRampKey:@100, kMPMessageTypeKey:kMPMessageTypeConfig, kMPRemoteConfigExceptionHandlingModeKey:kMPRemoteConfigExceptionHandlingModeForce, kMPRemoteConfigSessionTimeoutKey:@112}; NSError *error; - + MPConnectorResponse *response = [[MPConnectorResponse alloc] init]; NSHTTPURLResponse *httpResponse = [[NSHTTPURLResponse alloc] initWithURL:configURL statusCode:HTTPStatusCodeSuccess HTTPVersion:@"" headerFields:httpHeaders]; response.httpResponse = httpResponse; @@ -753,19 +760,19 @@ - (void)testRequestConfigWithManualMaxAgeAndInitialAge { options:NSJSONWritingPrettyPrinted error:&error]; - + [[[mockConnector stub] andReturn:response] responseFromGetRequestToURL:[networkCommunication configURL]]; - + [networkCommunication requestConfig:mockConnector withCompletionHandler:^(BOOL success) { XCTAssert(success); }]; - + MPUserDefaults *userDefaults = MPUserDefaultsConnector.userDefaults; [userDefaults synchronize]; - + NSNumber *provisionedInterval = userDefaults[kMPConfigProvisionedTimestampKey]; int approximateAge = ([[NSDate date] timeIntervalSince1970] - [provisionedInterval integerValue]); - + XCTAssertLessThanOrEqual(4000, approximateAge); XCTAssertLessThan(approximateAge, 4200); } @@ -773,13 +780,13 @@ - (void)testRequestConfigWithManualMaxAgeAndInitialAge { - (void)testRequestConfigWithManualMaxAgeOverMaxAllowed { MPUserDefaults *userDefaults = MPUserDefaultsConnector.userDefaults; userDefaults[kMPConfigProvisionedTimestampKey] = @5555; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; NSURL *configURL = [networkCommunication configURL].url; - + MPConnector *connector = [[MPConnector alloc] init]; id mockConnector = OCMPartialMock(connector); - + NSDictionary *httpHeaders = @{@"Age": @"0", kMPMessageTypeKey:kMPMessageTypeConfig, kMPHTTPETagHeaderKey: @"242f22f24c224", @@ -791,52 +798,52 @@ - (void)testRequestConfigWithManualMaxAgeOverMaxAllowed { @"appId":@"cool app key" } }; - + NSDictionary *configuration2 = @{ @"id":@312, @"as":@{ @"appId":@"cool app key 2" } }; - + NSArray *kitConfigs = @[configuration1, configuration2]; - + NSDictionary *responseConfiguration = @{kMPRemoteConfigKitsKey:kitConfigs, kMPMessageTypeKey:kMPMessageTypeConfig, kMPRemoteConfigRampKey:@100, kMPRemoteConfigExceptionHandlingModeKey:kMPRemoteConfigExceptionHandlingModeForce, kMPRemoteConfigSessionTimeoutKey:@112}; NSError *error; - + MPConnectorResponse *response = [[MPConnectorResponse alloc] init]; NSHTTPURLResponse *httpResponse = [[NSHTTPURLResponse alloc] initWithURL:configURL statusCode:HTTPStatusCodeSuccess HTTPVersion:@"" headerFields:httpHeaders]; response.httpResponse = httpResponse; response.data = [NSJSONSerialization dataWithJSONObject:responseConfiguration options:NSJSONWritingPrettyPrinted error:&error]; - + [[[mockConnector stub] andReturn:response] responseFromGetRequestToURL:[networkCommunication configURL]]; - + [networkCommunication requestConfig:mockConnector withCompletionHandler:^(BOOL success) { XCTAssert(success); }]; - + NSNumber *maxAge = userDefaults[kMPConfigMaxAgeHeaderKey]; NSNumber *maxExpiration = @(60*60*24.0); - + XCTAssertEqualObjects(maxAge, maxExpiration); } - (void)testRequestConfigWithComplexCacheControlHeader { MPUserDefaults *userDefaults = MPUserDefaultsConnector.userDefaults; userDefaults[kMPConfigProvisionedTimestampKey] = @5555; - + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; NSURL *configURL = [networkCommunication configURL].url; - + MPConnector *connector = [[MPConnector alloc] init]; id mockConnector = OCMPartialMock(connector); - + NSDictionary *httpHeaders = @{@"Age": @"0", kMPMessageTypeKey:kMPMessageTypeConfig, kMPHTTPETagHeaderKey: @"242f22f24c224", @@ -848,38 +855,38 @@ - (void)testRequestConfigWithComplexCacheControlHeader { @"appId":@"cool app key" } }; - + NSDictionary *configuration2 = @{ @"id":@312, @"as":@{ @"appId":@"cool app key 2" } }; - + NSArray *kitConfigs = @[configuration1, configuration2]; - + NSDictionary *responseConfiguration = @{kMPRemoteConfigKitsKey:kitConfigs, kMPMessageTypeKey:kMPMessageTypeConfig, kMPRemoteConfigRampKey:@100, kMPRemoteConfigExceptionHandlingModeKey:kMPRemoteConfigExceptionHandlingModeForce, kMPRemoteConfigSessionTimeoutKey:@112}; NSError *error; - + MPConnectorResponse *response = [[MPConnectorResponse alloc] init]; NSHTTPURLResponse *httpResponse = [[NSHTTPURLResponse alloc] initWithURL:configURL statusCode:HTTPStatusCodeSuccess HTTPVersion:@"" headerFields:httpHeaders]; response.httpResponse = httpResponse; response.data = [NSJSONSerialization dataWithJSONObject:responseConfiguration options:NSJSONWritingPrettyPrinted error:&error]; - + [[[mockConnector stub] andReturn:response] responseFromGetRequestToURL:[networkCommunication configURL]]; - + [networkCommunication requestConfig:mockConnector withCompletionHandler:^(BOOL success) { XCTAssert(success); }]; - + NSNumber *maxAge = userDefaults[kMPConfigMaxAgeHeaderKey]; - + XCTAssertEqualObjects(maxAge, @43200); } @@ -892,35 +899,35 @@ - (void)testMaxAgeForCacheEmptyString { - (void)testMaxAgeForCacheSimple { MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; - + NSString *test2 = @"max-age=12"; XCTAssertEqualObjects([networkCommunication maxAgeForCache:test2], @12); } - (void)testMaxAgeForCacheMultiValue1 { MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; - + NSString *test3 = @"max-age=13, max-stale=7"; XCTAssertEqualObjects([networkCommunication maxAgeForCache:test3], @13); } - (void)testMaxAgeForCacheMultiValue2 { MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; - + NSString *test4 = @"max-stale=34, max-age=14"; XCTAssertEqualObjects([networkCommunication maxAgeForCache:test4], @14); } - (void)testMaxAgeForCacheMultiValue3 { MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; - + NSString *test4 = @"max-stale=33434344, max-age=15, min-fresh=3553553"; XCTAssertEqualObjects([networkCommunication maxAgeForCache:test4], @15); } - (void)testMaxAgeForCacheCapitalization { MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; - + NSString *test5 = @"max-stale=34, MAX-age=16, min-fresh=3553553"; XCTAssertEqualObjects([networkCommunication maxAgeForCache:test5], @16); } @@ -938,28 +945,215 @@ - (void)testPodURLRoutingAndTrackingURL { ]; MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; MPStateMachine_PRIVATE *stateMachine = [MParticle sharedInstance].stateMachine; - + stateMachine.attAuthorizationStatus = @(MPATTAuthorizationStatusNotDetermined); for (NSArray *test in testKeys) { NSString *key = test[0]; stateMachine.apiKey = key; NSString *eventHost = test[1]; NSString *identityHost = test[2]; - + XCTAssertEqualObjects(eventHost, [networkCommunication defaultEventHost]); XCTAssertEqualObjects(identityHost, [networkCommunication defaultIdentityHost]); } - + stateMachine.attAuthorizationStatus = @(MPATTAuthorizationStatusAuthorized); for (NSArray *test in testKeys) { NSString *key = test[0]; stateMachine.apiKey = key; NSString *eventHost = test[3]; NSString *identityHost = test[4]; - + XCTAssertEqualObjects(eventHost, [networkCommunication defaultEventHost]); XCTAssertEqualObjects(identityHost, [networkCommunication defaultIdentityHost]); } } +#pragma mark - Background Task Tests + +- (void)testBeginSafeBackgroundTaskDispatchesToMainThread { + __block BOOL beginCalledOnMainThread = NO; + + id mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication beginBackgroundTaskWithExpirationHandler:OCMOCK_ANY]).andDo(^(NSInvocation *invocation) { + beginCalledOnMainThread = [NSThread isMainThread]; + UIBackgroundTaskIdentifier taskId = 42; + [invocation setReturnValue:&taskId]; + }); + + [MPApplication_PRIVATE setMockApplication:mockApplication]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background queue completed"]; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + MPNetworkCommunication_PRIVATE *nc = [[MPNetworkCommunication_PRIVATE alloc] init]; + UIBackgroundTaskIdentifier taskId = [nc beginSafeBackgroundTaskWithExpirationHandler:nil]; + XCTAssertEqual(taskId, (UIBackgroundTaskIdentifier)42); + [expectation fulfill]; + }); + + [self waitForExpectations:@[expectation] timeout:5.0]; + + XCTAssertTrue(beginCalledOnMainThread, @"beginBackgroundTaskWithExpirationHandler: must be called on the main thread"); + + [MPApplication_PRIVATE setMockApplication:nil]; +} + +- (void)testEndSafeBackgroundTaskDispatchesToMainThread { + __block BOOL endCalledOnMainThread = NO; + + id mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication endBackgroundTask:(UIBackgroundTaskIdentifier)42]).andDo(^(NSInvocation *invocation) { + endCalledOnMainThread = [NSThread isMainThread]; + }); + + [MPApplication_PRIVATE setMockApplication:mockApplication]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Background queue completed"]; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + MPNetworkCommunication_PRIVATE *nc = [[MPNetworkCommunication_PRIVATE alloc] init]; + [nc endSafeBackgroundTask:42]; + [expectation fulfill]; + }); + + [self waitForExpectations:@[expectation] timeout:5.0]; + + // Give main queue time to process the async dispatch + XCTestExpectation *mainQueueDrained = [self expectationWithDescription:@"Main queue drained"]; + dispatch_async(dispatch_get_main_queue(), ^{ + [mainQueueDrained fulfill]; + }); + [self waitForExpectations:@[mainQueueDrained] timeout:2.0]; + + XCTAssertTrue(endCalledOnMainThread, @"endBackgroundTask: must be called on the main thread"); + + [MPApplication_PRIVATE setMockApplication:nil]; +} + +- (void)testRequestConfigCallsBeginAndEndBackgroundTask { + __block BOOL beginCalled = NO; + __block BOOL endCalled = NO; + + id mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication beginBackgroundTaskWithExpirationHandler:OCMOCK_ANY]).andDo(^(NSInvocation *invocation) { + beginCalled = YES; + UIBackgroundTaskIdentifier taskId = 42; + [invocation setReturnValue:&taskId]; + }); + OCMStub([mockApplication endBackgroundTask:(UIBackgroundTaskIdentifier)42]).andDo(^(NSInvocation *invocation) { + endCalled = YES; + }); + + [MPApplication_PRIVATE setMockApplication:mockApplication]; + + int statusCode = 200; + id urlResponseMock = OCMClassMock([NSHTTPURLResponse class]); + [[[urlResponseMock stub] andReturnValue:OCMOCK_VALUE(statusCode)] statusCode]; + [[[urlResponseMock stub] andReturn:@{}] allHeaderFields]; + + MPConnectorResponse *response = [[MPConnectorResponse alloc] init]; + response.httpResponse = urlResponseMock; + + id mockConnector = OCMClassMock([MPConnector class]); + [[[mockConnector stub] andReturn:response] responseFromGetRequestToURL:OCMOCK_ANY]; + + MPNetworkCommunication_PRIVATE *networkCommunication = [[MPNetworkCommunication_PRIVATE alloc] init]; + id mockNetworkCommunication = OCMPartialMock(networkCommunication); + [[[mockNetworkCommunication stub] andReturn:mockConnector] makeConnector]; + + [networkCommunication requestConfig:mockConnector withCompletionHandler:^(BOOL success) {}]; + + // Give main queue time to process async endBackgroundTask dispatch + XCTestExpectation *mainQueueDrained = [self expectationWithDescription:@"Main queue drained"]; + dispatch_async(dispatch_get_main_queue(), ^{ + [mainQueueDrained fulfill]; + }); + [self waitForExpectations:@[mainQueueDrained] timeout:2.0]; + + XCTAssertTrue(beginCalled, @"beginBackgroundTaskWithExpirationHandler: should be called during config request"); + XCTAssertTrue(endCalled, @"endBackgroundTask: should be called after config request completes"); + + [MPApplication_PRIVATE setMockApplication:nil]; +} + +- (void)testExpirationHandlerEndsTask { + __block void (^capturedExpirationHandler)(void) = nil; + + id mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication beginBackgroundTaskWithExpirationHandler:([OCMArg checkWithBlock:^BOOL(id obj) { + capturedExpirationHandler = [obj copy]; + return YES; + }])]).andDo(^(NSInvocation *invocation) { + UIBackgroundTaskIdentifier taskId = 42; + [invocation setReturnValue:&taskId]; + }); + OCMStub([mockApplication endBackgroundTask:(UIBackgroundTaskIdentifier)42]); + + [MPApplication_PRIVATE setMockApplication:mockApplication]; + + MPNetworkCommunication_PRIVATE *nc = [[MPNetworkCommunication_PRIVATE alloc] init]; + [nc beginSafeBackgroundTaskWithExpirationHandler:nil]; + + XCTAssertNotNil(capturedExpirationHandler, @"Expiration handler should have been captured"); + + capturedExpirationHandler(); + + OCMVerify([mockApplication endBackgroundTask:(UIBackgroundTaskIdentifier)42]); + + [MPApplication_PRIVATE setMockApplication:nil]; +} + +- (void)testIdentityExpirationHandlerSetsIdentifyingNO { + __block void (^capturedExpirationHandler)(void) = nil; + + id mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication beginBackgroundTaskWithExpirationHandler:([OCMArg checkWithBlock:^BOOL(id obj) { + capturedExpirationHandler = [obj copy]; + return YES; + }])]).andDo(^(NSInvocation *invocation) { + UIBackgroundTaskIdentifier taskId = 42; + [invocation setReturnValue:&taskId]; + }); + OCMStub([mockApplication endBackgroundTask:(UIBackgroundTaskIdentifier)42]); + + [MPApplication_PRIVATE setMockApplication:mockApplication]; + + MPNetworkCommunication_PRIVATE *nc = [[MPNetworkCommunication_PRIVATE alloc] init]; + nc.identifying = YES; + + [nc beginSafeBackgroundTaskWithExpirationHandler:^{ + nc.identifying = NO; + }]; + + XCTAssertNotNil(capturedExpirationHandler, @"Expiration handler should have been captured"); + XCTAssertTrue(nc.identifying, @"identifying should still be YES before expiration"); + + capturedExpirationHandler(); + + XCTAssertFalse(nc.identifying, @"identifying should be NO after expiration handler fires"); + OCMVerify([mockApplication endBackgroundTask:(UIBackgroundTaskIdentifier)42]); + + [MPApplication_PRIVATE setMockApplication:nil]; +} + +- (void)testBackgroundTaskSkippedInAppExtension { + id mockStateMachine = OCMClassMock([MPStateMachine_PRIVATE class]); + OCMStub([mockStateMachine isAppExtension]).andReturn(YES); + + id mockApplication = OCMClassMock([UIApplication class]); + [[mockApplication reject] beginBackgroundTaskWithExpirationHandler:OCMOCK_ANY]; + + [MPApplication_PRIVATE setMockApplication:mockApplication]; + + MPNetworkCommunication_PRIVATE *nc = [[MPNetworkCommunication_PRIVATE alloc] init]; + UIBackgroundTaskIdentifier taskId = [nc beginSafeBackgroundTaskWithExpirationHandler:nil]; + + XCTAssertEqual(taskId, UIBackgroundTaskInvalid, @"Should return UIBackgroundTaskInvalid in app extension"); + OCMVerifyAll(mockApplication); + + [mockStateMachine stopMocking]; + [MPApplication_PRIVATE setMockApplication:nil]; +} + @end diff --git a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m index 041687959..6d172972c 100644 --- a/mParticle-Apple-SDK/Network/MPNetworkCommunication.m +++ b/mParticle-Apple-SDK/Network/MPNetworkCommunication.m @@ -68,6 +68,8 @@ @interface MParticle () @property (nonatomic, strong, readonly) MPBackendController_PRIVATE *backendController; - (void)logKitBatch:(NSString *)batch; ++ (void)executeOnMain:(void(^)(void))block; ++ (void)executeOnMainSync:(void(^)(void))block; @end @@ -109,9 +111,9 @@ - (instancetype)init { if (!self) { return nil; } - + self.identifying = NO; - + return self; } @@ -148,11 +150,11 @@ - (MPURL *)configURL { if (_configURL) { return _configURL; } - + MPStateMachine_PRIVATE *stateMachine = [MParticle sharedInstance].stateMachine; MPApplication_PRIVATE *application = [[MPApplication_PRIVATE alloc] init]; NSString *configHost = [MParticle sharedInstance].networkOptions.configHost ?: kMPURLHostConfig; - + NSString *dataPlanConfigString; NSString *dataPlanId = MParticle.sharedInstance.dataPlanId; if (dataPlanId != nil) { @@ -172,7 +174,7 @@ - (MPURL *)configURL { NSURL *defaultURL = [NSURL URLWithString:urlString]; urlString = [NSString stringWithFormat:configURLFormat, kMPURLScheme, configHost, kMPConfigVersion, stateMachine.apiKey, kMPConfigURL, [application.version percentEscape], kMParticleSDKVersion]; - + if ([MParticle sharedInstance].networkOptions.overridesConfigSubdirectory) { NSString *configURLFormat = [urlFormatOverride stringByAppendingString:@"?av=%@&sv=%@"]; urlString = [NSString stringWithFormat:configURLFormat, kMPURLScheme, configHost, stateMachine.apiKey, kMPConfigURL, [application.version percentEscape], kMParticleSDKVersion]; @@ -180,7 +182,7 @@ - (MPURL *)configURL { if (dataPlanConfigString) { urlString = [NSString stringWithFormat:@"%@%@", urlString, dataPlanConfigString]; } - + NSURL *modifiedURL = [NSURL URLWithString:urlString]; if (modifiedURL && defaultURL) { _configURL = [[MPURL alloc] initWithURL:modifiedURL defaultURL:defaultURL]; @@ -198,13 +200,13 @@ - (MPURL *)eventURLForUpload:(MPUpload *)mpUpload { } NSString *urlString = [NSString stringWithFormat:urlFormat, kMPURLScheme, self.defaultEventHost, kMPEventsVersion, mpUpload.uploadSettings.apiKey, kMPEventsURL]; NSURL *defaultURL = [NSURL URLWithString:urlString]; - + if (mpUpload.uploadSettings.overridesEventsSubdirectory) { urlString = [NSString stringWithFormat:urlFormatOverride, kMPURLScheme, eventHost, mpUpload.uploadSettings.apiKey, kMPEventsURL]; } else { urlString = [NSString stringWithFormat:urlFormat, kMPURLScheme, eventHost, kMPEventsVersion, mpUpload.uploadSettings.apiKey, kMPEventsURL]; } - + NSURL *modifiedURL = [NSURL URLWithString:urlString]; MPURL *eventURL; if (modifiedURL && defaultURL) { @@ -215,7 +217,7 @@ - (MPURL *)eventURLForUpload:(MPUpload *)mpUpload { - (MPURL *)audienceURL { MPStateMachine_PRIVATE *stateMachine = [MParticle sharedInstance].stateMachine; - + NSString *eventHost = [MParticle sharedInstance].networkOptions.eventsHost ?: self.defaultEventHost; NSString *audienceURLFormat = [audienceFormat stringByAppendingString:@"?mpid=%@"]; NSString *urlString = [NSString stringWithFormat:audienceURLFormat, kMPURLScheme, self.defaultEventHost, kMPAudienceVersion, stateMachine.apiKey, kMPAudienceURL, [MPPersistenceController_PRIVATE mpId]]; @@ -228,16 +230,16 @@ - (MPURL *)audienceURL { audienceURLFormat = [urlFormat stringByAppendingString:@"?mpid=%@"]; urlString = [NSString stringWithFormat:audienceURLFormat, kMPURLScheme, eventHost, kMPAudienceVersion, stateMachine.apiKey, kMPAudienceURL, [MPPersistenceController_PRIVATE mpId]]; } - + NSURL *modifiedURL = [NSURL URLWithString:urlString]; defaultURL.accessibilityHint = @"audience"; modifiedURL.accessibilityHint = @"audience"; - + MPURL *audienceURL; if (modifiedURL && defaultURL) { audienceURL = [[MPURL alloc] initWithURL:modifiedURL defaultURL:defaultURL]; } - + return audienceURL; } @@ -245,9 +247,9 @@ - (MPURL *)identifyURL { if (_identifyURL) { return _identifyURL; } - + _identifyURL = [self identityURL:@"identify"]; - + return _identifyURL; } @@ -255,9 +257,9 @@ - (MPURL *)loginURL { if (_loginURL) { return _loginURL; } - + _loginURL = [self identityURL:@"login"]; - + return _loginURL; } @@ -265,9 +267,9 @@ - (MPURL *)logoutURL { if (_logoutURL) { return _logoutURL; } - + _logoutURL = [self identityURL:@"logout"]; - + return _logoutURL; } @@ -281,22 +283,22 @@ - (MPURL *)identityURL:(NSString *)pathComponent { } NSString *urlString = [NSString stringWithFormat:identityURLFormat, kMPURLScheme, self.defaultIdentityHost, kMPIdentityVersion, pathComponent]; NSURL *defaultURL = [NSURL URLWithString:urlString]; - + if ([MParticle sharedInstance].networkOptions.overridesIdentitySubdirectory) { urlString = [NSString stringWithFormat:identityURLFormatOverride, kMPURLScheme, identityHost, pathComponent]; } else { urlString = [NSString stringWithFormat:identityURLFormat, kMPURLScheme, identityHost, kMPIdentityVersion, pathComponent]; } - + NSURL *modifiedURL = [NSURL URLWithString:urlString]; defaultURL.accessibilityHint = @"identity"; modifiedURL.accessibilityHint = @"identity"; - + MPURL *identityURL; if (modifiedURL && defaultURL) { identityURL = [[MPURL alloc] initWithURL:modifiedURL defaultURL:defaultURL]; } - + return identityURL; } @@ -321,18 +323,18 @@ - (MPURL *)modifyURL { NSURL *modifiedURL = [NSURL URLWithString:urlString]; defaultURL.accessibilityHint = @"identity"; modifiedURL.accessibilityHint = @"identity"; - + MPURL *modifyURL; if (modifiedURL && defaultURL) { modifyURL = [[MPURL alloc] initWithURL:modifiedURL defaultURL:defaultURL]; } - + return modifyURL; } - (MPURL *)aliasURLForUpload:(MPUpload *)mpUpload { NSString *pathComponent = @"alias"; - + NSString *eventHost; if (mpUpload.uploadSettings.aliasTrackingHost && [MParticle sharedInstance].stateMachine.attAuthorizationStatus.integerValue == MPATTAuthorizationStatusAuthorized) { eventHost = mpUpload.uploadSettings.aliasTrackingHost; @@ -341,28 +343,28 @@ - (MPURL *)aliasURLForUpload:(MPUpload *)mpUpload { } NSString *urlString = [NSString stringWithFormat:aliasURLFormat, kMPURLScheme, self.defaultEventHost, kMPIdentityVersion, kMPIdentityKey, mpUpload.uploadSettings.apiKey, pathComponent]; NSURL *defaultURL = [NSURL URLWithString:urlString]; - + BOOL overrides = mpUpload.uploadSettings.overridesAliasSubdirectory; if (!mpUpload.uploadSettings.eventsOnly && !mpUpload.uploadSettings.aliasHost) { eventHost = mpUpload.uploadSettings.eventsHost ?: self.defaultEventHost; overrides = mpUpload.uploadSettings.overridesEventsSubdirectory; } - + if (overrides) { urlString = [NSString stringWithFormat:aliasURLFormatOverride, kMPURLScheme, eventHost, mpUpload.uploadSettings.apiKey, pathComponent]; } else { urlString = [NSString stringWithFormat:aliasURLFormat, kMPURLScheme, eventHost, kMPIdentityVersion, kMPIdentityKey, mpUpload.uploadSettings.apiKey, pathComponent]; } - + NSURL *modifiedURL = [NSURL URLWithString:urlString]; defaultURL.accessibilityHint = @"identity"; modifiedURL.accessibilityHint = @"identity"; - + MPURL *aliasURL; if (modifiedURL && defaultURL) { aliasURL = [[MPURL alloc] initWithURL:modifiedURL defaultURL:defaultURL]; } - + return aliasURL; } @@ -386,7 +388,7 @@ - (void)throttleWithHTTPResponse:(NSHTTPURLResponse *)httpResponse uploadType:(M NSTimeInterval retryAfter = 7200; // Default of 2 hours NSTimeInterval maxRetryAfter = 86400; // Maximum of 24 hours id suggestedRetryAfter = httpHeaders[@"Retry-After"]; - + if (!MPIsNull(suggestedRetryAfter)) { if ([suggestedRetryAfter isKindOfClass:[NSString class]]) { if ([suggestedRetryAfter containsString:@":"]) { // Date @@ -409,7 +411,7 @@ - (void)throttleWithHTTPResponse:(NSHTTPURLResponse *)httpResponse uploadType:(M retryAfter = MIN([(NSNumber *)suggestedRetryAfter doubleValue], maxRetryAfter); } } - + NSDate *minUploadDate = [MParticle.sharedInstance.stateMachine minUploadDateForUploadType:uploadType]; if ([minUploadDate compare:now] == NSOrderedAscending) { [MParticle.sharedInstance.stateMachine setMinUploadDate:[now dateByAddingTimeInterval:retryAfter] uploadType:uploadType]; @@ -424,16 +426,16 @@ - (void)throttleWithHTTPResponse:(NSHTTPURLResponse *)httpResponse uploadType:(M - (NSNumber *)maxAgeForCache:(nonnull NSString *)cache { NSNumber *maxAge; cache = cache.lowercaseString; - + if ([cache containsString: @"max-age="]) { NSArray *maxAgeComponents = [cache componentsSeparatedByString:@"max-age="]; NSString *beginningOfMaxAgeString = [maxAgeComponents objectAtIndex:1]; NSArray *components = [beginningOfMaxAgeString componentsSeparatedByString:@","]; NSString *maxAgeValue = [components objectAtIndex:0]; - + maxAge = [NSNumber numberWithDouble:MIN([maxAgeValue doubleValue], CONFIG_REQUESTS_MAX_EXPIRATION_AGE)]; } - + return maxAge; } @@ -445,65 +447,76 @@ - (NSNumber *)maxAgeForCache:(nonnull NSString *)cache { return [[MPConnector alloc] init]; } +- (UIBackgroundTaskIdentifier)beginSafeBackgroundTaskWithExpirationHandler:(void(^_Nullable)(void))handler { + if ([MPStateMachine_PRIVATE isAppExtension]) { + return UIBackgroundTaskInvalid; + } + __block UIBackgroundTaskIdentifier taskId = UIBackgroundTaskInvalid; + [MParticle executeOnMainSync:^{ + taskId = [[MPApplication_PRIVATE sharedUIApplication] beginBackgroundTaskWithExpirationHandler:^{ + if (taskId != UIBackgroundTaskInvalid) { + MPILogDebug(@"Background task expiration handler invoked"); + if (handler) handler(); + [[MPApplication_PRIVATE sharedUIApplication] endBackgroundTask:taskId]; + taskId = UIBackgroundTaskInvalid; + } + }]; + }]; + return taskId; +} + +- (void)endSafeBackgroundTask:(UIBackgroundTaskIdentifier)taskId { + if (taskId == UIBackgroundTaskInvalid) return; + [MParticle executeOnMain:^{ + [[MPApplication_PRIVATE sharedUIApplication] endBackgroundTask:taskId]; + }]; +} + - (void)requestConfig:(nullable NSObject *)connector withCompletionHandler:(void(^)(BOOL success))completionHandler { MPUserDefaults *userDefaults = MPUserDefaultsConnector.userDefaults; BOOL shouldSendRequest = [userDefaults isConfigurationExpired]; - + if (!shouldSendRequest) { completionHandler(YES); return; } - + MPILogVerbose(@"Starting config request"); NSTimeInterval start = [[NSDate date] timeIntervalSince1970]; - - __block UIBackgroundTaskIdentifier backgroundTaskIdentifier = UIBackgroundTaskInvalid; - - if (![MPStateMachine_PRIVATE isAppExtension]) { - backgroundTaskIdentifier = [[MPApplication_PRIVATE sharedUIApplication] beginBackgroundTaskWithExpirationHandler:^{ - if (backgroundTaskIdentifier != UIBackgroundTaskInvalid) { - [[MPApplication_PRIVATE sharedUIApplication] endBackgroundTask:backgroundTaskIdentifier]; - backgroundTaskIdentifier = UIBackgroundTaskInvalid; - } - }]; - } - + + UIBackgroundTaskIdentifier backgroundTaskIdentifier = [self beginSafeBackgroundTaskWithExpirationHandler:nil]; + connector = connector ? connector : [self makeConnector]; NSObject *response = [connector responseFromGetRequestToURL:self.configURL]; NSData *data = response.data; NSHTTPURLResponse *httpResponse = response.httpResponse; - + NSString *cacheControl = httpResponse.allHeaderFields[kMPHTTPCacheControlHeaderKey]; NSString *ageString = httpResponse.allHeaderFields[kMPHTTPAgeHeaderKey]; NSNumber *maxAge = [self maxAgeForCache:cacheControl]; - - if (![MPStateMachine_PRIVATE isAppExtension]) { - if (backgroundTaskIdentifier != UIBackgroundTaskInvalid) { - [[MPApplication_PRIVATE sharedUIApplication] endBackgroundTask:backgroundTaskIdentifier]; - backgroundTaskIdentifier = UIBackgroundTaskInvalid; - } - } - + + [self endSafeBackgroundTask:backgroundTaskIdentifier]; + NSInteger responseCode = [httpResponse statusCode]; MPILogVerbose(@"Config Response Code: %ld, Execution Time: %.2fms", (long)responseCode, ([[NSDate date] timeIntervalSince1970] - start) * 1000.0); - + if (responseCode == HTTPStatusCodeNotModified) { MPILogDebug(@"Config response 304 Not Modified - using cached config"); MPUserDefaults *userDefaults = MPUserDefaultsConnector.userDefaults; [userDefaults setConfiguration:[userDefaults getConfiguration] eTag:userDefaults[kMPHTTPETagHeaderKey] requestTimestamp:[[NSDate date] timeIntervalSince1970] currentAge:ageString.doubleValue maxAge:maxAge]; - + completionHandler(YES); return; } - + BOOL success = responseCode == HTTPStatusCodeSuccess || responseCode == HTTPStatusCodeAccepted; - + if (!data && success) { completionHandler(NO); MPILogError(@"Config request failed - no data received (responseCode: %ld). Kits may not initialize.", (long)responseCode); return; } - + success = success && [data length] > 0; NSDictionary *configurationDictionary = nil; @@ -517,7 +530,7 @@ - (void)requestConfig:(nullable NSObject *)connector withCo responseCode = HTTPStatusCodeNoContent; } } - + if (success && configurationDictionary) { NSDictionary *headersDictionary = [httpResponse allHeaderFields]; NSString *eTag = headersDictionary[kMPHTTPETagHeaderKey]; @@ -528,7 +541,7 @@ - (void)requestConfig:(nullable NSObject *)connector withCo MPUserDefaults *userDefaults = MPUserDefaultsConnector.userDefaults; [userDefaults setConfiguration:configurationDictionary eTag:eTag requestTimestamp:[[NSDate date] timeIntervalSince1970] currentAge:ageString.doubleValue maxAge:maxAge]; } - + completionHandler(success); } else { MPILogError(@"Config request failed - could not parse response or wrong message type (responseCode: %ld). Kits may not initialize.", (long)responseCode); @@ -537,17 +550,8 @@ - (void)requestConfig:(nullable NSObject *)connector withCo } - (void)requestAudiencesWithCompletionHandler:(MPAudienceResponseHandler)completionHandler { - __block UIBackgroundTaskIdentifier backgroundTaskIdentifier = UIBackgroundTaskInvalid; - - if (![MPStateMachine_PRIVATE isAppExtension]) { - backgroundTaskIdentifier = [[MPApplication_PRIVATE sharedUIApplication] beginBackgroundTaskWithExpirationHandler:^{ - if (backgroundTaskIdentifier != UIBackgroundTaskInvalid) { - [[MPApplication_PRIVATE sharedUIApplication] endBackgroundTask:backgroundTaskIdentifier]; - backgroundTaskIdentifier = UIBackgroundTaskInvalid; - } - }]; - } - + UIBackgroundTaskIdentifier backgroundTaskIdentifier = [self beginSafeBackgroundTaskWithExpirationHandler:nil]; + __weak MPNetworkCommunication_PRIVATE *weakSelf = self; NSObject *connector = [self makeConnector]; @@ -559,14 +563,9 @@ - (void)requestAudiencesWithCompletionHandler:(MPAudienceResponseHandler)complet completionHandler(NO, nil, nil); return; } - - if (![MPStateMachine_PRIVATE isAppExtension]) { - if (backgroundTaskIdentifier != UIBackgroundTaskInvalid) { - [[MPApplication_PRIVATE sharedUIApplication] endBackgroundTask:backgroundTaskIdentifier]; - backgroundTaskIdentifier = UIBackgroundTaskInvalid; - } - } - + + [self endSafeBackgroundTask:backgroundTaskIdentifier]; + if (!data) { NSError *audienceError = [NSError errorWithDomain:@"mParticle Audiences" code:httpResponse.statusCode @@ -574,18 +573,18 @@ - (void)requestAudiencesWithCompletionHandler:(MPAudienceResponseHandler)complet completionHandler(NO, nil, audienceError); return; } - + NSMutableArray *currentAudiences = nil; BOOL success = NO; - + NSArray *audiencesList = nil; NSInteger responseCode = [httpResponse statusCode]; success = (responseCode == HTTPStatusCodeSuccess || responseCode == HTTPStatusCodeAccepted) && [data length] > 0; - + if (success) { NSError *serializationError = nil; NSDictionary *audiencesDictionary = nil; - + @try { audiencesDictionary = [NSJSONSerialization JSONObjectWithData:data options:0 error:&serializationError]; success = serializationError == nil; @@ -594,29 +593,29 @@ - (void)requestAudiencesWithCompletionHandler:(MPAudienceResponseHandler)complet success = NO; MPILogError(@"Audiences Error: %@", [exception reason]); } - + if (success) { audiencesList = audiencesDictionary[kMPAudienceMembershipKey]; } - + if (audiencesList.count > 0) { currentAudiences = [[NSMutableArray alloc] init]; - + for (NSDictionary *audienceDictionary in audiencesList) { MPAudience *audience = [[MPAudience alloc] initWithAudienceId:audienceDictionary[kMPAudienceIdKey]]; [currentAudiences addObject:audience]; } - + MPILogVerbose(@"Audiences Response Code: %ld", (long)responseCode); } else { MPILogWarning(@"Audiences Error - Response Code: %ld", (long)responseCode); } } - + if (currentAudiences.count == 0) { currentAudiences = nil; } - + NSError *audienceError = nil; if (responseCode == HTTPStatusCodeForbidden) { @@ -624,7 +623,7 @@ - (void)requestAudiencesWithCompletionHandler:(MPAudienceResponseHandler)complet code:responseCode userInfo:@{@"message":@"Audiences not enabled for this org."}]; } - + completionHandler(success, currentAudiences, audienceError); } @@ -633,24 +632,24 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { if ([minUploadDate compare:[NSDate date]] == NSOrderedDescending) { return YES; //stop upload loop } - + MPURL *eventURL = [self eventURLForUpload:upload]; - + NSString *uploadString = [upload serializedString]; NSObject *connector = [self makeConnector]; - + MPILogVerbose(@"Beginning upload for upload ID: %@", upload.uuid); - + NSData *zipUploadData; NSNumber *authTimestamp = [MParticle sharedInstance].stateMachine.attAuthorizationTimestamp; NSNumber *authStatus = [MParticle sharedInstance].stateMachine.attAuthorizationStatus; - + if (authStatus != nil && authTimestamp != nil) { NSDictionary *uploadDictionary = [NSJSONSerialization JSONObjectWithData:upload.uploadData options:0 error:nil]; NSMutableDictionary *uploadDict = [uploadDictionary mutableCopy]; - + NSMutableDictionary *deviceDict = [uploadDict[kMPDeviceInformationKey] mutableCopy]; - + switch (authStatus.integerValue) { case MPATTAuthorizationStatusNotDetermined: deviceDict[kMPATT] = @"not_determined"; @@ -670,11 +669,11 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { default: break; } - + deviceDict[kMPATTTimestamp] = authTimestamp; - + uploadDict[kMPDeviceInformationKey] = [deviceDict copy]; - + NSData *updatedData = [NSJSONSerialization dataWithJSONObject:[uploadDict copy] options:0 error:nil]; uploadString = [[NSString alloc] initWithData:updatedData encoding:NSUTF8StringEncoding]; @@ -682,20 +681,20 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { } else { zipUploadData = [MPZipPRIVATE compressedDataFromData:upload.uploadData]; } - + if (zipUploadData == nil || zipUploadData.length <= 0) { [[MParticle sharedInstance].persistenceController deleteUpload:upload]; return NO; } NSTimeInterval start = [[NSDate date] timeIntervalSince1970]; - + NSObject *response = [connector responseFromPostRequestToURL:eventURL message:uploadString serializedParams:zipUploadData secret:upload.uploadSettings.secret]; NSData *data = response.data; NSHTTPURLResponse *httpResponse = response.httpResponse; - + NSInteger responseCode = [httpResponse statusCode]; MPILogVerbose(@"Upload response code: %ld", (long)responseCode); BOOL isSuccessCode = responseCode >= 200 && responseCode < 300; @@ -706,7 +705,7 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { [[MParticle sharedInstance] logKitBatch:uploadString]; } } - + BOOL success = isSuccessCode && data && [data length] > 0; if (success) { @try { @@ -718,25 +717,25 @@ - (BOOL)performMessageUpload:(MPUpload *)upload { [MPNetworkCommunication_PRIVATE parseConfiguration:responseDictionary]; } MPILogVerbose(@"Upload complete: %@\n", uploadString); - + } @catch (NSException *exception) { MPILogError(@"Upload error: %@", [exception reason]); } } - + MPILogVerbose(@"Upload execution time: %.2fms", ([[NSDate date] timeIntervalSince1970] - start) * 1000.0); - + // 429, 503 if (responseCode == HTTPStatusCodeServiceUnavailable || responseCode == HTTPStatusCodeTooManyRequests) { [self throttleWithHTTPResponse:httpResponse uploadType:MPUploadTypeMessage]; return YES; } - + //5xx, 0, 999, -1, etc if (!isSuccessCode && !isInvalidCode) { return YES; } - + return NO; } @@ -745,47 +744,47 @@ - (BOOL)performAliasUpload:(MPUpload *)upload { if ([minUploadDate compare:[NSDate date]] == NSOrderedDescending) { return YES; //stop upload loop } - + MPURL *aliasURL = [self aliasURLForUpload:upload]; - + NSString *uploadString = [upload serializedString]; NSObject *connector = [self makeConnector]; - + MPILogVerbose(@"Beginning alias request with upload ID: %@", upload.uuid); - + if (upload.uploadData == nil || upload.uploadData.length <= 0) { [[MParticle sharedInstance].persistenceController deleteUpload:upload]; return NO; } NSTimeInterval start = [[NSDate date] timeIntervalSince1970]; - + MPILogVerbose(@"Alias request:\nURL: %@ \nBody:%@", aliasURL.url, uploadString); - + NSObject *response = [connector responseFromPostRequestToURL:aliasURL message:uploadString serializedParams:upload.uploadData secret:upload.uploadSettings.secret]; NSData *data = response.data; NSHTTPURLResponse *httpResponse = response.httpResponse; - + NSInteger responseCode = [httpResponse statusCode]; MPILogVerbose(@"Alias response code: %ld", (long)responseCode); - + BOOL isSuccessCode = responseCode >= 200 && responseCode < 300; BOOL isInvalidCode = responseCode != 429 && responseCode >= 400 && responseCode < 500; if (isSuccessCode || isInvalidCode) { [[MParticle sharedInstance].persistenceController deleteUpload:upload]; } - + NSString *responseString = [[NSString alloc] initWithData:response.data encoding:NSUTF8StringEncoding]; if (responseString != nil && responseString.length > 0) { MPILogVerbose(@"Alias response:\n%@", responseString); } - + MPAliasResponse *aliasResponse = [[MPAliasResponse alloc] init]; aliasResponse.responseCode = responseCode; aliasResponse.willRetry = NO; - + NSDictionary *requestDictionary = [NSJSONSerialization JSONObjectWithData:upload.uploadData options:0 error:nil]; NSNumber *sourceMPID = requestDictionary[@"source_mpid"]; NSNumber *destinationMPID = requestDictionary[@"destination_mpid"]; @@ -795,7 +794,7 @@ - (BOOL)performAliasUpload:(MPUpload *)upload { NSDate *endTime = [NSDate dateWithTimeIntervalSince1970:endTimeNumber.doubleValue/1000]; aliasResponse.requestID = requestDictionary[@"request_id"]; aliasResponse.request = [MPAliasRequest requestWithSourceMPID:sourceMPID destinationMPID:destinationMPID startTime:startTime endTime:endTime]; - + if (!isSuccessCode && data && data.length > 0) { @try { NSError *serializationError = nil; @@ -810,36 +809,27 @@ - (BOOL)performAliasUpload:(MPUpload *)upload { MPILogError(@"Alias error: %@", [exception reason]); } } - + MPILogVerbose(@"Alias execution time: %.2fms", ([[NSDate date] timeIntervalSince1970] - start) * 1000.0); - + // 429, 503 if (responseCode == HTTPStatusCodeServiceUnavailable || responseCode == HTTPStatusCodeTooManyRequests) { aliasResponse.willRetry = YES; [self throttleWithHTTPResponse:httpResponse uploadType:upload.uploadType]; return YES; } - + //5xx, 0, 999, -1, etc if (!isSuccessCode && !isInvalidCode) { return YES; } - + return NO; } - (void)upload:(NSArray *)uploads completionHandler:(MPUploadsCompletionHandler)completionHandler { - __block UIBackgroundTaskIdentifier backgroundTaskIdentifier = UIBackgroundTaskInvalid; - - if (![MPStateMachine_PRIVATE isAppExtension]) { - backgroundTaskIdentifier = [[MPApplication_PRIVATE sharedUIApplication] beginBackgroundTaskWithExpirationHandler:^{ - if (backgroundTaskIdentifier != UIBackgroundTaskInvalid) { - [[MPApplication_PRIVATE sharedUIApplication] endBackgroundTask:backgroundTaskIdentifier]; - backgroundTaskIdentifier = UIBackgroundTaskInvalid; - } - }]; - } - + UIBackgroundTaskIdentifier backgroundTaskIdentifier = [self beginSafeBackgroundTaskWithExpirationHandler:nil]; + for (int index = 0; index < uploads.count; index++) { @autoreleasepool { MPUpload *upload = uploads[index]; @@ -854,18 +844,13 @@ - (void)upload:(NSArray *)uploads completionHandler:(MPUploadsComple } } } - - if (![MPStateMachine_PRIVATE isAppExtension]) { - if (backgroundTaskIdentifier != UIBackgroundTaskInvalid) { - [[MPApplication_PRIVATE sharedUIApplication] endBackgroundTask:backgroundTaskIdentifier]; - backgroundTaskIdentifier = UIBackgroundTaskInvalid; - } - } + + [self endSafeBackgroundTask:backgroundTaskIdentifier]; completionHandler(); } - (void)identityApiRequestWithURL:(NSURL*)url identityRequest:(MPIdentityHTTPBaseRequest *_Nonnull)identityRequest blockOtherRequests: (BOOL) blockOtherRequests completion:(nullable MPIdentityApiManagerCallback)completion { - + if (self.identifying) { MPILogWarning(@"Identity API request blocked - another identity request is already in progress"); if (completion) { @@ -873,7 +858,7 @@ - (void)identityApiRequestWithURL:(NSURL*)url identityRequest:(MPIdentityHTTPBas } return; } - + if ([MParticle sharedInstance].stateMachine.optOut) { MPILogWarning(@"Identity API request blocked - SDK is opted out"); if (completion) { @@ -881,11 +866,11 @@ - (void)identityApiRequestWithURL:(NSURL*)url identityRequest:(MPIdentityHTTPBas } return; } - + if (blockOtherRequests) { self.identifying = YES; } - + MPEndpoint endpointType; MPURL *mpURL; if ([self.identifyURL.url.absoluteString isEqualToString:url.absoluteString]) { @@ -901,26 +886,25 @@ - (void)identityApiRequestWithURL:(NSURL*)url identityRequest:(MPIdentityHTTPBas endpointType = MPEndpointIdentityModify; mpURL = self.modifyURL; } - + NSTimeInterval start = [[NSDate date] timeIntervalSince1970]; - + NSDictionary *dictionary = [identityRequest dictionaryRepresentation]; NSData *data = [NSJSONSerialization dataWithJSONObject:dictionary options:0 error:nil]; NSString *jsonRequest = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - + MPILogVerbose(@"Identity request:\nURL: %@ \nBody:%@", url, jsonRequest); - - + BOOL success = NO; NSError *error = nil; NSDictionary *responseDictionary = nil; NSString *responseString = nil; NSInteger responseCode = 0; - + BOOL enableIdentityCaching = MParticle.sharedInstance.stateMachine.enableIdentityCaching; BOOL usedCachedResponse = NO; - + // Try to use the cache if enabled if (enableIdentityCaching) { MPIdentityCachedResponse *cachedResponse = [MPIdentityCaching getCachedIdentityResponseForEndpoint:endpointType identityRequest:identityRequest]; @@ -929,7 +913,7 @@ - (void)identityApiRequestWithURL:(NSURL*)url identityRequest:(MPIdentityHTTPBas NSError *serializationError = nil; responseString = [[NSString alloc] initWithData:cachedResponse.bodyData encoding:NSUTF8StringEncoding]; responseDictionary = [NSJSONSerialization JSONObjectWithData:cachedResponse.bodyData options:0 error:&serializationError]; - + if (serializationError) { responseDictionary = nil; success = NO; @@ -947,52 +931,38 @@ - (void)identityApiRequestWithURL:(NSURL*)url identityRequest:(MPIdentityHTTPBas MPILogError(@"Identity response serialization error: %@", [exception reason]); } } - } - + } + if (!usedCachedResponse) { - __block UIBackgroundTaskIdentifier backgroundTaskIdentifier = UIBackgroundTaskInvalid; - - if (![MPStateMachine_PRIVATE isAppExtension]) { - backgroundTaskIdentifier = [[MPApplication_PRIVATE sharedUIApplication] beginBackgroundTaskWithExpirationHandler:^{ - if (backgroundTaskIdentifier != UIBackgroundTaskInvalid) { - self.identifying = NO; - - [[MPApplication_PRIVATE sharedUIApplication] endBackgroundTask:backgroundTaskIdentifier]; - backgroundTaskIdentifier = UIBackgroundTaskInvalid; - } - }]; - } - + UIBackgroundTaskIdentifier backgroundTaskIdentifier = [self beginSafeBackgroundTaskWithExpirationHandler:^{ + self.identifying = NO; + }]; + NSObject *connector = [self makeConnector]; NSObject *response = [connector responseFromPostRequestToURL:mpURL message:nil - serializedParams:data + serializedParams:data secret:nil]; - + NSData *responseData = response.data; error = response.error; NSHTTPURLResponse *httpResponse = response.httpResponse; - - if (![MPStateMachine_PRIVATE isAppExtension]) { - if (backgroundTaskIdentifier != UIBackgroundTaskInvalid) { - [[MPApplication_PRIVATE sharedUIApplication] endBackgroundTask:backgroundTaskIdentifier]; - backgroundTaskIdentifier = UIBackgroundTaskInvalid; - } - } - + + [self endSafeBackgroundTask:backgroundTaskIdentifier]; + responseCode = [httpResponse statusCode]; success = responseCode == HTTPStatusCodeSuccess || responseCode == HTTPStatusCodeAccepted; success = success && [responseData length] > 0; - - + + MPILogVerbose(@"Identity response code: %ld", (long)responseCode); - + if (success) { @try { NSError *serializationError = nil; responseString = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding]; responseDictionary = [NSJSONSerialization JSONObjectWithData:responseData options:0 error:&serializationError]; - + if (responseDictionary && !serializationError) { // Cache response if it contains the custom max age header and the feature is enabled if (enableIdentityCaching) { @@ -1018,11 +988,11 @@ - (void)identityApiRequestWithURL:(NSURL*)url identityRequest:(MPIdentityHTTPBas } } } - + MPILogVerbose(@"Identity execution time: %.2fms", ([[NSDate date] timeIntervalSince1970] - start) * 1000.0); - + self.identifying = NO; - + if (success) { if (responseString) { MPILogVerbose(@"Identity response:\n%@", responseString); @@ -1068,7 +1038,7 @@ - (void)identify:(MPIdentityApiRequest *_Nonnull)identifyRequest completion:(nul if (!userDefaults[kMPATT] && identifyRequest.identities[@(MPIdentityIOSAdvertiserId)]) { MPILogDebug(@"The IDFA was supplied but the App Tracking Transparency Status not set with [[MParticle sharedInstance] setATTStatus:withATTStatusTimestampMillis:]"); } - + MPIdentifyHTTPRequest *request = [[MPIdentifyHTTPRequest alloc] initWithIdentityApiRequest:identifyRequest]; [self identityApiRequestWithURL:self.identifyURL.url identityRequest:request blockOtherRequests: YES completion:completion]; } @@ -1078,7 +1048,7 @@ - (void)login:(MPIdentityApiRequest *_Nullable)loginRequest completion:(nullable if (!userDefaults[kMPATT] && loginRequest.identities[@(MPIdentityIOSAdvertiserId)]) { MPILogDebug(@"The IDFA was supplied but the App Tracking Transparency Status not set with [[MParticle sharedInstance] setATTStatus:withATTStatusTimestampMillis:]"); } - + MPIdentifyHTTPRequest *request = [[MPIdentifyHTTPRequest alloc] initWithIdentityApiRequest:loginRequest]; [self identityApiRequestWithURL:self.loginURL.url identityRequest:request blockOtherRequests: YES completion:completion]; } @@ -1089,7 +1059,7 @@ - (void)logout:(MPIdentityApiRequest *_Nullable)logoutRequest completion:(nullab if (!userDefaults[kMPATT] && logoutRequest.identities[@(MPIdentityIOSAdvertiserId)]) { MPILogDebug(@"The IDFA was supplied but the App Tracking Transparency Status not set with [[MParticle sharedInstance] setATTStatus:withATTStatusTimestampMillis:]"); } - + MPIdentifyHTTPRequest *request = [[MPIdentifyHTTPRequest alloc] initWithIdentityApiRequest:logoutRequest]; [self identityApiRequestWithURL:self.logoutURL.url identityRequest:request blockOtherRequests: YES completion:completion]; } @@ -1099,19 +1069,19 @@ - (void)modify:(MPIdentityApiRequest *_Nonnull)modifyRequest completion:(nullabl if (!userDefaults[kMPATT] && modifyRequest.identities[@(MPIdentityIOSAdvertiserId)]) { MPILogDebug(@"The IDFA was supplied but the App Tracking Transparency Status not set with [[MParticle sharedInstance] setATTStatus:withATTStatusTimestampMillis:]"); } - + NSMutableArray *identityChanges = [NSMutableArray array]; - + NSDictionary *identitiesDictionary = modifyRequest.identities; NSDictionary *existingIdentities = [MParticle sharedInstance].identity.currentUser.identities; - + [identitiesDictionary enumerateKeysAndObjectsUsingBlock:^(NSNumber * _Nonnull identityType, NSString *value, BOOL * _Nonnull stop) { NSString *oldValue = existingIdentities[identityType]; - + if ((NSNull *)value == [NSNull null]) { value = nil; } - + if (!oldValue || ![value isEqualToString:oldValue]) { MPIdentity userIdentity = (MPIdentity)[identityType intValue]; NSString *stringType = [MPIdentityHTTPIdentities stringForIdentityType:userIdentity]; @@ -1119,9 +1089,9 @@ - (void)modify:(MPIdentityApiRequest *_Nonnull)modifyRequest completion:(nullabl [identityChanges addObject:identityChange]; } }]; - + [self modifyWithIdentityChanges:identityChanges blockOtherRequests:YES completion:completion]; - + } - (void)modifyDeviceID:(NSString *_Nonnull)deviceIdType value:(NSString *_Nonnull)value oldValue:(NSString *_Nonnull)oldValue { @@ -1132,7 +1102,7 @@ - (void)modifyDeviceID:(NSString *_Nonnull)deviceIdType value:(NSString *_Nonnul } - (void)modifyWithIdentityChanges:(NSArray *)identityChanges blockOtherRequests:(BOOL)blockOtherRequests completion:(nullable MPIdentityApiManagerModifyCallback)completion { - + if (identityChanges == nil || identityChanges.count == 0) { if (completion) { completion([[MPIdentityHTTPModifySuccessResponse alloc] init], nil); @@ -1160,7 +1130,7 @@ + (void)parseConfiguration:(nonnull NSDictionary *)configuration { if (MPIsNull(configuration) || MPIsNull(configuration[kMPMessageTypeKey])) { return; } - + MPPersistenceController_PRIVATE *persistence = [MParticle sharedInstance].persistenceController; // Consumer Information From 0a48dfdebd3ecaee243a37b2202965f2657321f1 Mon Sep 17 00:00:00 2001 From: Nickolas Dimitrakas Date: Mon, 16 Feb 2026 11:36:06 -0500 Subject: [PATCH 16/19] test: fix endSessionIfTimedOut failing tests (#585) * test: fix endSessionIfTimedOut failing tests * adjust fix for message queue mismatch * add test for automaticSessionTracking being disabled (cherry picked from commit 42571edd5a0b0ee5f4a20957d54be1b5467e1149) --- .../ObjCTests/MPBackendControllerTests.m | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/UnitTests/ObjCTests/MPBackendControllerTests.m b/UnitTests/ObjCTests/MPBackendControllerTests.m index c98ee9a6c..38cf89ec1 100644 --- a/UnitTests/ObjCTests/MPBackendControllerTests.m +++ b/UnitTests/ObjCTests/MPBackendControllerTests.m @@ -141,6 +141,8 @@ - (void)setUp { [MParticle sharedInstance].backendController = [[MPBackendController_PRIVATE alloc] initWithDelegate:(id)[MParticle sharedInstance]]; self.backendController = [MParticle sharedInstance].backendController; + messageQueue = [MParticle messageQueue]; + [self notificationController]; } @@ -2489,6 +2491,63 @@ - (void)testEndSessionIfTimedOutDispatchesToMessageQueue { XCTAssertNil(sessionAfter, @"Session should be nil after endSessionIfTimedOut processes on the message queue"); } +- (void)testEndSessionIfTimedOutDoesNothingWhenAutomaticSessionTrackingDisabled { + // Verify endSessionIfTimedOut is a no-op when automaticSessionTracking is disabled. + + dispatch_sync(messageQueue, ^{ + [self.backendController beginSessionWithIsManual:NO date:[NSDate date]]; + }); + XCTAssertNotNil(self.backendController.session, @"Session should exist"); + + NSTimeInterval pastTime = [[NSDate date] timeIntervalSince1970] - 200; + dispatch_sync(messageQueue, ^{ + self.backendController.sessionTimeout = 30; + self.backendController.timeOfLastEventInBackground = pastTime; + self.backendController.timeAppWentToBackgroundInCurrentSession = pastTime; + }); + + // Disable automatic session tracking without calling startWithOptions (which + // would recreate backendController and add unrelated async work). + [[MParticle sharedInstance] setValue:@NO forKey:@"automaticSessionTracking"]; + + __block NSString *sessionUUIDBefore; + __block NSTimeInterval endTimeBefore; + __block NSTimeInterval lastEventBefore; + dispatch_sync(messageQueue, ^{ + sessionUUIDBefore = self.backendController.session.uuid; + endTimeBefore = self.backendController.session.endTime; + lastEventBefore = self.backendController.timeOfLastEventInBackground; + }); + + XCTestExpectation *bgCallDone = [self expectationWithDescription:@"Background call completed"]; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [self.backendController endSessionIfTimedOut]; + [bgCallDone fulfill]; + }); + [self waitForExpectations:@[bgCallDone] timeout:2.0]; + + // Drain queue to ensure there is no delayed mutation. + XCTestExpectation *mqDrained = [self expectationWithDescription:@"Message queue drained"]; + dispatch_async(messageQueue, ^{ + [mqDrained fulfill]; + }); + [self waitForExpectations:@[mqDrained] timeout:5.0]; + + __block MPSession *sessionAfter; + __block NSTimeInterval endTimeAfter; + __block NSTimeInterval lastEventAfter; + dispatch_sync(messageQueue, ^{ + sessionAfter = self.backendController.session; + endTimeAfter = self.backendController.session.endTime; + lastEventAfter = self.backendController.timeOfLastEventInBackground; + }); + + XCTAssertNotNil(sessionAfter, @"Session should not be ended when automaticSessionTracking is disabled"); + XCTAssertEqualObjects(sessionAfter.uuid, sessionUUIDBefore, @"Session identity should be unchanged"); + XCTAssertEqualWithAccuracy(endTimeAfter, endTimeBefore, DBL_EPSILON, @"Session endTime should be unchanged"); + XCTAssertEqualWithAccuracy(lastEventAfter, lastEventBefore, DBL_EPSILON, @"Background last-event time should be unchanged"); +} + - (void)testConcurrentEndSessionIfTimedOutDoesNotCrash { // Verify that calling endSessionIfTimedOut simultaneously from a background // thread and the message queue does not crash. From 331805736fe6b8eacdbb346988b0c2c46dd4ac0b Mon Sep 17 00:00:00 2001 From: Brandon Stalnaker <33703490+BrandonStalnaker@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:30:58 -0500 Subject: [PATCH 17/19] ci: Reorder S3 upload to after branch push in release workflow (#586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reordered the release workflow steps so "Upload xcframeworks to S3" runs after "Push release branch" * New step order: Commit version changes → Push release branch → Upload to S3 → Create Pull Request * This ensures the irreversible S3 upload only happens after the git state is successfully committed and pushed, preventing version numbers from being burned on failed releases * Removed trunk fmt step from the workflow since it's no longer needed * Rewrote Scripts/update_mapping_versions.sh to use sed for in-place string replacement instead of jq. This preserves the original file formatting and eliminates the need for post-processing with prettier or trunk fmt. (cherry picked from commit 02cb9f0f5958aef077fb8d2e5e9d288c920ce822) --- .github/workflows/sdk-release-manual.yml | 13 ----------- Scripts/update_mapping_versions.sh | 29 ++++++++++++------------ 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/.github/workflows/sdk-release-manual.yml b/.github/workflows/sdk-release-manual.yml index 09562f679..1e145db29 100644 --- a/.github/workflows/sdk-release-manual.yml +++ b/.github/workflows/sdk-release-manual.yml @@ -227,21 +227,8 @@ jobs: echo "Updated Package.swift:" head -15 Package.swift - - name: Upload xcframeworks to S3 - env: - AWS_ACCESS_KEY_ID: ${{ secrets.MP_IOS_SDK_S3_KEY }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.MP_IOS_SDK_S3_SECRET }} - AWS_DEFAULT_REGION: ${{ secrets.MP_IOS_SDK_S3_REGION }} - run: | - aws s3 cp mParticle_Apple_SDK.xcframework.zip s3://static.mparticle.com/sdk/ios/v${VERSION}/mParticle_Apple_SDK.xcframework.zip - - echo "Uploaded xcframeworks to S3:" - echo " - s3://static.mparticle.com/sdk/ios/v${VERSION}/mParticle_Apple_SDK.xcframework.zip" - - name: Commit version changes run: | - # trunk format the files before committing - trunk fmt # Only add the version-related files, exclude build artifacts git add \ CHANGELOG.md \ diff --git a/Scripts/update_mapping_versions.sh b/Scripts/update_mapping_versions.sh index 40adf60dc..4008ad721 100755 --- a/Scripts/update_mapping_versions.sh +++ b/Scripts/update_mapping_versions.sh @@ -8,28 +8,27 @@ if [[ -z ${VERSION} ]]; then exit 1 fi -# Update SDK version in integration test mappings -# Update "sdk" field in all mapping files +# Escape dots in version for use in sed patterns +ESCAPED_VERSION="${VERSION//./\\.}" + +# Update SDK version in integration test mappings using sed +# This preserves the original file formatting (unlike jq which re-serializes) find IntegrationTests/wiremock-recordings/mappings -name "*.json" -type f | while read -r mapping_file; do - # Update top-level "sdk" field - if jq -e '.request.bodyPatterns[0].equalToJson.sdk' "${mapping_file}" >/dev/null 2>&1; then - tmp_file=$(mktemp) - jq --indent 2 '.request.bodyPatterns[0].equalToJson.sdk = "'"${VERSION}"'"' "${mapping_file}" >"${tmp_file}" && mv "${tmp_file}" "${mapping_file}" + # Update "sdk": "x.y.z" field + if grep -q '"sdk":' "${mapping_file}"; then + sed -i '' 's/"sdk": "[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"/"sdk": "'"${VERSION}"'"/' "${mapping_file}" fi - # Update "client_sdk.sdk_version" field (for v1 identify endpoints) - if jq -e '.request.bodyPatterns[0].equalToJson.client_sdk.sdk_version' "${mapping_file}" >/dev/null 2>&1; then - tmp_file=$(mktemp) - jq --indent 2 '.request.bodyPatterns[0].equalToJson.client_sdk.sdk_version = "'"${VERSION}"'"' "${mapping_file}" >"${tmp_file}" && mv "${tmp_file}" "${mapping_file}" + # Update "sdk_version": "x.y.z" field (for v1 identify endpoints) + if grep -q '"sdk_version":' "${mapping_file}"; then + sed -i '' 's/"sdk_version": "[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"/"sdk_version": "'"${VERSION}"'"/' "${mapping_file}" fi done || true -# Update SDK version in config mapping urlPattern (sv=...) -ESCAPED_VERSION="${VERSION//./\\.}" +# Update SDK version in config mapping urlPattern (sv=x.y.z with escaped dots) config_file="IntegrationTests/wiremock-recordings/mappings/mapping-v4-config-get-config.json" if [[ -f ${config_file} ]]; then - # Use jq with sub() function to replace version in urlPattern (matching escaped dots \.) - tmp_file=$(mktemp) - jq --indent 2 --arg new_version "${ESCAPED_VERSION}" '.request.urlPattern |= sub("sv=\\d+\\\\\\.\\d+\\\\\\.\\d+"; "sv=" + $new_version)' "${config_file}" >"${tmp_file}" && mv "${tmp_file}" "${config_file}" + # Match sv=X\.Y\.Z (escaped dots) and replace with new version (also escaped) + sed -i '' 's/sv=[0-9][0-9]*\\\\.[0-9][0-9]*\\\\.[0-9][0-9]*/sv='"${ESCAPED_VERSION}"'/' "${config_file}" echo "Updated SDK version in ${config_file} urlPattern to sv=${ESCAPED_VERSION}" else echo "Warning: ${config_file} not found" From 544c18fd1d8873e101df31e6825ccec31b23eb60 Mon Sep 17 00:00:00 2001 From: mParticle Bot User Date: Mon, 16 Feb 2026 14:42:54 -0500 Subject: [PATCH 18/19] chore: Release v8.43.1 (#588) chore: (release) 8.43.1 Updates version to 8.43.1 in: - CHANGELOG.md - Framework/Info.plist - Package.swift - mParticle-Apple-SDK.podspec - mParticle-Apple-SDK/MPConstants.swift - mParticle-Apple-SDK/MPIConstants.m - mParticle_Apple_SDK.json - IntegrationTests/wiremock-recordings/mappings/*.json (cherry picked from commit b0a3d007154fc1293481a0429ba8d75d49b04e59) --- CHANGELOG.md | 19 +++++++++++++++++++ Framework/Info.plist | 2 +- .../mappings/mapping-ccpa-consent.json | 2 +- .../mapping-commerce-event-purchase.json | 2 +- .../mappings/mapping-gdpr-consent.json | 2 +- ...apping-increment-session-attribute-ss.json | 2 +- .../mapping-increment-session-attribute.json | 2 +- .../mapping-increment-user-attribute-set.json | 2 +- .../mapping-increment-user-attribute.json | 2 +- .../mappings/mapping-log-error.json | 2 +- .../mapping-log-event-with-flags.json | 2 +- .../mappings/mapping-log-exception.json | 2 +- .../mappings/mapping-log-idfa.json | 2 +- .../mappings/mapping-log-screen.json | 2 +- .../mappings/mapping-rokt-identify.json | 2 +- .../mapping-rokt-select-placement.json | 2 +- .../mappings/mapping-set-att-status.json | 2 +- .../mapping-set-session-attribute.json | 2 +- .../mappings/mapping-set-user-attributes.json | 2 +- .../mappings/mapping-timed-event.json | 2 +- .../mappings/mapping-v1-identify.json | 2 +- .../mappings/mapping-v2-events-log-event.json | 2 +- .../mapping-v4-config-get-config.json | 2 +- mParticle-Apple-SDK.podspec | 2 +- mParticle-Apple-SDK/MPIConstants.m | 2 +- mParticle_Apple_SDK.json | 3 ++- 26 files changed, 45 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7fade928..df7521da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# [8.43.1](https://github.com/mParticle/mparticle-apple-sdk/compare/v8.42.2...v8.43.1) (2026-02-16) + +### Bug Fixes + +- fix: MPNetworkCommunication background task (#584) ([dfab795e](https://github.com/mParticle/mparticle-apple-sdk/commit/dfab795e2bd68b6eb51f5cb0e5bcf1f551ae1182)) +- fix: PreferredLanguages may be empty (#583) ([5a538c3b](https://github.com/mParticle/mparticle-apple-sdk/commit/5a538c3b3fc67444b76b8e3e756ec3f520c1d23c)) +- fix: endSessionIfTimedOut race condition (#582) ([b2eb508c](https://github.com/mParticle/mparticle-apple-sdk/commit/b2eb508c401c3b189f9f4abcb149066f5959cbea)) +- fix: Potential MPURLRequestBuilder crash (#578) ([70c0076c](https://github.com/mParticle/mparticle-apple-sdk/commit/70c0076c3e4d0f32a1ff4c0ad9c5518d8a1a5fb8)) +- fix: Add Try/Catch to File Write (#581) ([18045c9e](https://github.com/mParticle/mparticle-apple-sdk/commit/18045c9e32c1a01cb954e08dd9a5ee590e6eb096)) +- fix: App crash when JSON serialization of upload dictionary (#579) ([a5e19600](https://github.com/mParticle/mparticle-apple-sdk/commit/a5e196005cbcda92889f4f032498646a5108b58e)) +- fix: Guarantee UserDefaults Thread Safety (#580) ([7baa7b41](https://github.com/mParticle/mparticle-apple-sdk/commit/7baa7b41a3d38b7bbc5afa4364b948b5de09f6f3)) +- fix: background expiration race (#577) ([9d97bd32](https://github.com/mParticle/mparticle-apple-sdk/commit/9d97bd32fee29e25cb748c1ade4af1c933fa949a)) +- fix: Add Brackets Thread Safety Tests (#573) ([0d831cc5](https://github.com/mParticle/mparticle-apple-sdk/commit/0d831cc522c38834081483dce8428a1737ce8b1e)) +- fix: Thread-safe access to currentUser to prevent crash during kit replay (#576) ([a3ba57e8](https://github.com/mParticle/mparticle-apple-sdk/commit/a3ba57e860c68552c2d6297f7b8deb04ac09fe95)) +- fix: app crash from [MPUpload description] (#572) ([91c0c5d4](https://github.com/mParticle/mparticle-apple-sdk/commit/91c0c5d4880e064fcb07569283fddb886ce1eb33)) +- fix: MPURLRequestBuilder build crash (#575) ([604afeef](https://github.com/mParticle/mparticle-apple-sdk/commit/604afeefdfe32e5c2785cc3404c64941fcfda847)) +- fix: Mitigate Thread-safety of DateFormatter (#574) ([7b36691d](https://github.com/mParticle/mparticle-apple-sdk/commit/7b36691d5fb50739da8e17b76cdba127caefaa19)) +- fix: Use Defensive Copy for ActiveKitsRegistry (#571) ([e5d5e273](https://github.com/mParticle/mparticle-apple-sdk/commit/e5d5e27313bd6445ff491590168ab9718f05b86e)) + # [8.42.2](https://github.com/mParticle/mparticle-apple-sdk/compare/v8.42.1...v8.42.2) (2026-02-11) ### Features diff --git a/Framework/Info.plist b/Framework/Info.plist index 5f8e20ebc..c9656ac5d 100644 --- a/Framework/Info.plist +++ b/Framework/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 8.42.2 + 8.43.1 CFBundleSignature ???? CFBundleVersion diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-ccpa-consent.json b/IntegrationTests/wiremock-recordings/mappings/mapping-ccpa-consent.json index 5b52514e2..6248f6a80 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-ccpa-consent.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-ccpa-consent.json @@ -69,7 +69,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "p": "arm64", "tz": "${json-unit.ignore}", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-commerce-event-purchase.json b/IntegrationTests/wiremock-recordings/mappings/mapping-commerce-event-purchase.json index da8a70770..994ce15de 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-commerce-event-purchase.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-commerce-event-purchase.json @@ -81,7 +81,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "b": "arm64", "p": "arm64", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-gdpr-consent.json b/IntegrationTests/wiremock-recordings/mappings/mapping-gdpr-consent.json index e2abfdbe5..09b6d9302 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-gdpr-consent.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-gdpr-consent.json @@ -59,7 +59,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "tz": "${json-unit.ignore}", "p": "arm64", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-increment-session-attribute-ss.json b/IntegrationTests/wiremock-recordings/mappings/mapping-increment-session-attribute-ss.json index df5cd3857..07e50400d 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-increment-session-attribute-ss.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-increment-session-attribute-ss.json @@ -52,7 +52,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "b": "arm64", "tz": "${json-unit.ignore}", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-increment-session-attribute.json b/IntegrationTests/wiremock-recordings/mappings/mapping-increment-session-attribute.json index ca9d31b63..8f86bcb62 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-increment-session-attribute.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-increment-session-attribute.json @@ -58,7 +58,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "b": "arm64", "tz": "${json-unit.ignore}", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-increment-user-attribute-set.json b/IntegrationTests/wiremock-recordings/mappings/mapping-increment-user-attribute-set.json index 7ceccea5d..3b30f6df8 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-increment-user-attribute-set.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-increment-user-attribute-set.json @@ -56,7 +56,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "p": "arm64", "b": "arm64", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-increment-user-attribute.json b/IntegrationTests/wiremock-recordings/mappings/mapping-increment-user-attribute.json index 0b6fdaa8f..c8d78e201 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-increment-user-attribute.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-increment-user-attribute.json @@ -56,7 +56,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "p": "arm64", "b": "arm64", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-log-error.json b/IntegrationTests/wiremock-recordings/mappings/mapping-log-error.json index 0e5f75ecd..159807d00 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-log-error.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-log-error.json @@ -59,7 +59,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "p": "arm64", "b": "arm64", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-log-event-with-flags.json b/IntegrationTests/wiremock-recordings/mappings/mapping-log-event-with-flags.json index ebf3fd1a9..377542504 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-log-event-with-flags.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-log-event-with-flags.json @@ -65,7 +65,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "tz": "${json-unit.ignore}", "p": "arm64", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-log-exception.json b/IntegrationTests/wiremock-recordings/mappings/mapping-log-exception.json index e513bc5a7..22d7f1312 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-log-exception.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-log-exception.json @@ -57,7 +57,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "tz": "${json-unit.ignore}", "bid": "${json-unit.ignore}", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-log-idfa.json b/IntegrationTests/wiremock-recordings/mappings/mapping-log-idfa.json index c7578d667..ea07812dd 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-log-idfa.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-log-idfa.json @@ -8,7 +8,7 @@ "equalToJson": { "client_sdk": { "platform": "ios", - "sdk_version": "8.42.2", + "sdk_version": "8.43.1", "sdk_vendor": "mparticle" }, "environment": "development", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-log-screen.json b/IntegrationTests/wiremock-recordings/mappings/mapping-log-screen.json index 55693cfee..ced3e63cf 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-log-screen.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-log-screen.json @@ -54,7 +54,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "tz": "${json-unit.ignore}", "bid": "${json-unit.ignore}", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-rokt-identify.json b/IntegrationTests/wiremock-recordings/mappings/mapping-rokt-identify.json index 9dcc3c9d2..43b38eb81 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-rokt-identify.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-rokt-identify.json @@ -8,7 +8,7 @@ "equalToJson": { "client_sdk": { "platform": "ios", - "sdk_version": "8.42.2", + "sdk_version": "8.43.1", "sdk_vendor": "mparticle" }, "environment": "development", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-rokt-select-placement.json b/IntegrationTests/wiremock-recordings/mappings/mapping-rokt-select-placement.json index b4a92e710..532dc17b4 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-rokt-select-placement.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-rokt-select-placement.json @@ -63,7 +63,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "p": "arm64", "tz": "${json-unit.ignore}", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-set-att-status.json b/IntegrationTests/wiremock-recordings/mappings/mapping-set-att-status.json index 1fc2478de..7758ea13f 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-set-att-status.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-set-att-status.json @@ -59,7 +59,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "tz": "${json-unit.ignore}", "p": "arm64", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-set-session-attribute.json b/IntegrationTests/wiremock-recordings/mappings/mapping-set-session-attribute.json index 616981035..5b068004e 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-set-session-attribute.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-set-session-attribute.json @@ -58,7 +58,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "p": "arm64", "tz": "${json-unit.ignore}", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-set-user-attributes.json b/IntegrationTests/wiremock-recordings/mappings/mapping-set-user-attributes.json index d6107d888..5011f755b 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-set-user-attributes.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-set-user-attributes.json @@ -84,7 +84,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "p": "arm64", "b": "arm64", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-timed-event.json b/IntegrationTests/wiremock-recordings/mappings/mapping-timed-event.json index a5cdf8f47..c655bc33c 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-timed-event.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-timed-event.json @@ -59,7 +59,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "tz": "${json-unit.ignore}", "bid": "${json-unit.ignore}", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-v1-identify.json b/IntegrationTests/wiremock-recordings/mappings/mapping-v1-identify.json index d5713aa21..b4cca8a69 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-v1-identify.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-v1-identify.json @@ -8,7 +8,7 @@ "equalToJson": { "client_sdk": { "platform": "ios", - "sdk_version": "8.42.2", + "sdk_version": "8.43.1", "sdk_vendor": "mparticle" }, "environment": "development", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-v2-events-log-event.json b/IntegrationTests/wiremock-recordings/mappings/mapping-v2-events-log-event.json index c21fcb481..dadb0437c 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-v2-events-log-event.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-v2-events-log-event.json @@ -120,7 +120,7 @@ ], "uitl": 60, "oo": false, - "sdk": "8.42.2", + "sdk": "8.43.1", "di": { "p": "arm64", "bid": "${json-unit.ignore}", diff --git a/IntegrationTests/wiremock-recordings/mappings/mapping-v4-config-get-config.json b/IntegrationTests/wiremock-recordings/mappings/mapping-v4-config-get-config.json index 2f4c02fa0..e1c0a5369 100644 --- a/IntegrationTests/wiremock-recordings/mappings/mapping-v4-config-get-config.json +++ b/IntegrationTests/wiremock-recordings/mappings/mapping-v4-config-get-config.json @@ -1,7 +1,7 @@ { "id": "a7d323f1-a729-39c6-86cd-4035c71f7d05", "request": { - "urlPattern": "/v4/us1-[a-f0-9]+/config\\?av=1\\.0&sv=8\\.42\\.2", + "urlPattern": "/v4/us1-[a-f0-9]+/config\\?av=1\\.0&sv=8.43.1", "method": "GET" }, "response": { diff --git a/mParticle-Apple-SDK.podspec b/mParticle-Apple-SDK.podspec index 2be70f637..d68827670 100644 --- a/mParticle-Apple-SDK.podspec +++ b/mParticle-Apple-SDK.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "mParticle-Apple-SDK" - s.version = "8.42.2" + s.version = "8.43.1" s.summary = "mParticle Apple SDK." s.description = <<-DESC diff --git a/mParticle-Apple-SDK/MPIConstants.m b/mParticle-Apple-SDK/MPIConstants.m index 21526daf9..cc5b7c96b 100644 --- a/mParticle-Apple-SDK/MPIConstants.m +++ b/mParticle-Apple-SDK/MPIConstants.m @@ -1,7 +1,7 @@ #import "MPIConstants.h" // mParticle SDK Version -NSString *const kMParticleSDKVersion = @"8.42.2"; +NSString *const kMParticleSDKVersion = @"8.43.1"; // Message Type (dt) NSString *const kMPMessageTypeKey = @"dt"; diff --git a/mParticle_Apple_SDK.json b/mParticle_Apple_SDK.json index 9c4461e4b..485f9be55 100644 --- a/mParticle_Apple_SDK.json +++ b/mParticle_Apple_SDK.json @@ -125,5 +125,6 @@ "8.41.1": "https://github.com/mParticle/mparticle-apple-sdk/releases/download/v8.41.1/mParticle_Apple_SDK.framework.zip?alt=https://github.com/mParticle/mparticle-apple-sdk/releases/download/v8.41.1/mParticle_Apple_SDK.xcframework.zip", "8.42.0": "https://github.com/mParticle/mparticle-apple-sdk/releases/download/v8.42.0/mParticle_Apple_SDK.framework.zip?alt=https://github.com/mParticle/mparticle-apple-sdk/releases/download/v8.42.0/mParticle_Apple_SDK.xcframework.zip", "8.42.1": "https://github.com/mParticle/mparticle-apple-sdk/releases/download/v8.42.1/mParticle_Apple_SDK.framework.zip?alt=https://github.com/mParticle/mparticle-apple-sdk/releases/download/v8.42.1/mParticle_Apple_SDK.xcframework.zip", - "8.42.2": "https://github.com/mParticle/mparticle-apple-sdk/releases/download/v8.42.2/mParticle_Apple_SDK.framework.zip?alt=https://github.com/mParticle/mparticle-apple-sdk/releases/download/v8.42.2/mParticle_Apple_SDK.xcframework.zip" + "8.42.2": "https://github.com/mParticle/mparticle-apple-sdk/releases/download/v8.42.2/mParticle_Apple_SDK.framework.zip?alt=https://github.com/mParticle/mparticle-apple-sdk/releases/download/v8.42.2/mParticle_Apple_SDK.xcframework.zip", + "8.43.1": "https://github.com/mParticle/mparticle-apple-sdk/releases/download/v8.43.1/mParticle_Apple_SDK.framework.zip?alt=https://github.com/mParticle/mparticle-apple-sdk/releases/download/v8.43.1/mParticle_Apple_SDK.xcframework.zip" } From 4995b0d1cf64aa0869e3a98843d3fcf822eac1cd Mon Sep 17 00:00:00 2001 From: James Newman Date: Tue, 17 Feb 2026 11:08:52 -0500 Subject: [PATCH 19/19] chore: Add AGENTS file (#590) (cherry picked from commit 0d3d99b0855ec2fcdc4619ccb795cfa6102fe06a) --- AGENTS.md | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..047d621f7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,162 @@ +# AGENTS.md + +## About mParticle SDKs + +mParticle is a Customer Data Platform that collects, validates, and forwards event data to analytics and marketing integrations. The SDK is responsible for: + +- **Event Collection**: Capturing user interactions, commerce events, and custom events +- **Identity Management**: Managing user identity across sessions and platforms +- **Event Forwarding**: Routing events to configured integrations (kits/forwarders) +- **Data Validation**: Enforcing data quality through data plans +- **Consent Management**: Handling user consent preferences (GDPR, CCPA) +- **Session Management**: Tracking user sessions and engagement +- **Batch Upload**: Efficiently uploading events to mParticle servers + +### Glossary of Terms + +- **MPID (mParticle ID)**: Unique identifier for a user across sessions and devices +- **Kit/Forwarder**: Third-party integration (e.g., Google Analytics, Braze) that receives events from the SDK +- **Data Plan**: Validation schema that defines expected events and their attributes +- **Workspace**: A customer's mParticle environment (identified by API key) +- **Batch**: Collection of events grouped together for efficient server upload +- **Identity Request**: API call to identify, login, logout, or modify a user's identity +- **Session**: Period of user activity with automatic timeout (typically 30 minutes) +- **Consent State**: User's privacy preferences (GDPR, CCPA) that control data collection and forwarding +- **User Attributes**: Key-value pairs describing user properties (e.g., email, age, preferences) +- **Custom Events**: Application-specific events defined by the developer +- **Commerce Events**: Predefined events for e-commerce tracking (purchases, product views, etc.) +- **Event Type**: Category of event (Navigation, Location, Transaction, UserContent, UserPreference, Social, Other) + +## Role for agents + +You are a senior iOS SDK engineer specializing in customer data platform (CDP) SDK development. + +- Treat this as a **public SDK / framework** (distributed via SPM, and CocoaPods), not a full consumer app. +- Prioritize: API stability, minimal footprint, backward compatibility (iOS 15.6+, tvOS 15.6+), thread-safety, privacy compliance. +- The SDK handles event tracking, identity management, consent, commerce events, push notifications, and integration kits. +- Avoid proposing big refactors unless explicitly asked; prefer additive changes + deprecations. + +## Quick Start for Agents + +- Open the Xcode project/workspace with Xcode 16.4+. +- Primary actions: + - Build: via Xcode scheme or `xcodebuild`. + - Run unit tests: `Rokt_WidgetTests/` or via Xcode (Command + U). + - Lint: `trunk check` (primary enforcement tool). + - Pod lint: `pod lib lint mParticle-Apple-SDK.podspec`. + - Size report: Check binary size impact via CI workflow. +- Always validate changes with the full sequence in "Code style, quality, and validation" below before proposing or committing. + +## Strict Do's and Don'ts + +### Always Do + +- Maintain compatibility with mParticle's kit/integration ecosystem. +- Keep public API surface additive; deprecate instead of remove. +- Mark public APIs with thorough documentation (HeaderDoc for Obj-C, `///` for Swift). +- Ensure changes work on both iOS and tvOS targets. +- Run `trunk check` and unit tests before any commit. +- Measure & report size impact before proposing dependency or asset changes. +- Update `PrivacyInfo.xcprivacy` if data collection practices change. + +### Never + +- Introduce new third-party dependencies without size/performance justification and approval. +- Block the main thread (no synchronous network, heavy computation, etc.). +- Crash on bad input/network — always provide fallback / error callback. +- Touch CI configs (`.github/`), release scripts (`Scripts/`), or CI YAML without explicit request. +- Propose dropping iOS 15.6 / tvOS 15.6 support or raising min deployment target. +- Break kit/integration compatibility without explicit coordination. +- Modify vendored libraries in `Libraries/` without explicit request. + +## When to Ask for Clarification + +- Before adding any new dependency. +- Before dropping support for OS versions. +- Before making breaking API changes. +- When changes affect the kit/integration interface. +- When test failures suggest the original code may have had bugs. + +## Project overview + +- mParticle Apple SDK (Rokt fork): a comprehensive customer data platform SDK for iOS and tvOS written in Objective-C and Swift. +- Handles event tracking, user identity management, consent management, commerce events, push notification handling, and integration kit orchestration. +- Distributed via Swift Package Manager, CocoaPods, and Carthage. +- Integration kits (like the Rokt kit) plug into this SDK to forward events to third-party services. + +## Key paths + +- `mParticle-Apple-SDK/` — Main SDK source (40+ subdirectories). + - `Include/` — Public headers (46 files). + - `AppNotifications/` — Push notification handling. + - `Consent/` — Consent management. + - `Data Model/` — Core data structures. + - `Ecommerce/` — Commerce event handling. + - `Event/` — Event processing. + - `Identity/` — User identity management. + - `Kits/` — Integration kit infrastructure. + - `Network/` — Network communication. + - `Persistence/` — Data storage. +- `mParticle-Apple-SDK-Swift/` — Swift-only components. +- `UnitTests/` — Unit tests (ObjCTests, SwiftTests, Mocks). +- `IntegrationTests/` — Integration tests (Tuist + WireMock). +- `Example/` — Sample app (11 subdirectories). +- `Scripts/` — Build, release, and utility scripts. + - `release.sh`, `xcframework.sh`, `carthage.sh`, `check_coverage.sh`. +- `Package.swift` — SPM manifest (swift-tools-version 5.5). +- `mParticle-Apple-SDK.podspec` — CocoaPods spec (v8.41.1). +- `PrivacyInfo.xcprivacy` — iOS privacy manifest. +- `ARCHITECTURE.md` — Architecture documentation with sequence diagrams. +- `CHANGELOG.md` — Release notes (extensive). +- `MIGRATING.md` — Migration guides for older versions. +- `RELEASE.md` — Release process documentation. +- `CONTRIBUTING.md` — Contribution guidelines. + +## Code style, quality, and validation + +- **Lint & format tools**: + - SwiftFormat: configured in project. + - SwiftLint: configured in project. + - **Primary enforcement tool**: `trunk check` (via Trunk.io). If Trunk unavailable, fall back to `swiftformat .` && `swiftlint`. + - Important: Only add comments if absolutely necessary. If you're adding comments, review why the code is hard to reason with and rewrite that first. + +- **Strict post-change validation rule (always follow this)**: + After **any** code change, refactor, or addition — even small ones — you **must** run the full validation sequence: + 1. `trunk check` — to lint, format-check, and catch style/quality issues. + 2. Build the SDK: via Xcode or `xcodebuild` for both iOS and tvOS. + 3. Run unit tests: both Objective-C and Swift test suites in `UnitTests/`. + 4. `pod lib lint mParticle-Apple-SDK.podspec` — verify CocoaPods spec is valid. + 5. If change affects code, assets, or dependencies: check coverage via `Scripts/check_coverage.sh`. + - Only propose / commit changes if all steps pass cleanly. + - If `trunk check` suggests auto-fixes, apply them first and re-validate. + - Never bypass this — it's required to maintain SDK stability, footprint, and public API quality. + +- **Style preferences**: + - Objective-C: follow Apple's Coding Guidelines for Cocoa. + - Swift: prefer `let` over `var`; use value types where possible. + - Write thorough documentation for all public APIs. + - Avoid force-unwraps in Swift; use proper error handling in Objective-C. + +- **Testing expectations**: + - Unit tests in `UnitTests/ObjCTests/` and `UnitTests/SwiftTests/`. + - Mocks in `UnitTests/Mocks/`. + - Integration tests in `IntegrationTests/`. + - Code coverage tracked via `Scripts/check_coverage.sh`. + - After changes, always re-run affected tests + full suite if core/shared code is touched. + +- **CHANGELOG.md maintenance**: + - For **substantial changes**, **always add a clear entry** to `CHANGELOG.md`. + - Use standard categories: `Added`, `Changed`, `Deprecated`, `Fixed`, `Removed`, `Security`. + - Keep entries concise and written in imperative mood. + - Update `CHANGELOG.md` **before** finalizing a change. + - Never auto-generate or hallucinate changelog entries — flag for human review. + +## Pull request and branching + +- Follow mParticle's standard PR and branching conventions. + +## External Resources + +- [mParticle Apple SDK Documentation](https://docs.mparticle.com/developers/sdk/ios/) +- [Rokt mParticle Integration Docs](https://docs.rokt.com/developers/integration-guides/rokt-ads/customer-data-platforms/mparticle/) +- [ARCHITECTURE.md](./ARCHITECTURE.md) — SDK architecture and sequence diagrams.