Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 98 additions & 2 deletions demo-app/src/main/java/com/personalization/demo/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package com.personalization.demo

import android.os.Bundle
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.google.firebase.FirebaseApp
import com.personalization.SDK
import com.personalization.api.OnApiCallbackListener
import com.personalization.demo.BuildConfig
import com.personalization.sdk.data.models.dto.popUp.Components
import org.json.JSONObject
import com.personalization.sdk.data.models.dto.popUp.PopupActions
import com.personalization.sdk.data.models.dto.popUp.PopupDto
import com.personalization.sdk.data.models.dto.popUp.Position
Expand All @@ -14,6 +18,20 @@ class MainActivity : AppCompatActivity() {

private lateinit var sdk: SDK

private object DemoTrackEventConstants {
/** Same value as SDK client-side validation errors for custom field key collisions. */
const val CLIENT_VALIDATION_ERROR_CODE = -1
const val EVENT_NAME = "custom_event"
const val SAMPLE_UNIX_TIME = 123_456
const val CATEGORY = "demo_category"
const val LABEL = "demo_label"
const val SAMPLE_VALUE = 100
const val SAFE_CUSTOM_KEY = "demo_custom_key"
const val SAFE_CUSTOM_VALUE = "android_demo_app"
const val COLLISION_RESERVED_KEY = "shop_id"
const val COLLISION_PLACEHOLDER_VALUE = "collision_demo"
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
Expand Down Expand Up @@ -44,10 +62,88 @@ class MainActivity : AppCompatActivity() {
// Initialize fragment manager for popups
sdk.inAppNotificationManager.initFragmentManager(supportFragmentManager)

// Setup button
findViewById<android.widget.Button>(R.id.btnShowTestPopup).setOnClickListener {
findViewById<Button>(R.id.btnShowTestPopup).setOnClickListener {
showTestPopup()
}

findViewById<Button>(R.id.btnTrackEventCustomFields).setOnClickListener {
trackEventWithCustomFieldsSuccess()
}

findViewById<Button>(R.id.btnTrackEventCollision).setOnClickListener {
trackEventWithReservedKeyCollision()
}
}

private fun trackEventWithCustomFieldsSuccess() {
val customFields = mapOf(DemoTrackEventConstants.SAFE_CUSTOM_KEY to DemoTrackEventConstants.SAFE_CUSTOM_VALUE)
sdk.trackEvent(
event = DemoTrackEventConstants.EVENT_NAME,
time = DemoTrackEventConstants.SAMPLE_UNIX_TIME,
category = DemoTrackEventConstants.CATEGORY,
label = DemoTrackEventConstants.LABEL,
value = DemoTrackEventConstants.SAMPLE_VALUE,
customFields = customFields,
listener = object : OnApiCallbackListener() {
override fun onSuccess(response: JSONObject?) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
getString(R.string.track_event_ok),
Toast.LENGTH_LONG
).show()
}
}

override fun onError(code: Int, msg: String?) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"${getString(R.string.track_event_fail)}: $msg",
Toast.LENGTH_LONG
).show()
}
}
}
)
}

private fun trackEventWithReservedKeyCollision() {
val customFields = mapOf(
DemoTrackEventConstants.COLLISION_RESERVED_KEY to DemoTrackEventConstants.COLLISION_PLACEHOLDER_VALUE
)
sdk.trackEvent(
event = DemoTrackEventConstants.EVENT_NAME,
time = DemoTrackEventConstants.SAMPLE_UNIX_TIME,
category = DemoTrackEventConstants.CATEGORY,
label = DemoTrackEventConstants.LABEL,
value = DemoTrackEventConstants.SAMPLE_VALUE,
customFields = customFields,
listener = object : OnApiCallbackListener() {
override fun onSuccess(response: JSONObject?) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
getString(R.string.track_event_unexpected_success),
Toast.LENGTH_LONG
).show()
}
}

