diff --git a/__mocks__/react-native.ts b/__mocks__/react-native.ts index 517f1d10..4170f2ee 100644 --- a/__mocks__/react-native.ts +++ b/__mocks__/react-native.ts @@ -86,6 +86,7 @@ const mockRNOneSignal = { addOutcomeWithValue: vi.fn(), displayNotification: vi.fn(), preventDefault: vi.fn(), + trackEvent: vi.fn(), }; const mockPlatform = { diff --git a/android/build.gradle b/android/build.gradle index d48e354e..22f69e64 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -31,7 +31,7 @@ dependencies { // api is used instead of implementation so the parent :app project can access any of the OneSignal Java // classes if needed. Such as com.onesignal.NotificationExtenderService - api 'com.onesignal:OneSignal:5.4.2' + api 'com.onesignal:OneSignal:5.6.0' testImplementation 'junit:junit:4.12' } \ No newline at end of file diff --git a/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java b/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java index 35b3a059..14a8ce79 100644 --- a/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java +++ b/android/src/main/java/com/onesignal/rnonesignalandroid/RNOneSignal.java @@ -36,6 +36,7 @@ of this software and associated documentation files (the "Software"), to deal package com.onesignal.rnonesignalandroid; import android.content.Context; +import androidx.annotation.Nullable; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.LifecycleEventListener; import com.facebook.react.bridge.Promise; @@ -754,4 +755,12 @@ public void addListener(String eventName) { public void removeListeners(int count) { // Keep: Required for RN built in Event Emitter Calls. } + + @ReactMethod + public void trackEvent(String name, @Nullable ReadableMap properties) { + OneSignal.getUser() + .trackEvent( + name, + properties != null ? RNUtils.convertReadableMapToMap(properties) : new java.util.HashMap<>()); + } } diff --git a/android/src/main/java/com/onesignal/rnonesignalandroid/RNUtils.java b/android/src/main/java/com/onesignal/rnonesignalandroid/RNUtils.java index 29d1f722..c48af5c5 100644 --- a/android/src/main/java/com/onesignal/rnonesignalandroid/RNUtils.java +++ b/android/src/main/java/com/onesignal/rnonesignalandroid/RNUtils.java @@ -268,6 +268,70 @@ public static HashMap convertReadableMapIntoStringMap(ReadableMa return stringMap; } + public static Map convertReadableMapToMap(ReadableMap readableMap) { + Map map = new HashMap<>(); + ReadableMapKeySetIterator iterator = readableMap.keySetIterator(); + + while (iterator.hasNextKey()) { + String key = iterator.nextKey(); + ReadableType type = readableMap.getType(key); + Object value = convertValue(type, readableMap, key); + if (value != null) { + map.put(key, value); + } + } + + return map; + } + + public static List convertReadableArrayToList(ReadableArray readableArray) { + List list = new ArrayList<>(); + + for (int i = 0; i < readableArray.size(); i++) { + ReadableType type = readableArray.getType(i); + Object value = convertValue(type, readableArray, i); + if (value != null) { + list.add(value); + } + } + + return list; + } + + private static Object convertValue(ReadableType type, ReadableMap map, String key) { + switch (type) { + case Boolean: + return map.getBoolean(key); + case Number: + return map.getDouble(key); + case String: + return map.getString(key); + case Map: + return convertReadableMapToMap(map.getMap(key)); + case Array: + return convertReadableArrayToList(map.getArray(key)); + default: + return null; + } + } + + private static Object convertValue(ReadableType type, ReadableArray array, int index) { + switch (type) { + case Boolean: + return array.getBoolean(index); + case Number: + return array.getDouble(index); + case String: + return array.getString(index); + case Map: + return convertReadableMapToMap(array.getMap(index)); + case Array: + return convertReadableArrayToList(array.getArray(index)); + default: + return null; + } + } + public static HashMap convertPermissionToMap(boolean granted) { HashMap hash = new HashMap<>(); diff --git a/build.gradle b/build.gradle index 3168af42..20878c6e 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ spotless { java { target 'android/**/*.java', 'examples/RNOneSignalTS/android/app/src/**/*.java' targetExclude '**/build/**' - palantirJavaFormat('2.28.0') + palantirJavaFormat('2.85.0') removeUnusedImports() trimTrailingWhitespace() endWithNewline() diff --git a/examples/RNOneSignalTS/OSButtons.tsx b/examples/RNOneSignalTS/OSButtons.tsx index 00781d8a..7ef7f803 100644 --- a/examples/RNOneSignalTS/OSButtons.tsx +++ b/examples/RNOneSignalTS/OSButtons.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Text, View } from 'react-native'; +import { Platform, Text, View } from 'react-native'; import { OneSignal } from 'react-native-onesignal'; import { renderButtonView } from './Helpers'; // Remove: import {Text, Divider} from '@react-native-material/core'; @@ -396,11 +396,34 @@ const OSButtons: React.FC = ({ loggingFunction, inputFieldValue }) => { }, ); + const trackEventButton = renderButtonView('Track Event', () => { + loggingFunction('Tracking event: ', 'ReactNative'); + const platform = Platform.OS; // This will be 'ios' or 'android' + OneSignal.User.trackEvent(`ReactNative-${platform}-noprops`); + OneSignal.User.trackEvent(`ReactNative-${platform}`, { + someNum: 123, + someFloat: 3.14159, + someString: 'abc', + someBool: true, + someObject: { + abc: '123', + nested: { + def: '456', + }, + ghi: null, + }, + someArray: [1, 2], + someMixedArray: [1, '2', { abc: '123' }, null], + someNull: null, + }); + }); + return [ loginButton, logoutButton, addEmailButton, removeEmailButton, + trackEventButton, sendTagWithKeyButton, deleteTagWithKeyButton, addTagsButton, diff --git a/examples/RNOneSignalTS/ios/Podfile.lock b/examples/RNOneSignalTS/ios/Podfile.lock index 41650a06..d1cebe62 100644 --- a/examples/RNOneSignalTS/ios/Podfile.lock +++ b/examples/RNOneSignalTS/ios/Podfile.lock @@ -8,9 +8,9 @@ PODS: - hermes-engine (0.82.1): - hermes-engine/Pre-built (= 0.82.1) - hermes-engine/Pre-built (0.82.1) - - OneSignalXCFramework (5.2.16): - - OneSignalXCFramework/OneSignalComplete (= 5.2.16) - - OneSignalXCFramework/OneSignal (5.2.16): + - OneSignalXCFramework (5.4.0): + - OneSignalXCFramework/OneSignalComplete (= 5.4.0) + - OneSignalXCFramework/OneSignal (5.4.0): - OneSignalXCFramework/OneSignalCore - OneSignalXCFramework/OneSignalExtension - OneSignalXCFramework/OneSignalLiveActivities @@ -18,38 +18,38 @@ PODS: - OneSignalXCFramework/OneSignalOSCore - OneSignalXCFramework/OneSignalOutcomes - OneSignalXCFramework/OneSignalUser - - OneSignalXCFramework/OneSignalComplete (5.2.16): + - OneSignalXCFramework/OneSignalComplete (5.4.0): - OneSignalXCFramework/OneSignal - OneSignalXCFramework/OneSignalInAppMessages - OneSignalXCFramework/OneSignalLocation - - OneSignalXCFramework/OneSignalCore (5.2.16) - - OneSignalXCFramework/OneSignalExtension (5.2.16): + - OneSignalXCFramework/OneSignalCore (5.4.0) + - OneSignalXCFramework/OneSignalExtension (5.4.0): - OneSignalXCFramework/OneSignalCore - OneSignalXCFramework/OneSignalOutcomes - - OneSignalXCFramework/OneSignalInAppMessages (5.2.16): + - OneSignalXCFramework/OneSignalInAppMessages (5.4.0): - OneSignalXCFramework/OneSignalCore - OneSignalXCFramework/OneSignalNotifications - OneSignalXCFramework/OneSignalOSCore - OneSignalXCFramework/OneSignalOutcomes - OneSignalXCFramework/OneSignalUser - - OneSignalXCFramework/OneSignalLiveActivities (5.2.16): + - OneSignalXCFramework/OneSignalLiveActivities (5.4.0): - OneSignalXCFramework/OneSignalCore - OneSignalXCFramework/OneSignalOSCore - OneSignalXCFramework/OneSignalUser - - OneSignalXCFramework/OneSignalLocation (5.2.16): + - OneSignalXCFramework/OneSignalLocation (5.4.0): - OneSignalXCFramework/OneSignalCore - OneSignalXCFramework/OneSignalNotifications - OneSignalXCFramework/OneSignalOSCore - OneSignalXCFramework/OneSignalUser - - OneSignalXCFramework/OneSignalNotifications (5.2.16): + - OneSignalXCFramework/OneSignalNotifications (5.4.0): - OneSignalXCFramework/OneSignalCore - OneSignalXCFramework/OneSignalExtension - OneSignalXCFramework/OneSignalOutcomes - - OneSignalXCFramework/OneSignalOSCore (5.2.16): + - OneSignalXCFramework/OneSignalOSCore (5.4.0): - OneSignalXCFramework/OneSignalCore - - OneSignalXCFramework/OneSignalOutcomes (5.2.16): + - OneSignalXCFramework/OneSignalOutcomes (5.4.0): - OneSignalXCFramework/OneSignalCore - - OneSignalXCFramework/OneSignalUser (5.2.16): + - OneSignalXCFramework/OneSignalUser (5.4.0): - OneSignalXCFramework/OneSignalCore - OneSignalXCFramework/OneSignalNotifications - OneSignalXCFramework/OneSignalOSCore @@ -1828,8 +1828,8 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket - - react-native-onesignal (5.2.17): - - OneSignalXCFramework (= 5.2.16) + - react-native-onesignal (5.3.0): + - OneSignalXCFramework (= 5.4.0) - React (< 1.0.0, >= 0.13.0) - react-native-safe-area-context (5.6.2): - boost @@ -2768,7 +2768,7 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5 - OneSignalXCFramework: 8ed6648481bee0bd973a138fecd80331b798524f + OneSignalXCFramework: 95b6391df5a91b448003149c1a633ade42ceca1e RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: f17e2ebc07876ca9ab8eb6e4b0a4e4647497ae3a RCTRequired: e2c574c1b45231f7efb0834936bd609d75072b63 @@ -2802,7 +2802,7 @@ SPEC CHECKSUMS: React-logger: 500f2fa5697d224e63c33d913c8a4765319e19bf React-Mapbuffer: 06d59c448da7e34eb05b3fb2189e12f6a30fec57 React-microtasksnativemodule: d1ee999dc9052e23f6488b730fa2d383a4ea40e5 - react-native-onesignal: 3b6cd199ec0db87166ef7fb595715627a35b3244 + react-native-onesignal: 68c8423063cc8ead827e09bc71d139c14850feaf react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460 React-NativeModulesApple: 46690a0fe94ec28fc6fc686ec797b911d251ded0 React-oscompat: 95875e81f5d4b3c7b2c888d5bd2c9d83450d8bdb diff --git a/ios/RCTOneSignal/RCTOneSignalEventEmitter.m b/ios/RCTOneSignal/RCTOneSignalEventEmitter.m index 93abb6fe..46b13810 100644 --- a/ios/RCTOneSignal/RCTOneSignalEventEmitter.m +++ b/ios/RCTOneSignal/RCTOneSignalEventEmitter.m @@ -628,4 +628,9 @@ - (void)removeUserStateObserver { } } +RCT_EXPORT_METHOD(trackEvent : (NSString *)name withProperties : ( + NSDictionary *_Nullable)properties) { + [OneSignal.User trackEventWithName:name properties:properties]; +} + @end diff --git a/package.json b/package.json index 7d33ec61..81dc5771 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-onesignal", - "version": "5.2.17", + "version": "5.3.0", "description": "React Native OneSignal SDK", "files": [ "dist", diff --git a/react-native-onesignal.podspec b/react-native-onesignal.podspec index b7861442..3f6014e6 100644 --- a/react-native-onesignal.podspec +++ b/react-native-onesignal.podspec @@ -22,5 +22,5 @@ Pod::Spec.new do |s| # pod 'React', :path => '../node_modules/react-native/' # The Native OneSignal-iOS-SDK XCFramework from cocoapods. - s.dependency 'OneSignalXCFramework', '5.2.16' + s.dependency 'OneSignalXCFramework', '5.4.0' end diff --git a/src/helpers.test.ts b/src/helpers.test.ts index b3e5fb6c..ab31fabf 100644 --- a/src/helpers.test.ts +++ b/src/helpers.test.ts @@ -1,6 +1,10 @@ import type { NativeModule } from 'react-native'; import type { MockInstance } from 'vitest'; -import { isNativeModuleLoaded, isValidCallback } from './helpers'; +import { + isNativeModuleLoaded, + isObjectSerializable, + isValidCallback, +} from './helpers'; describe('helpers', () => { let errorSpy: MockInstance; @@ -58,4 +62,42 @@ describe('helpers', () => { expect(result).toBe(true); }); }); + + describe('isObjectSerializable', () => { + test.each([ + { description: 'an empty object', value: {} }, + { description: 'an object with string values', value: { key: 'value' } }, + { description: 'an object with number values', value: { count: 42 } }, + { description: 'an object with boolean values', value: { active: true } }, + { description: 'an object with null values', value: { data: null } }, + { + description: 'a nested object', + value: { outer: { inner: 'value' } }, + }, + { + description: 'an object with array values', + value: { items: [1, 2, 3] }, + }, + ])('should return true for $description', ({ value }) => { + expect(isObjectSerializable(value)).toBe(true); + }); + + test.each([ + { description: 'null', value: null }, + { description: 'undefined', value: undefined }, + { description: 'a string', value: 'string' }, + { description: 'a number', value: 123 }, + { description: 'a boolean', value: true }, + { description: 'an array', value: [1, 2, 3] }, + { description: 'a function', value: () => {} }, + ])('should return false for $description', ({ value }) => { + expect(isObjectSerializable(value)).toBe(false); + }); + + test('should return false for objects with circular references', () => { + const circular: Record = {}; + circular.self = circular; + expect(isObjectSerializable(circular)).toBe(false); + }); + }); }); diff --git a/src/helpers.ts b/src/helpers.ts index 8c71c9e2..4bd3c22f 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -16,3 +16,18 @@ export function isNativeModuleLoaded(module: NativeModule): boolean { return true; } + +/** + * Returns true if the value is a JSON-serializable object. + */ +export function isObjectSerializable(value: unknown): boolean { + if (!(typeof value === 'object' && value !== null && !Array.isArray(value))) { + return false; + } + try { + JSON.stringify(value); + return true; + } catch { + return false; + } +} diff --git a/src/index.test.ts b/src/index.test.ts index 6b40bd7d..4d877938 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -894,6 +894,52 @@ describe('OneSignal', () => { }); }); + describe('trackEvent', () => { + test('should track event with name and properties', () => { + const properties = { key: 'value', count: 42 }; + OneSignal.User.trackEvent('purchase', properties); + expect(mockRNOneSignal.trackEvent).toHaveBeenCalledWith( + 'purchase', + properties, + ); + }); + + test('should track event with just name using default empty properties', () => { + OneSignal.User.trackEvent('page_view'); + expect(mockRNOneSignal.trackEvent).toHaveBeenCalledWith( + 'page_view', + {}, + ); + }); + + test('should not track event if native module is not loaded', () => { + isNativeLoadedSpy.mockReturnValue(false); + OneSignal.User.trackEvent('event'); + expect(mockRNOneSignal.trackEvent).not.toHaveBeenCalled(); + }); + + test('should not track event if properties are not serializable', () => { + const circular: Record = {}; + circular.self = circular; + OneSignal.User.trackEvent('event', circular); + expect(errorSpy).toHaveBeenCalledWith( + 'Properties must be a JSON-serializable object', + ); + expect(mockRNOneSignal.trackEvent).not.toHaveBeenCalled(); + }); + + test('should not track event if properties is not an object', () => { + OneSignal.User.trackEvent( + 'event', + 'invalid' as unknown as Record, + ); + expect(errorSpy).toHaveBeenCalledWith( + 'Properties must be a JSON-serializable object', + ); + expect(mockRNOneSignal.trackEvent).not.toHaveBeenCalled(); + }); + }); + describe('Notifications', () => { describe('hasPermission (deprecated)', () => { test('should log deprecation warning', () => { diff --git a/src/index.ts b/src/index.ts index 2fc85415..7bced605 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,11 @@ import { import type { OSNotificationPermission } from './constants/subscription'; import EventManager from './events/EventManager'; import NotificationWillDisplayEvent from './events/NotificationWillDisplayEvent'; -import { isNativeModuleLoaded, isValidCallback } from './helpers'; +import { + isNativeModuleLoaded, + isObjectSerializable, + isValidCallback, +} from './helpers'; import type { InAppMessage, InAppMessageClickEvent, @@ -590,6 +594,24 @@ export namespace OneSignal { return RNOneSignal.getTags(); } + + /** + * Track custom events for the current user. + * Note: Currently, null values will be omitted for Android. + * */ + export function trackEvent( + name: string, + properties: Record = {}, + ) { + if (!isNativeModuleLoaded(RNOneSignal)) return; + + if (!isObjectSerializable(properties)) { + console.error('Properties must be a JSON-serializable object'); + return; + } + + RNOneSignal.trackEvent(name, properties); + } } export namespace Notifications {