Skip to content

Commit da28ad9

Browse files
Atharva8168claude
andauthored
feat(push): add X-Klaviyo-Sdk-Features header to push-token-register (MAGE-765) (#506)
* feat(push): add X-Klaviyo-Sdk-Features header to push-token-register (MAGE-765) Reports when a host has explicitly disabled automatic push token forwarding while automatic push tracking is on, so the backend can track opt-out adoption (MAGE-766). Scoped to PushTokenApiRequest only; omitted entirely when tracking is off or forwarding is left at its default-on state, since only the opt-out case needs reporting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(push): always send both SDK feature flags in push-token header (MAGE-765) Previously the X-Klaviyo-Sdk-Features header was omitted unless automatic push tracking was on and forwarding was disabled, carrying only the forwarding key. Now it's always attached with both auto_push_tracking and auto_push_token_forwarding so all four flag combinations are visible for analysis, not just the opt-out case. auto_push_token_forwarding now mirrors the disable flag directly (1 unless explicitly disabled), independent of the tracking flag. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(push): only include SDK feature flags whose manifest key is set (MAGE-765) Previously both auto_push_tracking and auto_push_token_forwarding were always present in X-Klaviyo-Sdk-Features, falling back to a default value when the host hadn't set the corresponding manifest key. Now each attribute is included only if its manifest key is actually declared, so the header reflects what the host explicitly configured rather than an assumed default. Adds Config.hasManifestKey(key) (mirroring getManifestBoolean/Int) to distinguish "key absent" from "key present and false", which getManifestBoolean's single default-value return couldn't do. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(core): resolve duplicate DISABLE_AUTOMATIC_TOKEN_FORWARDING after merge The merge from feat/auto-push-tracking (PR #503) landed a second declaration of this constant without a git conflict, since both additions fell in non-overlapping diff hunks. Keeps PR #503's PUSH_PREFIX-based definition, which is also the one Klaviyo.kt actually uses to gate automatic push token registration; our header logic already references the constant by name, so no other change is needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(core): extract scoped SdkFeatures catalog for X-Klaviyo-Sdk-Features (MAGE-765) Replaces the inline header-building in PushTokenApiRequest with a reusable, scoped feature catalog in core, mirroring the iOS design (klaviyo-swift-sdk#640) for cross-platform parity. - SdkFeatureScope enumerates the request surfaces a feature is reported on; SdkFeatureKey is a central catalog pairing each feature's wire name with its manifest key, scope, and raw->reported value mapping. - SdkFeatures.headerValue(scope) serializes only in-scope features whose manifest key is present, so a feature can never leak onto a request outside its scope. Adding a future (e.g. profile) feature header is a new key + scope, reusing the same serialization. Pure refactor: the emitted header is byte-identical for every manifest combination. Existing PushTokenApiRequestTest/EventApiRequestTest pass unchanged as the regression guard; SdkFeaturesTest covers the catalog. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 2946c54 commit da28ad9

9 files changed

Lines changed: 284 additions & 0 deletions

File tree

sdk/analytics/src/main/java/com/klaviyo/analytics/networking/requests/PushTokenApiRequest.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.klaviyo.analytics.networking.requests
33
import com.klaviyo.analytics.model.Profile
44
import com.klaviyo.core.DeviceProperties
55
import com.klaviyo.core.Registry
6+
import com.klaviyo.core.SdkFeatureScope
7+
import com.klaviyo.core.SdkFeatures
68
import org.json.JSONObject
79

810
/**
@@ -35,6 +37,11 @@ internal class PushTokenApiRequest(
3537
const val BG_UNAVAILABLE = "DENIED"
3638
}
3739

40+
init {
41+
SdkFeatures.headerValue(SdkFeatureScope.PUSH_TOKEN_REGISTRATION)
42+
?.let { headers[SdkFeatures.HEADER_NAME] = it }
43+
}
44+
3845
override val type: String = "Push Token"
3946

4047
/**

sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/EventApiRequestTest.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.klaviyo.analytics.Klaviyo
44
import com.klaviyo.analytics.model.Event
55
import com.klaviyo.analytics.model.EventKey
66
import com.klaviyo.analytics.model.EventMetric
7+
import com.klaviyo.core.Constants
78
import com.klaviyo.fixtures.mockDeviceProperties
89
import com.klaviyo.fixtures.unmockDeviceProperties
910
import io.mockk.every
@@ -14,6 +15,7 @@ import io.mockk.unmockkStatic
1415
import java.util.UUID
1516
import org.json.JSONObject
1617
import org.junit.After
18+
import org.junit.Assert.assertNull
1719
import org.junit.Before
1820
import org.junit.Test
1921

@@ -233,4 +235,14 @@ internal class EventApiRequestTest : BaseApiRequestTest<EventApiRequest>() {
233235

234236
compareJson(JSONObject(expectJson), JSONObject(request.requestBody!!))
235237
}
238+
239+
@Test
240+
fun `Does not include the SDK features header even when the flags that would trigger it on PushTokenApiRequest are set`() {
241+
every { mockConfig.hasManifestKey(Constants.AUTOMATIC_PUSH_TRACKING) } returns true
242+
every { mockConfig.hasManifestKey(Constants.DISABLE_AUTOMATIC_TOKEN_FORWARDING) } returns true
243+
every { mockConfig.getManifestBoolean(Constants.AUTOMATIC_PUSH_TRACKING, false) } returns true
244+
every { mockConfig.getManifestBoolean(Constants.DISABLE_AUTOMATIC_TOKEN_FORWARDING, false) } returns true
245+
val request = EventApiRequest(stubEvent, stubProfile)
246+
assertNull(request.headers["X-Klaviyo-Sdk-Features"])
247+
}
236248
}

sdk/analytics/src/test/java/com/klaviyo/analytics/networking/requests/PushTokenApiRequestTest.kt

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package com.klaviyo.analytics.networking.requests
22

3+
import com.klaviyo.core.Constants
34
import io.mockk.every
45
import org.json.JSONObject
56
import org.junit.Assert.assertEquals
67
import org.junit.Assert.assertNotEquals
8+
import org.junit.Assert.assertNull
79
import org.junit.Test
810

911
internal class PushTokenApiRequestTest : BaseApiRequestTest<PushTokenApiRequest>() {
@@ -94,4 +96,51 @@ internal class PushTokenApiRequestTest : BaseApiRequestTest<PushTokenApiRequest>
9496
val request = PushTokenApiRequest(PUSH_TOKEN, stubProfile)
9597
compareJson(JSONObject(expectJson), JSONObject(request.requestBody!!))
9698
}
99+
100+
@Test
101+
fun `Does not include SDK features header when neither manifest key is present`() {
102+
val request = PushTokenApiRequest(PUSH_TOKEN, stubProfile)
103+
assertNull(request.headers["X-Klaviyo-Sdk-Features"])
104+
}
105+
106+
@Test
107+
fun `SDK features header includes only auto_push_tracking when only that key is present`() {
108+
every { mockConfig.hasManifestKey(Constants.AUTOMATIC_PUSH_TRACKING) } returns true
109+
every { mockConfig.getManifestBoolean(Constants.AUTOMATIC_PUSH_TRACKING, false) } returns true
110+
val request = PushTokenApiRequest(PUSH_TOKEN, stubProfile)
111+
assertEquals("auto_push_tracking=1;", request.headers["X-Klaviyo-Sdk-Features"])
112+
}
113+
114+
@Test
115+
fun `SDK features header includes only auto_push_token_forwarding when only that key is present`() {
116+
every { mockConfig.hasManifestKey(Constants.DISABLE_AUTOMATIC_TOKEN_FORWARDING) } returns true
117+
every { mockConfig.getManifestBoolean(Constants.DISABLE_AUTOMATIC_TOKEN_FORWARDING, false) } returns true
118+
val request = PushTokenApiRequest(PUSH_TOKEN, stubProfile)
119+
assertEquals("auto_push_token_forwarding=0;", request.headers["X-Klaviyo-Sdk-Features"])
120+
}
121+
122+
@Test
123+
fun `SDK features header includes both attributes when both keys present and forwarding left at its default`() {
124+
every { mockConfig.hasManifestKey(Constants.AUTOMATIC_PUSH_TRACKING) } returns true
125+
every { mockConfig.hasManifestKey(Constants.DISABLE_AUTOMATIC_TOKEN_FORWARDING) } returns true
126+
every { mockConfig.getManifestBoolean(Constants.AUTOMATIC_PUSH_TRACKING, false) } returns true
127+
val request = PushTokenApiRequest(PUSH_TOKEN, stubProfile)
128+
assertEquals(
129+
"auto_push_tracking=1; auto_push_token_forwarding=1;",
130+
request.headers["X-Klaviyo-Sdk-Features"]
131+
)
132+
}
133+
134+
@Test
135+
fun `SDK features header includes both attributes when both keys present and forwarding explicitly disabled`() {
136+
every { mockConfig.hasManifestKey(Constants.AUTOMATIC_PUSH_TRACKING) } returns true
137+
every { mockConfig.hasManifestKey(Constants.DISABLE_AUTOMATIC_TOKEN_FORWARDING) } returns true
138+
every { mockConfig.getManifestBoolean(Constants.AUTOMATIC_PUSH_TRACKING, false) } returns true
139+
every { mockConfig.getManifestBoolean(Constants.DISABLE_AUTOMATIC_TOKEN_FORWARDING, false) } returns true
140+
val request = PushTokenApiRequest(PUSH_TOKEN, stubProfile)
141+
assertEquals(
142+
"auto_push_tracking=1; auto_push_token_forwarding=0;",
143+
request.headers["X-Klaviyo-Sdk-Features"]
144+
)
145+
}
97146
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.klaviyo.core
2+
3+
/**
4+
* The request surface a feature is reported on. Each request carrying the `X-Klaviyo-Sdk-Features`
5+
* header reports only the features in its scope, so unrelated domains never share a request.
6+
*/
7+
enum class SdkFeatureScope {
8+
PUSH_TOKEN_REGISTRATION
9+
}
10+
11+
/**
12+
* Central catalog of reportable SDK features. Each entry pairs its wire name (serialized into the
13+
* header) with the manifest key it reads, its [scope], and how the raw manifest value maps to the
14+
* reported value. Adding a feature — including one reported on a different request — is a new entry
15+
* here; a feature is never serialized outside its [scope].
16+
*/
17+
enum class SdkFeatureKey(
18+
val wireName: String,
19+
val manifestKey: String,
20+
val scope: SdkFeatureScope,
21+
val reportedValue: (manifestValue: Boolean) -> Boolean = { it }
22+
) {
23+
AUTO_PUSH_TRACKING(
24+
wireName = "auto_push_tracking",
25+
manifestKey = Constants.AUTOMATIC_PUSH_TRACKING,
26+
scope = SdkFeatureScope.PUSH_TOKEN_REGISTRATION
27+
),
28+
29+
// `disable_...` manifest key → reported as the inverse `auto_push_token_forwarding`. Reported
30+
// independently of the master flag, so the backend sees how the host set each flag.
31+
AUTO_PUSH_TOKEN_FORWARDING(
32+
wireName = "auto_push_token_forwarding",
33+
manifestKey = Constants.DISABLE_AUTOMATIC_TOKEN_FORWARDING,
34+
scope = SdkFeatureScope.PUSH_TOKEN_REGISTRATION,
35+
reportedValue = { disabled -> !disabled }
36+
)
37+
}
38+
39+
/**
40+
* Serializes SDK feature-adoption flags into the `X-Klaviyo-Sdk-Features` header, used for SDK
41+
* adoption telemetry.
42+
*
43+
* Each flag is sourced from a manifest `<meta-data>` boolean and reported only when the host
44+
* actually declared that key, so the backend can distinguish "configured false" from "not
45+
* configured" (absent keys are omitted from the header).
46+
*/
47+
object SdkFeatures {
48+
/** Name of the HTTP header these features are serialized into. */
49+
const val HEADER_NAME = "X-Klaviyo-Sdk-Features"
50+
51+
/**
52+
* Header value for the given [scope], e.g. `auto_push_tracking=1; auto_push_token_forwarding=0;`.
53+
* Includes only in-scope features whose manifest key is present; returns `null` (so callers omit
54+
* the header) when none are configured.
55+
*/
56+
fun headerValue(scope: SdkFeatureScope): String? =
57+
SdkFeatureKey.entries
58+
.filter { it.scope == scope }
59+
.mapNotNull { key ->
60+
if (Registry.config.hasManifestKey(key.manifestKey)) {
61+
val value = key.reportedValue(
62+
Registry.config.getManifestBoolean(key.manifestKey, false)
63+
)
64+
"${key.wireName}=${if (value) "1" else "0"};"
65+
} else {
66+
null
67+
}
68+
}
69+
.takeIf { it.isNotEmpty() }
70+
?.joinToString(" ")
71+
}

sdk/core/src/main/java/com/klaviyo/core/config/Config.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ interface Config {
3030

3131
fun getManifestBoolean(key: String, defaultValue: Boolean): Boolean
3232

33+
fun hasManifestKey(key: String): Boolean
34+
3335
interface Builder {
3436
fun apiKey(apiKey: String): Builder
3537
fun applicationContext(context: Context): Builder

sdk/core/src/main/java/com/klaviyo/core/config/KlaviyoConfig.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ object KlaviyoConfig : Config {
153153
applicationContext.getManifestBoolean(key, defaultValue)
154154
}
155155

156+
override fun hasManifestKey(key: String): Boolean =
157+
this::applicationContext.isInitialized && applicationContext.hasManifestKey(key)
158+
156159
/**
157160
* Nested class to enable the builder pattern for easy declaration of custom configurations
158161
*/
@@ -382,3 +385,15 @@ fun Context.getManifestBoolean(key: String, defaultValue: Boolean): Boolean {
382385
val manifestMetadata = appInfo?.metaData ?: Bundle.EMPTY
383386
return manifestMetadata.getBoolean(key, defaultValue)
384387
}
388+
389+
/**
390+
* Extension method to check whether a key is present in the manifest metadata at all,
391+
* regardless of its value
392+
*/
393+
fun Context.hasManifestKey(key: String): Boolean {
394+
val pkgName = packageName
395+
val pkgManager = packageManager
396+
val appInfo = pkgManager.getApplicationInfoCompat(pkgName, PackageManager.GET_META_DATA)
397+
val manifestMetadata = appInfo?.metaData ?: Bundle.EMPTY
398+
return manifestMetadata.containsKey(key)
399+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.klaviyo.core
2+
3+
import com.klaviyo.fixtures.BaseTest
4+
import io.mockk.every
5+
import org.junit.Assert.assertEquals
6+
import org.junit.Assert.assertNotNull
7+
import org.junit.Assert.assertNull
8+
import org.junit.Test
9+
10+
internal class SdkFeaturesTest : BaseTest() {
11+
12+
/**
13+
* Simulate a host declaring [manifestKey] in the manifest with the given boolean [value].
14+
* Absent keys are left at BaseTest's defaults (hasManifestKey → false).
15+
*/
16+
private fun stubManifestKey(manifestKey: String, value: Boolean) {
17+
every { mockConfig.hasManifestKey(manifestKey) } returns true
18+
every { mockConfig.getManifestBoolean(manifestKey, false) } returns value
19+
}
20+
21+
@Test
22+
fun `Header omitted when no manifest keys are present`() {
23+
assertNull(SdkFeatures.headerValue(SdkFeatureScope.PUSH_TOKEN_REGISTRATION))
24+
}
25+
26+
@Test
27+
fun `Reports only auto_push_tracking when only that key is present and true`() {
28+
stubManifestKey(SdkFeatureKey.AUTO_PUSH_TRACKING.manifestKey, true)
29+
assertEquals(
30+
"auto_push_tracking=1;",
31+
SdkFeatures.headerValue(SdkFeatureScope.PUSH_TOKEN_REGISTRATION)
32+
)
33+
}
34+
35+
@Test
36+
fun `Reports only auto_push_tracking when only that key is present and false`() {
37+
stubManifestKey(SdkFeatureKey.AUTO_PUSH_TRACKING.manifestKey, false)
38+
assertEquals(
39+
"auto_push_tracking=0;",
40+
SdkFeatures.headerValue(SdkFeatureScope.PUSH_TOKEN_REGISTRATION)
41+
)
42+
}
43+
44+
@Test
45+
fun `Reports auto_push_token_forwarding as inverse of the disable flag when disable is true`() {
46+
stubManifestKey(SdkFeatureKey.AUTO_PUSH_TOKEN_FORWARDING.manifestKey, true)
47+
assertEquals(
48+
"auto_push_token_forwarding=0;",
49+
SdkFeatures.headerValue(SdkFeatureScope.PUSH_TOKEN_REGISTRATION)
50+
)
51+
}
52+
53+
@Test
54+
fun `Reports auto_push_token_forwarding as inverse of the disable flag when disable is false`() {
55+
stubManifestKey(SdkFeatureKey.AUTO_PUSH_TOKEN_FORWARDING.manifestKey, false)
56+
assertEquals(
57+
"auto_push_token_forwarding=1;",
58+
SdkFeatures.headerValue(SdkFeatureScope.PUSH_TOKEN_REGISTRATION)
59+
)
60+
}
61+
62+
@Test
63+
fun `Reports both attributes in deterministic order when both keys are present`() {
64+
stubManifestKey(SdkFeatureKey.AUTO_PUSH_TRACKING.manifestKey, true)
65+
stubManifestKey(SdkFeatureKey.AUTO_PUSH_TOKEN_FORWARDING.manifestKey, true)
66+
assertEquals(
67+
"auto_push_tracking=1; auto_push_token_forwarding=0;",
68+
SdkFeatures.headerValue(SdkFeatureScope.PUSH_TOKEN_REGISTRATION)
69+
)
70+
}
71+
72+
@Test
73+
fun `Omits an absent key even when the other in-scope key is present`() {
74+
stubManifestKey(SdkFeatureKey.AUTO_PUSH_TRACKING.manifestKey, false)
75+
// Forwarding-disable key left absent
76+
assertEquals(
77+
"auto_push_tracking=0;",
78+
SdkFeatures.headerValue(SdkFeatureScope.PUSH_TOKEN_REGISTRATION)
79+
)
80+
}
81+
82+
@Test
83+
fun `Every feature key resolves exactly one scope`() {
84+
// Guard: a future entry cannot be added without a (non-null) scope, so it can't silently
85+
// escape the scope filter.
86+
SdkFeatureKey.entries.forEach { key ->
87+
assertNotNull(key.scope)
88+
}
89+
}
90+
91+
@Test
92+
fun `Header name matches the agreed wire contract`() {
93+
assertEquals("X-Klaviyo-Sdk-Features", SdkFeatures.HEADER_NAME)
94+
}
95+
}

sdk/core/src/test/java/com/klaviyo/core/config/KlaviyoConfigTest.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,4 +268,35 @@ internal class KlaviyoConfigTest : BaseTest() {
268268
assertEquals(true, mockContext.getManifestBoolean("missing.key", true))
269269
assertEquals(false, mockContext.getManifestBoolean("missing.key", false))
270270
}
271+
272+
@Test
273+
fun `hasManifestKey reflects presence of the key, regardless of its value`() {
274+
// Back the Bundle with a real map so containsKey has genuine present/absent semantics
275+
// rather than echoing a stubbed return.
276+
val metadata = mutableMapOf(
277+
"com.klaviyo.present_true" to true,
278+
"com.klaviyo.present_false" to false
279+
)
280+
val mockMetadata = mockk<Bundle> {
281+
every { containsKey(any()) } answers { metadata.containsKey(firstArg()) }
282+
}
283+
mockApplicationInfo.metaData = mockMetadata
284+
285+
mockkStatic(PackageManager.ApplicationInfoFlags::class)
286+
val mockApplicationInfoFlags = mockk<PackageManager.ApplicationInfoFlags>()
287+
every { PackageManager.ApplicationInfoFlags.of(any()) } returns mockApplicationInfoFlags
288+
setFinalStatic(Build.VERSION::class.java.getField("SDK_INT"), 33)
289+
every {
290+
mockPackageManager.getApplicationInfo(
291+
BuildConfig.LIBRARY_PACKAGE_NAME,
292+
mockApplicationInfoFlags
293+
)
294+
} returns mockApplicationInfo
295+
296+
// Present regardless of whether the value is true or false.
297+
assertEquals(true, mockContext.hasManifestKey("com.klaviyo.present_true"))
298+
assertEquals(true, mockContext.hasManifestKey("com.klaviyo.present_false"))
299+
// Absent key is reported as absent.
300+
assertEquals(false, mockContext.hasManifestKey("missing.key"))
301+
}
271302
}

sdk/fixtures/src/main/java/com/klaviyo/fixtures/BaseTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ abstract class BaseTest {
9999
every { formEnvironment } returns FormEnvironment.IN_APP
100100
// Default to the passed default (e.g. automatic push tracking off); override per-test to flip on
101101
every { getManifestBoolean(any(), any()) } answers { secondArg() }
102+
// Default to no manifest keys present; override per-test to simulate a host declaring one
103+
every { hasManifestKey(any()) } returns false
102104
}
103105

104106
protected val mockActivity: Activity = mockk(relaxed = true)

0 commit comments

Comments
 (0)