Skip to content

Commit 374d1b9

Browse files
jinliu9508AR Abdul Azeez
andcommitted
fix: custom events now handle null object within the event properties (#2537)
Co-authored-by: AR Abdul Azeez <[email protected]>
1 parent 6030947 commit 374d1b9

10 files changed

Lines changed: 414 additions & 10 deletions

File tree

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/JSONUtils.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ object JSONUtils {
187187
* Recursively convert a JSON-serializable map into a JSON-compatible format, handling
188188
* nested Maps and Lists appropriately.
189189
*/
190-
fun mapToJson(map: Map<String, Any>): JSONObject {
190+
fun mapToJson(map: Map<String, Any?>): JSONObject {
191191
val json = JSONObject()
192192
for ((key, value) in map) {
193193
json.put(key, convertToJson(value))
@@ -198,21 +198,23 @@ object JSONUtils {
198198
/**
199199
* Recursively converts maps and lists into JSON-compatible objects, transforming maps with
200200
* String keys into JSON objects, lists into JSON arrays, and leaving primitive values unchanged to support safe JSON serialization.
201+
* Null values are converted to JSONObject.NULL to preserve them in the JSON structure.
201202
*/
202-
fun convertToJson(value: Any): Any {
203+
fun convertToJson(value: Any?): Any? {
203204
return when (value) {
205+
null -> JSONObject.NULL
204206
is Map<*, *> -> {
205207
val subMap =
206208
value.entries
207209
.filter { it.key is String }
208210
.associate {
209-
it.key as String to convertToJson(it.value!!)
211+
it.key as String to convertToJson(it.value)
210212
}
211213
mapToJson(subMap)
212214
}
213215
is List<*> -> {
214216
val array = JSONArray()
215-
value.forEach { array.put(convertToJson(it!!)) }
217+
value.forEach { array.put(convertToJson(it)) }
216218
array
217219
}
218220
else -> value

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/IUserManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,6 @@ interface IUserManager {
175175
*/
176176
fun trackEvent(
177177
name: String,
178-
properties: Map<String, Any>? = null,
178+
properties: Map<String, Any?>? = null,
179179
)
180180
}

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ internal open class UserManager(
249249

250250
override fun trackEvent(
251251
name: String,
252-
properties: Map<String, Any>?,
252+
properties: Map<String, Any?>?,
253253
) {
254254
if (!JSONUtils.isValidJsonObject(properties)) {
255255
Logging.log(LogLevel.ERROR, "Custom event properties are not JSON-serializable")
Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
package com.onesignal.user.internal.customEvents
22

3+
/**
4+
* Interface for sending custom events to track user behavior.
5+
*/
36
interface ICustomEventController {
7+
/**
8+
* Sends a custom event with optional properties.
9+
*
10+
* @param name The name of the custom event
11+
* @param properties Optional map of event properties. Can contain nested maps, lists, and null values.
12+
* Properties will be converted to JSON format.
13+
*/
414
fun sendCustomEvent(
515
name: String,
6-
properties: Map<String, Any>?,
16+
properties: Map<String, Any?>?,
717
)
818
}

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventController.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import com.onesignal.user.internal.customEvents.ICustomEventController
88
import com.onesignal.user.internal.identity.IdentityModelStore
99
import com.onesignal.user.internal.operations.TrackCustomEventOperation
1010

11+
/**
12+
* Controller for custom events. Handles the creation and enqueueing of custom event operations
13+
* for tracking user events with optional properties.
14+
*/
1115
class CustomEventController(
1216
private val identityModelStore: IdentityModelStore,
1317
private val configModelStore: ConfigModelStore,
@@ -16,7 +20,7 @@ class CustomEventController(
1620
) : ICustomEventController {
1721
override fun sendCustomEvent(
1822
name: String,
19-
properties: Map<String, Any>?,
23+
properties: Map<String, Any?>?,
2024
) {
2125
val op =
2226
TrackCustomEventOperation(

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/customEvents/impl/CustomEventMetadata.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import com.onesignal.common.putSafe
44
import org.json.JSONException
55
import org.json.JSONObject
66

7+
/**
8+
* Metadata for custom events containing device and SDK information.
9+
* This metadata is included with custom events sent to the OneSignal backend.
10+
*/
711
class CustomEventMetadata(
812
val deviceType: String?,
913
val sdk: String?,
@@ -12,6 +16,12 @@ class CustomEventMetadata(
1216
val deviceModel: String?,
1317
val deviceOS: String?,
1418
) {
19+
/**
20+
* Converts the metadata to a JSONObject for serialization.
21+
*
22+
* @return JSONObject containing all metadata fields
23+
* @throws JSONException if JSON serialization fails
24+
*/
1525
@Throws(JSONException::class)
1626
fun toJSONObject(): JSONObject {
1727
val json = JSONObject()

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/TrackCustomEventOperation.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import com.onesignal.core.internal.operations.GroupComparisonType
55
import com.onesignal.core.internal.operations.Operation
66
import com.onesignal.user.internal.operations.impl.executors.CustomEventOperationExecutor
77

8+
/**
9+
* An [Operation] to track a single custom event with properties for the current user.
10+
* This operation is enqueued when a user tracks a custom event and will be processed
11+
* by the [CustomEventOperationExecutor] to send the event to the OneSignal backend.
12+
*/
813
class TrackCustomEventOperation() : Operation(CustomEventOperationExecutor.CUSTOM_EVENT) {
914
/**
1015
* The OneSignal appId the custom event was created.

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/JSONUtilsTests.kt

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,7 @@ class JSONUtilsTests : FunSpec({
772772
JSONUtils.convertToJson(true) shouldBe true
773773
JSONUtils.convertToJson(false) shouldBe false
774774
JSONUtils.convertToJson(3.14) shouldBe 3.14
775+
JSONUtils.convertToJson(null) shouldBe JSONObject.NULL
775776
}
776777

777778
test("should convert Map to JSONObject") {
@@ -887,18 +888,19 @@ class JSONUtilsTests : FunSpec({
887888

888889
test("should handle List with mixed types") {
889890
// Given
890-
val list = listOf("string", 42, true, 3.14)
891+
val list = listOf("string", 42, true, 3.14, null)
891892

892893
// When
893894
val result = JSONUtils.convertToJson(list)
894895

895896
// Then
896897
val jsonArray = result as JSONArray
897-
jsonArray.length() shouldBe 4
898+
jsonArray.length() shouldBe 5
898899
jsonArray.getString(0) shouldBe "string"
899900
jsonArray.getInt(1) shouldBe 42
900901
jsonArray.getBoolean(2) shouldBe true
901902
jsonArray.getDouble(3) shouldBe 3.14
903+
jsonArray.get(4) shouldBe JSONObject.NULL
902904
}
903905

904906
test("should filter out non-String keys from Map") {
@@ -941,5 +943,124 @@ class JSONUtilsTests : FunSpec({
941943
val level2Item = level2Array.getJSONObject(0)
942944
level2Item.getString("level3") shouldBe "deepValue"
943945
}
946+
947+
test("should handle null values in maps") {
948+
// Given
949+
val map = mapOf(
950+
"key1" to "value1",
951+
"key2" to null,
952+
"key3" to 42,
953+
)
954+
955+
// When
956+
val result = JSONUtils.convertToJson(map)
957+
958+
// Then
959+
val jsonObject = result as JSONObject
960+
jsonObject.getString("key1") shouldBe "value1"
961+
jsonObject.isNull("key2") shouldBe true
962+
jsonObject.getInt("key3") shouldBe 42
963+
}
964+
965+
test("should handle null values in nested objects") {
966+
// Given
967+
val map = mapOf(
968+
"someObject" to mapOf(
969+
"abc" to "123",
970+
"nested" to mapOf(
971+
"def" to "456",
972+
),
973+
"ghi" to null,
974+
),
975+
)
976+
977+
// When
978+
val result = JSONUtils.convertToJson(map)
979+
980+
// Then
981+
val jsonObject = result as JSONObject
982+
val someObject = jsonObject.getJSONObject("someObject")
983+
someObject.getString("abc") shouldBe "123"
984+
val nested = someObject.getJSONObject("nested")
985+
nested.getString("def") shouldBe "456"
986+
someObject.isNull("ghi") shouldBe true
987+
}
988+
989+
test("should handle null values in arrays") {
990+
// Given
991+
val map = mapOf(
992+
"someArray" to listOf(1, 2),
993+
"someMixedArray" to listOf(1, "2", mapOf("abc" to "123"), null),
994+
)
995+
996+
// When
997+
val result = JSONUtils.convertToJson(map)
998+
999+
// Then
1000+
val jsonObject = result as JSONObject
1001+
val someArray = jsonObject.getJSONArray("someArray")
1002+
someArray.length() shouldBe 2
1003+
someArray.getInt(0) shouldBe 1
1004+
someArray.getInt(1) shouldBe 2
1005+
1006+
val someMixedArray = jsonObject.getJSONArray("someMixedArray")
1007+
someMixedArray.length() shouldBe 4
1008+
someMixedArray.getInt(0) shouldBe 1
1009+
someMixedArray.getString(1) shouldBe "2"
1010+
val nestedObj = someMixedArray.getJSONObject(2)
1011+
nestedObj.getString("abc") shouldBe "123"
1012+
someMixedArray.get(3) shouldBe JSONObject.NULL
1013+
}
1014+
1015+
test("should handle complete example structure with nulls") {
1016+
// Given - matches the user's example structure
1017+
val map = mapOf(
1018+
"someNum" to 123,
1019+
"someFloat" to 3.14159,
1020+
"someString" to "abc",
1021+
"someBool" to true,
1022+
"someObject" to mapOf(
1023+
"abc" to "123",
1024+
"nested" to mapOf(
1025+
"def" to "456",
1026+
),
1027+
"ghi" to null,
1028+
),
1029+
"someArray" to listOf(1, 2),
1030+
"someMixedArray" to listOf(1, "2", mapOf("abc" to "123"), null),
1031+
"someNull" to null,
1032+
)
1033+
1034+
// When
1035+
val result = JSONUtils.convertToJson(map)
1036+
1037+
// Then
1038+
val jsonObject = result as JSONObject
1039+
jsonObject.getInt("someNum") shouldBe 123
1040+
jsonObject.getDouble("someFloat") shouldBe 3.14159
1041+
jsonObject.getString("someString") shouldBe "abc"
1042+
jsonObject.getBoolean("someBool") shouldBe true
1043+
1044+
val someObject = jsonObject.getJSONObject("someObject")
1045+
someObject.getString("abc") shouldBe "123"
1046+
val nested = someObject.getJSONObject("nested")
1047+
nested.getString("def") shouldBe "456"
1048+
someObject.isNull("ghi") shouldBe true
1049+
1050+
val someArray = jsonObject.getJSONArray("someArray")
1051+
someArray.length() shouldBe 2
1052+
someArray.getInt(0) shouldBe 1
1053+
someArray.getInt(1) shouldBe 2
1054+
1055+
val someMixedArray = jsonObject.getJSONArray("someMixedArray")
1056+
someMixedArray.length() shouldBe 4
1057+
someMixedArray.getInt(0) shouldBe 1
1058+
someMixedArray.getString(1) shouldBe "2"
1059+
val nestedObj = someMixedArray.getJSONObject(2)
1060+
nestedObj.getString("abc") shouldBe "123"
1061+
someMixedArray.get(3) shouldBe JSONObject.NULL
1062+
1063+
jsonObject.isNull("someNull") shouldBe true
1064+
}
9441065
}
9451066
})

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ class UserManagerTests : FunSpec({
210210
"key3" to 5.123,
211211
"key4" to mapOf("key4-1" to "value4-1"),
212212
"key5" to mapOf("key5-1" to mapOf("key5-1-1" to 0)),
213+
"key6" to null,
213214
)
214215

215216
// When

0 commit comments

Comments
 (0)