Skip to content

Commit 173ebd7

Browse files
authored
Stateful push token API to avoid unneeded calls (#151)
* To avoid duplicate/unnecessary push token API calls, started tracking entire push token API request body in state * If push state is unchanged, do not enqueue a new push token request when setPushToken is invoked * Frontload the state change (i.e. optimistic update) and clear on failure (e.g. retries exceeded) * 2.2.0 Version Increment --------- Co-authored-by: Evan Masseau <>
1 parent f5107fa commit 173ebd7

File tree

15 files changed

+162
-42
lines changed

15 files changed

+162
-42
lines changed

README.md

+4-4
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ send them timely push notifications via [FCM (Firebase Cloud Messaging)](https:/
5151
```kotlin
5252
// build.gradle.kts
5353
dependencies {
54-
implementation("com.github.klaviyo.klaviyo-android-sdk:analytics:2.1.1")
55-
implementation("com.github.klaviyo.klaviyo-android-sdk:push-fcm:2.1.1")
54+
implementation("com.github.klaviyo.klaviyo-android-sdk:analytics:2.2.0")
55+
implementation("com.github.klaviyo.klaviyo-android-sdk:push-fcm:2.2.0")
5656
}
5757
```
5858
</details>
@@ -63,8 +63,8 @@ send them timely push notifications via [FCM (Firebase Cloud Messaging)](https:/
6363
```groovy
6464
// build.gradle
6565
dependencies {
66-
implementation "com.github.klaviyo.klaviyo-android-sdk:analytics:2.1.1"
67-
implementation "com.github.klaviyo.klaviyo-android-sdk:push-fcm:2.1.1"
66+
implementation "com.github.klaviyo.klaviyo-android-sdk:analytics:2.2.0"
67+
implementation "com.github.klaviyo.klaviyo-android-sdk:push-fcm:2.2.0"
6868
}
6969
```
7070
</details>

docs/index.html

+1-1
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=./2.1.1/index.html">
2+
<meta HTTP-EQUIV="REFRESH" content="0; url=./2.2.0/index.html">

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

+4-6
Original file line numberDiff line numberDiff line change
@@ -167,15 +167,16 @@ object Klaviyo {
167167
* @param pushToken The push token provided by the device push service
168168
*/
169169
fun setPushToken(pushToken: String) = safeApply {
170-
Registry.dataStore.store(EventKey.PUSH_TOKEN.name, pushToken)
171-
Registry.get<ApiClient>().enqueuePushToken(pushToken, UserInfo.getAsProfile())
170+
UserInfo.setPushToken(pushToken) {
171+
Registry.get<ApiClient>().enqueuePushToken(pushToken, UserInfo.getAsProfile())
172+
}
172173
}
173174

174175
/**
175176
* @return The device push token, if one has been assigned to currently tracked profile
176177
*/
177178
fun getPushToken(): String? = safeCall {
178-
Registry.dataStore.fetch(EventKey.PUSH_TOKEN.name)?.ifEmpty { null }
179+
UserInfo.pushToken.ifEmpty { null }
179180
}
180181

181182
/**
@@ -226,9 +227,6 @@ object Klaviyo {
226227

227228
// Clear profile identifiers from state
228229
UserInfo.reset()
229-
230-
// If we had a push token, erase the local copy
231-
Registry.dataStore.clear(EventKey.PUSH_TOKEN.name)
232230
}
233231

234232
/**

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

+51-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import com.klaviyo.analytics.model.ProfileKey.ANONYMOUS_ID
66
import com.klaviyo.analytics.model.ProfileKey.EMAIL
77
import com.klaviyo.analytics.model.ProfileKey.EXTERNAL_ID
88
import com.klaviyo.analytics.model.ProfileKey.PHONE_NUMBER
9+
import com.klaviyo.analytics.model.ProfileKey.PUSH_STATE
10+
import com.klaviyo.analytics.model.ProfileKey.PUSH_TOKEN
11+
import com.klaviyo.analytics.networking.ApiClient
12+
import com.klaviyo.analytics.networking.requests.KlaviyoApiRequest.Status.Failed
13+
import com.klaviyo.analytics.networking.requests.PushTokenApiRequest
914
import com.klaviyo.core.Registry
1015
import java.util.UUID
1116

@@ -14,6 +19,15 @@ import java.util.UUID
1419
*/
1520
internal object UserInfo {
1621

22+
init {
23+
Registry.get<ApiClient>().onApiRequest { request ->
24+
// If push token request totally fails, we must remove it from state
25+
if (request is PushTokenApiRequest && request.status == Failed) {
26+
pushState = ""
27+
}
28+
}
29+
}
30+
1731
/**
1832
* Save or clear an identifier in the persistent store and return it
1933
*
@@ -56,9 +70,42 @@ internal object UserInfo {
5670
*/
5771
var anonymousId: String = ""
5872
private set(value) { field = persist(ANONYMOUS_ID, value) }
59-
get() = field.ifEmpty { fetch(ANONYMOUS_ID, generateUuid).also { anonymousId = it } }
73+
get() = field.ifEmpty { fetch(ANONYMOUS_ID, ::generateUuid).also { anonymousId = it } }
74+
75+
var pushToken: String = ""
76+
private set(value) { field = persist(PUSH_TOKEN, value) }
77+
get() = field.ifEmpty { fetch(PUSH_TOKEN).also { field = it } }
6078

61-
private val generateUuid = { UUID.randomUUID().toString() }
79+
/**
80+
* Track the most recent state of push token + device metadata sent to the backend API
81+
*/
82+
private var pushState: String = ""
83+
set(value) { field = persist(PUSH_STATE, value) }
84+
get() = field.ifEmpty { fetch(PUSH_STATE).also { field = it } }
85+
86+
/**
87+
* Save push token string to state
88+
* If push token or any other device metadata have changed,
89+
* invoke the onChanged callback (i.e. to enqueue the API request)
90+
*/
91+
fun setPushToken(token: String, onChanged: () -> Unit) {
92+
// Use the request body format as our state tracking value
93+
val newPushState = PushTokenApiRequest(token, getAsProfile()).requestBody
94+
95+
if (newPushState != pushState) {
96+
// Optimistic update algorithm: expect request to get to backend,
97+
// on failure reset push state (see initializer). The main advantage to
98+
// this algorithm is it prevents queueing duplicate requests immediately
99+
pushState = newPushState ?: ""
100+
pushToken = token
101+
onChanged()
102+
}
103+
}
104+
105+
/**
106+
* Generate a new UUID for anonymous ID
107+
*/
108+
private fun generateUuid() = UUID.randomUUID().toString()
62109

63110
/**
64111
* Indicate whether we currently have externally-set profile identifiers
@@ -75,6 +122,8 @@ internal object UserInfo {
75122
email = ""
76123
phoneNumber = ""
77124
anonymousId = ""
125+
pushToken = ""
126+
pushState = ""
78127
}
79128

80129
/**

sdk/analytics/src/main/java/com/klaviyo/analytics/model/ProfileKey.kt

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ sealed class ProfileKey(name: String) : Keyword(name) {
1111
object EMAIL : ProfileKey("email")
1212
object PHONE_NUMBER : ProfileKey("phone_number")
1313
internal object ANONYMOUS_ID : ProfileKey("anonymous_id")
14+
internal object PUSH_TOKEN : ProfileKey("push_token")
15+
internal object PUSH_STATE : ProfileKey("push_state")
1416

1517
// Personal information
1618
object FIRST_NAME : ProfileKey("first_name")

sdk/analytics/src/main/java/com/klaviyo/analytics/networking/KlaviyoApiClient.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ internal object KlaviyoApiClient : ApiClient {
339339
}
340340

341341
private fun computeRetryInterval(attempts: Int): Long {
342-
val minRetryInterval = flushInterval
342+
val minRetryInterval = Registry.config.networkFlushIntervals[networkType]
343343
val jitterSeconds = Registry.config.networkJitterRange.random()
344344
val exponentialBackoff = (2.0.pow(attempts).toLong() + jitterSeconds).times(1_000)
345345
val maxRetryInterval = Registry.config.networkMaxRetryInterval

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,8 @@ internal open class KlaviyoApiRequest(
114114
* Internal tracking of the request status
115115
* When status changes, this setter updates start and end timestamps
116116
*/
117-
protected var status: Status = Status.Unsent
118-
set(value) {
117+
var status: Status = Status.Unsent
118+
private set(value) {
119119
if (field == value) return
120120
field = value
121121

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

+27-1
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,39 @@ import android.os.Build
66
import com.klaviyo.fixtures.BaseTest
77
import io.mockk.every
88
import io.mockk.mockk
9+
import io.mockk.mockkObject
10+
import io.mockk.unmockkObject
911
import io.mockk.verify
1012
import org.junit.Assert.assertEquals
1113
import org.junit.Test
1214

1315
internal class DevicePropertiesTest : BaseTest() {
1416

15-
private val mockVersionCode = 123
17+
companion object {
18+
private val mockVersionCode = 123
19+
20+
fun mockDeviceProperties() {
21+
mockkObject(DeviceProperties)
22+
every { DeviceProperties.userAgent } returns "Mock User Agent"
23+
every { DeviceProperties.model } returns "Mock Model"
24+
every { DeviceProperties.applicationLabel } returns "Mock Application Label"
25+
every { DeviceProperties.appVersion } returns "Mock App Version"
26+
every { DeviceProperties.appVersionCode } returns "Mock Version Code"
27+
every { DeviceProperties.sdkName } returns "Mock SDK"
28+
every { DeviceProperties.sdkVersion } returns "Mock SDK Version"
29+
every { DeviceProperties.backgroundData } returns true
30+
every { DeviceProperties.notificationPermission } returns true
31+
every { DeviceProperties.applicationId } returns "Mock App ID"
32+
every { DeviceProperties.platform } returns "Android"
33+
every { DeviceProperties.deviceId } returns "Mock Device ID"
34+
every { DeviceProperties.manufacturer } returns "Mock Manufacturer"
35+
every { DeviceProperties.osVersion } returns "Mock OS Version"
36+
}
37+
38+
fun unmockDeviceProperties() {
39+
unmockkObject(DeviceProperties)
40+
}
41+
}
1642

1743
@Suppress("DEPRECATION")
1844
private val mockPackageInfo = mockk<PackageInfo>().apply {

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

+44-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import io.mockk.mockk
1919
import io.mockk.slot
2020
import io.mockk.verify
2121
import io.mockk.verifyAll
22+
import org.junit.After
2223
import org.junit.Assert.assertEquals
2324
import org.junit.Assert.assertNotEquals
2425
import org.junit.Assert.assertNull
@@ -81,13 +82,21 @@ internal class KlaviyoTest : BaseTest() {
8182

8283
override fun setup() {
8384
super.setup()
84-
UserInfo.reset()
8585
Registry.register<ApiClient> { apiClientMock }
8686
every { Registry.clock } returns staticClock
87+
every { apiClientMock.onApiRequest(any(), any()) } returns Unit
8788
every { apiClientMock.enqueueProfile(capture(capturedProfile)) } returns Unit
8889
every { apiClientMock.enqueueEvent(any(), any()) } returns Unit
8990
every { apiClientMock.enqueuePushToken(any(), any()) } returns Unit
9091
every { configMock.debounceInterval } returns debounceTime
92+
UserInfo.reset()
93+
DevicePropertiesTest.mockDeviceProperties()
94+
}
95+
96+
@After
97+
fun cleanup() {
98+
UserInfo.reset()
99+
DevicePropertiesTest.unmockDeviceProperties()
91100
}
92101

93102
@Test
@@ -290,6 +299,40 @@ internal class KlaviyoTest : BaseTest() {
290299
}
291300
}
292301

302+
@Test
303+
fun `Push token request is ignored if state has not changed`() {
304+
Klaviyo.setPushToken(PUSH_TOKEN)
305+
assertEquals(PUSH_TOKEN, dataStoreSpy.fetch("push_token"))
306+
307+
verify(exactly = 1) {
308+
apiClientMock.enqueuePushToken(PUSH_TOKEN, any())
309+
}
310+
311+
Klaviyo.setPushToken(PUSH_TOKEN)
312+
313+
verify(exactly = 1) {
314+
apiClientMock.enqueuePushToken(PUSH_TOKEN, any())
315+
}
316+
}
317+
318+
@Test
319+
fun `Push token request is repeated if state has changed`() {
320+
every { DeviceProperties.backgroundData } returns true
321+
Klaviyo.setPushToken(PUSH_TOKEN)
322+
assertEquals(PUSH_TOKEN, dataStoreSpy.fetch("push_token"))
323+
324+
verify(exactly = 1) {
325+
apiClientMock.enqueuePushToken(PUSH_TOKEN, any())
326+
}
327+
328+
every { DeviceProperties.backgroundData } returns false
329+
Klaviyo.setPushToken(PUSH_TOKEN)
330+
331+
verify(exactly = 2) {
332+
apiClientMock.enqueuePushToken(PUSH_TOKEN, any())
333+
}
334+
}
335+
293336
@Test
294337
fun `Retrieve saved push token from data store`() {
295338
assertNull(Klaviyo.getPushToken())

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

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ internal class KlaviyoUninitializedTest {
3535
Registry.unregister<Config>()
3636
Registry.register<Log>(logger)
3737
Registry.register<ApiClient>(mockApiClient)
38+
every { mockApiClient.onApiRequest(any(), any()) } returns Unit
3839
}
3940

4041
private inline fun <reified T> assertCaught() where T : Throwable {

sdk/analytics/src/test/java/com/klaviyo/analytics/networking/KlaviyoApiClientTest.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,8 @@ internal class KlaviyoApiClientTest : BaseRequestTest() {
517517
}
518518

519519
@After
520-
fun cleanup() {
520+
override fun cleanup() {
521+
super.cleanup()
521522
dataStoreSpy.clear(KlaviyoApiClient.QUEUE_KEY)
522523
KlaviyoApiClient.restoreQueue()
523524
assertEquals(0, KlaviyoApiClient.getQueueSize())
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,20 @@
11
package com.klaviyo.analytics.networking.requests
22

3-
import com.klaviyo.analytics.DeviceProperties
3+
import com.klaviyo.analytics.DevicePropertiesTest
44
import com.klaviyo.fixtures.BaseTest
5-
import io.mockk.every
6-
import io.mockk.mockkObject
5+
import org.junit.After
76
import org.junit.Before
87

98
internal open class BaseRequestTest : BaseTest() {
109

1110
@Before
1211
override fun setup() {
1312
super.setup()
14-
mockkObject(DeviceProperties)
15-
every { DeviceProperties.userAgent } returns "Mock User Agent"
16-
every { DeviceProperties.model } returns "Mock Model"
17-
every { DeviceProperties.applicationLabel } returns "Mock Application Label"
18-
every { DeviceProperties.appVersion } returns "Mock App Version"
19-
every { DeviceProperties.appVersionCode } returns "Mock Version Code"
20-
every { DeviceProperties.sdkName } returns "Mock SDK"
21-
every { DeviceProperties.sdkVersion } returns "Mock SDK Version"
22-
every { DeviceProperties.backgroundData } returns true
23-
every { DeviceProperties.notificationPermission } returns true
24-
every { DeviceProperties.applicationId } returns "Mock App ID"
25-
every { DeviceProperties.platform } returns "Android"
26-
every { DeviceProperties.deviceId } returns "Mock Device ID"
27-
every { DeviceProperties.manufacturer } returns "Mock Manufacturer"
28-
every { DeviceProperties.osVersion } returns "Mock OS Version"
13+
DevicePropertiesTest.mockDeviceProperties()
14+
}
15+
16+
@After
17+
open fun cleanup() {
18+
DevicePropertiesTest.unmockDeviceProperties()
2919
}
3020
}

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

+10
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import com.klaviyo.analytics.model.Event
55
import com.klaviyo.analytics.model.EventMetric
66
import com.klaviyo.analytics.model.Profile
77
import io.mockk.every
8+
import io.mockk.mockkObject
9+
import io.mockk.unmockkObject
810
import org.json.JSONObject
11+
import org.junit.After
912
import org.junit.Assert.assertEquals
1013
import org.junit.Test
1114

@@ -33,9 +36,16 @@ internal class EventApiRequestTest : BaseRequestTest() {
3336

3437
override fun setup() {
3538
super.setup()
39+
mockkObject(Klaviyo)
3640
every { Klaviyo.getPushToken() } returns "Mock Push Token"
3741
}
3842

43+
@After
44+
override fun cleanup() {
45+
super.cleanup()
46+
unmockkObject(Klaviyo)
47+
}
48+
3949
@Test
4050
fun `Uses correct endpoint`() {
4151
assertEquals(expectedUrlPath, EventApiRequest(stubEvent, stubProfile).urlPath)

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

+3-3
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ object KlaviyoConfig : Config {
8181
*
8282
* Reasoning: Most likely the rate limit should be cleared within 2-3 retries with exp backoff.
8383
*/
84-
private const val NETWORK_MAX_RETRIES_DEFAULT: Int = 50
84+
private const val NETWORK_MAX_ATTEMPTS_DEFAULT: Int = 50
8585

8686
/**
8787
* Maximum interval between retries for the exponential backoff, in milliseconds (3 minutes)
@@ -108,7 +108,7 @@ object KlaviyoConfig : Config {
108108
private set
109109
override var networkFlushDepth = NETWORK_FLUSH_DEPTH_DEFAULT
110110
private set
111-
override var networkMaxAttempts = NETWORK_MAX_RETRIES_DEFAULT
111+
override var networkMaxAttempts = NETWORK_MAX_ATTEMPTS_DEFAULT
112112
private set
113113
override var networkMaxRetryInterval = NETWORK_MAX_RETRY_INTERVAL_DEFAULT
114114
private set
@@ -135,7 +135,7 @@ object KlaviyoConfig : Config {
135135
NETWORK_FLUSH_INTERVAL_OFFLINE_DEFAULT
136136
)
137137
private var networkFlushDepth = NETWORK_FLUSH_DEPTH_DEFAULT
138-
private var networkMaxAttempts = NETWORK_MAX_RETRIES_DEFAULT
138+
private var networkMaxAttempts = NETWORK_MAX_ATTEMPTS_DEFAULT
139139
private var networkMaxRetryInterval = NETWORK_MAX_RETRY_INTERVAL_DEFAULT
140140

141141
private val requiredPermissions = arrayOf(

0 commit comments

Comments
 (0)