Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,59 @@ object JSONUtils {
`object`
}
}

/**
* Check if an object is JSON-serializable.
* Recursively check each item if object is a map or a list.
*/
fun isValidJsonObject(value: Any?): Boolean {
return when (value) {
null,
is Boolean,
is Number,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about finite checks?

is String,
is JSONObject,
is JSONArray,
-> true
is Map<*, *> -> value.keys.all { it is String } && value.values.all { isValidJsonObject(it) }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you please write a unit test for this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added more tests for JSONUtils.kt

is List<*> -> value.all { isValidJsonObject(it) }
else -> false
}
}

/**
* Recursively convert a JSON-serializable map into a JSON-compatible format, handling
* nested Maps and Lists appropriately.
*/
fun mapToJson(map: Map<String, Any>): JSONObject {
val json = JSONObject()
for ((key, value) in map) {
json.put(key, convertToJson(value))
}
return json
}

/**
* 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.
*/
fun convertToJson(value: Any): Any {
return when (value) {
is Map<*, *> -> {
val subMap =
value.entries
.filter { it.key is String }
.associate {
it.key as String to convertToJson(it.value!!)
}
mapToJson(subMap)
}
is List<*> -> {
val array = JSONArray()
value.forEach { array.put(convertToJson(it!!)) }
array
}
else -> value
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import com.onesignal.user.internal.operations.RefreshUserOperation
import com.onesignal.user.internal.operations.SetAliasOperation
import com.onesignal.user.internal.operations.SetPropertyOperation
import com.onesignal.user.internal.operations.SetTagOperation
import com.onesignal.user.internal.operations.TrackCustomEventOperation
import com.onesignal.user.internal.operations.TrackPurchaseOperation
import com.onesignal.user.internal.operations.TrackSessionEndOperation
import com.onesignal.user.internal.operations.TrackSessionStartOperation
import com.onesignal.user.internal.operations.TransferSubscriptionOperation
import com.onesignal.user.internal.operations.UpdateSubscriptionOperation
import com.onesignal.user.internal.operations.impl.executors.CustomEventOperationExecutor
import com.onesignal.user.internal.operations.impl.executors.IdentityOperationExecutor
import com.onesignal.user.internal.operations.impl.executors.LoginUserFromSubscriptionOperationExecutor
import com.onesignal.user.internal.operations.impl.executors.LoginUserOperationExecutor
Expand Down Expand Up @@ -60,6 +62,7 @@ internal class OperationModelStore(prefs: IPreferencesService) : ModelStore<Oper
UpdateUserOperationExecutor.TRACK_SESSION_START -> TrackSessionStartOperation()
UpdateUserOperationExecutor.TRACK_SESSION_END -> TrackSessionEndOperation()
UpdateUserOperationExecutor.TRACK_PURCHASE -> TrackPurchaseOperation()
CustomEventOperationExecutor.CUSTOM_EVENT -> TrackCustomEventOperation()
else -> throw Exception("Unrecognized operation: $operationName")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,15 @@ interface IUserManager {
* Remove an observer from the user state.
*/
fun removeObserver(observer: IUserStateObserver)

/**
* Tracks a custom event performed by the current user
*
* @param name for the custom event
* @param properties an optional property dictionary, must be serializable into a JSON Object
*/
fun trackEvent(
name: String,
properties: Map<String, Any>? = null,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,14 @@ import com.onesignal.user.internal.backend.impl.SubscriptionBackendService
import com.onesignal.user.internal.backend.impl.UserBackendService
import com.onesignal.user.internal.builduser.IRebuildUserService
import com.onesignal.user.internal.builduser.impl.RebuildUserService
import com.onesignal.user.internal.customEvents.ICustomEventBackendService
import com.onesignal.user.internal.customEvents.ICustomEventController
import com.onesignal.user.internal.customEvents.impl.CustomEventBackendService
import com.onesignal.user.internal.customEvents.impl.CustomEventController
import com.onesignal.user.internal.identity.IdentityModelStore
import com.onesignal.user.internal.migrations.RecoverConfigPushSubscription
import com.onesignal.user.internal.migrations.RecoverFromDroppedLoginBug
import com.onesignal.user.internal.operations.impl.executors.CustomEventOperationExecutor
import com.onesignal.user.internal.operations.impl.executors.IdentityOperationExecutor
import com.onesignal.user.internal.operations.impl.executors.LoginUserFromSubscriptionOperationExecutor
import com.onesignal.user.internal.operations.impl.executors.LoginUserOperationExecutor
Expand Down Expand Up @@ -71,6 +76,9 @@ internal class UserModule : IModule {
builder.register<LoginUserFromSubscriptionOperationExecutor>().provides<IOperationExecutor>()
builder.register<RefreshUserOperationExecutor>().provides<IOperationExecutor>()
builder.register<UserManager>().provides<IUserManager>()
builder.register<CustomEventController>().provides<ICustomEventController>()
builder.register<CustomEventOperationExecutor>().provides<IOperationExecutor>()
builder.register<CustomEventBackendService>().provides<ICustomEventBackendService>()

builder.register<UserRefreshService>().provides<IStartableService>()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.onesignal.user.internal

import com.onesignal.common.IDManager
import com.onesignal.common.JSONUtils
import com.onesignal.common.OneSignalUtils
import com.onesignal.common.events.EventProducer
import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler
Expand All @@ -10,6 +11,7 @@ import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.user.IUserManager
import com.onesignal.user.internal.backend.IdentityConstants
import com.onesignal.user.internal.customEvents.ICustomEventController
import com.onesignal.user.internal.identity.IdentityModel
import com.onesignal.user.internal.identity.IdentityModelStore
import com.onesignal.user.internal.properties.PropertiesModel
Expand All @@ -25,6 +27,7 @@ internal open class UserManager(
private val _subscriptionManager: ISubscriptionManager,
private val _identityModelStore: IdentityModelStore,
private val _propertiesModelStore: PropertiesModelStore,
private val _customEventController: ICustomEventController,
private val _languageContext: ILanguageContext,
) : IUserManager, ISingletonModelStoreChangeHandler<IdentityModel> {
override val onesignalId: String
Expand Down Expand Up @@ -244,6 +247,18 @@ internal open class UserManager(
changeHandlersNotifier.unsubscribe(observer)
}

override fun trackEvent(
name: String,
properties: Map<String, Any>?,
) {
if (!JSONUtils.isValidJsonObject(properties)) {
Logging.log(LogLevel.ERROR, "Custom event properties are not JSON-serializable")
return
}

_customEventController.sendCustomEvent(name, properties)
}

override fun onModelReplaced(
model: IdentityModel,
tag: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.onesignal.user.internal.customEvents

import com.onesignal.core.internal.operations.ExecutionResponse
import com.onesignal.user.internal.customEvents.impl.CustomEventMetadata

/**
* The backend service for custom events.
*/
interface ICustomEventBackendService {
/**
* Send an custom event to the backend and return the response.
*
* @param customEvent The custom event to send up.
*/
suspend fun sendCustomEvent(
appId: String,
onesignalId: String,
externalId: String?,
timestamp: Long,
eventName: String,
eventProperties: String?,
metadata: CustomEventMetadata,
): ExecutionResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.onesignal.user.internal.customEvents

interface ICustomEventController {
fun sendCustomEvent(
name: String,
properties: Map<String, Any>?,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.onesignal.user.internal.customEvents.impl

import com.onesignal.common.DateUtils
import com.onesignal.common.exceptions.BackendException
import com.onesignal.core.internal.http.IHttpClient
import com.onesignal.core.internal.operations.ExecutionResponse
import com.onesignal.core.internal.operations.ExecutionResult
import com.onesignal.user.internal.customEvents.ICustomEventBackendService
import org.json.JSONArray
import org.json.JSONObject
import java.util.TimeZone

internal class CustomEventBackendService(
private val httpClient: IHttpClient,
) : ICustomEventBackendService {
override suspend fun sendCustomEvent(
appId: String,
onesignalId: String,
externalId: String?,
timestamp: Long,
eventName: String,
eventProperties: String?,
metadata: CustomEventMetadata,
): ExecutionResponse {
val body = JSONObject()
body.put("name", eventName)
body.put("onesignal_id", onesignalId)
externalId?.let { body.put("external_id", it) }
body.put(
"timestamp",
DateUtils.iso8601Format().apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(
timestamp,
),
)

val payload = eventProperties?.let { JSONObject(it) } ?: JSONObject()

payload.put("os_sdk", metadata.toJSONObject())

body.put("payload", payload)
val jsonObject = JSONObject().put("events", JSONArray().put(body))

val response = httpClient.post("apps/$appId/custom_events", jsonObject)

if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
}

return ExecutionResponse(ExecutionResult.SUCCESS)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.onesignal.user.internal.customEvents.impl

import com.onesignal.common.JSONUtils
import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.operations.IOperationRepo
import com.onesignal.core.internal.time.ITime
import com.onesignal.user.internal.customEvents.ICustomEventController
import com.onesignal.user.internal.identity.IdentityModelStore
import com.onesignal.user.internal.operations.TrackCustomEventOperation

class CustomEventController(
private val identityModelStore: IdentityModelStore,
private val configModelStore: ConfigModelStore,
private val time: ITime,
private val opRepo: IOperationRepo,
) : ICustomEventController {
override fun sendCustomEvent(
name: String,
properties: Map<String, Any>?,
) {
val op =
TrackCustomEventOperation(
configModelStore.model.appId,
identityModelStore.model.onesignalId,
identityModelStore.model.externalId,
time.currentTimeMillis,
name,
properties?.let { JSONUtils.mapToJson(it).toString() },
)
opRepo.enqueue(op)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.onesignal.user.internal.customEvents.impl

import com.onesignal.common.putSafe
import org.json.JSONException
import org.json.JSONObject

class CustomEventMetadata(
val deviceType: String?,
val sdk: String?,
val appVersion: String?,
val type: String?,
val deviceModel: String?,
val deviceOS: String?,
) {
@Throws(JSONException::class)
fun toJSONObject(): JSONObject {
val json = JSONObject()
json.putSafe(SDK, sdk)
json.putSafe(APP_VERSION, appVersion)
json.putSafe(TYPE, type)
json.putSafe(DEVICE_TYPE, deviceType)
json.putSafe(DEVICE_MODEL, deviceModel)
json.putSafe(DEVICE_OS, deviceOS)
return json
}

override fun toString(): String {
return toJSONObject().toString()
}

companion object {
private const val DEVICE_TYPE = "device_type"
private const val SDK = "sdk"
private const val APP_VERSION = "app_version"
private const val TYPE = "type"
private const val DEVICE_MODEL = "device_model"
private const val DEVICE_OS = "device_os"
}
}
Loading
Loading