diff --git a/packages/analytics/__tests__/analytics.test.ts b/packages/analytics/__tests__/analytics.test.ts index 755f096e7b..ed94b33396 100644 --- a/packages/analytics/__tests__/analytics.test.ts +++ b/packages/analytics/__tests__/analytics.test.ts @@ -1390,7 +1390,7 @@ describe('Analytics', function () { () => analytics.logLevelEnd({ level: 12, - success: 'true', + success: true, }), 'logLevelEnd', ); @@ -1401,7 +1401,7 @@ describe('Analytics', function () { () => logLevelEnd(analytics, { level: 12, - success: 'true', + success: true, }), 'logLevelEnd', ); diff --git a/packages/analytics/android/src/reactnative/java/io/invertase/firebase/analytics/ReactNativeFirebaseAnalyticsModule.java b/packages/analytics/android/src/reactnative/java/io/invertase/firebase/analytics/ReactNativeFirebaseAnalyticsModule.java index 85020c6383..64ef750b7c 100644 --- a/packages/analytics/android/src/reactnative/java/io/invertase/firebase/analytics/ReactNativeFirebaseAnalyticsModule.java +++ b/packages/analytics/android/src/reactnative/java/io/invertase/firebase/analytics/ReactNativeFirebaseAnalyticsModule.java @@ -26,10 +26,27 @@ import com.google.firebase.analytics.FirebaseAnalytics; import io.invertase.firebase.common.ReactNativeFirebaseModule; import java.util.ArrayList; +import java.util.Locale; import javax.annotation.Nullable; public class ReactNativeFirebaseAnalyticsModule extends ReactNativeFirebaseModule { private static final String SERVICE_NAME = "Analytics"; + + /** + * GA4 parameters that must be sent as long values. React Native's bridge stores JS numbers as + * doubles in {@link Bundle}; Firebase Analytics expects integral types for these keys. + */ + private static final String[] LONG_NUMERIC_PARAM_KEYS = + new String[] { + FirebaseAnalytics.Param.QUANTITY, + FirebaseAnalytics.Param.INDEX, + FirebaseAnalytics.Param.LEVEL, + FirebaseAnalytics.Param.NUMBER_OF_NIGHTS, + FirebaseAnalytics.Param.NUMBER_OF_PASSENGERS, + FirebaseAnalytics.Param.NUMBER_OF_ROOMS, + FirebaseAnalytics.Param.SCORE, + }; + private final UniversalFirebaseAnalyticsModule module; ReactNativeFirebaseAnalyticsModule(ReactApplicationContext reactContext) { @@ -207,10 +224,7 @@ private Bundle toBundle(ReadableMap readableMap) { for (Object item : itemsArray) { if (item instanceof Bundle) { Bundle itemBundle = (Bundle) item; - if (itemBundle.containsKey(FirebaseAnalytics.Param.QUANTITY)) { - double number = itemBundle.getDouble(FirebaseAnalytics.Param.QUANTITY); - itemBundle.putInt(FirebaseAnalytics.Param.QUANTITY, (int) number); - } + coerceLongNumericParams(itemBundle); validBundles.add(itemBundle); } } @@ -219,10 +233,40 @@ private Bundle toBundle(ReadableMap readableMap) { } } + coerceLongNumericParams(bundle); + coerceSuccessParamToLong(bundle); + if (bundle.containsKey(FirebaseAnalytics.Param.EXTEND_SESSION)) { double number = bundle.getDouble(FirebaseAnalytics.Param.EXTEND_SESSION); bundle.putLong(FirebaseAnalytics.Param.EXTEND_SESSION, (long) number); } return bundle; } + + private static void coerceLongNumericParams(Bundle bundle) { + for (String key : LONG_NUMERIC_PARAM_KEYS) { + if (bundle.containsKey(key)) { + double number = bundle.getDouble(key); + bundle.putLong(key, (long) number); + } + } + } + + private static void coerceSuccessParamToLong(Bundle bundle) { + if (!bundle.containsKey(FirebaseAnalytics.Param.SUCCESS)) { + return; + } + Object value = bundle.get(FirebaseAnalytics.Param.SUCCESS); + bundle.remove(FirebaseAnalytics.Param.SUCCESS); + long asLong = 0L; + if (value instanceof Boolean) { + asLong = (Boolean) value ? 1L : 0L; + } else if (value instanceof Number) { + asLong = ((Number) value).longValue() != 0L ? 1L : 0L; + } else if (value instanceof String) { + String s = ((String) value).trim().toLowerCase(Locale.ROOT); + asLong = ("1".equals(s) || "true".equals(s) || "yes".equals(s)) ? 1L : 0L; + } + bundle.putLong(FirebaseAnalytics.Param.SUCCESS, asLong); + } } diff --git a/packages/analytics/e2e/analytics.e2e.js b/packages/analytics/e2e/analytics.e2e.js index bae707a881..c86bc7e354 100644 --- a/packages/analytics/e2e/analytics.e2e.js +++ b/packages/analytics/e2e/analytics.e2e.js @@ -264,7 +264,7 @@ describe('analytics()', function () { it('calls logLevelEnd', async function () { await firebase.analytics().logLevelEnd({ level: 123, - success: 'yes', + success: true, }); }); }); @@ -753,7 +753,7 @@ describe('analytics()', function () { const { getAnalytics, logLevelEnd } = analyticsModular; await logLevelEnd(getAnalytics(), { level: 123, - success: 'yes', + success: true, }); }); }); diff --git a/packages/analytics/ios/RNFBAnalytics/RNFBAnalyticsModule.m b/packages/analytics/ios/RNFBAnalytics/RNFBAnalyticsModule.m index afb9d4f2a2..f42e489bf8 100644 --- a/packages/analytics/ios/RNFBAnalytics/RNFBAnalyticsModule.m +++ b/packages/analytics/ios/RNFBAnalytics/RNFBAnalyticsModule.m @@ -32,6 +32,24 @@ #import #import "RNFBAnalyticsModule.h" +/** GA4 parameters that must be sent as integer NSNumber values (not doubles from JS). */ +static NSArray *RNFBAnalyticsLongNumericParameterKeys(void) { + static NSArray *keys; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + keys = @[ + kFIRParameterQuantity, + kFIRParameterIndex, + kFIRParameterLevel, + kFIRParameterNumberOfNights, + kFIRParameterNumberOfPassengers, + kFIRParameterNumberOfRooms, + kFIRParameterScore, + ]; + }); + return keys; +} + @implementation RNFBAnalyticsModule #pragma mark - #pragma mark Module Setup @@ -268,13 +286,13 @@ - (NSDictionary *)cleanJavascriptParams:(NSDictionary *)params { [(NSArray *)newParams[kFIRParameterItems] enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) { NSMutableDictionary *item = [obj mutableCopy]; - if (item[kFIRParameterQuantity]) { - item[kFIRParameterQuantity] = @([item[kFIRParameterQuantity] integerValue]); - } + [self rnfb_coerceLongNumericParametersInMutableDictionary:item]; [newItems addObject:[item copy]]; }]; newParams[kFIRParameterItems] = [newItems copy]; } + [self rnfb_coerceLongNumericParametersInMutableDictionary:newParams]; + [self rnfb_coerceSuccessParameterInMutableDictionary:newParams]; NSNumber *extendSession = [newParams valueForKey:kFIRParameterExtendSession]; if ([extendSession isEqualToNumber:@1]) { newParams[kFIRParameterExtendSession] = @YES; @@ -282,6 +300,33 @@ - (NSDictionary *)cleanJavascriptParams:(NSDictionary *)params { return [newParams copy]; } +- (void)rnfb_coerceLongNumericParametersInMutableDictionary:(NSMutableDictionary *)dict { + for (NSString *key in RNFBAnalyticsLongNumericParameterKeys()) { + id value = dict[key]; + if (value != nil && value != [NSNull null]) { + dict[key] = @([value integerValue]); + } + } +} + +- (void)rnfb_coerceSuccessParameterInMutableDictionary:(NSMutableDictionary *)dict { + id value = dict[kFIRParameterSuccess]; + if (value == nil || value == [NSNull null]) { + return; + } + int success = 0; + if ([value isKindOfClass:[NSString class]]) { + NSString *lower = [(NSString *)value lowercaseString]; + if ([lower isEqualToString:@"true"] || [lower isEqualToString:@"yes"] || + [lower isEqualToString:@"1"]) { + success = 1; + } + } else { + success = [value boolValue] ? 1 : 0; + } + dict[kFIRParameterSuccess] = @(success); +} + /// Converts null values received over the bridge from NSNull to nil /// @param value Nullable string value - (NSString *)convertNSNullToNil:(NSString *)value { diff --git a/packages/analytics/lib/structs.ts b/packages/analytics/lib/structs.ts index 9fba6f3fd8..b5fde97e00 100644 --- a/packages/analytics/lib/structs.ts +++ b/packages/analytics/lib/structs.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { object, string, number, array, optional, define, type } from 'superstruct'; +import { object, string, number, boolean, array, optional, define, type } from 'superstruct'; const ShortDate = define( 'ShortDate', @@ -36,6 +36,7 @@ const Item = type({ item_variant: optional(string()), quantity: optional(number()), price: optional(number()), + index: optional(number()), }); export const ScreenView = type({ @@ -104,7 +105,7 @@ export const JoinGroup = object({ export const LevelEnd = object({ level: number(), - success: optional(string()), + success: optional(boolean()), }); export const LevelStart = object({ diff --git a/packages/analytics/lib/types/analytics.ts b/packages/analytics/lib/types/analytics.ts index 8ebe9e022b..22ade88d20 100644 --- a/packages/analytics/lib/types/analytics.ts +++ b/packages/analytics/lib/types/analytics.ts @@ -282,7 +282,7 @@ export interface LevelEndEventParameters { /** * The result of an operation. */ - success?: string; + success?: boolean; } export interface LevelStartEventParameters { diff --git a/packages/analytics/type-test.ts b/packages/analytics/type-test.ts index 9880e4d2d9..4582265702 100644 --- a/packages/analytics/type-test.ts +++ b/packages/analytics/type-test.ts @@ -189,7 +189,7 @@ const earnVirtualCurrencyParams: EarnVirtualCurrencyEventParameters = { }; const generateLeadParams: GenerateLeadEventParameters = { value: 123, currency: 'USD' }; const joinGroupParams: JoinGroupEventParameters = { group_id: 'group1' }; -const levelEndParams: LevelEndEventParameters = { level: 1, success: 'true' }; +const levelEndParams: LevelEndEventParameters = { level: 1, success: true }; const levelStartParams: LevelStartEventParameters = { level: 1 }; const levelUpParams: LevelUpEventParameters = { level: 5, character: 'character1' }; const loginParams: LoginEventParameters = { method: 'email' };