Skip to content

Commit ee8614e

Browse files
authored
Merge pull request #89 from Parsely/coroutines
Migrate threading model to Coroutines - long running branch
2 parents 1e6e189 + ec44c99 commit ee8614e

32 files changed

+1414
-852
lines changed

Diff for: .github/workflows/readme.yml

+3-1
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,6 @@ jobs:
7272
if: always()
7373
with:
7474
name: artifact
75-
path: ./parsely/build/reports/*
75+
path: |
76+
./parsely/build/reports/*
77+
./parsely/build/outputs/androidTest-results

Diff for: example/src/main/java/com/example/MainActivity.java

+3-18
Original file line numberDiff line numberDiff line change
@@ -30,33 +30,18 @@ protected void onCreate(Bundle savedInstanceState) {
3030
// Set debugging to true so we don't actually send things to Parse.ly
3131
ParselyTracker.sharedInstance().setDebug(true);
3232

33-
final TextView queueView = (TextView) findViewById(R.id.queue_size);
34-
queueView.setText(String.format("Queued events: %d", ParselyTracker.sharedInstance().queueSize()));
35-
36-
final TextView storedView = (TextView) findViewById(R.id.stored_size);
37-
storedView.setText(String.format("Stored events: %d", ParselyTracker.sharedInstance().storedEventsCount()));
38-
3933
final TextView intervalView = (TextView) findViewById(R.id.interval);
40-
storedView.setText(String.format("Flush interval: %d", ParselyTracker.sharedInstance().getFlushInterval()));
4134

4235
updateEngagementStrings();
4336

44-
final TextView views[] = new TextView[3];
45-
views[0] = queueView;
46-
views[1] = storedView;
47-
views[2] = intervalView;
37+
final TextView views[] = new TextView[1];
38+
views[0] = intervalView;
4839

4940
final Handler mHandler = new Handler() {
5041
@Override
5142
public void handleMessage(Message msg) {
5243
TextView[] v = (TextView[]) msg.obj;
53-
TextView qView = v[0];
54-
qView.setText(String.format("Queued events: %d", ParselyTracker.sharedInstance().queueSize()));
55-
56-
TextView sView = v[1];
57-
sView.setText(String.format("Stored events: %d", ParselyTracker.sharedInstance().storedEventsCount()));
58-
59-
TextView iView = v[2];
44+
TextView iView = v[0];
6045
if (ParselyTracker.sharedInstance().flushTimerIsActive()) {
6146
iView.setText(String.format("Flush Interval: %d", ParselyTracker.sharedInstance().getFlushInterval()));
6247
} else {

Diff for: example/src/main/res/layout/activity_main.xml

+2-17
Original file line numberDiff line numberDiff line change
@@ -68,26 +68,11 @@
6868
android:onClick="trackReset"
6969
android:text="@string/button_reset_video" />
7070

71-
<TextView
72-
android:id="@+id/queue_size"
73-
android:layout_width="wrap_content"
74-
android:layout_height="wrap_content"
75-
android:layout_below="@+id/reset_video_button"
76-
android:layout_centerHorizontal="true"
77-
android:text="Queued events: 0" />
78-
79-
<TextView android:id="@+id/stored_size"
80-
android:layout_width="wrap_content"
81-
android:layout_height="wrap_content"
82-
android:layout_centerHorizontal="true"
83-
android:layout_below="@id/queue_size"
84-
android:text="Stored events: 0"/>
85-
8671
<TextView android:id="@+id/interval"
8772
android:layout_width="wrap_content"
8873
android:layout_height="wrap_content"
8974
android:layout_centerHorizontal="true"
90-
android:layout_below="@id/stored_size"
75+
android:layout_below="@id/reset_video_button"
9176
android:text="Flush timer inactive"/>
9277

9378
<TextView
@@ -107,4 +92,4 @@
10792
android:text="Video is inactive." />
10893

10994

110-
</RelativeLayout>
95+
</RelativeLayout>

Diff for: parsely/build.gradle

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ plugins {
66

77
ext {
88
assertJVersion = '3.24.2'
9+
coroutinesVersion = '1.7.3'
910
mockWebServerVersion = '4.12.0'
1011
}
1112

@@ -63,12 +64,14 @@ dependencies {
6364
implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.3'
6465
implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1'
6566
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
67+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
6668

6769
testImplementation 'org.robolectric:robolectric:4.10.3'
6870
testImplementation 'androidx.test:core:1.5.0'
6971
testImplementation "org.assertj:assertj-core:$assertJVersion"
7072
testImplementation 'junit:junit:4.13.2'
7173
testImplementation "com.squareup.okhttp3:mockwebserver:$mockWebServerVersion"
74+
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
7275

7376
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
7477
androidTestImplementation 'androidx.test:rules:1.5.0'

Diff for: parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt

+184-8
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import okhttp3.mockwebserver.MockResponse
2828
import okhttp3.mockwebserver.MockWebServer
2929
import okhttp3.mockwebserver.RecordedRequest
3030
import org.assertj.core.api.Assertions.assertThat
31+
import org.assertj.core.api.Assertions.within
3132
import org.junit.Test
3233
import org.junit.runner.RunWith
3334

@@ -49,12 +50,12 @@ class FunctionalTests {
4950
}
5051

5152
/**
52-
* In this scenario, the consumer application tracks more than 50 events-threshold during a flush interval.
53+
* In this scenario, the consumer application tracks 51 events-threshold during a flush interval.
5354
* The SDK will save the events to disk and send them in the next flush interval.
5455
* At the end, when all events are sent, the SDK will delete the content of local storage file.
5556
*/
5657
@Test
57-
fun appTracksEventsAboveQueueSizeLimit() {
58+
fun appTracksEventsDuringTheFlushInterval() {
5859
ActivityScenario.launch(SampleActivity::class.java).use { scenario ->
5960
scenario.onActivity { activity: Activity ->
6061
beforeEach(activity)
@@ -76,6 +77,47 @@ class FunctionalTests {
7677
}
7778
}
7879

80+
/**
81+
* In this scenario, the consumer app tracks 2 events during the first flush interval.
82+
* Then, we validate, that after flush interval passed the SDK sends the events
83+
* to Parse.ly servers.
84+
*
85+
* Then, the consumer app tracks another event and we validate that the SDK sends the event
86+
* to Parse.ly servers as well.
87+
*/
88+
@Test
89+
fun appFlushesEventsAfterFlushInterval() {
90+
ActivityScenario.launch(SampleActivity::class.java).use { scenario ->
91+
scenario.onActivity { activity: Activity ->
92+
beforeEach(activity)
93+
server.enqueue(MockResponse().setResponseCode(200))
94+
parselyTracker = initializeTracker(activity)
95+
96+
parselyTracker.trackPageview("url", null, null, null)
97+
}
98+
99+
Thread.sleep((defaultFlushInterval / 2).inWholeMilliseconds)
100+
101+
scenario.onActivity {
102+
parselyTracker.trackPageview("url", null, null, null)
103+
}
104+
105+
Thread.sleep((defaultFlushInterval / 2).inWholeMilliseconds)
106+
107+
val firstRequestPayload = server.takeRequest(2000, TimeUnit.MILLISECONDS)?.toMap()
108+
assertThat(firstRequestPayload!!["events"]).hasSize(2)
109+
110+
scenario.onActivity {
111+
parselyTracker.trackPageview("url", null, null, null)
112+
}
113+
114+
Thread.sleep(defaultFlushInterval.inWholeMilliseconds)
115+
116+
val secondRequestPayload = server.takeRequest(2000, TimeUnit.MILLISECONDS)?.toMap()
117+
assertThat(secondRequestPayload!!["events"]).hasSize(1)
118+
}
119+
}
120+
79121
/**
80122
* In this scenario, the consumer application:
81123
* 1. Goes to the background
@@ -113,6 +155,132 @@ class FunctionalTests {
113155
}
114156
}
115157

158+
/**
159+
* In this scenario we "stress test" the concurrency model to see if we have any conflict during
160+
*
161+
* - Unexpectedly high number of recorded events in small intervals (I/O locking)
162+
* - Scenario in which a request is sent at the same time as new events are recorded
163+
*/
164+
@Test
165+
fun stressTest() {
166+
val eventsToSend = 500
167+
168+
ActivityScenario.launch(SampleActivity::class.java).use { scenario ->
169+
scenario.onActivity { activity: Activity ->
170+
beforeEach(activity)
171+
server.enqueue(MockResponse().setResponseCode(200))
172+
parselyTracker = initializeTracker(activity)
173+
174+
repeat(eventsToSend) {
175+
parselyTracker.trackPageview("url", null, null, null)
176+
}
177+
}
178+
179+
// Wait some time to give events chance to be saved in local data storage
180+
Thread.sleep((defaultFlushInterval * 2).inWholeMilliseconds)
181+
182+
// Catch up to 10 requests. We don't know how many requests the device we test on will
183+
// perform. It's probably more like 1-2, but we're on safe (not flaky) side here.
184+
val requests = (1..10).mapNotNull {
185+
runCatching { server.takeRequest(100, TimeUnit.MILLISECONDS) }.getOrNull()
186+
}.flatMap {
187+
it.toMap()["events"]!!
188+
}
189+
190+
assertThat(requests).hasSize(eventsToSend)
191+
}
192+
}
193+
194+
/**
195+
* In this scenario consumer app starts an engagement session and after 27150 ms,
196+
* it stops the session.
197+
*
198+
* Intervals:
199+
* With current implementation of `HeartbeatIntervalCalculator`, the next intervals are:
200+
* - 10500ms for the first interval
201+
* - 13650ms for the second interval
202+
*
203+
* So after ~27,2s we should observe
204+
* - 2 `heartbeat` events from `startEngagement` + 1 `heartbeat` event caused by `stopEngagement` which is triggered during engagement interval
205+
*
206+
* Time off-differences in assertions are acceptable, because it's a time-sensitive test
207+
*/
208+
@Test
209+
fun engagementManagerTest() {
210+
val engagementUrl = "engagementUrl"
211+
var startTimestamp = Duration.ZERO
212+
val firstInterval = 10500.milliseconds
213+
val secondInterval = 13650.milliseconds
214+
val pauseInterval = 3.seconds
215+
ActivityScenario.launch(SampleActivity::class.java).use { scenario ->
216+
// given
217+
scenario.onActivity { activity: Activity ->
218+
beforeEach(activity)
219+
server.enqueue(MockResponse().setResponseCode(200))
220+
parselyTracker = initializeTracker(activity, flushInterval = 30.seconds)
221+
222+
// when
223+
startTimestamp = System.currentTimeMillis().milliseconds
224+
parselyTracker.startEngagement(engagementUrl, null)
225+
}
226+
227+
Thread.sleep((firstInterval + secondInterval + pauseInterval).inWholeMilliseconds)
228+
parselyTracker.stopEngagement()
229+
230+
// then
231+
val request = server.takeRequest(35, TimeUnit.SECONDS)!!.toMap()["events"]!!
232+
233+
assertThat(
234+
request.sortedBy { it.data.timestamp }
235+
.filter { it.action == "heartbeat" }
236+
).hasSize(3)
237+
.satisfies({
238+
val firstEvent = it[0]
239+
val secondEvent = it[1]
240+
val thirdEvent = it[2]
241+
242+
assertThat(firstEvent.data.timestamp).isCloseTo(
243+
(startTimestamp + firstInterval).inWholeMilliseconds,
244+
within(1.seconds.inWholeMilliseconds)
245+
)
246+
assertThat(firstEvent.totalTime).isCloseTo(
247+
firstInterval.inWholeMilliseconds,
248+
within(100L)
249+
)
250+
assertThat(firstEvent.incremental).isCloseTo(
251+
firstInterval.inWholeSeconds,
252+
within(1L)
253+
)
254+
255+
assertThat(secondEvent.data.timestamp).isCloseTo(
256+
(startTimestamp + firstInterval + secondInterval).inWholeMilliseconds,
257+
within(1.seconds.inWholeMilliseconds)
258+
)
259+
assertThat(secondEvent.totalTime).isCloseTo(
260+
(firstInterval + secondInterval).inWholeMilliseconds,
261+
within(100L)
262+
)
263+
assertThat(secondEvent.incremental).isCloseTo(
264+
secondInterval.inWholeSeconds,
265+
within(1L)
266+
)
267+
268+
assertThat(thirdEvent.data.timestamp).isCloseTo(
269+
(startTimestamp + firstInterval + secondInterval + pauseInterval).inWholeMilliseconds,
270+
within(1.seconds.inWholeMilliseconds)
271+
)
272+
assertThat(thirdEvent.totalTime).isCloseTo(
273+
(firstInterval + secondInterval + pauseInterval).inWholeMilliseconds,
274+
within(100L)
275+
)
276+
assertThat(thirdEvent.incremental).isCloseTo(
277+
(pauseInterval).inWholeSeconds,
278+
within(1L)
279+
)
280+
})
281+
}
282+
}
283+
116284
private fun RecordedRequest.toMap(): Map<String, List<Event>> {
117285
val listType: TypeReference<Map<String, List<Event>>> =
118286
object : TypeReference<Map<String, List<Event>>>() {}
@@ -123,6 +291,15 @@ class FunctionalTests {
123291
@JsonIgnoreProperties(ignoreUnknown = true)
124292
data class Event(
125293
@JsonProperty("idsite") var idsite: String,
294+
@JsonProperty("action") var action: String,
295+
@JsonProperty("data") var data: ExtraData,
296+
@JsonProperty("tt") var totalTime: Long,
297+
@JsonProperty("inc") var incremental: Long,
298+
)
299+
300+
@JsonIgnoreProperties(ignoreUnknown = true)
301+
data class ExtraData(
302+
@JsonProperty("ts") var timestamp: Long,
126303
)
127304

128305
private val locallyStoredEvents
@@ -137,19 +314,18 @@ class FunctionalTests {
137314
activity: Activity,
138315
flushInterval: Duration = defaultFlushInterval
139316
): ParselyTracker {
317+
val field: Field = ParselyTracker::class.java.getDeclaredField("ROOT_URL")
318+
field.isAccessible = true
319+
field.set(this, url)
140320
return ParselyTracker.sharedInstance(
141321
siteId, flushInterval.inWholeSeconds.toInt(), activity.application
142-
).apply {
143-
val f: Field = this::class.java.getDeclaredField("ROOT_URL")
144-
f.isAccessible = true
145-
f.set(this, url)
146-
}
322+
)
147323
}
148324

149325
private companion object {
150326
const val siteId = "123"
151327
const val localStorageFileName = "parsely-events.ser"
152-
val defaultFlushInterval = 10.seconds
328+
val defaultFlushInterval = 5.seconds
153329
}
154330

155331
class SampleActivity : Activity()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.parsely.parselyandroid
2+
3+
import android.content.Context
4+
import android.provider.Settings
5+
import com.google.android.gms.ads.identifier.AdvertisingIdClient
6+
import kotlinx.coroutines.CoroutineScope
7+
import kotlinx.coroutines.launch
8+
9+
internal class AdvertisementIdProvider(
10+
private val context: Context,
11+
coroutineScope: CoroutineScope
12+
) : IdProvider {
13+
14+
private var adKey: String? = null
15+
16+
init {
17+
coroutineScope.launch {
18+
try {
19+
adKey = AdvertisingIdClient.getAdvertisingIdInfo(context).id
20+
} catch (e: Exception) {
21+
ParselyTracker.PLog("No Google play services or error!")
22+
}
23+
}
24+
}
25+
26+
/**
27+
* @return advertisement id if the coroutine in the constructor finished executing AdvertisingIdClient#getAdvertisingIdInfo
28+
* null otherwise
29+
*/
30+
override fun provide(): String? = adKey
31+
}
32+
33+
internal class AndroidIdProvider(private val context: Context) : IdProvider {
34+
override fun provide(): String? {
35+
val uuid = try {
36+
Settings.Secure.getString(
37+
context.applicationContext.contentResolver,
38+
Settings.Secure.ANDROID_ID
39+
)
40+
} catch (ex: Exception) {
41+
null
42+
}
43+
ParselyTracker.PLog(String.format("Android ID: %s", uuid))
44+
return uuid
45+
}
46+
}
47+
48+
internal fun interface IdProvider {
49+
fun provide(): String?
50+
}

0 commit comments

Comments
 (0)