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..2eaf6ee 100644 --- a/android/src/main/java/com/mparticle/react/MParticleModule.kt +++ b/android/src/main/java/com/mparticle/react/MParticleModule.kt @@ -534,6 +534,31 @@ class MParticleModule( } } + @ReactMethod + override fun setDeviceConsentState(consentState: ReadableMap?) { + val instance = MParticle.getInstance() ?: return + if (consentState == null) { + return + } + val state = convertToConsentState(consentState) + instance.setDeviceConsentState(if (isEmptyConsentState(state)) null else state) + } + + @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 { @@ -928,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 { @@ -963,14 +1001,67 @@ 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 convertToConsentState(map: ReadableMap): ConsentState { + val builder = ConsentState.builder() + if (map.hasKey("gdpr")) { + 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")) { + map.getMap("ccpa")?.let { ccpaMap -> + convertToCCPAConsent(ccpaMap)?.let { builder.setCCPAConsentState(it) } } } 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 + } + 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..4e955b0 100644 --- a/ios/RNMParticle/RNMParticle.mm +++ b/ios/RNMParticle/RNMParticle.mm @@ -19,13 +19,49 @@ @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) + (MPCommerceEventAction)MPCommerceEventAction:(id)json; + (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; +} + +#ifdef RCT_NEW_ARCH_ENABLED +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); @@ -584,6 +620,33 @@ - (void)setCCPAConsentState:(JS::NativeMParticle::CCPAConsent &)consent { [consentState setCCPAConsentState:ccpaConsent]; user.consentState = consentState; } + +- (void)setDeviceConsentState:(JS::NativeMParticle::DeviceConsentState &)consentState { + NSMutableDictionary *dict = [NSMutableDictionary dictionary]; + 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()); + } + MPConsentState *state = [RCTConvert MPConsentState:dict]; + [MParticle sharedInstance].deviceConsentState = RNMParticleIsEmptyConsentState(state) ? nil : state; +} + +- (void)clearDeviceConsentState { + [MParticle sharedInstance].deviceConsentState = nil; +} + +- (void)getDeviceConsentState:(RCTResponseSenderBlock)callback { + MPConsentState *deviceConsent = [MParticle sharedInstance].deviceConsentState; + if (deviceConsent == nil) { + callback(@[[NSNull null]]); + return; + } + NSDictionary *consentDict = [RNMParticle consentStateToDictionary:deviceConsent]; + callback(@[consentDict ?: [NSNull null]]); +} #else RCT_EXPORT_METHOD(logMPEvent:(MPEvent *)event) @@ -614,6 +677,31 @@ - (void)setCCPAConsentState:(JS::NativeMParticle::CCPAConsent &)consent { user.consentState = consentState; } +RCT_EXPORT_METHOD(setDeviceConsentState:(NSDictionary *)consentState) +{ + if (consentState == nil || consentState == (id)[NSNull null]) { + return; + } + MPConsentState *state = [RCTConvert MPConsentState:consentState]; + [MParticle sharedInstance].deviceConsentState = RNMParticleIsEmptyConsentState(state) ? nil : state; +} + +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; + } + NSDictionary *consentDict = [RNMParticle consentStateToDictionary:deviceConsent]; + callback(@[consentDict ?: [NSNull null]]); +} + #endif // Helper method to create MPProduct from dictionary @@ -741,6 +829,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 @@ -938,6 +1079,7 @@ + (MParticleUser *)MParticleUser:(id)json; + (MPEvent *)MPEvent:(id)json; + (MPGDPRConsent *)MPGDPRConsent:(id)json; + (MPCCPAConsent *)MPCCPAConsent:(id)json; ++ (MPConsentState *)MPConsentState:(id)json; @end @@ -1206,7 +1348,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"]; @@ -1218,11 +1362,46 @@ + (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"]; 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] || ![consentJson isKindOfClass:[NSDictionary class]]) { + continue; + } + MPGDPRConsent *consent = [RCTConvert MPGDPRConsent:consentJson]; + if (consent != nil) { + [state addGDPRConsentState:consent purpose:purpose]; + } + } + } + + id ccpaJson = dict[@"ccpa"]; + if (ccpaJson != nil && ccpaJson != [NSNull null] && [ccpaJson isKindOfClass:[NSDictionary class]]) { + 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..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,6 +211,24 @@ export const removeCCPAConsentState = (): void => { MParticleModule.removeCCPAConsentState(); }; +export type { DeviceConsentState }; + +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 +949,9 @@ const MParticle = { removeGDPRConsentStateWithPurpose, setCCPAConsentState, removeCCPAConsentState, + setDeviceConsentState, + clearDeviceConsentState, + getDeviceConsentState, isKitActive, getAttributions, logPushRegistration,