Skip to content

Commit ba98e0f

Browse files
authored
Merge branch 'feat/auto-push-tracking' into ab/mage-765-android-add-x-klaviyo-sdk-features-header-to-push-token
2 parents 77b17c9 + 2946c54 commit ba98e0f

16 files changed

Lines changed: 930 additions & 423 deletions

File tree

sdk/analytics/src/main/java/com/klaviyo/analytics/Klaviyo.kt

Lines changed: 39 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@ import android.app.Application
44
import android.content.Context
55
import android.content.Intent
66
import android.net.Uri
7-
import androidx.core.app.NotificationManagerCompat
87
import androidx.core.net.toUri
98
import com.klaviyo.analytics.linking.DeepLinkHandler
109
import com.klaviyo.analytics.linking.DeepLinking
1110
import com.klaviyo.analytics.model.Event
12-
import com.klaviyo.analytics.model.EventKey
1311
import com.klaviyo.analytics.model.EventMetric
1412
import com.klaviyo.analytics.model.Profile
1513
import com.klaviyo.analytics.model.ProfileKey
@@ -19,22 +17,19 @@ import com.klaviyo.analytics.state.KlaviyoState
1917
import com.klaviyo.analytics.state.State
2018
import com.klaviyo.analytics.state.StateSideEffects
2119
import com.klaviyo.core.Constants
22-
import com.klaviyo.core.Constants.KEY_VALUE_PAIRS
2320
import com.klaviyo.core.Constants.PACKAGE_PREFIX
2421
import com.klaviyo.core.Constants.TRACKING_PARAMETER
2522
import com.klaviyo.core.Operation
23+
import com.klaviyo.core.PushTokenFetcher
2624
import com.klaviyo.core.Registry
2725
import com.klaviyo.core.config.Config
2826
import com.klaviyo.core.config.LifecycleException
2927
import com.klaviyo.core.safeApply
3028
import com.klaviyo.core.safeCall
31-
import com.klaviyo.core.utils.BoundedIdSet
32-
import com.klaviyo.core.utils.JSONUtil.toHashMap
3329
import com.klaviyo.core.utils.takeIf
3430
import java.io.Serializable
3531
import java.util.LinkedList
3632
import java.util.Queue
37-
import org.json.JSONObject
3833

3934
/**
4035
* Public API for the core Klaviyo SDK.
@@ -48,17 +43,6 @@ object Klaviyo {
4843
*/
4944
private val preInitQueue: Queue<Operation<Unit>> = LinkedList()
5045

51-
/**
52-
* Push delivery IDs already handled within this process, so a single tap records one
53-
* `$opened_push`. See [handlePush] for how entries are matched and added.
54-
*/
55-
private val handledPushDeliveries = BoundedIdSet()
56-
57-
/**
58-
* Key within the `_k` tracking payload whose value uniquely identifies a single push delivery.
59-
*/
60-
private const val PUSH_DELIVERY_KEY = "tm"
61-
6246
/**
6347
* This method is provided for apps that are unable to register their API key immediately
6448
* on app launch in order enable limited SDK functionality including tracking app lifecycle,
@@ -128,6 +112,43 @@ object Klaviyo {
128112
preInitQueue.poll()?.let { safeCall(null, it) }
129113
}
130114
}
115+
116+
// Optional side effect, kept last so it can never interfere with core initialization
117+
maybeAutoRegisterPushToken()
118+
}
119+
120+
/**
121+
* Opt-in automatic push token registration (via [Constants.AUTOMATIC_PUSH_TRACKING]): pull the
122+
* current token and forward it to Klaviyo. Called from [initialize] and on each app foreground so
123+
* rotations are picked up. No-op when the flag is off, when forwarding is opted out via
124+
* [Constants.DISABLE_AUTOMATIC_TOKEN_FORWARDING], or when `push-fcm` is absent.
125+
*/
126+
internal fun maybeAutoRegisterPushToken() {
127+
val autoTrackingEnabled = Registry.config.getManifestBoolean(
128+
Constants.AUTOMATIC_PUSH_TRACKING,
129+
false
130+
)
131+
// Avoid a second manifest read on the common flag-off path
132+
val tokenForwardingDisabled = autoTrackingEnabled && Registry.config.getManifestBoolean(
133+
Constants.DISABLE_AUTOMATIC_TOKEN_FORWARDING,
134+
false
135+
)
136+
if (!autoTrackingEnabled || tokenForwardingDisabled) {
137+
Registry.log.verbose(
138+
"Skipping automatic push token registration " +
139+
"(automaticPushTracking=$autoTrackingEnabled, tokenForwardingDisabled=$tokenForwardingDisabled)"
140+
)
141+
return
142+
}
143+
144+
Registry.getOrNull<PushTokenFetcher>()?.also { fetcher ->
145+
Registry.log.debug("Automatically fetching push token")
146+
// Contain any fetcher failure so this optional side effect cannot disrupt the caller
147+
runCatching { fetcher.fetchAndSetPushToken() }
148+
.onFailure { Registry.log.warning("Automatic push token fetch failed", it) }
149+
} ?: Registry.log.verbose(
150+
"No push token fetcher registered; skipping automatic registration"
151+
)
131152
}
132153

