diff --git a/parsely/build.gradle b/parsely/build.gradle index c1206c88..5062f878 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -6,6 +6,7 @@ plugins { ext { assertJVersion = '3.24.2' + coroutinesVersion = '1.7.3' mockWebServerVersion = '4.12.0' } @@ -63,12 +64,14 @@ dependencies { implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.3' implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1' implementation 'androidx.lifecycle:lifecycle-process:2.6.2' + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" testImplementation 'org.robolectric:robolectric:4.10.3' testImplementation 'androidx.test:core:1.5.0' testImplementation "org.assertj:assertj-core:$assertJVersion" testImplementation 'junit:junit:4.13.2' testImplementation "com.squareup.okhttp3:mockwebserver:$mockWebServerVersion" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test:rules:1.5.0' diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index e82fd24e..8c939c6d 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -12,6 +12,7 @@ import java.io.FileInputStream import java.io.ObjectInputStream import java.lang.reflect.Field import java.nio.file.Path +import java.util.concurrent.TimeUnit import kotlin.io.path.Path import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -70,6 +71,47 @@ class FunctionalTests { } } + /** + * In this scenario, the consumer app tracks 2 events during the first flush interval. + * Then, we validate, that after flush interval passed the SDK sends the events + * to Parse.ly servers. + * + * Then, the consumer app tracks another event and we validate that the SDK sends the event + * to Parse.ly servers as well. + */ + @Test + fun appFlushesEventsAfterFlushInterval() { + ActivityScenario.launch(SampleActivity::class.java).use { scenario -> + scenario.onActivity { activity: Activity -> + beforeEach(activity) + server.enqueue(MockResponse().setResponseCode(200)) + parselyTracker = initializeTracker(activity) + + parselyTracker.trackPageview("url", null, null, null) + } + + Thread.sleep((flushInterval / 2).inWholeMilliseconds) + + scenario.onActivity { + parselyTracker.trackPageview("url", null, null, null) + } + + Thread.sleep((flushInterval / 2).inWholeMilliseconds) + + val firstRequestPayload = server.takeRequest(2000, TimeUnit.MILLISECONDS)?.toMap() + assertThat(firstRequestPayload!!["events"]).hasSize(2) + + scenario.onActivity { + parselyTracker.trackPageview("url", null, null, null) + } + + Thread.sleep(flushInterval.inWholeMilliseconds) + + val secondRequestPayload = server.takeRequest(2000, TimeUnit.MILLISECONDS)?.toMap() + assertThat(secondRequestPayload!!["events"]).hasSize(1) + } + } + private fun RecordedRequest.toMap(): Map> { val listType: TypeReference>> = object : TypeReference>>() {} @@ -103,7 +145,7 @@ class FunctionalTests { private companion object { const val siteId = "123" const val localStorageFileName = "parsely-events.ser" - val flushInterval = 10.seconds + val flushInterval = 5.seconds } class SampleActivity : Activity() diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt new file mode 100644 index 00000000..121b6bf9 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt @@ -0,0 +1,38 @@ +package com.parsely.parselyandroid + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Manager for the event flush timer. + * + * + * Handles stopping and starting the flush timer. The flush timer + * controls how often we send events to Parse.ly servers. + */ +internal class FlushManager( + private val parselyTracker: ParselyTracker, + val intervalMillis: Long, + private val coroutineScope: CoroutineScope +) { + private var job: Job? = null + + fun start() { + if (job?.isActive == true) return + + job = coroutineScope.launch { + while (isActive) { + delay(intervalMillis) + parselyTracker.flushEvents() + } + } + } + + fun stop() = job?.cancel() + + val isRunning: Boolean + get() = job?.isActive ?: false +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyCoroutineScope.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyCoroutineScope.kt new file mode 100644 index 00000000..d36b4bcb --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyCoroutineScope.kt @@ -0,0 +1,7 @@ +package com.parsely.parselyandroid + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 7784970e..3b571229 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -43,7 +43,6 @@ import java.util.HashSet; import java.util.Map; import java.util.Timer; -import java.util.TimerTask; import java.util.UUID; /** @@ -87,7 +86,8 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { eventQueue = new ArrayList<>(); - flushManager = new FlushManager(timer, flushInterval * 1000L); + flushManager = new FlushManager(this, flushInterval * 1000L, + ParselyCoroutineScopeKt.getSdkScope()); if (getStoredQueue().size() > 0) { startFlushTimer(); @@ -668,56 +668,7 @@ protected synchronized Void doInBackground(Void... params) { } } - /** - * Manager for the event flush timer. - *

- * Handles stopping and starting the flush timer. The flush timer - * controls how often we send events to Parse.ly servers. - */ - private class FlushManager { - - private final Timer parentTimer; - private final long intervalMillis; - private TimerTask runningTask; - - public FlushManager(Timer parentTimer, long intervalMillis) { - this.parentTimer = parentTimer; - this.intervalMillis = intervalMillis; - } - - public void start() { - if (runningTask != null) { - return; - } - - runningTask = new TimerTask() { - public void run() { - flushEvents(); - } - }; - parentTimer.scheduleAtFixedRate(runningTask, intervalMillis, intervalMillis); - } - - public boolean stop() { - if (runningTask == null) { - return false; - } else { - boolean output = runningTask.cancel(); - runningTask = null; - return output; - } - } - - public boolean isRunning() { - return runningTask != null; - } - - public long getIntervalMillis() { - return intervalMillis; - } - } - - private void flushEvents() { + void flushEvents() { new FlushQueue().execute(); } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt new file mode 100644 index 00000000..cf2ef157 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt @@ -0,0 +1,106 @@ +package com.parsely.parselyandroid + +import androidx.test.core.app.ApplicationProvider +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class FlushManagerTest { + + private lateinit var sut: FlushManager + private val tracker = FakeTracker() + + @Test + fun `when timer starts and interval time passes, then flush queue`() = runTest { + sut = FlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + + sut.start() + advanceTimeBy(DEFAULT_INTERVAL_MILLIS) + runCurrent() + + assertThat(tracker.flushEventsCounter).isEqualTo(1) + } + + @Test + fun `when timer starts and three interval time passes, then flush queue 3 times`() = runTest { + sut = FlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + + sut.start() + advanceTimeBy(3 * DEFAULT_INTERVAL_MILLIS) + runCurrent() + + assertThat(tracker.flushEventsCounter).isEqualTo(3) + } + + @Test + fun `when timer starts and is stopped after 2 intervals passes, then flush queue 2 times`() = + runTest { + sut = FlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + + sut.start() + advanceTimeBy(2 * DEFAULT_INTERVAL_MILLIS) + runCurrent() + sut.stop() + advanceTimeBy(DEFAULT_INTERVAL_MILLIS) + runCurrent() + + assertThat(tracker.flushEventsCounter).isEqualTo(2) + } + + @Test + fun `when timer starts, is stopped before end of interval and then time of interval passes, then do not flush queue`() = + runTest { + sut = FlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + + sut.start() + advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) + runCurrent() + sut.stop() + advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) + runCurrent() + + assertThat(tracker.flushEventsCounter).isEqualTo(0) + } + + @Test + fun `when timer starts, and another timer starts after some time, then flush queue according to the first start`() = + runTest { + sut = FlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + + sut.start() + advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) + runCurrent() + sut.start() + advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) + runCurrent() + + assertThat(tracker.flushEventsCounter).isEqualTo(1) + + advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) + runCurrent() + + assertThat(tracker.flushEventsCounter).isEqualTo(1) + } + + private companion object { + val DEFAULT_INTERVAL_MILLIS: Long = 30.seconds.inWholeMilliseconds + } + + class FakeTracker : ParselyTracker( + "", 0, ApplicationProvider.getApplicationContext() + ) { + var flushEventsCounter = 0 + + override fun flushEvents() { + flushEventsCounter++ + } + } +}