From ceaee4229fdc024998ea6790d46b0ce123b39aae Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Thu, 25 Jun 2026 12:47:32 -0400 Subject: [PATCH 01/10] feat: expose device-based consent APIs in React Native bridge Bridge setDeviceConsentState, clearDeviceConsentState, and getDeviceConsentState to native device consent on iOS and Android. --- CHANGELOG.md | 2 + .../com/mparticle/react/MParticleModule.kt | 80 ++++++++ .../mparticle/react/NativeMParticleSpec.kt | 6 + ios/RNMParticle/RNMParticle.mm | 172 ++++++++++++++++++ js/codegenSpecs/NativeMParticle.ts | 10 + js/index.tsx | 24 +++ 6 files changed, 294 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e1fde..a5ad8f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Expose device-level consent APIs: `setDeviceConsentState`, `clearDeviceConsentState`, and `getDeviceConsentState`, bridging to native `MParticle.deviceConsentState` (iOS) and `MParticle.setDeviceConsentState()` (Android). Requires mParticle Apple SDK 9.2+ with device consent; Android resolves `android-core` `[5.79.2, 6.0)` and picks up device consent APIs once published. + - Expo config plugin: optional `pinningDisabled` for `MPNetworkOptions` / `NetworkOptions` at SDK startup ### Fixed diff --git a/android/src/main/java/com/mparticle/react/MParticleModule.kt b/android/src/main/java/com/mparticle/react/MParticleModule.kt index f2a90f6..8214876 100644 --- a/android/src/main/java/com/mparticle/react/MParticleModule.kt +++ b/android/src/main/java/com/mparticle/react/MParticleModule.kt @@ -534,6 +534,30 @@ class MParticleModule( } } + @ReactMethod + override fun setDeviceConsentState(consentState: ReadableMap?) { + val instance = MParticle.getInstance() ?: return + if (consentState == null) { + return + } + convertToConsentState(consentState)?.let { instance.setDeviceConsentState(it) } + } + + @ReactMethod + override fun clearDeviceConsentState() { + MParticle.getInstance()?.setDeviceConsentState(null) + } + + @ReactMethod + override fun getDeviceConsentState(callback: Callback) { + val instance = MParticle.getInstance() + if (instance == null) { + callback.invoke(null) + return + } + callback.invoke(consentStateToMap(instance.getDeviceConsentState())) + } + protected fun getWritableMap(): WritableMap = WritableNativeMap() private fun convertIdentityAPIRequest(map: ReadableMap?): IdentityApiRequest { @@ -973,4 +997,60 @@ class MParticleModule( } return builder.build() } + + private fun convertToConsentState(map: ReadableMap): ConsentState? { + val builder = ConsentState.builder() + if (map.hasKey("gdpr")) { + val gdprMap = map.getMap("gdpr") ?: return null + val iterator = gdprMap.keySetIterator() + while (iterator.hasNextKey()) { + val purpose = iterator.nextKey() + val consentMap = gdprMap.getMap(purpose) ?: continue + convertToGDPRConsent(consentMap)?.let { builder.addGDPRConsentState(purpose, it) } + } + } + if (map.hasKey("ccpa")) { + map.getMap("ccpa")?.let { ccpaMap -> + convertToCCPAConsent(ccpaMap)?.let { builder.setCCPAConsentState(it) } + } + } + return builder.build() + } + + private fun consentStateToMap(state: ConsentState?): WritableMap? { + if (state == null) { + return null + } + val result = Arguments.createMap() + val gdprConsentState = state.gdprConsentState + if (gdprConsentState.isNotEmpty()) { + val gdprMap = Arguments.createMap() + for ((purpose, consent) in gdprConsentState) { + gdprMap.putMap(purpose, gdprConsentToMap(consent)) + } + result.putMap("gdpr", gdprMap) + } + state.ccpaConsentState?.let { result.putMap("ccpa", ccpaConsentToMap(it)) } + return if (result.toHashMap().isEmpty()) null else result + } + + private fun gdprConsentToMap(consent: GDPRConsent): WritableMap { + val map = Arguments.createMap() + map.putBoolean("consented", consent.isConsented) + consent.document?.let { map.putString("document", it) } + consent.location?.let { map.putString("location", it) } + consent.hardwareId?.let { map.putString("hardwareId", it) } + consent.timestamp?.let { map.putDouble("timestamp", it.toDouble()) } + return map + } + + private fun ccpaConsentToMap(consent: CCPAConsent): WritableMap { + val map = Arguments.createMap() + map.putBoolean("consented", consent.isConsented) + consent.document?.let { map.putString("document", it) } + consent.location?.let { map.putString("location", it) } + consent.hardwareId?.let { map.putString("hardwareId", it) } + consent.timestamp?.let { map.putDouble("timestamp", it.toDouble()) } + return map + } } diff --git a/android/src/oldarch/java/com/mparticle/react/NativeMParticleSpec.kt b/android/src/oldarch/java/com/mparticle/react/NativeMParticleSpec.kt index a81e9fd..66278d2 100644 --- a/android/src/oldarch/java/com/mparticle/react/NativeMParticleSpec.kt +++ b/android/src/oldarch/java/com/mparticle/react/NativeMParticleSpec.kt @@ -57,6 +57,12 @@ abstract class NativeMParticleSpec( abstract fun removeCCPAConsentState() + abstract fun setDeviceConsentState(consentState: ReadableMap?) + + abstract fun clearDeviceConsentState() + + abstract fun getDeviceConsentState(callback: Callback) + abstract fun isKitActive( kitId: Double, callback: Callback, diff --git a/ios/RNMParticle/RNMParticle.mm b/ios/RNMParticle/RNMParticle.mm index db3a8b1..b37c36d 100644 --- a/ios/RNMParticle/RNMParticle.mm +++ b/ios/RNMParticle/RNMParticle.mm @@ -108,6 +108,29 @@ + (void)load { user.consentState = consentState; } +RCT_EXPORT_METHOD(setDeviceConsentState:(NSDictionary *)consentState) +{ + if (consentState == nil || consentState == (id)[NSNull null]) { + return; + } + [MParticle sharedInstance].deviceConsentState = [RCTConvert MPConsentState:consentState]; +} + +RCT_EXPORT_METHOD(clearDeviceConsentState) +{ + [MParticle sharedInstance].deviceConsentState = nil; +} + +RCT_EXPORT_METHOD(getDeviceConsentState:(RCTResponseSenderBlock)callback) +{ + MPConsentState *deviceConsent = [MParticle sharedInstance].deviceConsentState; + if (deviceConsent == nil) { + callback(@[[NSNull null]]); + return; + } + callback(@[[RNMParticle consentStateToDictionary:deviceConsent]]); +} + #if TARGET_OS_IOS == 1 RCT_EXPORT_METHOD(logPushRegistration:(NSString *)token senderId:(NSString *)senderId) { @@ -584,6 +607,68 @@ - (void)setCCPAConsentState:(JS::NativeMParticle::CCPAConsent &)consent { [consentState setCCPAConsentState:ccpaConsent]; user.consentState = consentState; } + +- (void)setDeviceConsentState:(JS::NativeMParticle::DeviceConsentState &)consentState { + NSMutableDictionary *dict = [NSMutableDictionary dictionary]; + if (consentState.gdpr().has_value()) { + NSMutableDictionary *gdprDict = [NSMutableDictionary dictionary]; + for (const auto &entry : consentState.gdpr().value()) { + const auto &consent = entry.second; + NSMutableDictionary *consentDict = [NSMutableDictionary dictionary]; + if (consent.consented().has_value()) { + consentDict[@"consented"] = @(consent.consented().value()); + } + if (consent.document()) { + consentDict[@"document"] = consent.document(); + } + if (consent.timestamp().has_value()) { + consentDict[@"timestamp"] = @(consent.timestamp().value()); + } + if (consent.location()) { + consentDict[@"location"] = consent.location(); + } + if (consent.hardwareId()) { + consentDict[@"hardwareId"] = consent.hardwareId(); + } + gdprDict[[NSString stringWithUTF8String:entry.first.c_str()]] = consentDict; + } + dict[@"gdpr"] = gdprDict; + } + if (consentState.ccpa().has_value()) { + const auto &ccpa = consentState.ccpa().value(); + NSMutableDictionary *ccpaDict = [NSMutableDictionary dictionary]; + if (ccpa.consented().has_value()) { + ccpaDict[@"consented"] = @(ccpa.consented().value()); + } + if (ccpa.document()) { + ccpaDict[@"document"] = ccpa.document(); + } + if (ccpa.timestamp().has_value()) { + ccpaDict[@"timestamp"] = @(ccpa.timestamp().value()); + } + if (ccpa.location()) { + ccpaDict[@"location"] = ccpa.location(); + } + if (ccpa.hardwareId()) { + ccpaDict[@"hardwareId"] = ccpa.hardwareId(); + } + dict[@"ccpa"] = ccpaDict; + } + [MParticle sharedInstance].deviceConsentState = [RCTConvert MPConsentState:dict]; +} + +- (void)clearDeviceConsentState { + [MParticle sharedInstance].deviceConsentState = nil; +} + +- (void)getDeviceConsentState:(RCTResponseSenderBlock)callback { + MPConsentState *deviceConsent = [MParticle sharedInstance].deviceConsentState; + if (deviceConsent == nil) { + callback(@[[NSNull null]]); + return; + } + callback(@[[RNMParticle consentStateToDictionary:deviceConsent]]); +} #else RCT_EXPORT_METHOD(logMPEvent:(MPEvent *)event) @@ -907,6 +992,59 @@ + (MPAliasRequest *)MPAliasRequest:(NSDictionary *)dict { return [MPAliasRequest requestWithSourceMPID:sourceMpid destinationMPID:destinationMpid startTime:startTime endTime:endTime]; } ++ (NSDictionary *)consentStateToDictionary:(MPConsentState *)consentState +{ + if (consentState == nil) { + return nil; + } + + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + NSDictionary *gdprState = consentState.gdprConsentState; + if (gdprState.count > 0) { + NSMutableDictionary *gdpr = [NSMutableDictionary dictionaryWithCapacity:gdprState.count]; + for (NSString *purpose in gdprState) { + MPGDPRConsent *consent = gdprState[purpose]; + NSMutableDictionary *consentDict = [NSMutableDictionary dictionary]; + consentDict[@"consented"] = @(consent.consented); + if (consent.document) { + consentDict[@"document"] = consent.document; + } + if (consent.timestamp) { + consentDict[@"timestamp"] = @((long long)([consent.timestamp timeIntervalSince1970] * 1000)); + } + if (consent.location) { + consentDict[@"location"] = consent.location; + } + if (consent.hardwareId) { + consentDict[@"hardwareId"] = consent.hardwareId; + } + gdpr[purpose] = consentDict; + } + result[@"gdpr"] = gdpr; + } + + MPCCPAConsent *ccpa = consentState.ccpaConsentState; + if (ccpa != nil) { + NSMutableDictionary *ccpaDict = [NSMutableDictionary dictionary]; + ccpaDict[@"consented"] = @(ccpa.consented); + if (ccpa.document) { + ccpaDict[@"document"] = ccpa.document; + } + if (ccpa.timestamp) { + ccpaDict[@"timestamp"] = @((long long)([ccpa.timestamp timeIntervalSince1970] * 1000)); + } + if (ccpa.location) { + ccpaDict[@"location"] = ccpa.location; + } + if (ccpa.hardwareId) { + ccpaDict[@"hardwareId"] = ccpa.hardwareId; + } + result[@"ccpa"] = ccpaDict; + } + + return result.count > 0 ? result : nil; +} + @end typedef NS_ENUM(NSUInteger, MPReactCommerceEventAction) { @@ -938,6 +1076,7 @@ + (MParticleUser *)MParticleUser:(id)json; + (MPEvent *)MPEvent:(id)json; + (MPGDPRConsent *)MPGDPRConsent:(id)json; + (MPCCPAConsent *)MPCCPAConsent:(id)json; ++ (MPConsentState *)MPConsentState:(id)json; @end @@ -1225,4 +1364,37 @@ + (MPCCPAConsent *)MPCCPAConsent:(id)json { return mpConsent; } ++ (MPConsentState *)MPConsentState:(id)json +{ + if (![json isKindOfClass:[NSDictionary class]]) { + return nil; + } + + NSDictionary *dict = (NSDictionary *)json; + MPConsentState *state = [[MPConsentState alloc] init]; + NSDictionary *gdpr = dict[@"gdpr"]; + if ([gdpr isKindOfClass:[NSDictionary class]]) { + for (NSString *purpose in gdpr) { + id consentJson = gdpr[purpose]; + if (consentJson == [NSNull null]) { + continue; + } + MPGDPRConsent *consent = [RCTConvert MPGDPRConsent:consentJson]; + if (consent != nil) { + [state addGDPRConsentState:consent purpose:purpose]; + } + } + } + + id ccpaJson = dict[@"ccpa"]; + if (ccpaJson != nil && ccpaJson != [NSNull null]) { + MPCCPAConsent *ccpa = [RCTConvert MPCCPAConsent:ccpaJson]; + if (ccpa != nil) { + [state setCCPAConsentState:ccpa]; + } + } + + return state; +} + @end diff --git a/js/codegenSpecs/NativeMParticle.ts b/js/codegenSpecs/NativeMParticle.ts index 5a96266..3efce97 100644 --- a/js/codegenSpecs/NativeMParticle.ts +++ b/js/codegenSpecs/NativeMParticle.ts @@ -86,6 +86,11 @@ export interface CCPAConsent { hardwareId?: string | null; } +export interface DeviceConsentState { + gdpr?: { [purpose: string]: GDPRConsent }; + ccpa?: CCPAConsent | null; +} + export type AttributionResult = { [key: string]: { [key: string]: string | number | boolean; @@ -139,6 +144,11 @@ export interface Spec extends TurboModule { removeGDPRConsentStateWithPurpose(purpose: string): void; setCCPAConsentState(consent: CCPAConsent): void; removeCCPAConsentState(): void; + setDeviceConsentState(consentState: DeviceConsentState): void; + clearDeviceConsentState(): void; + getDeviceConsentState( + callback: (result: DeviceConsentState | null) => void + ): void; isKitActive(kitId: number, callback: (result: boolean) => void): void; getAttributions(callback: (result: AttributionResult) => void): void; logPushRegistration(token: string, senderId: string): void; diff --git a/js/index.tsx b/js/index.tsx index 31dfcd4..6cd23b2 100644 --- a/js/index.tsx +++ b/js/index.tsx @@ -210,6 +210,27 @@ export const removeCCPAConsentState = (): void => { MParticleModule.removeCCPAConsentState(); }; +export interface DeviceConsentState { + gdpr?: { [purpose: string]: GDPRConsent }; + ccpa?: CCPAConsent | null; +} + +export const setDeviceConsentState = ( + consentState: DeviceConsentState +): void => { + MParticleModule.setDeviceConsentState(consentState); +}; + +export const clearDeviceConsentState = (): void => { + MParticleModule.clearDeviceConsentState(); +}; + +export const getDeviceConsentState = ( + completion: CompletionCallback +): void => { + MParticleModule.getDeviceConsentState(completion); +}; + export const isKitActive = ( kitId: number, completion: CompletionCallback @@ -930,6 +951,9 @@ const MParticle = { removeGDPRConsentStateWithPurpose, setCCPAConsentState, removeCCPAConsentState, + setDeviceConsentState, + clearDeviceConsentState, + getDeviceConsentState, isKitActive, getAttributions, logPushRegistration, From 31dc3929cc995384d148a7abe65b29fc8af80d3b Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Thu, 25 Jun 2026 14:03:27 -0400 Subject: [PATCH 02/10] fix: align DeviceConsentState type with codegen spec Re-export DeviceConsentState from NativeMParticle codegen so getDeviceConsentState callback matches the TurboModule signature. --- js/index.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/js/index.tsx b/js/index.tsx index 6cd23b2..4bad3ef 100644 --- a/js/index.tsx +++ b/js/index.tsx @@ -13,6 +13,7 @@ import type { Spec as NativeMParticleInterface, CallbackError, UserAttributes as NativeUserAttributes, + DeviceConsentState, } from './codegenSpecs/NativeMParticle'; import { getNativeModule } from './utils/architecture'; @@ -210,10 +211,7 @@ export const removeCCPAConsentState = (): void => { MParticleModule.removeCCPAConsentState(); }; -export interface DeviceConsentState { - gdpr?: { [purpose: string]: GDPRConsent }; - ccpa?: CCPAConsent | null; -} +export type { DeviceConsentState }; export const setDeviceConsentState = ( consentState: DeviceConsentState From 1290eb16e2911c82d7970fad9f548719c7c8a051 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Thu, 25 Jun 2026 15:12:02 -0400 Subject: [PATCH 03/10] fix(ios): forward-declare RCTConvert MPConsentState for old arch Device consent RCT_EXPORT_METHODs call MPConsentState: before the RCTConvert category is defined later in the file. --- ios/RNMParticle/RNMParticle.mm | 1 + 1 file changed, 1 insertion(+) diff --git a/ios/RNMParticle/RNMParticle.mm b/ios/RNMParticle/RNMParticle.mm index b37c36d..6e8125f 100644 --- a/ios/RNMParticle/RNMParticle.mm +++ b/ios/RNMParticle/RNMParticle.mm @@ -24,6 +24,7 @@ - (void)setUserId:(NSNumber *)userId; @interface RCTConvert (MPCommerceEvent) + (MPCommerceEventAction)MPCommerceEventAction:(id)json; + (MPPromotionAction)MPPromotionAction:(id)json; ++ (MPConsentState *)MPConsentState:(id)json; @end @implementation RNMParticle From 3cddb220fdd05252a51074b35c4fa0cf9e3f92a5 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Thu, 25 Jun 2026 15:20:06 -0400 Subject: [PATCH 04/10] fix(ios): resolve device consent new arch build errors - Move old-arch RCT_EXPORT_METHODs into #else block to avoid duplicate declarations when New Architecture is enabled. - Move consentStateToDictionary onto RNMParticle (was wrongly on RCTConvert). - Use NSDictionary codegen types for new-arch setDeviceConsentState. --- ios/RNMParticle/RNMParticle.mm | 204 ++++++++++++++------------------- 1 file changed, 86 insertions(+), 118 deletions(-) diff --git a/ios/RNMParticle/RNMParticle.mm b/ios/RNMParticle/RNMParticle.mm index 6e8125f..75a70fb 100644 --- a/ios/RNMParticle/RNMParticle.mm +++ b/ios/RNMParticle/RNMParticle.mm @@ -19,6 +19,10 @@ @interface MParticleUser () - (void)setUserId:(NSNumber *)userId; @end +@interface RNMParticle (DeviceConsent) ++ (NSDictionary *)consentStateToDictionary:(MPConsentState *)consentState; +@end + // Forward declare so New Arch `logCommerceEvent` can use the same JS→native // mappings as `RCTConvert (MPCommerceEvent)` (defined later in this file). @interface RCTConvert (MPCommerceEvent) @@ -109,29 +113,6 @@ + (void)load { user.consentState = consentState; } -RCT_EXPORT_METHOD(setDeviceConsentState:(NSDictionary *)consentState) -{ - if (consentState == nil || consentState == (id)[NSNull null]) { - return; - } - [MParticle sharedInstance].deviceConsentState = [RCTConvert MPConsentState:consentState]; -} - -RCT_EXPORT_METHOD(clearDeviceConsentState) -{ - [MParticle sharedInstance].deviceConsentState = nil; -} - -RCT_EXPORT_METHOD(getDeviceConsentState:(RCTResponseSenderBlock)callback) -{ - MPConsentState *deviceConsent = [MParticle sharedInstance].deviceConsentState; - if (deviceConsent == nil) { - callback(@[[NSNull null]]); - return; - } - callback(@[[RNMParticle consentStateToDictionary:deviceConsent]]); -} - #if TARGET_OS_IOS == 1 RCT_EXPORT_METHOD(logPushRegistration:(NSString *)token senderId:(NSString *)senderId) { @@ -611,49 +592,13 @@ - (void)setCCPAConsentState:(JS::NativeMParticle::CCPAConsent &)consent { - (void)setDeviceConsentState:(JS::NativeMParticle::DeviceConsentState &)consentState { NSMutableDictionary *dict = [NSMutableDictionary dictionary]; - if (consentState.gdpr().has_value()) { - NSMutableDictionary *gdprDict = [NSMutableDictionary dictionary]; - for (const auto &entry : consentState.gdpr().value()) { - const auto &consent = entry.second; - NSMutableDictionary *consentDict = [NSMutableDictionary dictionary]; - if (consent.consented().has_value()) { - consentDict[@"consented"] = @(consent.consented().value()); - } - if (consent.document()) { - consentDict[@"document"] = consent.document(); - } - if (consent.timestamp().has_value()) { - consentDict[@"timestamp"] = @(consent.timestamp().value()); - } - if (consent.location()) { - consentDict[@"location"] = consent.location(); - } - if (consent.hardwareId()) { - consentDict[@"hardwareId"] = consent.hardwareId(); - } - gdprDict[[NSString stringWithUTF8String:entry.first.c_str()]] = consentDict; - } - dict[@"gdpr"] = gdprDict; + id gdpr = consentState.gdpr(); + if (gdpr != nil && gdpr != [NSNull null]) { + dict[@"gdpr"] = gdpr; } - if (consentState.ccpa().has_value()) { - const auto &ccpa = consentState.ccpa().value(); - NSMutableDictionary *ccpaDict = [NSMutableDictionary dictionary]; - if (ccpa.consented().has_value()) { - ccpaDict[@"consented"] = @(ccpa.consented().value()); - } - if (ccpa.document()) { - ccpaDict[@"document"] = ccpa.document(); - } - if (ccpa.timestamp().has_value()) { - ccpaDict[@"timestamp"] = @(ccpa.timestamp().value()); - } - if (ccpa.location()) { - ccpaDict[@"location"] = ccpa.location(); - } - if (ccpa.hardwareId()) { - ccpaDict[@"hardwareId"] = ccpa.hardwareId(); - } - dict[@"ccpa"] = ccpaDict; + id ccpa = consentState.ccpa(); + if (ccpa != nil && ccpa != [NSNull null]) { + dict[@"ccpa"] = ccpa; } [MParticle sharedInstance].deviceConsentState = [RCTConvert MPConsentState:dict]; } @@ -700,6 +645,29 @@ - (void)getDeviceConsentState:(RCTResponseSenderBlock)callback { user.consentState = consentState; } +RCT_EXPORT_METHOD(setDeviceConsentState:(NSDictionary *)consentState) +{ + if (consentState == nil || consentState == (id)[NSNull null]) { + return; + } + [MParticle sharedInstance].deviceConsentState = [RCTConvert MPConsentState:consentState]; +} + +RCT_EXPORT_METHOD(clearDeviceConsentState) +{ + [MParticle sharedInstance].deviceConsentState = nil; +} + +RCT_EXPORT_METHOD(getDeviceConsentState:(RCTResponseSenderBlock)callback) +{ + MPConsentState *deviceConsent = [MParticle sharedInstance].deviceConsentState; + if (deviceConsent == nil) { + callback(@[[NSNull null]]); + return; + } + callback(@[[RNMParticle consentStateToDictionary:deviceConsent]]); +} + #endif // Helper method to create MPProduct from dictionary @@ -827,6 +795,59 @@ + (BOOL)isNumericIdentityKey:(NSString *)key { return [numericSet isSupersetOfSet:keyCharacterSet]; } ++ (NSDictionary *)consentStateToDictionary:(MPConsentState *)consentState +{ + if (consentState == nil) { + return nil; + } + + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + NSDictionary *gdprState = consentState.gdprConsentState; + if (gdprState.count > 0) { + NSMutableDictionary *gdpr = [NSMutableDictionary dictionaryWithCapacity:gdprState.count]; + for (NSString *purpose in gdprState) { + MPGDPRConsent *consent = gdprState[purpose]; + NSMutableDictionary *consentDict = [NSMutableDictionary dictionary]; + consentDict[@"consented"] = @(consent.consented); + if (consent.document) { + consentDict[@"document"] = consent.document; + } + if (consent.timestamp) { + consentDict[@"timestamp"] = @((long long)([consent.timestamp timeIntervalSince1970] * 1000)); + } + if (consent.location) { + consentDict[@"location"] = consent.location; + } + if (consent.hardwareId) { + consentDict[@"hardwareId"] = consent.hardwareId; + } + gdpr[purpose] = consentDict; + } + result[@"gdpr"] = gdpr; + } + + MPCCPAConsent *ccpa = consentState.ccpaConsentState; + if (ccpa != nil) { + NSMutableDictionary *ccpaDict = [NSMutableDictionary dictionary]; + ccpaDict[@"consented"] = @(ccpa.consented); + if (ccpa.document) { + ccpaDict[@"document"] = ccpa.document; + } + if (ccpa.timestamp) { + ccpaDict[@"timestamp"] = @((long long)([ccpa.timestamp timeIntervalSince1970] * 1000)); + } + if (ccpa.location) { + ccpaDict[@"location"] = ccpa.location; + } + if (ccpa.hardwareId) { + ccpaDict[@"hardwareId"] = ccpa.hardwareId; + } + result[@"ccpa"] = ccpaDict; + } + + return result.count > 0 ? result : nil; +} + @end // RCTConvert category methods for mParticle types @@ -993,59 +1014,6 @@ + (MPAliasRequest *)MPAliasRequest:(NSDictionary *)dict { return [MPAliasRequest requestWithSourceMPID:sourceMpid destinationMPID:destinationMpid startTime:startTime endTime:endTime]; } -+ (NSDictionary *)consentStateToDictionary:(MPConsentState *)consentState -{ - if (consentState == nil) { - return nil; - } - - NSMutableDictionary *result = [NSMutableDictionary dictionary]; - NSDictionary *gdprState = consentState.gdprConsentState; - if (gdprState.count > 0) { - NSMutableDictionary *gdpr = [NSMutableDictionary dictionaryWithCapacity:gdprState.count]; - for (NSString *purpose in gdprState) { - MPGDPRConsent *consent = gdprState[purpose]; - NSMutableDictionary *consentDict = [NSMutableDictionary dictionary]; - consentDict[@"consented"] = @(consent.consented); - if (consent.document) { - consentDict[@"document"] = consent.document; - } - if (consent.timestamp) { - consentDict[@"timestamp"] = @((long long)([consent.timestamp timeIntervalSince1970] * 1000)); - } - if (consent.location) { - consentDict[@"location"] = consent.location; - } - if (consent.hardwareId) { - consentDict[@"hardwareId"] = consent.hardwareId; - } - gdpr[purpose] = consentDict; - } - result[@"gdpr"] = gdpr; - } - - MPCCPAConsent *ccpa = consentState.ccpaConsentState; - if (ccpa != nil) { - NSMutableDictionary *ccpaDict = [NSMutableDictionary dictionary]; - ccpaDict[@"consented"] = @(ccpa.consented); - if (ccpa.document) { - ccpaDict[@"document"] = ccpa.document; - } - if (ccpa.timestamp) { - ccpaDict[@"timestamp"] = @((long long)([ccpa.timestamp timeIntervalSince1970] * 1000)); - } - if (ccpa.location) { - ccpaDict[@"location"] = ccpa.location; - } - if (ccpa.hardwareId) { - ccpaDict[@"hardwareId"] = ccpa.hardwareId; - } - result[@"ccpa"] = ccpaDict; - } - - return result.count > 0 ? result : nil; -} - @end typedef NS_ENUM(NSUInteger, MPReactCommerceEventAction) { From 315591d87a840282cd2ae47d6d0db989295b8ea8 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Thu, 25 Jun 2026 15:25:52 -0400 Subject: [PATCH 05/10] fix(ios): avoid NSInvalidArgumentException on empty device consent Use NSNull in getDeviceConsentState callback when consentStateToDictionary returns nil for an empty MPConsentState. --- ios/RNMParticle/RNMParticle.mm | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ios/RNMParticle/RNMParticle.mm b/ios/RNMParticle/RNMParticle.mm index 75a70fb..9706ea3 100644 --- a/ios/RNMParticle/RNMParticle.mm +++ b/ios/RNMParticle/RNMParticle.mm @@ -613,7 +613,8 @@ - (void)getDeviceConsentState:(RCTResponseSenderBlock)callback { callback(@[[NSNull null]]); return; } - callback(@[[RNMParticle consentStateToDictionary:deviceConsent]]); + NSDictionary *consentDict = [RNMParticle consentStateToDictionary:deviceConsent]; + callback(@[consentDict ?: [NSNull null]]); } #else @@ -665,7 +666,8 @@ - (void)getDeviceConsentState:(RCTResponseSenderBlock)callback { callback(@[[NSNull null]]); return; } - callback(@[[RNMParticle consentStateToDictionary:deviceConsent]]); + NSDictionary *consentDict = [RNMParticle consentStateToDictionary:deviceConsent]; + callback(@[consentDict ?: [NSNull null]]); } #endif From f684e60b76abbdf76a7d39ee9ede4cf32d1f3ff7 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Thu, 25 Jun 2026 15:31:22 -0400 Subject: [PATCH 06/10] fix(android): skip invalid GDPR map without dropping CCPA When gdpr is null or not a map, continue processing ccpa instead of returning null from convertToConsentState. --- .../java/com/mparticle/react/MParticleModule.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/com/mparticle/react/MParticleModule.kt b/android/src/main/java/com/mparticle/react/MParticleModule.kt index 8214876..1d6d4d7 100644 --- a/android/src/main/java/com/mparticle/react/MParticleModule.kt +++ b/android/src/main/java/com/mparticle/react/MParticleModule.kt @@ -1001,12 +1001,13 @@ class MParticleModule( private fun convertToConsentState(map: ReadableMap): ConsentState? { val builder = ConsentState.builder() if (map.hasKey("gdpr")) { - val gdprMap = map.getMap("gdpr") ?: return null - val iterator = gdprMap.keySetIterator() - while (iterator.hasNextKey()) { - val purpose = iterator.nextKey() - val consentMap = gdprMap.getMap(purpose) ?: continue - convertToGDPRConsent(consentMap)?.let { builder.addGDPRConsentState(purpose, it) } + map.getMap("gdpr")?.let { gdprMap -> + val iterator = gdprMap.keySetIterator() + while (iterator.hasNextKey()) { + val purpose = iterator.nextKey() + val consentMap = gdprMap.getMap(purpose) ?: continue + convertToGDPRConsent(consentMap)?.let { builder.addGDPRConsentState(purpose, it) } + } } } if (map.hasKey("ccpa")) { From a30b9e85f63d823d3c772d323b606122aae5e6be Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Thu, 25 Jun 2026 15:39:56 -0400 Subject: [PATCH 07/10] fix(ios): parse consent timestamps as milliseconds MPConsentState and RCT_EXPORT consent paths use MPGDPRConsent/ MPCCPAConsent converters that treated JS timestamps as seconds via RCTConvert NSDate. Divide by 1000 to match Date.now() and read path. --- ios/RNMParticle/RNMParticle.mm | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ios/RNMParticle/RNMParticle.mm b/ios/RNMParticle/RNMParticle.mm index 9706ea3..94d8d91 100644 --- a/ios/RNMParticle/RNMParticle.mm +++ b/ios/RNMParticle/RNMParticle.mm @@ -1316,7 +1316,9 @@ + (MPGDPRConsent *)MPGDPRConsent:(id)json { mpConsent.consented = [RCTConvert BOOL:json[@"consented"]]; mpConsent.document = json[@"document"]; - mpConsent.timestamp = [RCTConvert NSDate:json[@"timestamp"]]; + if (json[@"timestamp"] && json[@"timestamp"] != [NSNull null]) { + mpConsent.timestamp = [NSDate dateWithTimeIntervalSince1970:[json[@"timestamp"] doubleValue] / 1000.0]; + } mpConsent.location = json[@"location"]; mpConsent.hardwareId = json[@"hardwareId"]; @@ -1328,7 +1330,9 @@ + (MPCCPAConsent *)MPCCPAConsent:(id)json { mpConsent.consented = [RCTConvert BOOL:json[@"consented"]]; mpConsent.document = json[@"document"]; - mpConsent.timestamp = [RCTConvert NSDate:json[@"timestamp"]]; + if (json[@"timestamp"] && json[@"timestamp"] != [NSNull null]) { + mpConsent.timestamp = [NSDate dateWithTimeIntervalSince1970:[json[@"timestamp"] doubleValue] / 1000.0]; + } mpConsent.location = json[@"location"]; mpConsent.hardwareId = json[@"hardwareId"]; From 25d13518ab5ffb6d13029f36fb17bbb76a86b9e2 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Thu, 25 Jun 2026 15:41:57 -0400 Subject: [PATCH 08/10] fix: treat empty setDeviceConsentState as clear on iOS and Android Assign nil/null when the converted consent state has no GDPR or CCPA entries, matching clearDeviceConsentState behavior. --- .../java/com/mparticle/react/MParticleModule.kt | 7 +++++-- ios/RNMParticle/RNMParticle.mm | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/mparticle/react/MParticleModule.kt b/android/src/main/java/com/mparticle/react/MParticleModule.kt index 1d6d4d7..99d596b 100644 --- a/android/src/main/java/com/mparticle/react/MParticleModule.kt +++ b/android/src/main/java/com/mparticle/react/MParticleModule.kt @@ -540,7 +540,8 @@ class MParticleModule( if (consentState == null) { return } - convertToConsentState(consentState)?.let { instance.setDeviceConsentState(it) } + val state = convertToConsentState(consentState) + instance.setDeviceConsentState(if (isEmptyConsentState(state)) null else state) } @ReactMethod @@ -998,7 +999,7 @@ class MParticleModule( return builder.build() } - private fun convertToConsentState(map: ReadableMap): ConsentState? { + private fun convertToConsentState(map: ReadableMap): ConsentState { val builder = ConsentState.builder() if (map.hasKey("gdpr")) { map.getMap("gdpr")?.let { gdprMap -> @@ -1018,6 +1019,8 @@ class MParticleModule( return builder.build() } + private fun isEmptyConsentState(state: ConsentState) = state.gdprConsentState.isEmpty() && state.ccpaConsentState == null + private fun consentStateToMap(state: ConsentState?): WritableMap? { if (state == null) { return null diff --git a/ios/RNMParticle/RNMParticle.mm b/ios/RNMParticle/RNMParticle.mm index 94d8d91..a4004f8 100644 --- a/ios/RNMParticle/RNMParticle.mm +++ b/ios/RNMParticle/RNMParticle.mm @@ -31,6 +31,14 @@ + (MPPromotionAction)MPPromotionAction:(id)json; + (MPConsentState *)MPConsentState:(id)json; @end +static BOOL RNMParticleIsEmptyConsentState(MPConsentState *state) +{ + if (state == nil) { + return YES; + } + return state.gdprConsentState.count == 0 && state.ccpaConsentState == nil; +} + @implementation RNMParticle RCT_EXTERN void RCTRegisterModule(Class); @@ -600,7 +608,8 @@ - (void)setDeviceConsentState:(JS::NativeMParticle::DeviceConsentState &)consent if (ccpa != nil && ccpa != [NSNull null]) { dict[@"ccpa"] = ccpa; } - [MParticle sharedInstance].deviceConsentState = [RCTConvert MPConsentState:dict]; + MPConsentState *state = [RCTConvert MPConsentState:dict]; + [MParticle sharedInstance].deviceConsentState = RNMParticleIsEmptyConsentState(state) ? nil : state; } - (void)clearDeviceConsentState { @@ -651,7 +660,8 @@ - (void)getDeviceConsentState:(RCTResponseSenderBlock)callback { if (consentState == nil || consentState == (id)[NSNull null]) { return; } - [MParticle sharedInstance].deviceConsentState = [RCTConvert MPConsentState:consentState]; + MPConsentState *state = [RCTConvert MPConsentState:consentState]; + [MParticle sharedInstance].deviceConsentState = RNMParticleIsEmptyConsentState(state) ? nil : state; } RCT_EXPORT_METHOD(clearDeviceConsentState) From 3a92363baf47a84f0167a3c90e5bb97b0417a768 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Thu, 25 Jun 2026 15:53:16 -0400 Subject: [PATCH 09/10] fix: address Bugbot findings and iOS new arch build Read consent timestamps as Number or String on Android so getDeviceConsentState round-trips correctly. Guard iOS MPConsentState conversion against non-dictionary GDPR/CCPA values. Fix new arch setDeviceConsentState by converting codegen structs to NSDictionary before RCTConvert. --- .../com/mparticle/react/MParticleModule.kt | 35 ++++++----- ios/RNMParticle/RNMParticle.mm | 63 ++++++++++++++++--- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/android/src/main/java/com/mparticle/react/MParticleModule.kt b/android/src/main/java/com/mparticle/react/MParticleModule.kt index 99d596b..2eaf6ee 100644 --- a/android/src/main/java/com/mparticle/react/MParticleModule.kt +++ b/android/src/main/java/com/mparticle/react/MParticleModule.kt @@ -953,17 +953,30 @@ class MParticleModule( map.getString("location")?.let { builder.location(it) } } if (map.hasKey("timestamp")) { - try { - val timestampString = map.getString("timestamp") - val timestamp = timestampString?.toLong() - timestamp?.let { builder.timestamp(it) } - } catch (ex: Exception) { - Logger.warning("failed to convert \"timestamp\" value to Long") - } + readConsentTimestampMillis(map, "timestamp")?.let { builder.timestamp(it) } } return builder.build() } + private fun readConsentTimestampMillis( + map: ReadableMap, + key: String, + ): Long? { + if (!map.hasKey(key)) { + return null + } + return try { + when (map.getType(key)) { + ReadableType.Number -> map.getDouble(key).toLong() + ReadableType.String -> map.getString(key)?.toLongOrNull() + else -> null + } + } catch (ex: Exception) { + Logger.warning("failed to convert \"$key\" timestamp value to Long") + null + } + } + private fun convertToCCPAConsent(map: ReadableMap): CCPAConsent? { val consented = try { @@ -988,13 +1001,7 @@ class MParticleModule( map.getString("location")?.let { builder.location(it) } } if (map.hasKey("timestamp")) { - try { - val timestampString = map.getString("timestamp") - val timestamp = timestampString?.toLong() - timestamp?.let { builder.timestamp(it) } - } catch (ex: Exception) { - Logger.warning("failed to convert \"timestamp\" value to Long") - } + readConsentTimestampMillis(map, "timestamp")?.let { builder.timestamp(it) } } return builder.build() } diff --git a/ios/RNMParticle/RNMParticle.mm b/ios/RNMParticle/RNMParticle.mm index a4004f8..e201753 100644 --- a/ios/RNMParticle/RNMParticle.mm +++ b/ios/RNMParticle/RNMParticle.mm @@ -39,6 +39,50 @@ static BOOL RNMParticleIsEmptyConsentState(MPConsentState *state) return state.gdprConsentState.count == 0 && state.ccpaConsentState == nil; } +#ifdef RCT_NEW_ARCH_ENABLED +static NSMutableDictionary *RNMParticleGDPRConsentStructToDict(const JS::NativeMParticle::GDPRConsent &consent) +{ + NSMutableDictionary *consentDict = [NSMutableDictionary dictionary]; + if (consent.consented().has_value()) { + consentDict[@"consented"] = @(consent.consented().value()); + } + if (consent.document()) { + consentDict[@"document"] = consent.document(); + } + if (consent.timestamp().has_value()) { + consentDict[@"timestamp"] = @(consent.timestamp().value()); + } + if (consent.location()) { + consentDict[@"location"] = consent.location(); + } + if (consent.hardwareId()) { + consentDict[@"hardwareId"] = consent.hardwareId(); + } + return consentDict; +} + +static NSMutableDictionary *RNMParticleCCPAConsentStructToDict(const JS::NativeMParticle::CCPAConsent &consent) +{ + NSMutableDictionary *consentDict = [NSMutableDictionary dictionary]; + if (consent.consented().has_value()) { + consentDict[@"consented"] = @(consent.consented().value()); + } + if (consent.document()) { + consentDict[@"document"] = consent.document(); + } + if (consent.timestamp().has_value()) { + consentDict[@"timestamp"] = @(consent.timestamp().value()); + } + if (consent.location()) { + consentDict[@"location"] = consent.location(); + } + if (consent.hardwareId()) { + consentDict[@"hardwareId"] = consent.hardwareId(); + } + return consentDict; +} +#endif + @implementation RNMParticle RCT_EXTERN void RCTRegisterModule(Class); @@ -600,13 +644,16 @@ - (void)setCCPAConsentState:(JS::NativeMParticle::CCPAConsent &)consent { - (void)setDeviceConsentState:(JS::NativeMParticle::DeviceConsentState &)consentState { NSMutableDictionary *dict = [NSMutableDictionary dictionary]; - id gdpr = consentState.gdpr(); - if (gdpr != nil && gdpr != [NSNull null]) { - dict[@"gdpr"] = gdpr; + if (consentState.gdpr().has_value()) { + NSMutableDictionary *gdprDict = [NSMutableDictionary dictionary]; + for (const auto &entry : consentState.gdpr().value()) { + gdprDict[[NSString stringWithUTF8String:entry.first.c_str()]] = + RNMParticleGDPRConsentStructToDict(entry.second); + } + dict[@"gdpr"] = gdprDict; } - id ccpa = consentState.ccpa(); - if (ccpa != nil && ccpa != [NSNull null]) { - dict[@"ccpa"] = ccpa; + if (consentState.ccpa().has_value()) { + dict[@"ccpa"] = RNMParticleCCPAConsentStructToDict(consentState.ccpa().value()); } MPConsentState *state = [RCTConvert MPConsentState:dict]; [MParticle sharedInstance].deviceConsentState = RNMParticleIsEmptyConsentState(state) ? nil : state; @@ -1361,7 +1408,7 @@ + (MPConsentState *)MPConsentState:(id)json if ([gdpr isKindOfClass:[NSDictionary class]]) { for (NSString *purpose in gdpr) { id consentJson = gdpr[purpose]; - if (consentJson == [NSNull null]) { + if (consentJson == [NSNull null] || ![consentJson isKindOfClass:[NSDictionary class]]) { continue; } MPGDPRConsent *consent = [RCTConvert MPGDPRConsent:consentJson]; @@ -1372,7 +1419,7 @@ + (MPConsentState *)MPConsentState:(id)json } id ccpaJson = dict[@"ccpa"]; - if (ccpaJson != nil && ccpaJson != [NSNull null]) { + if (ccpaJson != nil && ccpaJson != [NSNull null] && [ccpaJson isKindOfClass:[NSDictionary class]]) { MPCCPAConsent *ccpa = [RCTConvert MPCCPAConsent:ccpaJson]; if (ccpa != nil) { [state setCCPAConsentState:ccpa]; From ab85cc1493a67de6377c67d338058f6da39fdc74 Mon Sep 17 00:00:00 2001 From: Denis Chilik Date: Thu, 25 Jun 2026 16:05:46 -0400 Subject: [PATCH 10/10] fix(ios): treat gdpr as NSDictionary in new arch device consent Codegen exposes DeviceConsentState.gdpr() as id, not std::optional, so setDeviceConsentState must not call has_value/value on it. --- ios/RNMParticle/RNMParticle.mm | 31 +++---------------------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/ios/RNMParticle/RNMParticle.mm b/ios/RNMParticle/RNMParticle.mm index e201753..4e955b0 100644 --- a/ios/RNMParticle/RNMParticle.mm +++ b/ios/RNMParticle/RNMParticle.mm @@ -40,27 +40,6 @@ static BOOL RNMParticleIsEmptyConsentState(MPConsentState *state) } #ifdef RCT_NEW_ARCH_ENABLED -static NSMutableDictionary *RNMParticleGDPRConsentStructToDict(const JS::NativeMParticle::GDPRConsent &consent) -{ - NSMutableDictionary *consentDict = [NSMutableDictionary dictionary]; - if (consent.consented().has_value()) { - consentDict[@"consented"] = @(consent.consented().value()); - } - if (consent.document()) { - consentDict[@"document"] = consent.document(); - } - if (consent.timestamp().has_value()) { - consentDict[@"timestamp"] = @(consent.timestamp().value()); - } - if (consent.location()) { - consentDict[@"location"] = consent.location(); - } - if (consent.hardwareId()) { - consentDict[@"hardwareId"] = consent.hardwareId(); - } - return consentDict; -} - static NSMutableDictionary *RNMParticleCCPAConsentStructToDict(const JS::NativeMParticle::CCPAConsent &consent) { NSMutableDictionary *consentDict = [NSMutableDictionary dictionary]; @@ -644,13 +623,9 @@ - (void)setCCPAConsentState:(JS::NativeMParticle::CCPAConsent &)consent { - (void)setDeviceConsentState:(JS::NativeMParticle::DeviceConsentState &)consentState { NSMutableDictionary *dict = [NSMutableDictionary dictionary]; - if (consentState.gdpr().has_value()) { - NSMutableDictionary *gdprDict = [NSMutableDictionary dictionary]; - for (const auto &entry : consentState.gdpr().value()) { - gdprDict[[NSString stringWithUTF8String:entry.first.c_str()]] = - RNMParticleGDPRConsentStructToDict(entry.second); - } - dict[@"gdpr"] = gdprDict; + id gdpr = consentState.gdpr(); + if (gdpr != nil && gdpr != (id)[NSNull null]) { + dict[@"gdpr"] = gdpr; } if (consentState.ccpa().has_value()) { dict[@"ccpa"] = RNMParticleCCPAConsentStructToDict(consentState.ccpa().value());