Skip to content

Commit d9ace10

Browse files
Patch Release 4.3.2 (#439)
* 4.3.2 patch release version bump * fix: skip profile reset when setProfile called with same identifiers (#435) Previously, calling `setProfile` without actually changing any higher-level identifiers would still reset your anonymous ID. This creates an unnecessary state-change that in turn leads to extra API requests. If a developer primarily uses setProfile, the anonymous ID change defeats our de-duping logic intended to reduce unnecessary API traffic. This fix prevents an anonymous ID mutation if higher order IDs are unchanged, without any other changes to how profile attributes are processed by setProfile. * Typo in sdk/analytics/src/main/java/com/klaviyo/analytics/Klaviyo.kt --------- Co-authored-by: Jason Myers <102050376+jason-myers-klaviyo@users.noreply.github.com>
1 parent c2594fa commit d9ace10

6 files changed

Lines changed: 191 additions & 14 deletions

File tree

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,10 @@ The sample app serves as both a reference implementation and a testing tool for
103103
```kotlin
104104
// build.gradle.kts
105105
dependencies {
106-
implementation("com.github.klaviyo.klaviyo-android-sdk:analytics:4.3.1")
107-
implementation("com.github.klaviyo.klaviyo-android-sdk:push-fcm:4.3.1")
108-
implementation("com.github.klaviyo.klaviyo-android-sdk:forms:4.3.1")
109-
implementation("com.github.klaviyo.klaviyo-android-sdk:location:4.3.1")
106+
implementation("com.github.klaviyo.klaviyo-android-sdk:analytics:4.3.2")
107+
implementation("com.github.klaviyo.klaviyo-android-sdk:push-fcm:4.3.2")
108+
implementation("com.github.klaviyo.klaviyo-android-sdk:forms:4.3.2")
109+
implementation("com.github.klaviyo.klaviyo-android-sdk:location:4.3.2")
110110
}
111111
```
112112
</details>
@@ -117,10 +117,10 @@ The sample app serves as both a reference implementation and a testing tool for
117117
```groovy
118118
// build.gradle
119119
dependencies {
120-
implementation "com.github.klaviyo.klaviyo-android-sdk:analytics:4.3.1"
121-
implementation "com.github.klaviyo.klaviyo-android-sdk:push-fcm:4.3.1"
122-
implementation "com.github.klaviyo.klaviyo-android-sdk:forms:4.3.1"
123-
implementation "com.github.klaviyo.klaviyo-android-sdk:location:4.3.1"
120+
implementation "com.github.klaviyo.klaviyo-android-sdk:analytics:4.3.2"
121+
implementation "com.github.klaviyo.klaviyo-android-sdk:push-fcm:4.3.2"
122+
implementation "com.github.klaviyo.klaviyo-android-sdk:forms:4.3.2"
123+
implementation "com.github.klaviyo.klaviyo-android-sdk:location:4.3.2"
124124
}
125125
```
126126
</details>

docs/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
<!-- Redirect to latest version -->
2-
<meta HTTP-EQUIV="REFRESH" content="0; url=./4.3.1/index.html">
2+
<meta HTTP-EQUIV="REFRESH" content="0; url=./4.3.2/index.html">

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,20 @@ internal class KlaviyoState : State {
107107
* Update user state from a new [Profile] model object
108108
*/
109109
override fun setProfile(profile: Profile) {
110-
if (!externalId.isNullOrEmpty() || !email.isNullOrEmpty() || !phoneNumber.isNullOrEmpty()) {
111-
// If a profile with external identifiers is already in state, we must reset.
112-
// This conditional is important to preserve merging with an anonymous profile.
110+
val currentIds = listOf(externalId, email, phoneNumber)
111+
val isIdentified = currentIds.any { !it.isNullOrEmpty() }
112+
val incomingIds = listOf(profile.externalId, profile.email, profile.phoneNumber).map {
113+
// Normalize incoming values the same way PersistentObservableString does
114+
// (trim whitespace, treat empty as null) so padded inputs match stored state.
115+
it?.trim()?.ifEmpty { null }
116+
}
117+
118+
// Only reset if the incoming profile has different identifiers.
119+
// Anonymous ID is the lowest-order identifier, so there's no reason to regenerate it
120+
// when higher-order identifiers haven't changed. Resetting with the same identifiers
121+
// causes unnecessary anonymous ID churn, which triggers spurious API requests.
122+
// resetProfile() remains available for explicitly clobbering all state.
123+
if (isIdentified && currentIds != incomingIds) {
113124
reset()
114125
}
115126

sdk/analytics/src/test/java/com/klaviyo/analytics/state/KlaviyoStateTest.kt

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,4 +331,170 @@ internal class KlaviyoStateTest : BaseTest() {
331331
"createEvent should return a new event object, not mutate the original"
332332
}
333333
}
334+
335+
@Test
336+
fun `setProfile with same external ID does not reset anonymous ID`() {
337+
// Setup: create initial state with identifiers
338+
state.externalId = EXTERNAL_ID
339+
state.email = EMAIL
340+
val initialAnonId = state.anonymousId
341+
342+
// Call setProfile with same identifiers
343+
state.setProfile(
344+
Profile(externalId = EXTERNAL_ID, email = EMAIL)
345+
)
346+
347+
// Verify anonymous ID is preserved (no reset)
348+
assertEquals(initialAnonId, state.anonymousId)
349+
}
350+
351+
@Test
352+
fun `setProfile with same email does not reset anonymous ID`() {
353+
state.email = EMAIL
354+
val initialAnonId = state.anonymousId
355+
356+
state.setProfile(Profile(email = EMAIL))
357+
358+
assertEquals(initialAnonId, state.anonymousId)
359+
}
360+
361+
@Test
362+
fun `setProfile with same phone number does not reset anonymous ID`() {
363+
state.phoneNumber = PHONE
364+
val initialAnonId = state.anonymousId
365+
366+
state.setProfile(Profile(phoneNumber = PHONE))
367+
368+
assertEquals(initialAnonId, state.anonymousId)
369+
}
370+
371+
@Test
372+
fun `setProfile with different external ID triggers reset and new anonymous ID`() {
373+
state.externalId = EXTERNAL_ID
374+
val initialAnonId = state.anonymousId
375+
376+
state.setProfile(Profile(externalId = "different_external_id"))
377+
378+
assertNotEquals(initialAnonId, state.anonymousId)
379+
assertEquals("different_external_id", state.externalId)
380+
}
381+
382+
@Test
383+
fun `setProfile with different email triggers reset and new anonymous ID`() {
384+
state.email = EMAIL
385+
val initialAnonId = state.anonymousId
386+
387+
state.setProfile(Profile(email = "different@email.com"))
388+
389+
assertNotEquals(initialAnonId, state.anonymousId)
390+
assertEquals("different@email.com", state.email)
391+
}
392+
393+
@Test
394+
fun `setProfile with different phone triggers reset and new anonymous ID`() {
395+
state.phoneNumber = PHONE
396+
val initialAnonId = state.anonymousId
397+
398+
state.setProfile(Profile(phoneNumber = "9999999999"))
399+
400+
assertNotEquals(initialAnonId, state.anonymousId)
401+
assertEquals("9999999999", state.phoneNumber)
402+
}
403+
404+
@Test
405+
fun `setProfile repeated calls with same identifiers do not trigger spurious resets`() {
406+
state.externalId = EXTERNAL_ID
407+
state.email = EMAIL
408+
val initialAnonId = state.anonymousId
409+
410+
// Call setProfile multiple times with same identifiers (simulates Wyze behavior)
411+
repeat(3) {
412+
state.setProfile(Profile(externalId = EXTERNAL_ID, email = EMAIL))
413+
assertEquals(initialAnonId, state.anonymousId)
414+
}
415+
}
416+
417+
@Test
418+
fun `setProfile with same identifiers but different attributes does not reset`() {
419+
state.externalId = EXTERNAL_ID
420+
state.email = EMAIL
421+
val initialAnonId = state.anonymousId
422+
423+
state.setProfile(
424+
Profile(
425+
externalId = EXTERNAL_ID,
426+
email = EMAIL,
427+
properties = mapOf(ProfileKey.FIRST_NAME to "Kermit")
428+
)
429+
)
430+
431+
// Key assertion: anonymous ID should not change even though attributes differ
432+
assertEquals(initialAnonId, state.anonymousId)
433+
}
434+
435+
@Test
436+
fun `resetProfile explicitly clobbers all state regardless of setProfile fix`() {
437+
state.externalId = EXTERNAL_ID
438+
state.email = EMAIL
439+
state.phoneNumber = PHONE
440+
state.setAttribute(ProfileKey.FIRST_NAME, "Kermit")
441+
442+
val anonIdBeforeReset = state.anonymousId
443+
444+
state.reset()
445+
446+
assertNull(state.externalId)
447+
assertNull(state.email)
448+
assertNull(state.phoneNumber)
449+
assertNull(state.getAsProfile()[ProfileKey.FIRST_NAME])
450+
assertNotEquals(anonIdBeforeReset, state.anonymousId)
451+
}
452+
453+
@Test
454+
fun `setProfile with one identifier changed triggers reset while others are same`() {
455+
state.externalId = EXTERNAL_ID
456+
state.email = EMAIL
457+
state.phoneNumber = PHONE
458+
val initialAnonId = state.anonymousId
459+
460+
state.setProfile(
461+
Profile(
462+
externalId = EXTERNAL_ID,
463+
email = "different@email.com",
464+
phoneNumber = PHONE
465+
)
466+
)
467+
468+
assertNotEquals(initialAnonId, state.anonymousId)
469+
assertEquals(EXTERNAL_ID, state.externalId)
470+
assertEquals("different@email.com", state.email)
471+
assertEquals(PHONE, state.phoneNumber)
472+
}
473+
474+
@Test
475+
fun `setProfile on fresh state with no prior identifiers sets identifiers without reset`() {
476+
// Don't set any identifiers on state first — all are null
477+
val initialAnonId = state.anonymousId
478+
479+
state.setProfile(Profile(externalId = EXTERNAL_ID, email = EMAIL))
480+
481+
assertEquals(EXTERNAL_ID, state.externalId)
482+
assertEquals(EMAIL, state.email)
483+
assertNotEquals(null, state.anonymousId)
484+
// Anonymous ID should be preserved since no prior identifiers existed (outer guard is false)
485+
assertEquals(initialAnonId, state.anonymousId)
486+
}
487+
488+
@Test
489+
fun `setProfile with all-null identifiers triggers reset when state has non-null identifiers`() {
490+
state.externalId = EXTERNAL_ID
491+
state.email = EMAIL
492+
val initialAnonId = state.anonymousId
493+
494+
// Pass an empty profile — all identifier fields are null
495+
state.setProfile(Profile())
496+
497+
// Reset should fire because null != "abcdefg" is true for the existing identifiers
498+
assertNotEquals(initialAnonId, state.anonymousId)
499+
}
334500
}

sdk/core/src/main/res/values/strings.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<resources>
33
<!-- Please do not modify / override these values, they are critical for internal versioning -->
44
<string name="klaviyo_sdk_name_override">android</string>
5-
<string name="klaviyo_sdk_version_override">4.3.1</string>
5+
<string name="klaviyo_sdk_version_override">4.3.2</string>
66
<string name="klaviyo_sdk_plugin_name_override"/>
77
<string name="klaviyo_sdk_plugin_version_override"/>
88
</resources>

versions.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# NOTE: Semantic version of the SDK lives in strings.xml
1414
# Use this shell script to update the version numbers automatically:
1515
# ./bumpVersion.sh
16-
version.klaviyo.versionCode=41
16+
version.klaviyo.versionCode=42
1717

1818
# Project gradle plugins
1919
plugin.android=8.11.0

0 commit comments

Comments
 (0)