From d71046e514f138fce6e5d4e6c82e7d179150dff6 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Fri, 30 Jan 2026 02:02:29 -0500 Subject: [PATCH 1/2] fix: custom events now handle null object within the event properties Co-Authored-By: AR Abdul Azeez --- .../java/com/onesignal/common/JSONUtils.kt | 10 +- .../java/com/onesignal/user/IUserManager.kt | 2 +- .../onesignal/user/internal/UserManager.kt | 2 +- .../customEvents/ICustomEventController.kt | 2 +- .../impl/CustomEventController.kt | 2 +- .../com/onesignal/common/JSONUtilsTests.kt | 125 ++++++++- .../user/internal/UserManagerTests.kt | 1 + .../impl/CustomEventControllerTests.kt | 251 ++++++++++++++++++ 8 files changed, 385 insertions(+), 10 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/customEvents/impl/CustomEventControllerTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/JSONUtils.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/JSONUtils.kt index 3169fd3d16..0cf3b0bdd1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/JSONUtils.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/JSONUtils.kt @@ -187,7 +187,7 @@ object JSONUtils { * Recursively convert a JSON-serializable map into a JSON-compatible format, handling * nested Maps and Lists appropriately. */ - fun mapToJson(map: Map): JSONObject { + fun mapToJson(map: Map): JSONObject { val json = JSONObject() for ((key, value) in map) { json.put(key, convertToJson(value)) @@ -198,21 +198,23 @@ object JSONUtils { /** * Recursively converts maps and lists into JSON-compatible objects, transforming maps with * String keys into JSON objects, lists into JSON arrays, and leaving primitive values unchanged to support safe JSON serialization. + * Null values are converted to JSONObject.NULL to preserve them in the JSON structure. */ - fun convertToJson(value: Any): Any { + fun convertToJson(value: Any?): Any? { return when (value) { + null -> JSONObject.NULL is Map<*, *> -> { val subMap = value.entries .filter { it.key is String } .associate { - it.key as String to convertToJson(it.value!!) + it.key as String to convertToJson(it.value) } mapToJson(subMap) } is List<*> -> { val array = JSONArray() - value.forEach { array.put(convertToJson(it!!)) } + value.forEach { array.put(convertToJson(it)) } array } else -> value diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/IUserManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/IUserManager.kt index 7ebf37f14f..be2dd9ed61 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/IUserManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/IUserManager.kt @@ -175,6 +175,6 @@ interface IUserManager { */ fun trackEvent( name: String, - properties: Map? = null, + properties: Map? = null, ) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt index 60f322b805..328cb9da7d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt @@ -249,7 +249,7 @@ internal open class UserManager( override fun trackEvent( name: String, - properties: Map?, + properties: Map?, ) { if (!JSONUtils.isValidJsonObject(properties)) { Logging.log(LogLevel.ERROR, "Custom event properties are not JSON-serializable") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventController.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventController.kt index 35c307539c..cd6059f46b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventController.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventController.kt @@ -3,6 +3,6 @@ package com.onesignal.user.internal.customEvents interface ICustomEventController { fun sendCustomEvent( name: String, - properties: Map?, + properties: Map?, ) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventController.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventController.kt index 05142fe784..41d51ced44 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventController.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventController.kt @@ -16,7 +16,7 @@ class CustomEventController( ) : ICustomEventController { override fun sendCustomEvent( name: String, - properties: Map?, + properties: Map?, ) { val op = TrackCustomEventOperation( diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/JSONUtilsTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/JSONUtilsTests.kt index 1320369ca4..d92ecd58ff 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/JSONUtilsTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/JSONUtilsTests.kt @@ -772,6 +772,7 @@ class JSONUtilsTests : FunSpec({ JSONUtils.convertToJson(true) shouldBe true JSONUtils.convertToJson(false) shouldBe false JSONUtils.convertToJson(3.14) shouldBe 3.14 + JSONUtils.convertToJson(null) shouldBe JSONObject.NULL } test("should convert Map to JSONObject") { @@ -887,18 +888,19 @@ class JSONUtilsTests : FunSpec({ test("should handle List with mixed types") { // Given - val list = listOf("string", 42, true, 3.14) + val list = listOf("string", 42, true, 3.14, null) // When val result = JSONUtils.convertToJson(list) // Then val jsonArray = result as JSONArray - jsonArray.length() shouldBe 4 + jsonArray.length() shouldBe 5 jsonArray.getString(0) shouldBe "string" jsonArray.getInt(1) shouldBe 42 jsonArray.getBoolean(2) shouldBe true jsonArray.getDouble(3) shouldBe 3.14 + jsonArray.get(4) shouldBe JSONObject.NULL } test("should filter out non-String keys from Map") { @@ -941,5 +943,124 @@ class JSONUtilsTests : FunSpec({ val level2Item = level2Array.getJSONObject(0) level2Item.getString("level3") shouldBe "deepValue" } + + test("should handle null values in maps") { + // Given + val map = mapOf( + "key1" to "value1", + "key2" to null, + "key3" to 42, + ) + + // When + val result = JSONUtils.convertToJson(map) + + // Then + val jsonObject = result as JSONObject + jsonObject.getString("key1") shouldBe "value1" + jsonObject.isNull("key2") shouldBe true + jsonObject.getInt("key3") shouldBe 42 + } + + test("should handle null values in nested objects") { + // Given + val map = mapOf( + "someObject" to mapOf( + "abc" to "123", + "nested" to mapOf( + "def" to "456", + ), + "ghi" to null, + ), + ) + + // When + val result = JSONUtils.convertToJson(map) + + // Then + val jsonObject = result as JSONObject + val someObject = jsonObject.getJSONObject("someObject") + someObject.getString("abc") shouldBe "123" + val nested = someObject.getJSONObject("nested") + nested.getString("def") shouldBe "456" + someObject.isNull("ghi") shouldBe true + } + + test("should handle null values in arrays") { + // Given + val map = mapOf( + "someArray" to listOf(1, 2), + "someMixedArray" to listOf(1, "2", mapOf("abc" to "123"), null), + ) + + // When + val result = JSONUtils.convertToJson(map) + + // Then + val jsonObject = result as JSONObject + val someArray = jsonObject.getJSONArray("someArray") + someArray.length() shouldBe 2 + someArray.getInt(0) shouldBe 1 + someArray.getInt(1) shouldBe 2 + + val someMixedArray = jsonObject.getJSONArray("someMixedArray") + someMixedArray.length() shouldBe 4 + someMixedArray.getInt(0) shouldBe 1 + someMixedArray.getString(1) shouldBe "2" + val nestedObj = someMixedArray.getJSONObject(2) + nestedObj.getString("abc") shouldBe "123" + someMixedArray.get(3) shouldBe JSONObject.NULL + } + + test("should handle complete example structure with nulls") { + // Given - matches the user's example structure + val map = mapOf( + "someNum" to 123, + "someFloat" to 3.14159, + "someString" to "abc", + "someBool" to true, + "someObject" to mapOf( + "abc" to "123", + "nested" to mapOf( + "def" to "456", + ), + "ghi" to null, + ), + "someArray" to listOf(1, 2), + "someMixedArray" to listOf(1, "2", mapOf("abc" to "123"), null), + "someNull" to null, + ) + + // When + val result = JSONUtils.convertToJson(map) + + // Then + val jsonObject = result as JSONObject + jsonObject.getInt("someNum") shouldBe 123 + jsonObject.getDouble("someFloat") shouldBe 3.14159 + jsonObject.getString("someString") shouldBe "abc" + jsonObject.getBoolean("someBool") shouldBe true + + val someObject = jsonObject.getJSONObject("someObject") + someObject.getString("abc") shouldBe "123" + val nested = someObject.getJSONObject("nested") + nested.getString("def") shouldBe "456" + someObject.isNull("ghi") shouldBe true + + val someArray = jsonObject.getJSONArray("someArray") + someArray.length() shouldBe 2 + someArray.getInt(0) shouldBe 1 + someArray.getInt(1) shouldBe 2 + + val someMixedArray = jsonObject.getJSONArray("someMixedArray") + someMixedArray.length() shouldBe 4 + someMixedArray.getInt(0) shouldBe 1 + someMixedArray.getString(1) shouldBe "2" + val nestedObj = someMixedArray.getJSONObject(2) + nestedObj.getString("abc") shouldBe "123" + someMixedArray.get(3) shouldBe JSONObject.NULL + + jsonObject.isNull("someNull") shouldBe true + } } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt index ada9f00f68..d4f3121b8a 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt @@ -210,6 +210,7 @@ class UserManagerTests : FunSpec({ "key3" to 5.123, "key4" to mapOf("key4-1" to "value4-1"), "key5" to mapOf("key5-1" to mapOf("key5-1-1" to 0)), + "key6" to null, ) // When diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/customEvents/impl/CustomEventControllerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/customEvents/impl/CustomEventControllerTests.kt new file mode 100644 index 0000000000..01bb4b4018 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/customEvents/impl/CustomEventControllerTests.kt @@ -0,0 +1,251 @@ +package com.onesignal.user.internal.customEvents.impl + +import com.onesignal.common.JSONUtils +import com.onesignal.core.internal.operations.IOperationRepo +import com.onesignal.mocks.MockHelper +import com.onesignal.user.internal.operations.TrackCustomEventOperation +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import org.json.JSONObject + +class CustomEventControllerTests : FunSpec({ + test("should create and enqueue TrackCustomEventOperation with all fields") { + // Given + val appId = "test-app-id" + val onesignalId = "test-onesignal-id" + val externalId = "test-external-id" + val timestamp = 1234567890L + val eventName = "test-event" + val properties = mapOf( + "key1" to "value1", + "key2" to 42, + ) + + val configModelStore = MockHelper.configModelStore { + it.appId = appId + } + val identityModelStore = MockHelper.identityModelStore { + it.onesignalId = onesignalId + it.externalId = externalId + } + val time = MockHelper.time(timestamp) + val opRepo = mockk(relaxed = true) + every { opRepo.enqueue(any()) } just runs + + val controller = CustomEventController( + identityModelStore, + configModelStore, + time, + opRepo, + ) + + // When + controller.sendCustomEvent(eventName, properties) + + // Then + val operationSlot = slot() + verify(exactly = 1) { opRepo.enqueue(capture(operationSlot)) } + + val operation = operationSlot.captured + operation.appId shouldBe appId + operation.onesignalId shouldBe onesignalId + operation.externalId shouldBe externalId + operation.timeStamp shouldBe timestamp + operation.eventName shouldBe eventName + operation.eventProperties shouldBe JSONUtils.mapToJson(properties).toString() + } + + test("should handle null properties") { + // Given + val appId = "test-app-id" + val onesignalId = "test-onesignal-id" + val timestamp = 1234567890L + val eventName = "test-event" + + val configModelStore = MockHelper.configModelStore { + it.appId = appId + } + val identityModelStore = MockHelper.identityModelStore { + it.onesignalId = onesignalId + } + val time = MockHelper.time(timestamp) + val opRepo = mockk(relaxed = true) + every { opRepo.enqueue(any()) } just runs + + val controller = CustomEventController( + identityModelStore, + configModelStore, + time, + opRepo, + ) + + // When + controller.sendCustomEvent(eventName, null) + + // Then + val operationSlot = slot() + verify(exactly = 1) { opRepo.enqueue(capture(operationSlot)) } + + val operation = operationSlot.captured + operation.appId shouldBe appId + operation.onesignalId shouldBe onesignalId + operation.timeStamp shouldBe timestamp + operation.eventName shouldBe eventName + operation.eventProperties shouldBe null + } + + test("should convert properties with nested structures to JSON") { + // Given + val appId = "test-app-id" + val onesignalId = "test-onesignal-id" + val timestamp = 1234567890L + val eventName = "test-event" + val properties = mapOf( + "someNum" to 123, + "someFloat" to 3.14159, + "someString" to "abc", + "someBool" to true, + "someObject" to mapOf( + "abc" to "123", + "nested" to mapOf( + "def" to "456", + ), + "ghi" to null, + ), + "someArray" to listOf(1, 2), + "someMixedArray" to listOf(1, "2", mapOf("abc" to "123"), null), + "someNull" to null, + ) + + val configModelStore = MockHelper.configModelStore { + it.appId = appId + } + val identityModelStore = MockHelper.identityModelStore { + it.onesignalId = onesignalId + } + val time = MockHelper.time(timestamp) + val opRepo = mockk(relaxed = true) + every { opRepo.enqueue(any()) } just runs + + val controller = CustomEventController( + identityModelStore, + configModelStore, + time, + opRepo, + ) + + // When + controller.sendCustomEvent(eventName, properties) + + // Then + val operationSlot = slot() + verify(exactly = 1) { opRepo.enqueue(capture(operationSlot)) } + + val operation = operationSlot.captured + val jsonProperties = JSONObject(operation.eventProperties!!) + + jsonProperties.getInt("someNum") shouldBe 123 + jsonProperties.getDouble("someFloat") shouldBe 3.14159 + jsonProperties.getString("someString") shouldBe "abc" + jsonProperties.getBoolean("someBool") shouldBe true + + val someObject = jsonProperties.getJSONObject("someObject") + someObject.getString("abc") shouldBe "123" + val nested = someObject.getJSONObject("nested") + nested.getString("def") shouldBe "456" + someObject.isNull("ghi") shouldBe true + + val someArray = jsonProperties.getJSONArray("someArray") + someArray.length() shouldBe 2 + someArray.getInt(0) shouldBe 1 + someArray.getInt(1) shouldBe 2 + + val someMixedArray = jsonProperties.getJSONArray("someMixedArray") + someMixedArray.length() shouldBe 4 + someMixedArray.getInt(0) shouldBe 1 + someMixedArray.getString(1) shouldBe "2" + val arrayObj = someMixedArray.getJSONObject(2) + arrayObj.getString("abc") shouldBe "123" + someMixedArray.get(3) shouldBe JSONObject.NULL + + jsonProperties.isNull("someNull") shouldBe true + } + + test("should handle empty properties map") { + // Given + val appId = "test-app-id" + val onesignalId = "test-onesignal-id" + val timestamp = 1234567890L + val eventName = "test-event" + val properties = emptyMap() + + val configModelStore = MockHelper.configModelStore { + it.appId = appId + } + val identityModelStore = MockHelper.identityModelStore { + it.onesignalId = onesignalId + } + val time = MockHelper.time(timestamp) + val opRepo = mockk(relaxed = true) + every { opRepo.enqueue(any()) } just runs + + val controller = CustomEventController( + identityModelStore, + configModelStore, + time, + opRepo, + ) + + // When + controller.sendCustomEvent(eventName, properties) + + // Then + val operationSlot = slot() + verify(exactly = 1) { opRepo.enqueue(capture(operationSlot)) } + + val operation = operationSlot.captured + val jsonProperties = JSONObject(operation.eventProperties!!) + jsonProperties.length() shouldBe 0 + } + + test("should use current timestamp from time service") { + // Given + val appId = "test-app-id" + val onesignalId = "test-onesignal-id" + val timestamp = 1000L + val eventName = "test-event" + + val configModelStore = MockHelper.configModelStore { + it.appId = appId + } + val identityModelStore = MockHelper.identityModelStore { + it.onesignalId = onesignalId + } + val time = MockHelper.time(timestamp) + val opRepo = mockk(relaxed = true) + every { opRepo.enqueue(any()) } just runs + + val controller = CustomEventController( + identityModelStore, + configModelStore, + time, + opRepo, + ) + + // When + controller.sendCustomEvent(eventName, null) + + // Then + val operationSlot = slot() + verify(exactly = 1) { opRepo.enqueue(capture(operationSlot)) } + + val operation = operationSlot.captured + operation.timeStamp shouldBe timestamp + } +}) From de5517c423f7b238e17c20544e9f779a5a40bce0 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Fri, 30 Jan 2026 10:27:46 -0500 Subject: [PATCH 2/2] chore: add documentation for classes related to custom events Co-Authored-By: AR Abdul Azeez --- .../internal/customEvents/ICustomEventController.kt | 10 ++++++++++ .../customEvents/impl/CustomEventController.kt | 4 ++++ .../internal/customEvents/impl/CustomEventMetadata.kt | 10 ++++++++++ .../internal/operations/TrackCustomEventOperation.kt | 5 +++++ 4 files changed, 29 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventController.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventController.kt index cd6059f46b..a72ad38f35 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventController.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/ICustomEventController.kt @@ -1,6 +1,16 @@ package com.onesignal.user.internal.customEvents +/** + * Interface for sending custom events to track user behavior. + */ interface ICustomEventController { + /** + * Sends a custom event with optional properties. + * + * @param name The name of the custom event + * @param properties Optional map of event properties. Can contain nested maps, lists, and null values. + * Properties will be converted to JSON format. + */ fun sendCustomEvent( name: String, properties: Map?, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventController.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventController.kt index 41d51ced44..f63f69775c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventController.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventController.kt @@ -8,6 +8,10 @@ import com.onesignal.user.internal.customEvents.ICustomEventController import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.operations.TrackCustomEventOperation +/** + * Controller for custom events. Handles the creation and enqueueing of custom event operations + * for tracking user events with optional properties. + */ class CustomEventController( private val identityModelStore: IdentityModelStore, private val configModelStore: ConfigModelStore, diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventMetadata.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventMetadata.kt index cd14d6a909..de64db7f09 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventMetadata.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventMetadata.kt @@ -4,6 +4,10 @@ import com.onesignal.common.putSafe import org.json.JSONException import org.json.JSONObject +/** + * Metadata for custom events containing device and SDK information. + * This metadata is included with custom events sent to the OneSignal backend. + */ class CustomEventMetadata( val deviceType: String?, val sdk: String?, @@ -12,6 +16,12 @@ class CustomEventMetadata( val deviceModel: String?, val deviceOS: String?, ) { + /** + * Converts the metadata to a JSONObject for serialization. + * + * @return JSONObject containing all metadata fields + * @throws JSONException if JSON serialization fails + */ @Throws(JSONException::class) fun toJSONObject(): JSONObject { val json = JSONObject() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt index 73313f97ec..b510a4fd3f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt @@ -5,6 +5,11 @@ import com.onesignal.core.internal.operations.GroupComparisonType import com.onesignal.core.internal.operations.Operation import com.onesignal.user.internal.operations.impl.executors.CustomEventOperationExecutor +/** + * An [Operation] to track a single custom event with properties for the current user. + * This operation is enqueued when a user tracks a custom event and will be processed + * by the [CustomEventOperationExecutor] to send the event to the OneSignal backend. + */ class TrackCustomEventOperation() : Operation(CustomEventOperationExecutor.CUSTOM_EVENT) { /** * The OneSignal appId the custom event was created.