133154
/**
@@ -337,53 +358,7 @@ object Klaviyo {
337358
*/
338359
@JvmStatic
339360
fun handlePush(intent: Intent?): Klaviyo {
340-
if (intent == null || !intent.isKlaviyoNotificationIntent) {
341-
Registry.log.verbose("Non-Klaviyo intent ignored")
342-
return this
343-
}
344-
345-
// Dedup guard: track each push delivery at most once per process. The trampoline calls
346-
// handlePush and forwards the same intent to the host, so a leftover manual handlePush call
347-
// (or singleTask re-entry) would otherwise double-track one tap. The first call to see a
348-
// delivery records it and proceeds; a later call for the same delivery short-circuits — no
349-
// event, dismissal, or deep link.
350-
val deliveryId = intent.pushDeliveryId
351-
if (deliveryId != null && !handledPushDeliveries.markOnce(deliveryId)) {
352-
Registry.log.verbose("Ignoring duplicate push open")
353-
return this
354-
}
355-
356-
// Create and enqueue an $opened_push. safeApply(preInitQueue) buffers this for replay if
357-
// handlePush runs before initialize(), and guards against unexpected failures.
358-
safeApply(preInitQueue) {
359-
val state = Registry.get<State>()
360-
val event = Event(EventMetric.OPENED_PUSH)
361-
event.appendKlaviyoExtras(intent)
362-
state.pushToken?.let { event[EventKey.PUSH_TOKEN] = it }
363-
// Not using Klaviyo.createEvent here to avoid nesting safeApply calls
364-
state.createEvent(event, state.getAsProfile())
365-
}
366-
367-
// Dismiss the notification if opened via an action button. Body taps auto-cancel via
368-
// setAutoCancel(true) on the builder; action button taps don't (standard Android behavior).
369-
safeApply {
370-
val notificationTag = intent.getStringExtra(Constants.NOTIFICATION_TAG_EXTRA)
371-
if (notificationTag != null) {
372-
NotificationManagerCompat
373-
.from(Registry.config.applicationContext)
374-
.cancel(notificationTag, Constants.NOTIFICATION_ID)
375-
}
376-
}
377-
378-
// If the notification carries a deep link and a handler is registered, invoke it. Otherwise
379-
// do nothing — the host already received the appropriate intent.
380-
safeApply {
381-
val deepLink = intent.data
382-
if (deepLink != null && DeepLinking.isHandlerRegistered) {
383-
DeepLinking.handleDeepLink(deepLink)
384-
}
385-
}
386-
361+
KlaviyoPushOpenHandler.handle(intent, preInitQueue)
387362
return this
388363
}
389364

