Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Preserve explicitly null custom event and product attributes as empty strings on iOS and Android, including product attributes with iOS New Architecture
- iOS: raise the `mParticle-Apple-SDK-ObjC` floor to `>= 9.2.2` (was `~> 9.2`); the device-consent bridge uses `MParticle.deviceConsentState`, added in SDK `9.2.2`, so the previous floor permitted `9.2.0`/`9.2.1` which fail to compile
- iOS sample CI: pin `Rokt-Widget` to `5.2.0` and `DcuiSchema` to `2.7.0` (avoids `2.8.x` schema floating under Rokt’s `~> 2.6` and breaking `RoktUXHelper` Swift compile); GitHub Actions stays on Xcode 16.x
- Android: raise the `com.mparticle:android-core` / `android-rokt-kit` dependency floor to `[5.79.2, 6.0)` (Expo plugin kit injection and bridge `android/build.gradle`), and bump the sample app to `5.79.2`. This guarantees consumers resolve a Rokt kit built against `com.rokt:roktsdk` `4.14.5`, which observes the Activity lifecycle from process start so overlay/bottom-sheet placements display even when `Rokt.init()` runs after the host Activity has resumed (deferred / late RN initialisation)

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,16 @@ To log screen events:
MParticle.logScreenEvent('Test screen', { 'Test key': 'Test value' });
```

### Null Custom Attribute Values

Custom event and product attributes accept `null`. The React Native SDK
preserves an explicitly null attribute as an empty string on both platforms, so
`{ coupon_code: null }` appears as `{ coupon_code: "" }` in Live Stream. Omit
the key when the attribute should be absent.

Because `null` and `""` intentionally produce the same output, use a separate
boolean or status attribute when analytics must distinguish those states.

## User

To set, remove, and get user details, call the `User` or `Identity` methods as follows:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.mparticle.react;

import com.facebook.react.bridge.ReactApplicationContext;
import com.mparticle.BaseEvent;
import com.mparticle.MParticle;
import com.mparticle.react.testutils.MockMap;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;

import java.util.HashMap;
import java.util.Map;

import static org.junit.Assert.assertEquals;

@RunWith(MockitoJUnitRunner.class)
public class MParticleModuleTest {
private MParticle mParticle;
private MParticleModule module;

@Before
public void before() {
mParticle = Mockito.mock(MParticle.class);
MParticle.setInstance(mParticle);
module = new MParticleModule(Mockito.mock(ReactApplicationContext.class));
}

@Test
public void logEventConvertsNullCustomAttributeToEmptyString() {
Map<String, Object> attributes = new HashMap<>();
attributes.put("coupon_code", null);

module.logEvent("Purchase", MParticle.EventType.Other.getValue(), new MockMap(attributes));

ArgumentCaptor<BaseEvent> eventCaptor = ArgumentCaptor.forClass(BaseEvent.class);
Mockito.verify(mParticle).logEvent(eventCaptor.capture());
assertEquals("", eventCaptor.getValue().getCustomAttributes().get("coupon_code"));
}
}
82 changes: 71 additions & 11 deletions ios/RNMParticle/RNMParticle.mm
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,48 @@ static BOOL RNMParticleIsEmptyConsentState(MPConsentState *state)
return state.gdprConsentState.count == 0 && state.ccpaConsentState == nil;
}

static NSDictionary<NSString *, NSString *> *RNMParticleStringAttributes(id attributes)
{
if (![attributes isKindOfClass:[NSDictionary class]]) {
return nil;
}

NSMutableDictionary<NSString *, NSString *> *normalizedAttributes = [NSMutableDictionary dictionary];
[(NSDictionary *)attributes enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) {
if (![key isKindOfClass:[NSString class]]) {
return;
}

if (value == [NSNull null]) {
normalizedAttributes[key] = @"";
} else if ([value isKindOfClass:[NSString class]]) {
normalizedAttributes[key] = value;
} else if ([value isKindOfClass:[NSNumber class]]) {
if (CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID()) {
normalizedAttributes[key] = [value boolValue] ? @"true" : @"false";
} else {
normalizedAttributes[key] = [value stringValue];
}
}
}];
return normalizedAttributes;
}

// Event/commerce-event level: preserve value types (matches pre-existing iOS
// behaviour); only normalise explicit null to "" for Live Stream parity.
static NSDictionary *RNMParticleEventAttributes(id attributes)
{
if (![attributes isKindOfClass:[NSDictionary class]]) {
return nil;
}

NSMutableDictionary *normalizedAttributes = [NSMutableDictionary dictionary];
[(NSDictionary *)attributes enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) {
normalizedAttributes[key] = (value == [NSNull null]) ? @"" : value;
}];
return normalizedAttributes;
}

#ifdef RCT_NEW_ARCH_ENABLED
static NSMutableDictionary *RNMParticleCCPAConsentStructToDict(const JS::NativeMParticle::CCPAConsent &consent)
{
Expand Down Expand Up @@ -92,12 +134,16 @@ + (void)load {

RCT_EXPORT_METHOD(logEvent:(NSString *)eventName eventType:(double)eventType attributes:(NSDictionary *)attributes)
{
[[MParticle sharedInstance] logEvent:eventName eventType:(MPEventType)eventType eventInfo:attributes];
[[MParticle sharedInstance] logEvent:eventName
eventType:(MPEventType)eventType
eventInfo:RNMParticleEventAttributes(attributes)];
}

RCT_EXPORT_METHOD(logScreenEvent:(NSString *)screenName attributes:(NSDictionary *)attributes shouldUploadEvent:(BOOL)shouldUploadEvent)
{
[[MParticle sharedInstance] logScreen:screenName eventInfo:attributes shouldUploadEvent:shouldUploadEvent];
[[MParticle sharedInstance] logScreen:screenName
eventInfo:RNMParticleEventAttributes(attributes)
shouldUploadEvent:shouldUploadEvent];
}

RCT_EXPORT_METHOD(setATTStatus:(double)status withATTStatusTimestampMillis:(nonnull NSNumber *)timestamp)
Expand Down Expand Up @@ -455,7 +501,7 @@ - (void)logMPEvent:(JS::NativeMParticle::Event &)event {
MPEvent *mpEvent = [[MPEvent alloc] initWithName:eventName type:eventType];

if (event.info()) {
mpEvent.customAttributes = (NSDictionary *)event.info();
mpEvent.customAttributes = RNMParticleEventAttributes((NSDictionary *)event.info());
}

if (event.duration().has_value()) {
Expand Down Expand Up @@ -555,7 +601,8 @@ - (void)logCommerceEvent:(JS::NativeMParticle::CommerceEvent &)commerceEvent {
}

if (commerceEvent.customAttributes()) {
mpCommerceEvent.customAttributes = (NSDictionary *)commerceEvent.customAttributes();
mpCommerceEvent.customAttributes =
RNMParticleEventAttributes((NSDictionary *)commerceEvent.customAttributes());
}

[[MParticle sharedInstance] logEvent:mpCommerceEvent];
Expand Down Expand Up @@ -734,6 +781,11 @@ - (MPProduct *)createMPProductFromDict:(NSDictionary *)productDict {
if (productDict[@"variant"]) {
product.variant = productDict[@"variant"];
}
NSDictionary<NSString *, NSString *> *customAttributes =
RNMParticleStringAttributes(productDict[@"customAttributes"]);
for (NSString *key in customAttributes) {
[product setObject:customAttributes[key] forKeyedSubscript:key];
}

return product;
}
Expand Down Expand Up @@ -891,7 +943,7 @@ + (MPEvent *)MPEvent:(NSDictionary *)dict {
MPEvent *event = [[MPEvent alloc] initWithName:dict[@"name"] type:(MPEventType)[dict[@"type"] integerValue]];

if (dict[@"info"] && dict[@"info"] != [NSNull null]) {
event.customAttributes = dict[@"info"];
event.customAttributes = RNMParticleEventAttributes(dict[@"info"]);
}

if (dict[@"duration"] && dict[@"duration"] != [NSNull null]) {
Expand Down Expand Up @@ -940,6 +992,11 @@ + (MPCommerceEvent *)MPCommerceEvent:(NSDictionary *)dict {
sku:productDict[@"sku"]
quantity:productDict[@"quantity"]
price:productDict[@"price"]];
NSDictionary<NSString *, NSString *> *customAttributes =
RNMParticleStringAttributes(productDict[@"customAttributes"]);
for (NSString *key in customAttributes) {
[product setObject:customAttributes[key] forKeyedSubscript:key];
}
[products addObject:product];
}
[commerceEvent addProducts:products];
Expand Down Expand Up @@ -970,7 +1027,8 @@ + (MPCommerceEvent *)MPCommerceEvent:(NSDictionary *)dict {
}

if (dict[@"customAttributes"] && dict[@"customAttributes"] != [NSNull null]) {
commerceEvent.customAttributes = dict[@"customAttributes"];
commerceEvent.customAttributes =
RNMParticleEventAttributes(dict[@"customAttributes"]);
}

if (dict[@"shouldUploadEvent"] && dict[@"shouldUploadEvent"] != [NSNull null]) {
Expand Down Expand Up @@ -1117,7 +1175,8 @@ + (MPCommerceEvent *)MPCommerceEvent:(id)json {
commerceEvent.shouldUploadEvent = [json[@"shouldUploadEvent"] boolValue];
}
if (json[@"customAttributes"] != nil) {
commerceEvent.customAttributes = json[@"customAttributes"];
commerceEvent.customAttributes =
RNMParticleEventAttributes(json[@"customAttributes"]);
}

NSMutableArray *products = [NSMutableArray array];
Expand Down Expand Up @@ -1185,9 +1244,10 @@ + (MPProduct *)MPProduct:(id)json {
product.position = [json[@"position"] intValue];
product.quantity = @([json[@"quantity"] intValue]);
NSDictionary *jsonAttributes = json[@"customAttributes"];
for (NSString *key in jsonAttributes) {
NSString *value = jsonAttributes[key];
[product setObject:value forKeyedSubscript:key];
NSDictionary<NSString *, NSString *> *customAttributes =
RNMParticleStringAttributes(jsonAttributes);
for (NSString *key in customAttributes) {
[product setObject:customAttributes[key] forKeyedSubscript:key];
}
return product;
}
Expand Down Expand Up @@ -1326,7 +1386,7 @@ + (MPEvent *)MPEvent:(id)json {
event.category = json[@"category"];
event.duration = json[@"duration"];
event.endTime = json[@"endTime"];
event.customAttributes = json[@"info"];
event.customAttributes = RNMParticleEventAttributes(json[@"info"]);
event.name = json[@"name"];
event.startTime = json[@"startTime"];
[event setType:(MPEventType)[json[@"type"] intValue]];
Expand Down
5 changes: 5 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['<rootDir>/js/**/*.test.ts?(x)'],
};
Loading
Loading