diff --git a/android/src/main/java/com/mparticle/react/rokt/MPRoktModuleImpl.kt b/android/src/main/java/com/mparticle/react/rokt/MPRoktModuleImpl.kt index e13f8f1..7124161 100644 --- a/android/src/main/java/com/mparticle/react/rokt/MPRoktModuleImpl.kt +++ b/android/src/main/java/com/mparticle/react/rokt/MPRoktModuleImpl.kt @@ -10,6 +10,7 @@ import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.core.DeviceEventManagerModule import com.mparticle.MParticle @@ -23,6 +24,7 @@ import com.mparticle.rokt.RoktConfig import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +import java.math.BigDecimal class MPRoktModuleImpl( private val reactContext: ReactApplicationContext, @@ -121,9 +123,29 @@ class MPRoktModuleImpl( reactContext?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)?.emit(eventName, params) } - fun readableMapToMapOfStrings(attributes: ReadableMap?): Map = - attributes?.toHashMap()?.filter { it.value is String }?.mapValues { it.value as String } - ?: emptyMap() + fun readableMapToMapOfStrings(attributes: ReadableMap?): Map { + if (attributes == null) { + return emptyMap() + } + + val result = mutableMapOf() + val iterator = attributes.keySetIterator() + while (iterator.hasNextKey()) { + val key = iterator.nextKey() + when (attributes.getType(key)) { + ReadableType.String -> attributes.getString(key)?.let { result[key] = it } + ReadableType.Number -> result[key] = formatNumberAttribute(attributes.getDouble(key)) + ReadableType.Boolean -> + result[key] = if (attributes.getBoolean(key)) "true" else "false" + ReadableType.Map -> + attributes.getMap(key)?.toHashMap()?.let { result[key] = it.toString() } + ReadableType.Array -> + attributes.getArray(key)?.toArrayList()?.let { result[key] = it.toString() } + ReadableType.Null -> Unit + } + } + return result + } fun String.toColorMode(): RoktConfig.ColorMode = when (this) { @@ -274,5 +296,17 @@ class MPRoktModuleImpl( companion object { const val MAX_LISTENERS = 5 const val MODULE_NAME = "RNMPRokt" + + // Match iOS NSNumber stringValue: plain decimals, no trailing ".0", no scientific notation. + internal fun formatNumberAttribute(value: Double): String { + if (value.isNaN() || value.isInfinite()) { + return value.toString() + } + val longValue = value.toLong() + if (value == longValue.toDouble()) { + return longValue.toString() + } + return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString() + } } } diff --git a/android/src/test/java/com/mparticle/react/rokt/MPRoktModuleImplTest.kt b/android/src/test/java/com/mparticle/react/rokt/MPRoktModuleImplTest.kt new file mode 100644 index 0000000..a7e6bf8 --- /dev/null +++ b/android/src/test/java/com/mparticle/react/rokt/MPRoktModuleImplTest.kt @@ -0,0 +1,56 @@ +package com.mparticle.react.rokt + +import com.facebook.react.bridge.ReactApplicationContext +import com.mparticle.react.testutils.MockMap +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.Mockito.mock + +class MPRoktModuleImplTest { + private val impl = MPRoktModuleImpl(mock(ReactApplicationContext::class.java)) + + @Test + fun readableMapToMapOfStrings_coercesNumbersAndBooleans() { + val attributes = + MockMap( + mapOf( + "amount" to 23.47, + "confirmationref" to 4600097641L, + "sandbox" to true, + "country" to "US", + ), + ) + + val result = impl.readableMapToMapOfStrings(attributes) + + assertEquals("23.47", result["amount"]) + assertEquals("4600097641", result["confirmationref"]) + assertEquals("true", result["sandbox"]) + assertEquals("US", result["country"]) + } + + @Test + fun readableMapToMapOfStrings_skipsNullValues() { + val attributes = MockMap() + attributes.putString("country", "US") + attributes.putNull("amount") + + val result = impl.readableMapToMapOfStrings(attributes) + + assertEquals(mapOf("country" to "US"), result) + } + + @Test + fun readableMapToMapOfStrings_formatsWholeNumberDoublesWithoutTrailingDecimal() { + val attributes = MockMap(mapOf("quantity" to 100.0)) + + val result = impl.readableMapToMapOfStrings(attributes) + + assertEquals("100", result["quantity"]) + } + + @Test + fun formatNumberAttribute_avoidsScientificNotation() { + assertEquals("10000000000", MPRoktModuleImpl.formatNumberAttribute(1.0e10)) + } +} diff --git a/android/src/test/java/com/mparticle/react/testutils/MockMap.java b/android/src/test/java/com/mparticle/react/testutils/MockMap.java index e1b74c0..be33047 100644 --- a/android/src/test/java/com/mparticle/react/testutils/MockMap.java +++ b/android/src/test/java/com/mparticle/react/testutils/MockMap.java @@ -51,12 +51,12 @@ public boolean isNull(String name) { @Override public boolean getBoolean(String name) { - return (boolean) map.get(name); + return (Boolean) map.get(name); } @Override public double getDouble(String name) { - return (double) map.get(name); + return ((Number) map.get(name)).doubleValue(); } @Override diff --git a/ios/RNMParticle/RNMPRokt.mm b/ios/RNMParticle/RNMPRokt.mm index 123f37b..cc07266 100644 --- a/ios/RNMParticle/RNMPRokt.mm +++ b/ios/RNMParticle/RNMPRokt.mm @@ -102,6 +102,10 @@ - (void)ensureEventManager { } _rokt_log(@"[mParticle-Rokt] safeExtractRoktConfigDict: extracting config"); NSMutableDictionary *roktConfigDict = [[NSMutableDictionary alloc] init]; + if (roktConfig.colorMode() != nil) { + roktConfigDict[@"colorMode"] = roktConfig.colorMode(); + _rokt_log(@"[mParticle-Rokt] safeExtractRoktConfigDict: colorMode present"); + } if (roktConfig.cacheConfig().has_value()) { NSMutableDictionary *cacheConfigDict = [[NSMutableDictionary alloc] init]; auto cacheConfig = roktConfig.cacheConfig().value(); @@ -274,19 +278,36 @@ - (void)getSessionIdWithResolve:(RCTPromiseResolveBlock)resolve success:success]; } -- (NSMutableDictionary*)convertToMutableDictionaryOfStrings:(NSDictionary*)attributes +// Coerces React Native attribute values to strings, matching mParticle-Rokt kit +// transformValuesToString behavior (MPKitRokt.m). +- (NSMutableDictionary *)convertToMutableDictionaryOfStrings:(NSDictionary *)attributes { - NSMutableDictionary *finalAttributes = [attributes mutableCopy]; - NSArray *keysForNullValues = [finalAttributes allKeysForObject:[NSNull null]]; - [finalAttributes removeObjectsForKeys:keysForNullValues]; + NSMutableDictionary *finalAttributes = [[NSMutableDictionary alloc] init]; + if (!attributes) { + return finalAttributes; + } + + [attributes enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + if (![key isKindOfClass:[NSString class]] || obj == nil || obj == [NSNull null]) { + return; + } - NSSet *keys = [finalAttributes keysOfEntriesPassingTest:^BOOL(id key, id obj, BOOL *stop) { - return ![obj isKindOfClass:[NSString class]]; + NSString *stringKey = (NSString *)key; + if ([obj isKindOfClass:[NSString class]]) { + finalAttributes[stringKey] = obj; + } else if ([obj isKindOfClass:[NSNumber class]]) { + NSNumber *numberAttribute = (NSNumber *)obj; + if (numberAttribute == (id)kCFBooleanTrue || numberAttribute == (id)kCFBooleanFalse) { + finalAttributes[stringKey] = [numberAttribute boolValue] ? @"true" : @"false"; + } else { + finalAttributes[stringKey] = [numberAttribute stringValue]; + } + } else if ([obj isKindOfClass:[NSDictionary class]] || [obj isKindOfClass:[NSArray class]]) { + finalAttributes[stringKey] = [obj description]; + } }]; - [finalAttributes removeObjectsForKeys:[keys allObjects]]; return finalAttributes; - } - (RoktConfig *)buildRoktConfigFromDict:(NSDictionary *)configMap { diff --git a/js/codegenSpecs/rokt/NativeMPRokt.ts b/js/codegenSpecs/rokt/NativeMPRokt.ts index 001215c..71a829f 100644 --- a/js/codegenSpecs/rokt/NativeMPRokt.ts +++ b/js/codegenSpecs/rokt/NativeMPRokt.ts @@ -3,6 +3,8 @@ import { TurboModuleRegistry } from 'react-native'; type ColorMode = string; +type RoktAttributeValue = string | number | boolean; + type CacheConfig = { readonly cacheDurationInSeconds?: number; readonly cacheAttributes?: { [key: string]: string }; @@ -16,7 +18,7 @@ type RoktConfigType = { export interface Spec extends TurboModule { selectPlacements( identifier: string, - attributes?: { [key: string]: string }, + attributes?: { [key: string]: RoktAttributeValue }, placeholders?: { [key: string]: number | null }, roktConfig?: RoktConfigType, fontFilesMap?: { [key: string]: string } @@ -30,7 +32,7 @@ export interface Spec extends TurboModule { selectShoppableAds( identifier: string, - attributes: { [key: string]: string }, + attributes: { [key: string]: RoktAttributeValue }, roktConfig?: RoktConfigType ): void; diff --git a/js/rokt/rokt.ts b/js/rokt/rokt.ts index fc4faf0..dbf4cfb 100644 --- a/js/rokt/rokt.ts +++ b/js/rokt/rokt.ts @@ -4,12 +4,14 @@ import type { Spec as NativeMPRoktInterface } from '../codegenSpecs/rokt/NativeM const MPRokt = getNativeModule('RNMPRokt'); +export type RoktAttributeValue = string | number | boolean; + export abstract class Rokt { /** * Selects placements with a [identifier], [attributes], optional [placeholders], optional [roktConfig], and optional [fontFilePathMap]. * * @param {string} identifier - The page identifier for the placement. - * @param {Record} attributes - Attributes to be associated with the placement. + * @param {Record} attributes - Attributes to be associated with the placement. * @param {Record} [placeholders] - Optional placeholders for dynamic content. * @param {IRoktConfig} [roktConfig] - Optional configuration settings for Rokt. * @param {Record} [fontFilesMap] - Optional mapping of font files. @@ -17,7 +19,7 @@ export abstract class Rokt { */ static async selectPlacements( identifier: string, - attributes: Record, + attributes: Record, placeholders?: Record, roktConfig?: IRoktConfig, fontFilesMap?: Record @@ -33,7 +35,7 @@ export abstract class Rokt { static async selectShoppableAds( identifier: string, - attributes: Record, + attributes: Record, roktConfig?: IRoktConfig ): Promise { MPRokt.selectShoppableAds(identifier, attributes, roktConfig);