Skip to content

Commit 247f49d

Browse files
authored
fix ANR caused by DRM api (#71)
* make DRM API async * make analytics inheritable and delegate coroutine config out * add test analytics that fully runs synchronizely * replace with the new testAnalytics * remove verify timeout * update coroutine version to 1.6.0 * replace everything with the new test api * replace runBlocking with runTest * bug fix * bug fix * remove unused import * use empty string as default device id * cache device id
1 parent 2895e29 commit 247f49d

File tree

31 files changed

+399
-276
lines changed

31 files changed

+399
-276
lines changed

android/build.gradle

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ dependencies {
5454
api project(':core')
5555
api 'com.segment:sovran-kotlin:1.2.1'
5656
api "org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.2"
57-
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
58-
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
57+
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
58+
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
5959
implementation 'androidx.lifecycle:lifecycle-process:2.4.0'
6060
implementation 'androidx.lifecycle:lifecycle-common-java8:2.4.0'
6161

@@ -64,7 +64,7 @@ dependencies {
6464
// TESTING
6565
testImplementation 'org.junit.jupiter:junit-jupiter:5.7.2'
6666
testImplementation 'io.mockk:mockk:1.10.6'
67-
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.2'
67+
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
6868
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
6969
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
7070

android/src/main/java/com/segment/analytics/kotlin/android/plugins/AndroidContextPlugin.kt

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import java.util.TimeZone
2424
import java.util.UUID
2525
import java.lang.System as JavaSystem
2626
import android.media.MediaDrm
27+
import com.segment.analytics.kotlin.core.utilities.*
28+
import kotlinx.coroutines.*
2729
import java.lang.Exception
2830
import java.security.MessageDigest
2931

@@ -123,16 +125,53 @@ class AndroidContextPlugin : Plugin {
123125
emptyJsonObject
124126
}
125127

128+
// use empty string to indicate device id not yet ready
129+
val deviceId = storage.read(Storage.Constants.DeviceId) ?: ""
126130
device = buildJsonObject {
127-
put(DEVICE_ID_KEY, getDeviceId(collectDeviceId))
131+
put(DEVICE_ID_KEY, deviceId)
128132
put(DEVICE_MANUFACTURER_KEY, Build.MANUFACTURER)
129133
put(DEVICE_MODEL_KEY, Build.MODEL)
130134
put(DEVICE_NAME_KEY, Build.DEVICE)
131135
put(DEVICE_TYPE_KEY, "android")
132136
}
137+
138+
if (deviceId.isEmpty()) {
139+
loadDeviceId(collectDeviceId)
140+
}
141+
}
142+
143+
private fun loadDeviceId(collectDeviceId: Boolean) {
144+
// run `getDeviceId` in coroutine, since the DRM API takes a long time
145+
// to generate device id on certain devices and causes ANR issue.
146+
analytics.analyticsScope.launch(analytics.analyticsDispatcher) {
147+
148+
// generate random identifier that does not persist across installations
149+
// use it as the fallback in case DRM API failed to generate one.
150+
val fallbackDeviceId = UUID.randomUUID().toString()
151+
var deviceId = fallbackDeviceId
152+
153+
// have to use a different scope than analyticsScope.
154+
// otherwise, timeout cancellation won't work (i.e. the scope can't cancel itself)
155+
val task = CoroutineScope(SupervisorJob()).async {
156+
getDeviceId(collectDeviceId, fallbackDeviceId)
157+
}
158+
159+
// restrict getDeviceId to 2s to avoid ANR
160+
withTimeoutOrNull(2_000) {
161+
deviceId = task.await()
162+
}
163+
164+
if (deviceId != fallbackDeviceId) {
165+
device = updateJsonObject(device) {
166+
it[DEVICE_ID_KEY] = deviceId
167+
}
168+
}
169+
170+
storage.write(Storage.Constants.DeviceId, deviceId)
171+
}
133172
}
134173

135-
internal fun getDeviceId(collectDeviceId: Boolean): String {
174+
internal fun getDeviceId(collectDeviceId: Boolean, fallbackDeviceId: String): String {
136175
if (!collectDeviceId) {
137176
return storage.read(Storage.Constants.AnonymousId) ?: ""
138177
}
@@ -142,9 +181,8 @@ class AndroidContextPlugin : Plugin {
142181
if (!uniqueId.isNullOrEmpty()) {
143182
return uniqueId
144183
}
145-
// If this still fails, generate random identifier that does not persist across
146-
// installations
147-
return UUID.randomUUID().toString()
184+
// If this still fails, falls back to the random uuid
185+
return fallbackDeviceId
148186
}
149187

150188
@SuppressLint("MissingPermission")

android/src/test/java/com/segment/analytics/kotlin/android/AndroidContextCollectorTests.kt

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@ package com.segment.analytics.kotlin.android
22

33
import android.content.Context
44
import android.content.SharedPreferences
5-
import android.util.Log
65
import androidx.test.platform.app.InstrumentationRegistry
76
import com.segment.analytics.kotlin.core.*
87
import com.segment.analytics.kotlin.android.plugins.AndroidContextPlugin
98
import com.segment.analytics.kotlin.android.plugins.getUniqueID
109
import com.segment.analytics.kotlin.android.utils.MemorySharedPreferences
10+
import com.segment.analytics.kotlin.android.utils.testAnalytics
1111
import io.mockk.every
1212
import io.mockk.mockkStatic
1313
import io.mockk.spyk
14-
import kotlinx.coroutines.runBlocking
14+
import kotlinx.coroutines.test.*
1515
import kotlinx.serialization.json.*
1616
import org.junit.Assert.*
17+
import org.junit.Before
1718
import org.junit.Test
1819
import org.junit.runner.RunWith
1920
import org.robolectric.RobolectricTestRunner
@@ -24,27 +25,32 @@ import java.util.*
2425
@Config(manifest = Config.NONE)
2526
class AndroidContextCollectorTests {
2627

27-
val appContext: Context
28-
val analytics: Analytics
28+
lateinit var appContext: Context
29+
lateinit var analytics: Analytics
2930

30-
init {
31+
private val testDispatcher = UnconfinedTestDispatcher()
32+
private val testScope = TestScope(testDispatcher)
33+
34+
@Before
35+
fun setUp() {
3136
appContext = spyk(InstrumentationRegistry.getInstrumentation().targetContext)
3237
val sharedPreferences: SharedPreferences = MemorySharedPreferences()
3338
every { appContext.getSharedPreferences(any(), any()) } returns sharedPreferences
3439
mockkStatic("com.segment.analytics.kotlin.android.plugins.AndroidContextPluginKt")
3540
every { getUniqueID() } returns "unknown"
3641

37-
analytics = Analytics(
42+
analytics = testAnalytics(
3843
Configuration(
3944
writeKey = "123",
4045
application = appContext,
4146
storageProvider = AndroidStorageProvider
42-
)
47+
),
48+
testScope, testDispatcher
4349
)
4450
}
4551

4652
@Test
47-
fun `context fields applied correctly`() {
53+
fun `context fields applied correctly`() {
4854
// Context of the app under test.
4955
analytics.configuration.collectDeviceId = true
5056
val contextCollector = AndroidContextPlugin()
@@ -101,14 +107,41 @@ class AndroidContextCollectorTests {
101107
}
102108

103109
@Test
104-
fun `getDeviceId returns anonId when disabled`() = runBlocking {
110+
fun `getDeviceId returns anonId when disabled`() = runTest {
105111
analytics.storage.write(Storage.Constants.AnonymousId, "anonId")
106112
val contextCollector = AndroidContextPlugin()
107113
contextCollector.setup(analytics)
108-
val deviceId = contextCollector.getDeviceId(false)
109-
Log.d("debug flaky test", deviceId)
114+
val deviceId = contextCollector.getDeviceId(false, "")
110115
assertEquals(deviceId, "anonId")
111116
}
112117

118+
@Test
119+
fun `device id cache is used when presented`() = runTest {
120+
analytics.storage.write(Storage.Constants.DeviceId, "anonId")
121+
122+
analytics.configuration.collectDeviceId = true
123+
val contextCollector = AndroidContextPlugin()
124+
contextCollector.setup(analytics)
125+
126+
val event = TrackEvent(
127+
event = "clicked",
128+
properties = buildJsonObject { put("behaviour", "good") })
129+
.apply {
130+
messageId = "qwerty-1234"
131+
anonymousId = "anonId"
132+
integrations = emptyJsonObject
133+
context = emptyJsonObject
134+
timestamp = Date(0).toInstant().toString()
135+
}
136+
contextCollector.execute(event)
137+
138+
with(event.context) {
139+
assertTrue(this.containsKey("device"))
140+
this["device"]?.jsonObject?.let {
141+
assertEquals("anonId", it["id"].asString())
142+
}
143+
}
144+
}
145+
113146
private fun JsonElement?.asString(): String? = this?.jsonPrimitive?.content
114147
}

android/src/test/java/com/segment/analytics/kotlin/android/AndroidLifecyclePluginTests.kt

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import com.segment.analytics.kotlin.core.platform.Plugin
1313
import com.segment.analytics.kotlin.android.plugins.AndroidLifecycle
1414
import com.segment.analytics.kotlin.android.plugins.AndroidLifecyclePlugin
1515
import com.segment.analytics.kotlin.android.utils.mockHTTPClient
16+
import com.segment.analytics.kotlin.android.utils.testAnalytics
1617
import io.mockk.*
17-
import kotlinx.coroutines.delay
18-
import kotlinx.coroutines.runBlocking
18+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
19+
import kotlinx.coroutines.test.TestScope
20+
import kotlinx.coroutines.test.runTest
1921
import kotlinx.serialization.json.buildJsonObject
2022
import kotlinx.serialization.json.put
2123
import org.junit.Assert.*
@@ -35,6 +37,9 @@ class AndroidLifecyclePluginTests {
3537
private lateinit var analytics: Analytics
3638
private val mockContext = spyk(InstrumentationRegistry.getInstrumentation().targetContext)
3739

40+
private val testDispatcher = UnconfinedTestDispatcher()
41+
private val testScope = TestScope(testDispatcher)
42+
3843
init {
3944
val packageInfo = PackageInfo()
4045
packageInfo.versionCode = 100
@@ -56,12 +61,13 @@ class AndroidLifecyclePluginTests {
5661

5762
@Before
5863
fun setup() {
59-
analytics = Analytics(
64+
analytics = testAnalytics(
6065
Configuration(
6166
writeKey = "123",
6267
application = mockContext,
6368
storageProvider = AndroidStorageProvider
64-
)
69+
),
70+
testScope, testDispatcher
6571
)
6672
}
6773

@@ -121,7 +127,7 @@ class AndroidLifecyclePluginTests {
121127
}
122128

123129
@Test
124-
fun `application opened is tracked`() = runBlocking{
130+
fun `application opened is tracked`() {
125131
analytics.configuration.trackApplicationLifecycleEvents = true
126132
analytics.configuration.trackDeepLinks = false
127133
analytics.configuration.useLifecycleObserver = false
@@ -134,12 +140,10 @@ class AndroidLifecyclePluginTests {
134140

135141
// Simulate activity startup
136142
lifecyclePlugin.onActivityCreated(mockActivity, mockBundle)
137-
delay(500)
138143
lifecyclePlugin.onActivityStarted(mockActivity)
139-
delay(500)
140144
lifecyclePlugin.onActivityResumed(mockActivity)
141145

142-
verify (timeout = 2000){ mockPlugin.updateState(true) }
146+
verify { mockPlugin.updateState(true) }
143147
val tracks = mutableListOf<TrackEvent>()
144148
verify { mockPlugin.track(capture(tracks)) }
145149
assertEquals(2, tracks.size)
@@ -169,7 +173,7 @@ class AndroidLifecyclePluginTests {
169173
lifecyclePlugin.onActivityStopped(mockActivity)
170174
lifecyclePlugin.onActivityDestroyed(mockActivity)
171175

172-
verify (timeout = 2000){ mockPlugin.updateState(true) }
176+
verify { mockPlugin.updateState(true) }
173177
val track = slot<TrackEvent>()
174178
verify { mockPlugin.track(capture(track)) }
175179
with(track.captured) {
@@ -193,7 +197,7 @@ class AndroidLifecyclePluginTests {
193197
// Simulate activity startup
194198
lifecyclePlugin.onActivityCreated(mockActivity, mockBundle)
195199

196-
verify (timeout = 4000){ mockPlugin.updateState(true) }
200+
verify { mockPlugin.updateState(true) }
197201
val track = slot<TrackEvent>()
198202
verify { mockPlugin.track(capture(track)) }
199203
with(track.captured) {
@@ -206,7 +210,7 @@ class AndroidLifecyclePluginTests {
206210
}
207211

208212
@Test
209-
fun `application updated is tracked`() = runBlocking {
213+
fun `application updated is tracked`() = runTest {
210214
analytics.configuration.trackApplicationLifecycleEvents = true
211215
analytics.configuration.trackDeepLinks = false
212216
analytics.configuration.useLifecycleObserver = false
@@ -224,7 +228,7 @@ class AndroidLifecyclePluginTests {
224228
// Simulate activity startup
225229
lifecyclePlugin.onActivityCreated(mockActivity, mockBundle)
226230

227-
verify (timeout = 2000){ mockPlugin.updateState(true) }
231+
verify { mockPlugin.updateState(true) }
228232
val track = slot<TrackEvent>()
229233
verify { mockPlugin.track(capture(track)) }
230234
with(track.captured) {
@@ -281,7 +285,7 @@ class AndroidLifecyclePluginTests {
281285
// Simulate activity startup
282286
lifecyclePlugin.onActivityCreated(mockActivity, mockBundle)
283287

284-
verify (timeout = 2000){ mockPlugin.updateState(true) }
288+
verify { mockPlugin.updateState(true) }
285289
val track = slot<TrackEvent>()
286290
verify { mockPlugin.track(capture(track)) }
287291
with(track.captured) {
@@ -316,7 +320,7 @@ class AndroidLifecyclePluginTests {
316320
// Simulate activity startup
317321
lifecyclePlugin.onActivityCreated(mockActivity, mockBundle)
318322

319-
verify (timeout = 4000){ mockPlugin.updateState(true) }
323+
verify { mockPlugin.updateState(true) }
320324
val track = slot<TrackEvent>()
321325
verify { mockPlugin.track(capture(track)) }
322326
with(track.captured) {

0 commit comments

Comments
 (0)