override fun onError(code: Int, msg: String?) {
runOnUiThread {
val isClientValidation = code == DemoTrackEventConstants.CLIENT_VALIDATION_ERROR_CODE
&& msg?.contains("customFields contains reserved keys") == true
val text = if (isClientValidation) {
"${getString(R.string.track_event_collision_ok)}\n$msg"
} else {
"${getString(R.string.track_event_fail)}: $msg"
}
Toast.makeText(this@MainActivity, text, Toast.LENGTH_LONG).show()
}
}
}
)
}

private fun showTestPopup() {
Expand Down
14 changes: 14 additions & 0 deletions demo-app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@
android:text="@string/show_test_popup"
android:layout_marginTop="32dp" />

<Button
android:id="@+id/btnTrackEventCustomFields"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/track_event_custom_fields"
android:layout_marginTop="16dp" />

<Button
android:id="@+id/btnTrackEventCollision"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/track_event_reserved_collision"
android:layout_marginTop="16dp" />

</LinearLayout>


6 changes: 6 additions & 0 deletions demo-app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
<resources>
<string name="app_name">Personalization SDK Demo</string>
<string name="show_test_popup">Show Test Popup</string>
<string name="track_event_custom_fields">Track event (custom fields)</string>
<string name="track_event_reserved_collision">Track event (reserved key collision)</string>
<string name="track_event_ok">trackEvent: request sent</string>
<string name="track_event_collision_ok">trackEvent: validation error (expected)</string>
<string name="track_event_fail">trackEvent failed</string>
<string name="track_event_unexpected_success">Unexpected: expected client validation failure</string>
</resources>


4 changes: 4 additions & 0 deletions personalization-sdk/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ dependencies {
implementation("com.google.android.gms:play-services-ads-identifier:17.0.0")

kapt 'com.google.dagger:dagger-compiler:2.48'

testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.11'
testImplementation 'org.robolectric:robolectric:4.12.1'
}

configurations {
Expand Down
48 changes: 45 additions & 3 deletions personalization-sdk/src/main/kotlin/com/personalization/SDK.kt
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,37 @@ open class SDK {
trackEventManager.track(event, params, listener)
}

/**
* Custom event tracking (aligned with the iOS SDK).
*
* @param event Event key
* @param time Optional UNIX time in seconds
* @param category Event category
* @param label Event label
* @param value Event value (sent as string in JSON)
* @param customFields Optional map merged at top level and under `payload`
* @param listener Callback
*/
fun trackEvent(
event: String,
time: Int? = null,
category: String? = null,
label: String? = null,
value: Int? = null,
customFields: Map<String, Any?>? = null,
listener: OnApiCallbackListener? = null
) {
trackEventManager.trackEvent(
event = event,
time = time,
category = category,
label = label,
value = value,
customFields = customFields,
listener = listener
)
}

/**
* Tracking custom events
*
Expand All @@ -504,9 +535,12 @@ open class SDK {
* @param listener Callback
*/
@Deprecated(
"This method will be removed in future versions.",
"This method will be removed in future versions. Use trackEvent.",
level = DeprecationLevel.WARNING,
replaceWith = ReplaceWith("trackEventManager.customTrack(event, category, label, value = value, listener = listener)")
replaceWith = ReplaceWith(
"trackEvent(event = event, category = category, label = label, value = value, listener = listener)",
imports = []
)
)
fun track(
event: String,
Expand All @@ -515,7 +549,15 @@ open class SDK {
value: Int? = null,
listener: OnApiCallbackListener? = null
) {
trackEventManager.customTrack(event, category, label, value = value, listener = listener)
trackEventManager.trackEvent(
event = event,
time = null,
category = category,
label = label,
value = value,
customFields = null,
listener = listener
)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,31 @@ interface TrackEventManager {
)

/**
* Tracking custom events
* Custom event tracking (aligned with the iOS SDK `trackEvent`).
*
* @param event Event key
* @param time Optional UNIX time (seconds), same as iOS
* @param category Optional category
* @param label Optional label
* @param value Optional value (sent as string in JSON, same as iOS)
* @param customFields Optional map merged at the top level and duplicated under `payload` (payload only contains these entries)
* @param listener Callback
*/
fun trackEvent(
event: String,
time: Int? = null,
category: String? = null,
label: String? = null,
value: Int? = null,
customFields: Map<String, Any?>? = null,
listener: OnApiCallbackListener? = null
)

/**
* Tracking custom events (legacy).
*
* Use [trackEvent] for the standard event shape (`time`, `customFields`).
* This method remains for optional identity fields: `email`, `phone`, `loyalty_id`, `external_id`.
*
* @param event Event key
* @param email Email
Expand All @@ -40,6 +64,14 @@ interface TrackEventManager {
* @param value Event value
* @param listener Callback
*/
@Deprecated(
message = "Use trackEvent(event, time, category, label, value, customFields, listener) for the iOS-aligned API. " +
"Use this method only when you still need email, phone, loyalty_id, or external_id.",
replaceWith = ReplaceWith(
expression = "trackEvent(event = event, time = null, category = category, label = label, value = value, customFields = null, listener = listener)",
imports = []
)
)
fun customTrack(
event: String,
email: String? = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.personalization.features.trackEvent.impl

import com.personalization.sdk.data.models.params.UserBasicParams
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject

/**
* Shared rules for custom event `customFields` merge and validation (parity with iOS trackEvent).
*/
internal object TrackCustomEventPayloadHelper {

const val CLIENT_VALIDATION_ERROR_CODE: Int = -1

private const val KEY_EVENT = "event"
private const val KEY_TIME = "time"
private const val KEY_CATEGORY = "category"
private const val KEY_LABEL = "label"
private const val KEY_VALUE = "value"
private const val KEY_SOURCE = "source"
private const val KEY_PAYLOAD = "payload"
private const val KEY_FROM = "from"
private const val KEY_CODE = "code"
private const val KEY_STREAM = "stream"

val RESERVED_CUSTOM_EVENT_KEYS: Set<String> = setOf(
UserBasicParams.SHOP_ID,
UserBasicParams.DID,
UserBasicParams.SEANCE,
UserBasicParams.SID,
UserBasicParams.SEGMENT,
KEY_STREAM,
KEY_EVENT,
KEY_TIME,
KEY_CATEGORY,
KEY_LABEL,
KEY_VALUE,
KEY_SOURCE,
KEY_PAYLOAD,
KEY_FROM,
KEY_CODE,
)

fun effectiveCustomFields(map: Map<String, Any?>?): Map<String, Any> {
if (map.isNullOrEmpty()) return emptyMap()
val out = LinkedHashMap<String, Any>()
for ((key, value) in map) {
if (key.isBlank() || value == null) continue
out[key] = value
}
return out
}

/**
* @return error message for [listener.onError], or null if valid
*/
fun validateNoReservedKeyCollisions(customFields: Map<String, Any>): String? {
if (customFields.isEmpty()) return null
val collisions = customFields.keys.intersect(RESERVED_CUSTOM_EVENT_KEYS)
if (collisions.isEmpty()) return null
val sorted = collisions.toSortedSet().joinToString(", ")
return "trackEvent: customFields contains reserved keys: $sorted"
}

@Throws(JSONException::class)
fun putJsonValue(target: JSONObject, key: String, value: Any) {
when (value) {
is String -> target.put(key, value)
is Int -> target.put(key, value)
is Long -> target.put(key, value)
is Double -> target.put(key, value)
is Float -> target.put(key, value.toDouble())
is Boolean -> target.put(key, value)
is JSONObject -> target.put(key, value)
is JSONArray -> target.put(key, value)
else -> target.put(key, value.toString())
}
}
}
Loading
Loading