@@ -456,29 +431,6 @@ object Klaviyo {
456431
@JvmStatic
457432
fun isKlaviyoNotificationIntent(intent: Intent?): Boolean = intent.isKlaviyoNotificationIntent
458433

459-
/**
460-
* Dedup key for this intent's push delivery, or `null` if none is available. Prefers the `tm`
461-
* field of the `_k` payload (a per-delivery ULID on campaign sends), else the SDK-generated
462-
* per-notification [Constants.NOTIFICATION_UID_EXTRA]. Both are copied forward to the host's
463-
* intent, so a delivery handled by the trampoline and then a leftover manual call resolve to the
464-
* same key, while distinct notifications stay distinct.
465-
*
466-
* Deliberately not the raw `_k`: minus `tm` it is per-message metadata shared across deliveries,
467-
* so it would collapse distinct opens.
468-
*/
469-
private val Intent?.pushDeliveryId: String?
470-
get() {
471-
val deliveryId = this?.getStringExtra(PACKAGE_PREFIX + TRACKING_PARAMETER)
472-
?.takeIf { it.isNotEmpty() }
473-
?.let { trackingPayload ->
474-
runCatching { JSONObject(trackingPayload).optString(PUSH_DELIVERY_KEY) }
475-
.getOrNull()
476-
?.takeIf { it.isNotEmpty() }
477-
}
478-
return deliveryId ?: this?.getStringExtra(Constants.NOTIFICATION_UID_EXTRA)
479-
?.takeIf { it.isNotEmpty() }
480-
}
481-
482434
/**
483435
* Determine if an intent is a Klaviyo click-tracking universal/app link
484436
*/
@@ -508,33 +460,4 @@ object Klaviyo {
508460
*/
509461
@JvmStatic
510462
fun isKlaviyoUniversalTrackingUri(uri: Uri): Boolean = uri.isKlaviyoUniversalTrackingUri
511-
512-
/**
513-
* Appends Klaviyo extras from an intent to this event, parsing special fields as needed
514-
*/
515-
internal fun Event.appendKlaviyoExtras(intent: Intent?) {
516-
intent?.extras?.keySet()?.forEach { key ->
517-
if (key.contains(PACKAGE_PREFIX)) {
518-
val eventKey = EventKey.CUSTOM(key.replace(PACKAGE_PREFIX, ""))
519-
val rawValue = intent.extras?.getString(key, "") ?: ""
520-
val parsedValue = when (eventKey.name) {
521-
KEY_VALUE_PAIRS -> {
522-
try {
523-
JSONObject(rawValue).toHashMap()
524-
} catch (e: Exception) {
525-
Registry.log.warning(
526-
"Failed to parse $KEY_VALUE_PAIRS JSON: $rawValue",
527-
e
528-
)
529-
rawValue
530-
}
531-
}
532-
533-
else -> rawValue
534-
}
535-
536-
this[eventKey] = parsedValue
537-
}
538-
}
539-
}
540463
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package com.klaviyo.analytics
2+
3+
import android.content.Intent
4+
import androidx.core.app.NotificationManagerCompat
5+
import com.klaviyo.analytics.linking.DeepLinking
6+
import com.klaviyo.analytics.model.Event
7+
import com.klaviyo.analytics.model.EventKey
8+
import com.klaviyo.analytics.model.EventMetric
9+
import com.klaviyo.analytics.state.State
10+
import com.klaviyo.core.Constants
11+
import com.klaviyo.core.Constants.KEY_VALUE_PAIRS
12+
import com.klaviyo.core.Constants.PACKAGE_PREFIX
13+
import com.klaviyo.core.Constants.TRACKING_PARAMETER
14+
import com.klaviyo.core.Operation
15+
import com.klaviyo.core.Registry
16+
import com.klaviyo.core.safeApply
17+
import com.klaviyo.core.utils.BoundedIdSet
18+
import com.klaviyo.core.utils.JSONUtil.toHashMap
19+
import java.util.Queue
20+
import org.json.JSONObject
21+
22+
internal object KlaviyoPushOpenHandler {
23+
24+
/**
25+
* Push delivery IDs already handled within this process, so a single tap records one
26+
* `$opened_push`. See [handle] for how entries are matched and added.
27+
*/
28+
private val handledPushDeliveries = BoundedIdSet()
29+
30+
/**
31+
* Key within the `_k` tracking payload whose value uniquely identifies a single push delivery.
32+
*/
33+
private const val PUSH_DELIVERY_KEY = "tm"
34+
35+
/**
36+
* Core push-open handling: guards, event enqueue, notification dismissal, deep-link dispatch.
37+
* Called by [Klaviyo.handlePush]; not meant for direct use outside this module.
38+
*/
39+
internal fun handle(intent: Intent?, preInitQueue: Queue<Operation<Unit>>) {
40+
if (intent == null || !Klaviyo.isKlaviyoNotificationIntent(intent)) {
41+
Registry.log.verbose("Non-Klaviyo intent ignored")
42+
return
43+
}
44+
45+
// Dedup guard: track each push delivery at most once per process. The trampoline calls
46+
// handlePush and forwards the same intent to the host, so a leftover manual handlePush call
47+
// (or singleTask re-entry) would otherwise double-track one tap. The first call to see a
48+
// delivery records it and proceeds; a later call for the same delivery short-circuits — no
49+
// event, dismissal, or deep link.
50+
val deliveryId = intent.pushDeliveryId
51+
if (deliveryId != null && !handledPushDeliveries.markOnce(deliveryId)) {
52+
Registry.log.verbose("Ignoring duplicate push open")
53+
return
54+
}
55+
56+
// Create and enqueue an $opened_push. safeApply(preInitQueue) buffers this for replay if
57+
// handlePush runs before initialize(), and guards against unexpected failures.
58+
safeApply(preInitQueue) {
59+
val state = Registry.get<State>()
60+
val event = Event(EventMetric.OPENED_PUSH)
61+
event.appendKlaviyoExtras(intent)
62+
state.pushToken?.let { event[EventKey.PUSH_TOKEN] = it }
63+
// Not using Klaviyo.createEvent here to avoid nesting safeApply calls
64+
state.createEvent(event, state.getAsProfile())
65+
}
66+
67+
// Dismiss the notification if opened via an action button. Body taps auto-cancel via
68+
// setAutoCancel(true) on the builder; action button taps don't (standard Android behavior).
69+
safeApply {
70+
val notificationTag = intent.getStringExtra(Constants.NOTIFICATION_TAG_EXTRA)
71+
if (notificationTag != null) {
72+
NotificationManagerCompat
73+
.from(Registry.config.applicationContext)
74+
.cancel(notificationTag, Constants.NOTIFICATION_ID)
75+
}
76+
}
77+
78+
// If the notification carries a deep link and a handler is registered, invoke it. Otherwise
79+
// do nothing — the host already received the appropriate intent.
80+
safeApply {
81+
val deepLink = intent.data
82+
if (deepLink != null && DeepLinking.isHandlerRegistered) {
83+
DeepLinking.handleDeepLink(deepLink)
84+
}
85+
}
86+
}
87+
88+
/**
89+
* Dedup key for this intent's push delivery, or `null` if none is available. Prefers the `tm`
90+
* field of the `_k` payload (a per-delivery ULID on campaign sends), else the SDK-generated
91+
* [Constants.NOTIFICATION_UID_EXTRA] stamped on trampoline intents. Both are copied forward to
92+
* the host's intent, so the trampoline call and a leftover manual call for the same tap resolve
93+
* to the same key while distinct notifications stay distinct.
94+
*
95+
* Deliberately not the raw `_k`: minus `tm` it is per-message metadata shared across deliveries,
96+
* so it would collapse distinct opens.
97+
*/
98+
private val Intent?.pushDeliveryId: String?
99+
get() {
100+
val deliveryId = this?.getStringExtra(PACKAGE_PREFIX + TRACKING_PARAMETER)
101+
?.takeIf { it.isNotEmpty() }
102+
?.let { trackingPayload ->
103+
runCatching { JSONObject(trackingPayload).optString(PUSH_DELIVERY_KEY) }
104+
.getOrNull()
105+
?.takeIf { it.isNotEmpty() }
106+
}
107+
return deliveryId ?: this?.getStringExtra(Constants.NOTIFICATION_UID_EXTRA)
108+
?.takeIf { it.isNotEmpty() }
109+
}
110+
111+
/**
112+
* Appends Klaviyo extras from an intent to this event, parsing special fields as needed
113+
*/
114+
private fun Event.appendKlaviyoExtras(intent: Intent?) {
115+
intent?.extras?.keySet()?.forEach { key ->
116+
if (key.contains(PACKAGE_PREFIX)) {
117+
val eventKey = EventKey.CUSTOM(key.replace(PACKAGE_PREFIX, ""))
118+
val rawValue = intent.extras?.getString(key, "") ?: ""
119+
val parsedValue = when (eventKey.name) {
120+
KEY_VALUE_PAIRS -> {
121+
try {
122+
JSONObject(rawValue).toHashMap()
123+
} catch (e: Exception) {
124+
Registry.log.warning(
125+
"Failed to parse $KEY_VALUE_PAIRS JSON: $rawValue",
126+
e
127+
)
128+
rawValue
129+
}
130+
}
131+
132+
else -> rawValue
133+
}
134+
135+
this[eventKey] = parsedValue
136+
}
137+
}
138+
}
139+
}

sdk/analytics/src/main/java/com/klaviyo/analytics/state/StateSideEffects.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ internal class StateSideEffects(
180180
// Trigger the token in state to refresh overall push state, if changed
181181
Klaviyo.setPushToken(it)
182182
}
183+
// Re-pull the token on foreground so rotations while backgrounded are caught
184+
Klaviyo.maybeAutoRegisterPushToken()
183185
}
184186
}
185187
}

sdk/analytics/src/test/java/com/klaviyo/analytics/KlaviyoPreInitializeTest.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ internal class KlaviyoPreInitializeTest : BaseTest() {
8181

8282
@Test
8383
fun `Opened Push events are replayed upon initializing`() {
84-
Klaviyo.handlePush(KlaviyoTest.mockIntent(KlaviyoTest.stubIntentExtras))
84+
Klaviyo.handlePush(
85+
KlaviyoPushOpenHandlerTest.mockIntent(KlaviyoPushOpenHandlerTest.stubIntentExtras)
86+
)
8587
verify { spyLog.warning(any(), any<MissingConfig>()) } // Warning bc it will be replayed
8688

8789
Klaviyo.createEvent(EventMetric.OPENED_APP)

0 commit comments

Comments
 (0)