From 0395b314a126ebf15b08a0261774737260850a50 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 31 Oct 2023 17:06:00 +0100 Subject: [PATCH 001/101] tests: add functional test for validating flushing queue --- .../parsely/parselyandroid/FunctionalTests.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index e82fd24e..ea3feb1c 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(100, TimeUnit.MILLISECONDS)?.toMap() + assertThat(firstRequestPayload!!["events"]).hasSize(2) + + scenario.onActivity { + parselyTracker.trackPageview("url", null, null, null) + } + + Thread.sleep(flushInterval.inWholeMilliseconds) + + val secondRequestPayload = server.takeRequest(100, TimeUnit.MILLISECONDS)?.toMap() + assertThat(secondRequestPayload!!["events"]).hasSize(1) + } + } + private fun RecordedRequest.toMap(): Map> { val listType: TypeReference>> = object : TypeReference>>() {} From 4d5ca5d885ef3d8cd1da88d3016689224727ac5a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 10:57:24 +0100 Subject: [PATCH 002/101] refactor: extract `FlushManager` to separate class --- .../parsely/parselyandroid/FlushManager.java | 55 +++++++++++++++++++ .../parselyandroid/ParselyTracker.java | 54 +----------------- 2 files changed, 57 insertions(+), 52 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/FlushManager.java diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.java b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.java new file mode 100644 index 00000000..c3cbfe02 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.java @@ -0,0 +1,55 @@ +package com.parsely.parselyandroid; + +import java.util.Timer; +import java.util.TimerTask; + +/** + * 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. + */ +class FlushManager { + + private final ParselyTracker parselyTracker; + private final Timer parentTimer; + private final long intervalMillis; + private TimerTask runningTask; + + public FlushManager(ParselyTracker parselyTracker, Timer parentTimer, long intervalMillis) { + this.parselyTracker = parselyTracker; + this.parentTimer = parentTimer; + this.intervalMillis = intervalMillis; + } + + public void start() { + if (runningTask != null) { + return; + } + + runningTask = new TimerTask() { + public void run() { + parselyTracker.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; + } +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 7784970e..97ae8a2d 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,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { eventQueue = new ArrayList<>(); - flushManager = new FlushManager(timer, flushInterval * 1000L); + flushManager = new FlushManager(this, timer, flushInterval * 1000L); if (getStoredQueue().size() > 0) { startFlushTimer(); @@ -668,56 +667,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(); } From 8b9a247c729974700373ee7ef5eaf022cdd2752e Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 11:08:07 +0100 Subject: [PATCH 003/101] Rename .java to .kt --- .../parsely/parselyandroid/{FlushManager.java => FlushManager.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename parsely/src/main/java/com/parsely/parselyandroid/{FlushManager.java => FlushManager.kt} (100%) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.java b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt similarity index 100% rename from parsely/src/main/java/com/parsely/parselyandroid/FlushManager.java rename to parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt From a5511de87031840ce225b61af5c25f524d4597c6 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 11:08:07 +0100 Subject: [PATCH 004/101] refactor: move the class to Kotlin This class is relatively simple and difficult to test because of `java.utils.Timer`. That's why I decided to make migration to Kotlin right away, without unit tests coverage first. --- .../parsely/parselyandroid/FlushManager.kt | 66 ++++++++----------- .../parselyandroid/ParselyTracker.java | 4 +- 2 files changed, 29 insertions(+), 41 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt index c3cbfe02..2415b0c9 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt @@ -1,55 +1,43 @@ -package com.parsely.parselyandroid; +package com.parsely.parselyandroid -import java.util.Timer; -import java.util.TimerTask; +import java.util.Timer +import java.util.TimerTask /** * 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. */ -class FlushManager { - - private final ParselyTracker parselyTracker; - private final Timer parentTimer; - private final long intervalMillis; - private TimerTask runningTask; - - public FlushManager(ParselyTracker parselyTracker, Timer parentTimer, long intervalMillis) { - this.parselyTracker = parselyTracker; - this.parentTimer = parentTimer; - this.intervalMillis = intervalMillis; - } - - public void start() { +internal class FlushManager( + private val parselyTracker: ParselyTracker, + private val parentTimer: Timer, + @JvmField val intervalMillis: Long +) { + private var runningTask: TimerTask? = null + fun start() { if (runningTask != null) { - return; + return } - - runningTask = new TimerTask() { - public void run() { - parselyTracker.flushEvents(); + runningTask = object : TimerTask() { + override fun run() { + parselyTracker.flushEvents() } - }; - parentTimer.scheduleAtFixedRate(runningTask, intervalMillis, intervalMillis); + } + parentTimer.scheduleAtFixedRate(runningTask, intervalMillis, intervalMillis) } - public boolean stop() { - if (runningTask == null) { - return false; + fun stop(): Boolean { + return if (runningTask == null) { + false } else { - boolean output = runningTask.cancel(); - runningTask = null; - return output; + val output = runningTask!!.cancel() + runningTask = null + output } } - public boolean isRunning() { - return runningTask != null; - } - - public long getIntervalMillis() { - return intervalMillis; - } -} + val isRunning: Boolean + get() = runningTask != null +} \ No newline at end of file diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 97ae8a2d..b81c16e7 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -192,7 +192,7 @@ public boolean videoIsActive() { * @return The interval at which the event queue is flushed to Parse.ly. */ public long getFlushInterval() { - return flushManager.getIntervalMillis() / 1000; + return flushManager.intervalMillis / 1000; } /** @@ -420,7 +420,7 @@ void enqueueEvent(Map event) { new QueueManager().execute(); if (!flushTimerIsActive()) { startFlushTimer(); - PLog("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000)); + PLog("Flush flushTimer set to %ds", (flushManager.intervalMillis / 1000)); } } From 3fc205fb51e6fe9a043adf2598524d75c5122815 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 14:03:09 +0100 Subject: [PATCH 005/101] refactor: make `stop` return `void` The returned `Boolean` wasn't used anywhere. --- .../main/java/com/parsely/parselyandroid/FlushManager.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt index 2415b0c9..e7748654 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt @@ -28,13 +28,10 @@ internal class FlushManager( parentTimer.scheduleAtFixedRate(runningTask, intervalMillis, intervalMillis) } - fun stop(): Boolean { - return if (runningTask == null) { - false - } else { - val output = runningTask!!.cancel() + fun stop() { + if (runningTask != null) { + runningTask!!.cancel() runningTask = null - output } } From 171ed1071374f023cc1e0b646ea6330b7f0f6b90 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 18:08:09 +0100 Subject: [PATCH 006/101] build: add coroutines dependency --- parsely/build.gradle | 3 +++ 1 file changed, 3 insertions(+) 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' From 7f3e28b5a4de52212068d114463cb7f8469d7efb Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 18:09:09 +0100 Subject: [PATCH 007/101] feat: rewrite `FlushManager` to coroutines --- .../parsely/parselyandroid/FlushManager.kt | 32 +++++++++++-------- .../parselyandroid/ParselyCoroutineScope.kt | 7 ++++ .../parselyandroid/ParselyTracker.java | 7 ++-- 3 files changed, 29 insertions(+), 17 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/ParselyCoroutineScope.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt index e7748654..da359be7 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt @@ -1,7 +1,10 @@ package com.parsely.parselyandroid -import java.util.Timer -import java.util.TimerTask +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. @@ -12,29 +15,30 @@ import java.util.TimerTask */ internal class FlushManager( private val parselyTracker: ParselyTracker, - private val parentTimer: Timer, - @JvmField val intervalMillis: Long + val intervalMillis: Long, + private val coroutineScope: CoroutineScope ) { - private var runningTask: TimerTask? = null + private var job: Job? = null + fun start() { - if (runningTask != null) { + if (job?.isActive == true) { return } - runningTask = object : TimerTask() { - override fun run() { + job = coroutineScope.launch { + while (isActive) { + delay(intervalMillis) parselyTracker.flushEvents() } } - parentTimer.scheduleAtFixedRate(runningTask, intervalMillis, intervalMillis) } fun stop() { - if (runningTask != null) { - runningTask!!.cancel() - runningTask = null + if (job != null) { + job!!.cancel() + job = null } } val isRunning: Boolean - get() = runningTask != null -} \ No newline at end of file + 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 b81c16e7..3b571229 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -86,7 +86,8 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { eventQueue = new ArrayList<>(); - flushManager = new FlushManager(this, timer, flushInterval * 1000L); + flushManager = new FlushManager(this, flushInterval * 1000L, + ParselyCoroutineScopeKt.getSdkScope()); if (getStoredQueue().size() > 0) { startFlushTimer(); @@ -192,7 +193,7 @@ public boolean videoIsActive() { * @return The interval at which the event queue is flushed to Parse.ly. */ public long getFlushInterval() { - return flushManager.intervalMillis / 1000; + return flushManager.getIntervalMillis() / 1000; } /** @@ -420,7 +421,7 @@ void enqueueEvent(Map event) { new QueueManager().execute(); if (!flushTimerIsActive()) { startFlushTimer(); - PLog("Flush flushTimer set to %ds", (flushManager.intervalMillis / 1000)); + PLog("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000)); } } From ae2bac5fd5cb98c86d5d66797afec3225cafd12d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 18:09:31 +0100 Subject: [PATCH 008/101] tests: add unit tests for `FlushManager` --- .../parselyandroid/FlushManagerTest.kt | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt 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..6500fbb8 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt @@ -0,0 +1,88 @@ +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) + } + + private companion object { + val DEFAULT_INTERVAL_MILLIS: Long = 30.seconds.inWholeMilliseconds + } + + class FakeTracker : ParselyTracker( + "", + 0, + ApplicationProvider.getApplicationContext() + ) { + var flushEventsCounter = 0 + + override fun flushEvents() { + flushEventsCounter++ + } + } +} From 9b9e5270132e54c1814cab7b6639014d8ad8cf84 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 18:23:49 +0100 Subject: [PATCH 009/101] tests: "double start" `FlushManager` test case Added a new test to validate the behavior of the FlushManager's queue when two timers are initiated sequentially. The test asserts that the queue flushes correctly as per the time of the first timer's start. --- .../parselyandroid/FlushManagerTest.kt | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt index 6500fbb8..cf2ef157 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt @@ -70,14 +70,32 @@ class FlushManagerTest { 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() + "", 0, ApplicationProvider.getApplicationContext() ) { var flushEventsCounter = 0 From 4f3f4c1a6423d3c88da1dd062222770490aa5202 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 18:24:17 +0100 Subject: [PATCH 010/101] refactor: remove unnecessary `null` check on `job` --- .../src/main/java/com/parsely/parselyandroid/FlushManager.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt index da359be7..b640378f 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt @@ -33,10 +33,7 @@ internal class FlushManager( } fun stop() { - if (job != null) { - job!!.cancel() - job = null - } + job?.cancel() } val isRunning: Boolean From b5bc33eeaf310e47fae38f864c7cc4d4a79913ef Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 31 Oct 2023 17:08:30 +0100 Subject: [PATCH 011/101] tests: reduce time of default interval in tests to 5sec --- .../java/com/parsely/parselyandroid/FunctionalTests.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index ea3feb1c..9f74c5a2 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -145,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() From 13266445b8b60d611c3031b65f560fdd40ad6858 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 31 Oct 2023 17:24:37 +0100 Subject: [PATCH 012/101] tests: increase request waiting timeout to 2s To give SDK time to prepare and trigger the HTTP request. 100ms is to small timeout for CI emulator. It's enough for local builds and Firebase Test Lab tests. There's no business logic mistake - just the CI emulator is extremly slow. --- .../java/com/parsely/parselyandroid/FunctionalTests.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index 9f74c5a2..8c939c6d 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -98,7 +98,7 @@ class FunctionalTests { Thread.sleep((flushInterval / 2).inWholeMilliseconds) - val firstRequestPayload = server.takeRequest(100, TimeUnit.MILLISECONDS)?.toMap() + val firstRequestPayload = server.takeRequest(2000, TimeUnit.MILLISECONDS)?.toMap() assertThat(firstRequestPayload!!["events"]).hasSize(2) scenario.onActivity { @@ -107,7 +107,7 @@ class FunctionalTests { Thread.sleep(flushInterval.inWholeMilliseconds) - val secondRequestPayload = server.takeRequest(100, TimeUnit.MILLISECONDS)?.toMap() + val secondRequestPayload = server.takeRequest(2000, TimeUnit.MILLISECONDS)?.toMap() assertThat(secondRequestPayload!!["events"]).hasSize(1) } } From aad46293956d5bef5c670f271630a8ed7184a196 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 3 Nov 2023 14:33:29 +0100 Subject: [PATCH 013/101] style: simplify FlushManager to have one-liners --- .../main/java/com/parsely/parselyandroid/FlushManager.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt index b640378f..121b6bf9 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt @@ -21,9 +21,8 @@ internal class FlushManager( private var job: Job? = null fun start() { - if (job?.isActive == true) { - return - } + if (job?.isActive == true) return + job = coroutineScope.launch { while (isActive) { delay(intervalMillis) @@ -32,9 +31,7 @@ internal class FlushManager( } } - fun stop() { - job?.cancel() - } + fun stop() = job?.cancel() val isRunning: Boolean get() = job?.isActive ?: false From 00134455da7c073be0f6181ce4699d76712bfae4 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 6 Nov 2023 12:05:44 +0100 Subject: [PATCH 014/101] tests: add functional stress test --- .../parsely/parselyandroid/FunctionalTests.kt | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index 8c939c6d..cf91cb0a 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -14,8 +14,10 @@ import java.lang.reflect.Field import java.nio.file.Path import java.util.concurrent.TimeUnit import kotlin.io.path.Path +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +import kotlin.time.times import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield @@ -90,13 +92,13 @@ class FunctionalTests { parselyTracker.trackPageview("url", null, null, null) } - Thread.sleep((flushInterval / 2).inWholeMilliseconds) + Thread.sleep((defaultFlushInterval / 2).inWholeMilliseconds) scenario.onActivity { parselyTracker.trackPageview("url", null, null, null) } - Thread.sleep((flushInterval / 2).inWholeMilliseconds) + Thread.sleep((defaultFlushInterval / 2).inWholeMilliseconds) val firstRequestPayload = server.takeRequest(2000, TimeUnit.MILLISECONDS)?.toMap() assertThat(firstRequestPayload!!["events"]).hasSize(2) @@ -105,13 +107,47 @@ class FunctionalTests { parselyTracker.trackPageview("url", null, null, null) } - Thread.sleep(flushInterval.inWholeMilliseconds) + Thread.sleep(defaultFlushInterval.inWholeMilliseconds) val secondRequestPayload = server.takeRequest(2000, TimeUnit.MILLISECONDS)?.toMap() assertThat(secondRequestPayload!!["events"]).hasSize(1) } } + /** + * In this scenario we "stress test" the concurrency model to see if we have any conflict during + * + * - Unexpectedly high number of recorded events in small intervals (I/O locking) + * - Scenario in which a request is sent at the same time as new events are recorded + */ + @Test + fun stressTest() { + val stressMultiplier = 10 + val batchSize = 50 + + ActivityScenario.launch(SampleActivity::class.java).use { scenario -> + scenario.onActivity { activity: Activity -> + beforeEach(activity) + server.enqueue(MockResponse().setResponseCode(200)) + parselyTracker = initializeTracker(activity) + + repeat(stressMultiplier * batchSize) { + parselyTracker.trackPageview("url", null, null, null) + } + } + + Thread.sleep((stressMultiplier * defaultFlushInterval).inWholeMilliseconds) + + val requests = (1..stressMultiplier).mapNotNull { + runCatching { server.takeRequest(500, TimeUnit.MILLISECONDS) }.getOrNull() + }.flatMap { + it.toMap()["events"]!! + } + + assertThat(requests).hasSize(stressMultiplier * batchSize) + } + } + private fun RecordedRequest.toMap(): Map> { val listType: TypeReference>> = object : TypeReference>>() {} @@ -132,7 +168,10 @@ class FunctionalTests { } } - private fun initializeTracker(activity: Activity): ParselyTracker { + private fun initializeTracker( + activity: Activity, + flushInterval: Duration = defaultFlushInterval + ): ParselyTracker { return ParselyTracker.sharedInstance( siteId, flushInterval.inWholeSeconds.toInt(), activity.application ).apply { @@ -145,7 +184,7 @@ class FunctionalTests { private companion object { const val siteId = "123" const val localStorageFileName = "parsely-events.ser" - val flushInterval = 5.seconds + val defaultFlushInterval = 5.seconds } class SampleActivity : Activity() From cbae82da36ee63eb5459c3783d14e7262b4c9025 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 6 Nov 2023 14:09:27 +0100 Subject: [PATCH 015/101] refactor: replace `AsyncTask` with Kotlin Coroutines in `QueueManager` --- .../parselyandroid/ParselyTracker.java | 5 ++- .../parsely/parselyandroid/QueueManager.kt | 37 +++++++++++-------- .../parselyandroid/QueueManagerTest.kt | 34 ++++++++--------- 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 76869f87..acd6cf3f 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -67,6 +67,8 @@ public class ParselyTracker { private final HeartbeatIntervalCalculator intervalCalculator = new HeartbeatIntervalCalculator(new Clock()); @NonNull private final LocalStorageRepository localStorageRepository; + @NonNull + private final QueueManager queueManager; /** * Create a new ParselyTracker instance. @@ -75,6 +77,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); eventsBuilder = new EventsBuilder(context, siteId); localStorageRepository = new LocalStorageRepository(context); + queueManager = new QueueManager(this, localStorageRepository, ParselyCoroutineScopeKt.getSdkScope()); // get the adkey straight away on instantiation timer = new Timer(); @@ -418,7 +421,7 @@ public void resetVideo() { void enqueueEvent(Map event) { // Push it onto the queue eventQueue.add(event); - new QueueManager(this, localStorageRepository).execute(); + queueManager.validateQueue(); if (!flushTimerIsActive()) { startFlushTimer(); PLog("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000)); diff --git a/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt index 59d5401b..480a71f0 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt @@ -1,26 +1,33 @@ package com.parsely.parselyandroid -import android.os.AsyncTask +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock -@Suppress("DEPRECATION") internal class QueueManager( private val parselyTracker: ParselyTracker, - private val localStorageRepository: LocalStorageRepository -) : AsyncTask() { + private val localStorageRepository: LocalStorageRepository, + private val coroutineScope: CoroutineScope, +) { - @Deprecated("Deprecated in Java") - override fun doInBackground(vararg params: Void?): Void? { - // if event queue is too big, push to persisted storage - if (parselyTracker.inMemoryQueue.size > QUEUE_SIZE_LIMIT) { - ParselyTracker.PLog("Queue size exceeded, expelling oldest event to persistent memory") - localStorageRepository.persistQueue(parselyTracker.inMemoryQueue) - parselyTracker.inMemoryQueue.removeAt(0) - // if persisted storage is too big, expel one - if (parselyTracker.storedEventsCount() > STORAGE_SIZE_LIMIT) { - localStorageRepository.expelStoredEvent() + private val mutex = Mutex() + + fun validateQueue() { + coroutineScope.launch { + mutex.withLock { + if (parselyTracker.inMemoryQueue.size > QUEUE_SIZE_LIMIT) { + ParselyTracker.PLog("Queue size exceeded, expelling oldest event to persistent memory") + val copyInMemoryQueue = parselyTracker.inMemoryQueue.toList() + localStorageRepository.persistQueue(copyInMemoryQueue) + parselyTracker.inMemoryQueue.removeFirstOrNull() + // if persisted storage is too big, expel one + if (parselyTracker.storedEventsCount() > STORAGE_SIZE_LIMIT) { + localStorageRepository.expelStoredEvent() + } + } } } - return null } companion object { diff --git a/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt index 86613295..98465233 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt @@ -3,17 +3,17 @@ package com.parsely.parselyandroid import androidx.test.core.app.ApplicationProvider import com.parsely.parselyandroid.QueueManager.Companion.QUEUE_SIZE_LIMIT import com.parsely.parselyandroid.QueueManager.Companion.STORAGE_SIZE_LIMIT +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat -import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.LooperMode -import org.robolectric.shadows.ShadowLooper.shadowMainLooper -@Suppress("DEPRECATION") +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) -@LooperMode(LooperMode.Mode.PAUSED) internal class QueueManagerTest { private lateinit var sut: QueueManager @@ -21,20 +21,16 @@ internal class QueueManagerTest { private val tracker = FakeTracker() private val repository = FakeLocalRepository() - @Before - fun setUp() { - sut = QueueManager(tracker, repository) - } - @Test - fun `given the queue is smaller than any threshold, when querying flush manager, do nothing`() { + fun `given the queue is smaller than any threshold, when querying flush manager, do nothing`() = runTest { // given + sut = QueueManager(tracker, repository, this) val initialInMemoryQueue = listOf(mapOf("test" to "test")) tracker.applyFakeQueue(initialInMemoryQueue) // when - sut.execute().get() - shadowMainLooper().idle(); + sut.validateQueue() + runCurrent() // then assertThat(tracker.inMemoryQueue).isEqualTo(initialInMemoryQueue) @@ -42,14 +38,15 @@ internal class QueueManagerTest { } @Test - fun `given the in-memory queue is above the in-memory limit, when querying flush manager, then save queue to local storage and remove first event`() { + fun `given the in-memory queue is above the in-memory limit, when querying flush manager, then save queue to local storage and remove first event`() = runTest { // given + sut = QueueManager(tracker, repository, this) val initialInMemoryQueue = (1..QUEUE_SIZE_LIMIT + 1).map { mapOf("test" to it) } tracker.applyFakeQueue(initialInMemoryQueue) // when - sut.execute().get() - shadowMainLooper().idle(); + sut.validateQueue() + runCurrent() // then assertThat(repository.getStoredQueue()).isEqualTo(initialInMemoryQueue) @@ -57,16 +54,17 @@ internal class QueueManagerTest { } @Test - fun `given the in-memory queue is above the in-memory limit and stored events queue is above stored-queue limit, when querying flush manager, then expel the last event from local storage`() { + fun `given the in-memory queue is above the in-memory limit and stored events queue is above stored-queue limit, when querying flush manager, then expel the last event from local storage`() = runTest { // given + sut = QueueManager(tracker, repository, this) val initialInMemoryQueue = (1..QUEUE_SIZE_LIMIT + 1).map { mapOf("in memory" to it) } tracker.applyFakeQueue(initialInMemoryQueue) val initialStoredQueue = (1..STORAGE_SIZE_LIMIT + 1).map { mapOf("storage" to it) } repository.persistQueue(initialStoredQueue) // when - sut.execute().get() - shadowMainLooper().idle(); + sut.validateQueue() + runCurrent() // then assertThat(repository.wasEventExpelled).isTrue From d3b77262fc6a10ec9d7bfd9749806c579f738b6d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 6 Nov 2023 15:00:01 +0100 Subject: [PATCH 016/101] fix: close file streams when finished using --- .../java/com/parsely/parselyandroid/LocalStorageRepository.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index 1f4f903b..a9f8e626 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -21,6 +21,7 @@ internal open class LocalStorageRepository(private val context: Context) { val oos = ObjectOutputStream(fos) oos.writeObject(o) oos.close() + fos.close() } catch (ex: Exception) { ParselyTracker.PLog("Exception thrown during queue serialization: %s", ex.toString()) } @@ -46,6 +47,7 @@ internal open class LocalStorageRepository(private val context: Context) { @Suppress("UNCHECKED_CAST") storedQueue = ois.readObject() as ArrayList?> ois.close() + fis.close() } catch (ex: EOFException) { // Nothing to do here. } catch (ex: FileNotFoundException) { From e9d88d63827d46fef00e55fd973a63b84a3932a8 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 13:42:50 +0100 Subject: [PATCH 017/101] feat: add `remove` and `persistEvent` methods to `LocalStorageRepository` --- .../parselyandroid/LocalStorageRepository.kt | 10 ++++++ .../LocalStorageRepositoryTest.kt | 31 ++++++++++++------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index a9f8e626..84ae977a 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -34,6 +34,10 @@ internal open class LocalStorageRepository(private val context: Context) { persistObject(ArrayList>()) } + fun remove(toRemove: List>) { + persistObject(getStoredQueue() - toRemove.toSet()) + } + /** * Get the stored event queue from persistent storage. * @@ -69,6 +73,12 @@ internal open class LocalStorageRepository(private val context: Context) { storedQueue.removeAt(0) } + open fun persistEvent(event: Map) { + val storedQueue = getStoredQueue() + ParselyTracker.PLog("Persisting event queue. Current size: ${storedQueue.size}") + persistObject(ArrayList(storedQueue.plus(event).distinct())) + } + /** * Save the event queue to persistent storage. */ diff --git a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt index 2879fdab..97a63e7c 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt @@ -23,7 +23,9 @@ class LocalStorageRepositoryTest { @Test fun `when expelling stored event, then assert that it has no effect`() { // given - sut.persistQueue((1..100).map { mapOf("index" to it) }) + ((1..100).map { mapOf("index" to it) }).forEach { + sut.persistEvent(it) + } // when sut.expelStoredEvent() @@ -38,7 +40,9 @@ class LocalStorageRepositoryTest { val eventsList = (1..10).map { mapOf("index" to it) } // when - sut.persistQueue(eventsList) + eventsList.forEach { + sut.persistEvent(it) + } // then assertThat(sut.getStoredQueue()).hasSize(10).containsExactlyInAnyOrderElementsOf(eventsList) @@ -50,14 +54,14 @@ class LocalStorageRepositoryTest { } @Test - fun `given stored queue with some elements, when persisting in-memory queue, then assert there'll be no duplicates and queues will be combined`() { + fun `given stored queue with some elements, when persisting an event, then assert there'll be no duplicates`() { // given val storedQueue = (1..5).map { mapOf("index" to it) } - val inMemoryQueue = (3..10).map { mapOf("index" to it) } - sut.persistQueue(storedQueue) + val newEvents = (3..10).map { mapOf("index" to it) } + storedQueue.forEach { sut.persistEvent(it) } // when - sut.persistQueue(inMemoryQueue) + newEvents.forEach { sut.persistEvent(it) } // then val expectedQueue = (1..10).map { mapOf("index" to it) } @@ -65,16 +69,21 @@ class LocalStorageRepositoryTest { } @Test - fun `given stored queue, when purging stored queue, then assert queue is purged`() { + fun `given stored queue, when removing some events, then assert queue is doesn't contain removed events and contains not removed events`() { // given - val eventsList = (1..10).map { mapOf("index" to it) } - sut.persistQueue(eventsList) + val initialList = (1..10).map { mapOf("index" to it) } + initialList.forEach { sut.persistEvent(it) } + val eventsToRemove = initialList.slice(0..5) + val eventsToKeep = initialList.slice(6..9) // when - sut.purgeStoredQueue() + sut.remove(eventsToRemove) // then - assertThat(sut.getStoredQueue()).isEmpty() + assertThat(sut.getStoredQueue()) + .hasSize(4) + .containsExactlyInAnyOrderElementsOf(eventsToKeep) + .doesNotContainAnyElementsOf(eventsToRemove) } @Test From 71cc691ecd19b6f76ecd84ac047d7d358c9458ba Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 17:47:47 +0100 Subject: [PATCH 018/101] refactor: remove `QueueManager`. Replace with `InMemoryBuffer`. The logic of validating queue is no longer relevant as discussed internally. This commits removes `QueueManager` completely, and replaces it with a thread-safe `InMemoryBuffer`, which holds newly recorded events and, as soon as possible, adds them to local persistence. The new event not added to persistence right away, because the local storage file might be occupied/locked by, e.g. process/coroutine of sending events. We do not want to make consumer of the library to wait for adding a new event ever. Hence, the `InMemoryRepository#buffer`. --- .../parsely/parselyandroid/InMemoryBuffer.kt | 37 ++++++ .../parselyandroid/ParselyTracker.java | 9 +- .../parsely/parselyandroid/QueueManager.kt | 37 ------ .../parselyandroid/QueueManagerTest.kt | 106 ------------------ 4 files changed, 40 insertions(+), 149 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt delete mode 100644 parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt delete mode 100644 parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt new file mode 100644 index 00000000..610586af --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt @@ -0,0 +1,37 @@ +package com.parsely.parselyandroid + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class InMemoryBuffer( + private val coroutineScope: CoroutineScope, + private val localStorageRepository: LocalStorageRepository, +) { + + private val mutex = Mutex() + private val buffer = mutableListOf>() + + init { + coroutineScope.launch { + while (true) { + mutex.withLock { + if (buffer.isNotEmpty()) { + localStorageRepository.insertEvents(buffer) + buffer.clear() + } + } + } + } + } + + fun add(event: Map) { + coroutineScope.launch { + mutex.withLock { + ParselyTracker.PLog("Event added") + buffer.add(event) + } + } + } +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index acd6cf3f..109242c5 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -68,7 +68,7 @@ public class ParselyTracker { @NonNull private final LocalStorageRepository localStorageRepository; @NonNull - private final QueueManager queueManager; + private final InMemoryBuffer inMemoryBuffer; /** * Create a new ParselyTracker instance. @@ -77,7 +77,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); eventsBuilder = new EventsBuilder(context, siteId); localStorageRepository = new LocalStorageRepository(context); - queueManager = new QueueManager(this, localStorageRepository, ParselyCoroutineScopeKt.getSdkScope()); + inMemoryBuffer = new InMemoryBuffer(ParselyCoroutineScopeKt.getSdkScope(), localStorageRepository); // get the adkey straight away on instantiation timer = new Timer(); @@ -413,15 +413,12 @@ public void resetVideo() { *

* Place a data structure representing the event into the in-memory queue for later use. *

- * **Note**: Events placed into this queue will be discarded if the size of the persistent queue - * store exceeds {@link QueueManager#STORAGE_SIZE_LIMIT}. * * @param event The event Map to enqueue. */ void enqueueEvent(Map event) { // Push it onto the queue - eventQueue.add(event); - queueManager.validateQueue(); + inMemoryBuffer.add(event); if (!flushTimerIsActive()) { startFlushTimer(); PLog("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000)); diff --git a/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt deleted file mode 100644 index 480a71f0..00000000 --- a/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.parsely.parselyandroid - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -internal class QueueManager( - private val parselyTracker: ParselyTracker, - private val localStorageRepository: LocalStorageRepository, - private val coroutineScope: CoroutineScope, -) { - - private val mutex = Mutex() - - fun validateQueue() { - coroutineScope.launch { - mutex.withLock { - if (parselyTracker.inMemoryQueue.size > QUEUE_SIZE_LIMIT) { - ParselyTracker.PLog("Queue size exceeded, expelling oldest event to persistent memory") - val copyInMemoryQueue = parselyTracker.inMemoryQueue.toList() - localStorageRepository.persistQueue(copyInMemoryQueue) - parselyTracker.inMemoryQueue.removeFirstOrNull() - // if persisted storage is too big, expel one - if (parselyTracker.storedEventsCount() > STORAGE_SIZE_LIMIT) { - localStorageRepository.expelStoredEvent() - } - } - } - } - } - - companion object { - const val QUEUE_SIZE_LIMIT = 50 - const val STORAGE_SIZE_LIMIT = 100 - } -} diff --git a/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt deleted file mode 100644 index 98465233..00000000 --- a/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.parsely.parselyandroid - -import androidx.test.core.app.ApplicationProvider -import com.parsely.parselyandroid.QueueManager.Companion.QUEUE_SIZE_LIMIT -import com.parsely.parselyandroid.QueueManager.Companion.STORAGE_SIZE_LIMIT -import kotlinx.coroutines.ExperimentalCoroutinesApi -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 -import org.robolectric.annotation.LooperMode - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -internal class QueueManagerTest { - - private lateinit var sut: QueueManager - - private val tracker = FakeTracker() - private val repository = FakeLocalRepository() - - @Test - fun `given the queue is smaller than any threshold, when querying flush manager, do nothing`() = runTest { - // given - sut = QueueManager(tracker, repository, this) - val initialInMemoryQueue = listOf(mapOf("test" to "test")) - tracker.applyFakeQueue(initialInMemoryQueue) - - // when - sut.validateQueue() - runCurrent() - - // then - assertThat(tracker.inMemoryQueue).isEqualTo(initialInMemoryQueue) - assertThat(repository.getStoredQueue()).isEmpty() - } - - @Test - fun `given the in-memory queue is above the in-memory limit, when querying flush manager, then save queue to local storage and remove first event`() = runTest { - // given - sut = QueueManager(tracker, repository, this) - val initialInMemoryQueue = (1..QUEUE_SIZE_LIMIT + 1).map { mapOf("test" to it) } - tracker.applyFakeQueue(initialInMemoryQueue) - - // when - sut.validateQueue() - runCurrent() - - // then - assertThat(repository.getStoredQueue()).isEqualTo(initialInMemoryQueue) - assertThat(tracker.inMemoryQueue).hasSize(QUEUE_SIZE_LIMIT) - } - - @Test - fun `given the in-memory queue is above the in-memory limit and stored events queue is above stored-queue limit, when querying flush manager, then expel the last event from local storage`() = runTest { - // given - sut = QueueManager(tracker, repository, this) - val initialInMemoryQueue = (1..QUEUE_SIZE_LIMIT + 1).map { mapOf("in memory" to it) } - tracker.applyFakeQueue(initialInMemoryQueue) - val initialStoredQueue = (1..STORAGE_SIZE_LIMIT + 1).map { mapOf("storage" to it) } - repository.persistQueue(initialStoredQueue) - - // when - sut.validateQueue() - runCurrent() - - // then - assertThat(repository.wasEventExpelled).isTrue - } - - inner class FakeTracker : ParselyTracker( - "siteId", 10, ApplicationProvider.getApplicationContext() - ) { - - private var fakeQueue: List> = emptyList() - - internal override fun getInMemoryQueue(): List> = fakeQueue - - fun applyFakeQueue(fakeQueue: List>) { - this.fakeQueue = fakeQueue.toList() - } - - override fun storedEventsCount(): Int { - return repository.getStoredQueue().size - } - } - - class FakeLocalRepository : - LocalStorageRepository(ApplicationProvider.getApplicationContext()) { - - private var localFileQueue = emptyList?>() - var wasEventExpelled = false - - override fun persistQueue(inMemoryQueue: List?>) { - this.localFileQueue += inMemoryQueue - } - - override fun getStoredQueue() = ArrayList(localFileQueue) - - override fun expelStoredEvent() { - wasEventExpelled = true - } - } -} From 04877191351c14cd440837652fd2eacb0d5d6905 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 18:02:06 +0100 Subject: [PATCH 019/101] feat: make `LocalStorageRepository#insertEvents` thread safe. By using mutual exclusion (`Mutex`). Also, remove the `persistEvent` method, as it's no longer needed actually. --- .../parselyandroid/LocalStorageRepository.kt | 18 +++++------ .../LocalStorageRepositoryTest.kt | 31 +++++++++++-------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index 84ae977a..fa6f1ec0 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -5,8 +5,14 @@ import java.io.EOFException import java.io.FileNotFoundException import java.io.ObjectInputStream import java.io.ObjectOutputStream +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock internal open class LocalStorageRepository(private val context: Context) { + + private val mutex = Mutex() + /** * Persist an object to storage. * @@ -73,19 +79,13 @@ internal open class LocalStorageRepository(private val context: Context) { storedQueue.removeAt(0) } - open fun persistEvent(event: Map) { - val storedQueue = getStoredQueue() - ParselyTracker.PLog("Persisting event queue. Current size: ${storedQueue.size}") - persistObject(ArrayList(storedQueue.plus(event).distinct())) - } - /** * Save the event queue to persistent storage. */ - @Synchronized - open fun persistQueue(inMemoryQueue: List?>) { + open suspend fun insertEvents(toInsert: List?>) = mutex.withLock { + println("Test: ${currentCoroutineContext()}") ParselyTracker.PLog("Persisting event queue") - persistObject((inMemoryQueue + getStoredQueue()).distinct()) + persistObject(ArrayList((toInsert + getStoredQueue()).distinct())) } companion object { diff --git a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt index 97a63e7c..f92b0d59 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt @@ -3,12 +3,16 @@ package com.parsely.parselyandroid import android.content.Context import androidx.test.core.app.ApplicationProvider import java.io.File +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class LocalStorageRepositoryTest { @@ -21,11 +25,10 @@ class LocalStorageRepositoryTest { } @Test - fun `when expelling stored event, then assert that it has no effect`() { + fun `when expelling stored event, then assert that it has no effect`() = runTest { // given - ((1..100).map { mapOf("index" to it) }).forEach { - sut.persistEvent(it) - } + sut.insertEvents(((1..100).map { mapOf("index" to it) })) + runCurrent() // when sut.expelStoredEvent() @@ -35,14 +38,13 @@ class LocalStorageRepositoryTest { } @Test - fun `given the list of events, when persisting the list, then querying the list returns the same result`() { + fun `given the list of events, when persisting the list, then querying the list returns the same result`() = runTest { // given val eventsList = (1..10).map { mapOf("index" to it) } // when - eventsList.forEach { - sut.persistEvent(it) - } + sut.insertEvents(eventsList) + runCurrent() // then assertThat(sut.getStoredQueue()).hasSize(10).containsExactlyInAnyOrderElementsOf(eventsList) @@ -54,14 +56,16 @@ class LocalStorageRepositoryTest { } @Test - fun `given stored queue with some elements, when persisting an event, then assert there'll be no duplicates`() { + fun `given stored queue with some elements, when persisting an event, then assert there'll be no duplicates`() = runTest { // given val storedQueue = (1..5).map { mapOf("index" to it) } val newEvents = (3..10).map { mapOf("index" to it) } - storedQueue.forEach { sut.persistEvent(it) } + sut.insertEvents(storedQueue) + runCurrent() // when - newEvents.forEach { sut.persistEvent(it) } + sut.insertEvents(newEvents) + runCurrent() // then val expectedQueue = (1..10).map { mapOf("index" to it) } @@ -69,10 +73,11 @@ class LocalStorageRepositoryTest { } @Test - fun `given stored queue, when removing some events, then assert queue is doesn't contain removed events and contains not removed events`() { + fun `given stored queue, when removing some events, then assert queue is doesn't contain removed events and contains not removed events`() = runTest { // given val initialList = (1..10).map { mapOf("index" to it) } - initialList.forEach { sut.persistEvent(it) } + sut.insertEvents(initialList) + runCurrent() val eventsToRemove = initialList.slice(0..5) val eventsToKeep = initialList.slice(6..9) From 1905ffcee307cc689b23dae7cda548b6f92c561b Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 18:03:42 +0100 Subject: [PATCH 020/101] feat: make SDK's `CoroutineScope` `internal` Also, add a name for easier identification while debugging --- .../java/com/parsely/parselyandroid/ParselyCoroutineScope.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyCoroutineScope.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyCoroutineScope.kt index d36b4bcb..1c7be0fe 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyCoroutineScope.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyCoroutineScope.kt @@ -1,7 +1,9 @@ package com.parsely.parselyandroid +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) +internal val sdkScope = + CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineName("Parse.ly SDK Scope")) From 8a518cec533534a8bece1adaabc5eac3da941c87 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 19:22:44 +0100 Subject: [PATCH 021/101] feat: check the buffer every second To reduce overhead of constant loop, the `InMemoryBuffer` will check `buffer` list every second. --- .../main/java/com/parsely/parselyandroid/InMemoryBuffer.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt index 610586af..aabefd64 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt @@ -1,6 +1,9 @@ package com.parsely.parselyandroid +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -15,13 +18,14 @@ internal class InMemoryBuffer( init { coroutineScope.launch { - while (true) { + while (isActive) { mutex.withLock { if (buffer.isNotEmpty()) { localStorageRepository.insertEvents(buffer) buffer.clear() } } + delay(1.seconds) } } } From 01503d6e60f1412dba19e433e398bc5906002a11 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 19:23:00 +0100 Subject: [PATCH 022/101] tests: add unit tests for `InMemoryBuffer` --- .../parselyandroid/InMemoryBufferTest.kt | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt diff --git a/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt new file mode 100644 index 00000000..72fb7e52 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt @@ -0,0 +1,77 @@ +package com.parsely.parselyandroid + +import androidx.test.core.app.ApplicationProvider +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +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) +internal class InMemoryBufferTest { + + private lateinit var sut: InMemoryBuffer + private val repository = FakeLocalStorageRepository() + + @Test + fun `when adding a new event, then save it to local storage`() = runTest { + // given + val event = mapOf("test" to 123) + sut = InMemoryBuffer(backgroundScope, repository) + + // when + sut.add(event) + advanceTimeBy(1.seconds) + runCurrent() + backgroundScope.cancel() + + // then + assertThat(repository.getStoredQueue()).containsOnlyOnce(event) + } + + @Test + fun `when adding multiple events in different intervals, then save all of them to local storage without duplicates`() = + runTest { + // given + val events = (0..2).map { mapOf("test" to it) } + sut = InMemoryBuffer(backgroundScope, repository) + + // when + sut.add(events[0]) + advanceTimeBy(1.seconds) + runCurrent() + + sut.add(events[1]) + advanceTimeBy(0.5.seconds) + runCurrent() + + sut.add(events[2]) + advanceTimeBy(0.5.seconds) + runCurrent() + + backgroundScope.cancel() + + // then + assertThat(repository.getStoredQueue()).containsOnlyOnceElementsOf(events) + } + + class FakeLocalStorageRepository() : + LocalStorageRepository(ApplicationProvider.getApplicationContext()) { + + private val events = mutableListOf?>() + + override suspend fun insertEvents(toInsert: List?>) { + events.addAll(toInsert) + } + + override fun getStoredQueue(): ArrayList?> { + return ArrayList(events) + } + } +} From f963a0e727c1327f3b7a8702e63c23602f325966 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 19:38:24 +0100 Subject: [PATCH 023/101] feat: introduce listener after adding an event As `inMemoryBuffer` triggers asynchronous operation, this PR adds a listener that asserts starting flush queue after adding an event. --- .../parsely/parselyandroid/InMemoryBuffer.kt | 2 ++ .../parselyandroid/ParselyTracker.java | 19 ++++++++++++------- .../parselyandroid/InMemoryBufferTest.kt | 10 ++++++---- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt index aabefd64..6396d3a8 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.sync.withLock internal class InMemoryBuffer( private val coroutineScope: CoroutineScope, private val localStorageRepository: LocalStorageRepository, + private val onEventAddedListener: () -> Unit, ) { private val mutex = Mutex() @@ -35,6 +36,7 @@ internal class InMemoryBuffer( mutex.withLock { ParselyTracker.PLog("Event added") buffer.add(event) + onEventAddedListener() } } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 109242c5..faec62c2 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -40,6 +40,9 @@ import java.util.Timer; import java.util.UUID; +import kotlin.Unit; +import kotlin.jvm.functions.Function0; + /** * Tracks Parse.ly app views in Android apps *

@@ -77,7 +80,15 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); eventsBuilder = new EventsBuilder(context, siteId); localStorageRepository = new LocalStorageRepository(context); - inMemoryBuffer = new InMemoryBuffer(ParselyCoroutineScopeKt.getSdkScope(), localStorageRepository); + flushManager = new FlushManager(this, flushInterval * 1000L, + ParselyCoroutineScopeKt.getSdkScope()); + inMemoryBuffer = new InMemoryBuffer(ParselyCoroutineScopeKt.getSdkScope(), localStorageRepository, () -> { + if (!flushTimerIsActive()) { + startFlushTimer(); + PLog("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000)); + } + return Unit.INSTANCE; + }); // get the adkey straight away on instantiation timer = new Timer(); @@ -85,8 +96,6 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { eventQueue = new ArrayList<>(); - flushManager = new FlushManager(this, flushInterval * 1000L, - ParselyCoroutineScopeKt.getSdkScope()); if (localStorageRepository.getStoredQueue().size() > 0) { startFlushTimer(); @@ -419,10 +428,6 @@ public void resetVideo() { void enqueueEvent(Map event) { // Push it onto the queue inMemoryBuffer.add(event); - if (!flushTimerIsActive()) { - startFlushTimer(); - PLog("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000)); - } } /** diff --git a/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt index 72fb7e52..1925627e 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt @@ -20,10 +20,11 @@ internal class InMemoryBufferTest { private val repository = FakeLocalStorageRepository() @Test - fun `when adding a new event, then save it to local storage`() = runTest { + fun `when adding a new event, then save it to local storage and run onEventAdded listener`() = runTest { // given val event = mapOf("test" to 123) - sut = InMemoryBuffer(backgroundScope, repository) + var onEventAddedExecuted = false + sut = InMemoryBuffer(backgroundScope, repository) { onEventAddedExecuted = true } // when sut.add(event) @@ -33,6 +34,7 @@ internal class InMemoryBufferTest { // then assertThat(repository.getStoredQueue()).containsOnlyOnce(event) + assertThat(onEventAddedExecuted).isTrue() } @Test @@ -40,7 +42,7 @@ internal class InMemoryBufferTest { runTest { // given val events = (0..2).map { mapOf("test" to it) } - sut = InMemoryBuffer(backgroundScope, repository) + sut = InMemoryBuffer(backgroundScope, repository) {} // when sut.add(events[0]) @@ -61,7 +63,7 @@ internal class InMemoryBufferTest { assertThat(repository.getStoredQueue()).containsOnlyOnceElementsOf(events) } - class FakeLocalStorageRepository() : + class FakeLocalStorageRepository : LocalStorageRepository(ApplicationProvider.getApplicationContext()) { private val events = mutableListOf?>() From 54e66a9a3248e2e2d198aca03b3787ec1622e5dc Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 8 Nov 2023 08:15:53 +0100 Subject: [PATCH 024/101] ci: add androidTest-results to CI job artifacts --- .github/workflows/readme.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/readme.yml b/.github/workflows/readme.yml index 257d0df3..01172d57 100644 --- a/.github/workflows/readme.yml +++ b/.github/workflows/readme.yml @@ -72,4 +72,6 @@ jobs: if: always() with: name: artifact - path: ./parsely/build/reports/* + path: | + ./parsely/build/reports/* + ./parsely/build/outputs/androidTest-results From 52c7a43b98b7f8fd787fb7f6b54e8813f78eabdc Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 8 Nov 2023 08:59:17 +0100 Subject: [PATCH 025/101] tests: update stress test to not verify removed logic 50 events batches are no longer a thing since we removed `QueueManager` --- .../parsely/parselyandroid/FunctionalTests.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index cf91cb0a..aeef987b 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -122,8 +122,7 @@ class FunctionalTests { */ @Test fun stressTest() { - val stressMultiplier = 10 - val batchSize = 50 + val eventsToSend = 500 ActivityScenario.launch(SampleActivity::class.java).use { scenario -> scenario.onActivity { activity: Activity -> @@ -131,20 +130,23 @@ class FunctionalTests { server.enqueue(MockResponse().setResponseCode(200)) parselyTracker = initializeTracker(activity) - repeat(stressMultiplier * batchSize) { + repeat(eventsToSend) { parselyTracker.trackPageview("url", null, null, null) } } - Thread.sleep((stressMultiplier * defaultFlushInterval).inWholeMilliseconds) + // Wait some time to give events chance to be saved in local data storage + Thread.sleep((defaultFlushInterval * 2).inWholeMilliseconds) - val requests = (1..stressMultiplier).mapNotNull { - runCatching { server.takeRequest(500, TimeUnit.MILLISECONDS) }.getOrNull() + // Catch up to 10 requests. We don't know how many requests the device we test on will + // perform. It's probably more like 1-2, but we're on safe (not flaky) side here. + val requests = (1..10).mapNotNull { + runCatching { server.takeRequest(100, TimeUnit.MILLISECONDS) }.getOrNull() }.flatMap { it.toMap()["events"]!! } - assertThat(requests).hasSize(stressMultiplier * batchSize) + assertThat(requests).hasSize(eventsToSend) } } From 3e127baaadef5516013834eec38670d7524af6ce Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 8 Nov 2023 09:00:15 +0100 Subject: [PATCH 026/101] tests: update basic tracks events scenario 50 events batches are no longer a thing since we removed `QueueManager` --- .../java/com/parsely/parselyandroid/FunctionalTests.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index aeef987b..5dbaa607 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -17,7 +17,6 @@ import kotlin.io.path.Path import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -import kotlin.time.times import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield @@ -46,12 +45,12 @@ class FunctionalTests { } /** - * In this scenario, the consumer application tracks more than 50 events-threshold during a flush interval. + * In this scenario, the consumer application tracks 51 events-threshold during a flush interval. * The SDK will save the events to disk and send them in the next flush interval. * At the end, when all events are sent, the SDK will delete the content of local storage file. */ @Test - fun appTracksEventsAboveQueueSizeLimit() { + fun appTracksEventsDuringTheFlushInterval() { ActivityScenario.launch(SampleActivity::class.java).use { scenario -> scenario.onActivity { activity: Activity -> beforeEach(activity) From e1234ac0f4439ab8e3513282b08b40e98111ef12 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 8 Nov 2023 09:36:06 +0100 Subject: [PATCH 027/101] style: improve log messages --- .../src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt | 2 +- .../java/com/parsely/parselyandroid/LocalStorageRepository.kt | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt index 6396d3a8..b944f898 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt @@ -34,7 +34,7 @@ internal class InMemoryBuffer( fun add(event: Map) { coroutineScope.launch { mutex.withLock { - ParselyTracker.PLog("Event added") + ParselyTracker.PLog("Event added to buffer") buffer.add(event) onEventAddedListener() } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index fa6f1ec0..b0db6250 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -83,8 +83,7 @@ internal open class LocalStorageRepository(private val context: Context) { * Save the event queue to persistent storage. */ open suspend fun insertEvents(toInsert: List?>) = mutex.withLock { - println("Test: ${currentCoroutineContext()}") - ParselyTracker.PLog("Persisting event queue") + ParselyTracker.PLog("Persisting ${toInsert.size} events") persistObject(ArrayList((toInsert + getStoredQueue()).distinct())) } From 9efd59fd0e40d5cf81911ef1d00e8e7f6a9c195d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 8 Nov 2023 16:56:13 +0100 Subject: [PATCH 028/101] style: remove in-memory `eventQueue` All events are now add to `InMemoryBuffer` and then from there to local storage which is our SSOT for events that are about to be sent. --- .../parselyandroid/ParselyTracker.java | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index faec62c2..6a72bbb0 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -34,14 +34,11 @@ import java.util.ArrayList; import java.util.Formatter; import java.util.HashMap; -import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.Timer; import java.util.UUID; import kotlin.Unit; -import kotlin.jvm.functions.Function0; /** * Tracks Parse.ly app views in Android apps @@ -56,7 +53,6 @@ public class ParselyTracker { @SuppressWarnings("StringOperationCanBeSimplified") // private static final String ROOT_URL = "http://10.0.2.2:5001/".intern(); // emulator localhost private static final String ROOT_URL = "https://p1.parsely.com/".intern(); - private final ArrayList> eventQueue; private boolean isDebug; private final Context context; private final Timer timer; @@ -94,9 +90,6 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { timer = new Timer(); isDebug = false; - eventQueue = new ArrayList<>(); - - if (localStorageRepository.getStoredQueue().size() > 0) { startFlushTimer(); } @@ -110,10 +103,6 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { ); } - List> getInMemoryQueue() { - return eventQueue; - } - /** * Singleton instance accessor. Note: This must be called after {@link #sharedInstance(String, Context)} * @@ -480,7 +469,6 @@ private boolean isReachable() { } void purgeEventsQueue() { - eventQueue.clear(); localStorageRepository.purgeStoredQueue(); } @@ -541,7 +529,7 @@ private String generatePixelId() { * @return The number of events waiting to be flushed to Parsely. */ public int queueSize() { - return eventQueue.size(); + return localStorageRepository.getStoredQueue().size(); } /** @@ -558,9 +546,9 @@ private class FlushQueue extends AsyncTask { @Override protected synchronized Void doInBackground(Void... params) { ArrayList> storedQueue = localStorageRepository.getStoredQueue(); - PLog("%d events in queue, %d stored events", eventQueue.size(), storedEventsCount()); + PLog("%d events in stored queue", storedEventsCount()); // in case both queues have been flushed and app quits, don't crash - if ((eventQueue == null || eventQueue.size() == 0) && storedQueue.size() == 0) { + if (storedQueue.isEmpty()) { stopFlushTimer(); return null; } @@ -568,14 +556,9 @@ protected synchronized Void doInBackground(Void... params) { PLog("Network unreachable. Not flushing."); return null; } - HashSet> hs = new HashSet<>(); - ArrayList> newQueue = new ArrayList<>(); - hs.addAll(eventQueue); - hs.addAll(storedQueue); - newQueue.addAll(hs); PLog("Flushing queue"); - sendBatchRequest(newQueue); + sendBatchRequest(storedQueue); return null; } } From 5c999ccdd69c317b229887b890be3b27f3847027 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 09:23:14 +0100 Subject: [PATCH 029/101] refactor: extract json mapping logic to Kotlin object --- .../parsely/parselyandroid/JsonSerializer.kt | 26 +++++++++++++++++++ .../parselyandroid/ParselyTracker.java | 23 ++-------------- 2 files changed, 28 insertions(+), 21 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/JsonSerializer.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/JsonSerializer.kt b/parsely/src/main/java/com/parsely/parselyandroid/JsonSerializer.kt new file mode 100644 index 00000000..95d6b314 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/JsonSerializer.kt @@ -0,0 +1,26 @@ +package com.parsely.parselyandroid + +import com.fasterxml.jackson.databind.ObjectMapper +import java.io.IOException +import java.io.StringWriter + +internal object JsonSerializer { + /** + * Encode an event Map as JSON. + * + * @param map The Map object to encode as JSON. + * @return The JSON-encoded value of `map`. + */ + fun toJson(map: Map): String? { + val mapper = ObjectMapper() + var ret: String? = null + try { + val strWriter = StringWriter() + mapper.writeValue(strWriter, map) + ret = strWriter.toString() + } catch (e: IOException) { + e.printStackTrace() + } + return ret + } +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 6a72bbb0..57359dc6 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -450,10 +450,10 @@ private void sendBatchRequest(ArrayList> events) { PLog("Debug mode on. Not sending to Parse.ly"); purgeEventsQueue(); } else { - new ParselyAPIConnection(this).execute(ROOT_URL + "mobileproxy", JsonEncode(batchMap)); + new ParselyAPIConnection(this).execute(ROOT_URL + "mobileproxy", JsonSerializer.INSTANCE.toJson(batchMap)); PLog("Requested %s", ROOT_URL); } - PLog("POST Data %s", JsonEncode(batchMap)); + PLog("POST Data %s", JsonSerializer.INSTANCE.toJson(batchMap)); } /** @@ -472,25 +472,6 @@ void purgeEventsQueue() { localStorageRepository.purgeStoredQueue(); } - /** - * Encode an event Map as JSON. - * - * @param map The Map object to encode as JSON. - * @return The JSON-encoded value of `map`. - */ - private String JsonEncode(Map map) { - ObjectMapper mapper = new ObjectMapper(); - String ret = null; - try { - StringWriter strWriter = new StringWriter(); - mapper.writeValue(strWriter, map); - ret = strWriter.toString(); - } catch (IOException e) { - e.printStackTrace(); - } - return ret; - } - /** * Start the timer to flush events to Parsely. *

From de701d2cc30574810d191aac3a11d8dfca397ac9 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 09:59:01 +0100 Subject: [PATCH 030/101] refactor: introduce `SendEvents` use case with same logic as `ParselyTracker#sendBatchRequest` --- .../parselyandroid/ParselyTracker.java | 30 ++++------------ .../com/parsely/parselyandroid/SendEvents.kt | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 24 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 57359dc6..6849d662 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -27,13 +27,8 @@ import androidx.lifecycle.LifecycleEventObserver; import androidx.lifecycle.ProcessLifecycleOwner; -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.io.IOException; -import java.io.StringWriter; import java.util.ArrayList; import java.util.Formatter; -import java.util.HashMap; import java.util.Map; import java.util.Timer; import java.util.UUID; @@ -51,8 +46,8 @@ public class ParselyTracker { private static final int DEFAULT_FLUSH_INTERVAL_SECS = 60; private static final int DEFAULT_ENGAGEMENT_INTERVAL_MILLIS = 10500; @SuppressWarnings("StringOperationCanBeSimplified") -// private static final String ROOT_URL = "http://10.0.2.2:5001/".intern(); // emulator localhost - private static final String ROOT_URL = "https://p1.parsely.com/".intern(); +// static final String ROOT_URL = "http://10.0.2.2:5001/".intern(); // emulator localhost + static final String ROOT_URL = "https://p1.parsely.com/".intern(); private boolean isDebug; private final Context context; private final Timer timer; @@ -68,6 +63,8 @@ public class ParselyTracker { private final LocalStorageRepository localStorageRepository; @NonNull private final InMemoryBuffer inMemoryBuffer; + @NonNull + private final SendEvents sendEvents; /** * Create a new ParselyTracker instance. @@ -85,6 +82,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { } return Unit.INSTANCE; }); + sendEvents = new SendEvents(this, localStorageRepository); // get the adkey straight away on instantiation timer = new Timer(); @@ -437,23 +435,7 @@ public void flushEventQueue() { * @param events The list of event dictionaries to serialize */ private void sendBatchRequest(ArrayList> events) { - if (events == null || events.size() == 0) { - return; - } - PLog("Sending request with %d events", events.size()); - - // Put in a Map for the proxy server - Map batchMap = new HashMap<>(); - batchMap.put("events", events); - - if (isDebug) { - PLog("Debug mode on. Not sending to Parse.ly"); - purgeEventsQueue(); - } else { - new ParselyAPIConnection(this).execute(ROOT_URL + "mobileproxy", JsonSerializer.INSTANCE.toJson(batchMap)); - PLog("Requested %s", ROOT_URL); - } - PLog("POST Data %s", JsonSerializer.INSTANCE.toJson(batchMap)); + sendEvents.invoke(isDebug); } /** diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt new file mode 100644 index 00000000..476d5257 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt @@ -0,0 +1,34 @@ +package com.parsely.parselyandroid + +import com.parsely.parselyandroid.JsonSerializer.toJson + +internal class SendEvents( + private val parselyTracker: ParselyTracker, + private val localStorageRepository: LocalStorageRepository +) { + + operator fun invoke(isDebug: Boolean) { + val eventsToSend = localStorageRepository.getStoredQueue() + + if (eventsToSend.isEmpty()) { + return + } + ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) + + val batchMap: MutableMap = HashMap() + batchMap["events"] = eventsToSend + val jsonPayload = toJson(batchMap) + + if (isDebug) { + ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly") + localStorageRepository.purgeStoredQueue() + } else { + ParselyTracker.PLog("Requested %s", ParselyTracker.ROOT_URL) + ParselyAPIConnection(parselyTracker).execute( + ParselyTracker.ROOT_URL + "mobileproxy", + jsonPayload + ) + } + ParselyTracker.PLog("POST Data %s", toJson(batchMap)) + } +} From 4ae78b20778cc2e051938507cf5f7d337be9cbef Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 10:25:00 +0100 Subject: [PATCH 031/101] refactor: migrate `ParselyAPIConnection` to Coroutines --- .../parselyandroid/ParselyAPIConnection.kt | 62 +++++++++---------- .../parselyandroid/ParselyTracker.java | 2 +- .../com/parsely/parselyandroid/SendEvents.kt | 42 +++++++------ 3 files changed, 54 insertions(+), 52 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt index 8d0634bd..7f8a3d07 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt @@ -13,50 +13,48 @@ See the License for the specific language governing permissions and limitations under the License. */ -@file:Suppress("DEPRECATION") package com.parsely.parselyandroid -import android.os.AsyncTask +import com.parsely.parselyandroid.ParselyTracker.ROOT_URL import java.net.HttpURLConnection import java.net.URL - -internal class ParselyAPIConnection(private val tracker: ParselyTracker) : AsyncTask() { +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal class ParselyAPIConnection @JvmOverloads constructor( + private val url: String, + private val tracker: ParselyTracker, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) { private var exception: Exception? = null - @Deprecated("Deprecated in Java") - override fun doInBackground(vararg data: String?): Void? { - var connection: HttpURLConnection? = null - try { - if (data.size == 1) { // non-batched (since no post data is included) - connection = URL(data[0]).openConnection() as HttpURLConnection - connection.inputStream - } else if (data.size == 2) { // batched (post data included) - connection = URL(data[0]).openConnection() as HttpURLConnection - connection.doOutput = true // Triggers POST (aka silliest interface ever) + suspend fun send(payload: String) { + withContext(dispatcher) { + val connection: HttpURLConnection? + try { + connection = URL(url).openConnection() as HttpURLConnection + connection.doOutput = true connection.setRequestProperty("Content-Type", "application/json") val output = connection.outputStream - output.write(data[1]?.toByteArray()) + output.write(payload.toByteArray()) output.close() connection.inputStream + } catch (ex: Exception) { + exception = ex } - } catch (ex: Exception) { - exception = ex - } - return null - } - @Deprecated("Deprecated in Java") - override fun onPostExecute(result: Void?) { - if (exception != null) { - ParselyTracker.PLog("Pixel request exception") - ParselyTracker.PLog(exception.toString()) - } else { - ParselyTracker.PLog("Pixel request success") - - // only purge the queue if the request was successful - tracker.purgeEventsQueue() - ParselyTracker.PLog("Event queue empty, flush timer cleared.") - tracker.stopFlushTimer() + if (exception != null) { + ParselyTracker.PLog("Pixel request exception") + ParselyTracker.PLog(exception.toString()) + } else { + ParselyTracker.PLog("Pixel request success") + + // only purge the queue if the request was successful + tracker.purgeEventsQueue() + ParselyTracker.PLog("Event queue empty, flush timer cleared.") + tracker.stopFlushTimer() + } } } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 6849d662..3d7e01d7 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -82,7 +82,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { } return Unit.INSTANCE; }); - sendEvents = new SendEvents(this, localStorageRepository); + sendEvents = new SendEvents(this, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy", this), ParselyCoroutineScopeKt.getSdkScope()); // get the adkey straight away on instantiation timer = new Timer(); diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt index 476d5257..c18201cb 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt @@ -1,34 +1,38 @@ package com.parsely.parselyandroid import com.parsely.parselyandroid.JsonSerializer.toJson +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch internal class SendEvents( private val parselyTracker: ParselyTracker, - private val localStorageRepository: LocalStorageRepository + private val localStorageRepository: LocalStorageRepository, + private val parselyAPIConnection: ParselyAPIConnection, + private val scope: CoroutineScope ) { operator fun invoke(isDebug: Boolean) { - val eventsToSend = localStorageRepository.getStoredQueue() + scope.launch { + val eventsToSend = localStorageRepository.getStoredQueue() - if (eventsToSend.isEmpty()) { - return - } - ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) + if (eventsToSend.isEmpty()) { + return@launch + } + ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) + + val batchMap: MutableMap = HashMap() + batchMap["events"] = eventsToSend + val jsonPayload = toJson(batchMap).orEmpty() - val batchMap: MutableMap = HashMap() - batchMap["events"] = eventsToSend - val jsonPayload = toJson(batchMap) + if (isDebug) { + ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly") + localStorageRepository.purgeStoredQueue() + } else { + ParselyTracker.PLog("Requested %s", ParselyTracker.ROOT_URL) - if (isDebug) { - ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly") - localStorageRepository.purgeStoredQueue() - } else { - ParselyTracker.PLog("Requested %s", ParselyTracker.ROOT_URL) - ParselyAPIConnection(parselyTracker).execute( - ParselyTracker.ROOT_URL + "mobileproxy", - jsonPayload - ) + parselyAPIConnection.send(jsonPayload) + } + ParselyTracker.PLog("POST Data %s", toJson(batchMap)) } - ParselyTracker.PLog("POST Data %s", toJson(batchMap)) } } From 920e995306ce010948823edbe7b04642d837b442 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 10:34:34 +0100 Subject: [PATCH 032/101] test: update `ParselyAPIConnectionTest` to support Coroutines --- .../ParselyAPIConnectionTest.kt | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index 815abc93..80d461b1 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -1,6 +1,8 @@ package com.parsely.parselyandroid import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.assertj.core.api.Assertions.assertThat @@ -13,7 +15,6 @@ import org.robolectric.annotation.LooperMode import org.robolectric.shadows.ShadowLooper.shadowMainLooper @RunWith(RobolectricTestRunner::class) -@LooperMode(LooperMode.Mode.PAUSED) class ParselyAPIConnectionTest { private lateinit var sut: ParselyAPIConnection @@ -23,7 +24,7 @@ class ParselyAPIConnectionTest { @Before fun setUp() { - sut = ParselyAPIConnection(tracker) + sut = ParselyAPIConnection(url, tracker) } @After @@ -32,12 +33,12 @@ class ParselyAPIConnectionTest { } @Test - fun `given successful response, when making connection without any events, then make GET request`() { + fun `given successful response, when making connection without any events, then make GET request`() = runTest { // given mockServer.enqueue(MockResponse().setResponseCode(200)) // when - sut.execute(url).get() + sut.send("") shadowMainLooper().idle(); // then @@ -49,13 +50,13 @@ class ParselyAPIConnectionTest { } @Test - fun `given successful response, when making connection with events, then make POST request with JSON Content-Type header`() { + fun `given successful response, when making connection with events, then make POST request with JSON Content-Type header`() = runTest { // given mockServer.enqueue(MockResponse().setResponseCode(200)) // when - sut.execute(url, pixelPayload).get() - shadowMainLooper().idle(); + sut.send(pixelPayload) + runCurrent() // then assertThat(mockServer.takeRequest()).satisfies({ @@ -66,14 +67,14 @@ class ParselyAPIConnectionTest { } @Test - fun `given successful response, when request is made, then purge events queue and stop flush timer`() { + fun `given successful response, when request is made, then purge events queue and stop flush timer`() = runTest { // given mockServer.enqueue(MockResponse().setResponseCode(200)) tracker.events.add(mapOf("idsite" to "example.com")) // when - sut.execute(url).get() - shadowMainLooper().idle(); + sut.send(pixelPayload) + runCurrent() // then assertThat(tracker.events).isEmpty() @@ -81,15 +82,15 @@ class ParselyAPIConnectionTest { } @Test - fun `given unsuccessful response, when request is made, then do not purge events queue and do not stop flush timer`() { + fun `given unsuccessful response, when request is made, then do not purge events queue and do not stop flush timer`() = runTest { // given mockServer.enqueue(MockResponse().setResponseCode(400)) val sampleEvents = mapOf("idsite" to "example.com") tracker.events.add(sampleEvents) // when - sut.execute(url).get() - shadowMainLooper().idle(); + sut.send(pixelPayload) + runCurrent() // then assertThat(tracker.events).containsExactly(sampleEvents) From 17e7ecd107e48cdd0a96833e4917fff29f95c68b Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 10:47:34 +0100 Subject: [PATCH 033/101] tests: remove unit tests that checks for `GET` request on empty payload This behavior was intentionally removed in 4ae78b2. It's not present on iOS. --- .../parselyandroid/ParselyAPIConnectionTest.kt | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index 80d461b1..8356a65e 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -32,23 +32,6 @@ class ParselyAPIConnectionTest { mockServer.shutdown() } - @Test - fun `given successful response, when making connection without any events, then make GET request`() = runTest { - // given - mockServer.enqueue(MockResponse().setResponseCode(200)) - - // when - sut.send("") - shadowMainLooper().idle(); - - // then - val request = mockServer.takeRequest() - assertThat(request).satisfies({ - assertThat(it.method).isEqualTo("GET") - assertThat(it.failure).isNull() - }) - } - @Test fun `given successful response, when making connection with events, then make POST request with JSON Content-Type header`() = runTest { // given From 5bff337cd1b37ac22571aac23c52fa83d2e66821 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 10:59:09 +0100 Subject: [PATCH 034/101] tests: update `ROOT_URL` before `ParselyTracker` constructor Now, as `ROOT_URL` is used inside the constructor, we have to change this field **before** the `ParselyTracker` is initialized --- .../java/com/parsely/parselyandroid/FunctionalTests.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index 48bf7503..ead1058c 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -214,13 +214,12 @@ class FunctionalTests { activity: Activity, flushInterval: Duration = defaultFlushInterval ): ParselyTracker { + val field: Field = ParselyTracker::class.java.getDeclaredField("ROOT_URL") + field.isAccessible = true + field.set(this, url) return ParselyTracker.sharedInstance( siteId, flushInterval.inWholeSeconds.toInt(), activity.application - ).apply { - val f: Field = this::class.java.getDeclaredField("ROOT_URL") - f.isAccessible = true - f.set(this, url) - } + ) } private companion object { From 51c89b9f077d145d23794f5b720649538a1ec3d4 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 11:04:59 +0100 Subject: [PATCH 035/101] feat: closing the connection after successful request --- .../java/com/parsely/parselyandroid/ParselyAPIConnection.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt index 7f8a3d07..00195ca5 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt @@ -31,7 +31,7 @@ internal class ParselyAPIConnection @JvmOverloads constructor( suspend fun send(payload: String) { withContext(dispatcher) { - val connection: HttpURLConnection? + var connection: HttpURLConnection? = null try { connection = URL(url).openConnection() as HttpURLConnection connection.doOutput = true @@ -42,6 +42,8 @@ internal class ParselyAPIConnection @JvmOverloads constructor( connection.inputStream } catch (ex: Exception) { exception = ex + } finally { + connection?.disconnect() } if (exception != null) { From 479d262c378746202f114ed498e5d94c0f8aba5a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 11:12:00 +0100 Subject: [PATCH 036/101] feat: close the connection after successful request --- .../main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt index 00195ca5..401cecc1 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt @@ -15,7 +15,6 @@ */ package com.parsely.parselyandroid -import com.parsely.parselyandroid.ParselyTracker.ROOT_URL import java.net.HttpURLConnection import java.net.URL import kotlinx.coroutines.CoroutineDispatcher From 742fc5d9d5f66f2d5a3261ae1a52da0361a2fa09 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 12:28:07 +0100 Subject: [PATCH 037/101] feat: make `ParselyAPIConnection` return `Result` --- .../parselyandroid/ParselyAPIConnection.kt | 26 +++-- .../ParselyAPIConnectionTest.kt | 94 ++++++++++--------- 2 files changed, 61 insertions(+), 59 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt index 401cecc1..6f2789ed 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt @@ -26,10 +26,8 @@ internal class ParselyAPIConnection @JvmOverloads constructor( private val tracker: ParselyTracker, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) { - private var exception: Exception? = null - - suspend fun send(payload: String) { - withContext(dispatcher) { + suspend fun send(payload: String): Result { + return withContext(dispatcher) { var connection: HttpURLConnection? = null try { connection = URL(url).openConnection() as HttpURLConnection @@ -40,22 +38,20 @@ internal class ParselyAPIConnection @JvmOverloads constructor( output.close() connection.inputStream } catch (ex: Exception) { - exception = ex + ParselyTracker.PLog("Pixel request exception") + ParselyTracker.PLog(ex.toString()) + return@withContext Result.failure(ex) } finally { connection?.disconnect() } - if (exception != null) { - ParselyTracker.PLog("Pixel request exception") - ParselyTracker.PLog(exception.toString()) - } else { - ParselyTracker.PLog("Pixel request success") + ParselyTracker.PLog("Pixel request success") - // only purge the queue if the request was successful - tracker.purgeEventsQueue() - ParselyTracker.PLog("Event queue empty, flush timer cleared.") - tracker.stopFlushTimer() - } + // only purge the queue if the request was successful + tracker.purgeEventsQueue() + ParselyTracker.PLog("Event queue empty, flush timer cleared.") + tracker.stopFlushTimer() + Result.success(Unit) } } } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index 8356a65e..dad4698e 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -5,14 +5,13 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import okio.IOException import org.assertj.core.api.Assertions.assertThat import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.LooperMode -import org.robolectric.shadows.ShadowLooper.shadowMainLooper @RunWith(RobolectricTestRunner::class) class ParselyAPIConnectionTest { @@ -33,52 +32,59 @@ class ParselyAPIConnectionTest { } @Test - fun `given successful response, when making connection with events, then make POST request with JSON Content-Type header`() = runTest { - // given - mockServer.enqueue(MockResponse().setResponseCode(200)) - - // when - sut.send(pixelPayload) - runCurrent() - - // then - assertThat(mockServer.takeRequest()).satisfies({ - assertThat(it.method).isEqualTo("POST") - assertThat(it.headers["Content-Type"]).isEqualTo("application/json") - assertThat(it.body.readUtf8()).isEqualTo(pixelPayload) - }) - } + fun `given successful response, when making connection with events, then make POST request with JSON Content-Type header`() = + runTest { + // given + mockServer.enqueue(MockResponse().setResponseCode(200)) + + // when + val result = sut.send(pixelPayload) + runCurrent() + + // then + assertThat(mockServer.takeRequest()).satisfies({ + assertThat(it.method).isEqualTo("POST") + assertThat(it.headers["Content-Type"]).isEqualTo("application/json") + assertThat(it.body.readUtf8()).isEqualTo(pixelPayload) + }) + assertThat(result.isSuccess).isTrue + } @Test - fun `given successful response, when request is made, then purge events queue and stop flush timer`() = runTest { - // given - mockServer.enqueue(MockResponse().setResponseCode(200)) - tracker.events.add(mapOf("idsite" to "example.com")) - - // when - sut.send(pixelPayload) - runCurrent() - - // then - assertThat(tracker.events).isEmpty() - assertThat(tracker.flushTimerStopped).isTrue - } + fun `given successful response, when request is made, then purge events queue and stop flush timer`() = + runTest { + // given + mockServer.enqueue(MockResponse().setResponseCode(200)) + tracker.events.add(mapOf("idsite" to "example.com")) + + // when + val result = sut.send(pixelPayload) + runCurrent() + + // then + assertThat(tracker.events).isEmpty() + assertThat(tracker.flushTimerStopped).isTrue + assertThat(result.isSuccess).isTrue + } @Test - fun `given unsuccessful response, when request is made, then do not purge events queue and do not stop flush timer`() = runTest { - // given - mockServer.enqueue(MockResponse().setResponseCode(400)) - val sampleEvents = mapOf("idsite" to "example.com") - tracker.events.add(sampleEvents) - - // when - sut.send(pixelPayload) - runCurrent() - - // then - assertThat(tracker.events).containsExactly(sampleEvents) - assertThat(tracker.flushTimerStopped).isFalse - } + fun `given unsuccessful response, when request is made, then do not purge events queue and do not stop flush timer`() = + runTest { + // given + mockServer.enqueue(MockResponse().setResponseCode(400)) + val sampleEvents = mapOf("idsite" to "example.com") + tracker.events.add(sampleEvents) + + // when + val result = sut.send(pixelPayload) + runCurrent() + + // then + assertThat(tracker.events).containsExactly(sampleEvents) + assertThat(tracker.flushTimerStopped).isFalse + assertThat(result.isFailure).isTrue + assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java) + } companion object { val pixelPayload: String = From 9668a91dba26b8015d20fda5198de5ee58a07fc5 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 12:41:00 +0100 Subject: [PATCH 038/101] refactor: move handling HTTP request results to `SendEvents` The responsibility of `ParselyAPIConnection` should be "sending http requests" only. Orchestrating flow of events will be handled by `SendEvents` use case. This reduces coupling. --- .../parselyandroid/ParselyAPIConnection.kt | 9 --------- .../parsely/parselyandroid/ParselyTracker.java | 2 +- .../com/parsely/parselyandroid/SendEvents.kt | 16 ++++++++++++++-- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt index 6f2789ed..f9f621e5 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.withContext internal class ParselyAPIConnection @JvmOverloads constructor( private val url: String, - private val tracker: ParselyTracker, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) { suspend fun send(payload: String): Result { @@ -38,19 +37,11 @@ internal class ParselyAPIConnection @JvmOverloads constructor( output.close() connection.inputStream } catch (ex: Exception) { - ParselyTracker.PLog("Pixel request exception") - ParselyTracker.PLog(ex.toString()) return@withContext Result.failure(ex) } finally { connection?.disconnect() } - ParselyTracker.PLog("Pixel request success") - - // only purge the queue if the request was successful - tracker.purgeEventsQueue() - ParselyTracker.PLog("Event queue empty, flush timer cleared.") - tracker.stopFlushTimer() Result.success(Unit) } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 3d7e01d7..1a049607 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -82,7 +82,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { } return Unit.INSTANCE; }); - sendEvents = new SendEvents(this, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy", this), ParselyCoroutineScopeKt.getSdkScope()); + sendEvents = new SendEvents(this, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy"), ParselyCoroutineScopeKt.getSdkScope()); // get the adkey straight away on instantiation timer = new Timer(); diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt index c18201cb..a1b5be18 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt @@ -24,15 +24,27 @@ internal class SendEvents( batchMap["events"] = eventsToSend val jsonPayload = toJson(batchMap).orEmpty() + ParselyTracker.PLog("POST Data %s", jsonPayload) + if (isDebug) { ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly") localStorageRepository.purgeStoredQueue() } else { ParselyTracker.PLog("Requested %s", ParselyTracker.ROOT_URL) - parselyAPIConnection.send(jsonPayload) + .fold( + onSuccess = { + ParselyTracker.PLog("Pixel request success") + parselyTracker.purgeEventsQueue() + ParselyTracker.PLog("Event queue empty, flush timer cleared.") + parselyTracker.stopFlushTimer() + }, + onFailure = { + ParselyTracker.PLog("Pixel request exception") + ParselyTracker.PLog(it.toString()) + } + ) } - ParselyTracker.PLog("POST Data %s", toJson(batchMap)) } } } From b7c98a745f2c22bc91d762e8533ac94f045f6ed7 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 16:48:14 +0100 Subject: [PATCH 039/101] tests: update unit tests to reflect current scope of work of `ParselyAPIConnection` --- .../ParselyAPIConnectionTest.kt | 44 ++----------------- 1 file changed, 4 insertions(+), 40 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index dad4698e..135ca268 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -1,6 +1,6 @@ package com.parsely.parselyandroid -import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import okhttp3.mockwebserver.MockResponse @@ -13,17 +13,17 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) class ParselyAPIConnectionTest { private lateinit var sut: ParselyAPIConnection private val mockServer = MockWebServer() private val url = mockServer.url("").toString() - private val tracker = FakeTracker() @Before fun setUp() { - sut = ParselyAPIConnection(url, tracker) + sut = ParselyAPIConnection(url) } @After @@ -51,37 +51,17 @@ class ParselyAPIConnectionTest { } @Test - fun `given successful response, when request is made, then purge events queue and stop flush timer`() = - runTest { - // given - mockServer.enqueue(MockResponse().setResponseCode(200)) - tracker.events.add(mapOf("idsite" to "example.com")) - - // when - val result = sut.send(pixelPayload) - runCurrent() - - // then - assertThat(tracker.events).isEmpty() - assertThat(tracker.flushTimerStopped).isTrue - assertThat(result.isSuccess).isTrue - } - - @Test - fun `given unsuccessful response, when request is made, then do not purge events queue and do not stop flush timer`() = + fun `given unsuccessful response, when request is made, then return failure with exception`() = runTest { // given mockServer.enqueue(MockResponse().setResponseCode(400)) val sampleEvents = mapOf("idsite" to "example.com") - tracker.events.add(sampleEvents) // when val result = sut.send(pixelPayload) runCurrent() // then - assertThat(tracker.events).containsExactly(sampleEvents) - assertThat(tracker.flushTimerStopped).isFalse assertThat(result.isFailure).isTrue assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java) } @@ -90,20 +70,4 @@ class ParselyAPIConnectionTest { val pixelPayload: String = this::class.java.getResource("pixel_payload.json")?.readText().orEmpty() } - - private class FakeTracker : ParselyTracker( - "siteId", 10, ApplicationProvider.getApplicationContext() - ) { - - var flushTimerStopped = false - val events = mutableListOf>() - - override fun purgeEventsQueue() { - events.clear() - } - - override fun stopFlushTimer() { - flushTimerStopped = true - } - } } From 591a72ddfb421fc3951abc8ffe069091304df139 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 20:06:37 +0100 Subject: [PATCH 040/101] style: move log out of --- .../src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt | 1 + .../java/com/parsely/parselyandroid/LocalStorageRepository.kt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt index b944f898..c92c0c7a 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt @@ -22,6 +22,7 @@ internal class InMemoryBuffer( while (isActive) { mutex.withLock { if (buffer.isNotEmpty()) { + ParselyTracker.PLog("Persisting ${buffer.size} events") localStorageRepository.insertEvents(buffer) buffer.clear() } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index b0db6250..d3873326 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -83,7 +83,6 @@ internal open class LocalStorageRepository(private val context: Context) { * Save the event queue to persistent storage. */ open suspend fun insertEvents(toInsert: List?>) = mutex.withLock { - ParselyTracker.PLog("Persisting ${toInsert.size} events") persistObject(ArrayList((toInsert + getStoredQueue()).distinct())) } From 86ac0eb8908c803ab91da9c5a3053bb6b9b6caf0 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 20:09:12 +0100 Subject: [PATCH 041/101] tests: split adding new event tests in `InMemoryBuffer` --- .../parselyandroid/InMemoryBufferTest.kt | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt index 1925627e..384ab195 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt @@ -21,6 +21,22 @@ internal class InMemoryBufferTest { @Test fun `when adding a new event, then save it to local storage and run onEventAdded listener`() = runTest { + // given + val event = mapOf("test" to 123) + sut = InMemoryBuffer(backgroundScope, repository) { } + + // when + sut.add(event) + advanceTimeBy(1.seconds) + runCurrent() + backgroundScope.cancel() + + // then + assertThat(repository.getStoredQueue()).containsOnlyOnce(event) + } + + @Test + fun `given an onEventAdded listener, when adding a new event, then run the onEventAdded listener`() = runTest { // given val event = mapOf("test" to 123) var onEventAddedExecuted = false @@ -33,8 +49,7 @@ internal class InMemoryBufferTest { backgroundScope.cancel() // then - assertThat(repository.getStoredQueue()).containsOnlyOnce(event) - assertThat(onEventAddedExecuted).isTrue() + assertThat(onEventAddedExecuted).isTrue } @Test From 8c4ce7047753f200845539ddedd9a8d875c0aa9d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 20:35:01 +0100 Subject: [PATCH 042/101] style: fix test name --- .../test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt index 384ab195..66bc9614 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt @@ -20,7 +20,7 @@ internal class InMemoryBufferTest { private val repository = FakeLocalStorageRepository() @Test - fun `when adding a new event, then save it to local storage and run onEventAdded listener`() = runTest { + fun `when adding a new event, then save it to local storage`() = runTest { // given val event = mapOf("test" to 123) sut = InMemoryBuffer(backgroundScope, repository) { } From 038c0abb716b1fa538bee74da8aaf6644d094df9 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 10 Nov 2023 13:57:10 +0100 Subject: [PATCH 043/101] refactor: use `LocalStorageRepository` methods directly Not using `ParselyTracker` as middleman, reduces complexity --- .../main/java/com/parsely/parselyandroid/ParselyTracker.java | 4 ---- .../src/main/java/com/parsely/parselyandroid/SendEvents.kt | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 1a049607..328aef4f 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -450,10 +450,6 @@ private boolean isReachable() { return netInfo != null && netInfo.isConnectedOrConnecting(); } - void purgeEventsQueue() { - localStorageRepository.purgeStoredQueue(); - } - /** * Start the timer to flush events to Parsely. *

diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt index a1b5be18..2b4f3391 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt @@ -35,7 +35,7 @@ internal class SendEvents( .fold( onSuccess = { ParselyTracker.PLog("Pixel request success") - parselyTracker.purgeEventsQueue() + localStorageRepository.purgeStoredQueue() ParselyTracker.PLog("Event queue empty, flush timer cleared.") parselyTracker.stopFlushTimer() }, From f294f73c8d0d5dafa01944918314dc3d826f8b6f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 18:29:46 +0100 Subject: [PATCH 044/101] refactor: use `FlushManager` methods directly Not using `ParselyTracker` as middleman, reduces complexity --- .../main/java/com/parsely/parselyandroid/ParselyTracker.java | 2 +- .../src/main/java/com/parsely/parselyandroid/SendEvents.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 328aef4f..e1ed6205 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -82,7 +82,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { } return Unit.INSTANCE; }); - sendEvents = new SendEvents(this, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy"), ParselyCoroutineScopeKt.getSdkScope()); + sendEvents = new SendEvents(flushManager, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy"), ParselyCoroutineScopeKt.getSdkScope()); // get the adkey straight away on instantiation timer = new Timer(); diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt index 2b4f3391..05e27c86 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch internal class SendEvents( - private val parselyTracker: ParselyTracker, + private val flushManager: FlushManager, private val localStorageRepository: LocalStorageRepository, private val parselyAPIConnection: ParselyAPIConnection, private val scope: CoroutineScope @@ -37,7 +37,7 @@ internal class SendEvents( ParselyTracker.PLog("Pixel request success") localStorageRepository.purgeStoredQueue() ParselyTracker.PLog("Event queue empty, flush timer cleared.") - parselyTracker.stopFlushTimer() + flushManager.stop() }, onFailure = { ParselyTracker.PLog("Pixel request exception") From c93b5678278006d46dda785e18474ad210ea0a25 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 18:38:56 +0100 Subject: [PATCH 045/101] fix: on successful request, remove only sent events If an event would be added to local repository between getting stored queue from local repository and sending it in `SendEvents`, it'd be removed and never sent. This change fixes this risk by removing only events that were sent. --- .../java/com/parsely/parselyandroid/LocalStorageRepository.kt | 2 +- .../src/main/java/com/parsely/parselyandroid/SendEvents.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index d3873326..e835d1a2 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -40,7 +40,7 @@ internal open class LocalStorageRepository(private val context: Context) { persistObject(ArrayList>()) } - fun remove(toRemove: List>) { + suspend fun remove(toRemove: List?>) = mutex.withLock { persistObject(getStoredQueue() - toRemove.toSet()) } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt index 05e27c86..bb3deaa2 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt @@ -28,14 +28,14 @@ internal class SendEvents( if (isDebug) { ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly") - localStorageRepository.purgeStoredQueue() + localStorageRepository.remove(eventsToSend) } else { ParselyTracker.PLog("Requested %s", ParselyTracker.ROOT_URL) parselyAPIConnection.send(jsonPayload) .fold( onSuccess = { ParselyTracker.PLog("Pixel request success") - localStorageRepository.purgeStoredQueue() + localStorageRepository.remove(eventsToSend) ParselyTracker.PLog("Event queue empty, flush timer cleared.") flushManager.stop() }, From debd023d421dd4ca0a4406c5ac4d3aa3cea9829b Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 18:41:05 +0100 Subject: [PATCH 046/101] style: remove unused methods of `LocalStorageRepository` --- .../parselyandroid/LocalStorageRepository.kt | 16 ---------------- .../parselyandroid/LocalStorageRepositoryTest.kt | 13 ------------- 2 files changed, 29 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index e835d1a2..16912ea1 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -5,7 +5,6 @@ import java.io.EOFException import java.io.FileNotFoundException import java.io.ObjectInputStream import java.io.ObjectOutputStream -import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -33,13 +32,6 @@ internal open class LocalStorageRepository(private val context: Context) { } } - /** - * Delete the stored queue from persistent storage. - */ - fun purgeStoredQueue() { - persistObject(ArrayList>()) - } - suspend fun remove(toRemove: List?>) = mutex.withLock { persistObject(getStoredQueue() - toRemove.toSet()) } @@ -71,14 +63,6 @@ internal open class LocalStorageRepository(private val context: Context) { return storedQueue } - /** - * Delete an event from the stored queue. - */ - open fun expelStoredEvent() { - val storedQueue = getStoredQueue() - storedQueue.removeAt(0) - } - /** * Save the event queue to persistent storage. */ diff --git a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt index f92b0d59..76e5f186 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt @@ -24,19 +24,6 @@ class LocalStorageRepositoryTest { sut = LocalStorageRepository(context) } - @Test - fun `when expelling stored event, then assert that it has no effect`() = runTest { - // given - sut.insertEvents(((1..100).map { mapOf("index" to it) })) - runCurrent() - - // when - sut.expelStoredEvent() - - // then - assertThat(sut.getStoredQueue()).hasSize(100) - } - @Test fun `given the list of events, when persisting the list, then querying the list returns the same result`() = runTest { // given From 940b2412de9c299976ba13da04237bb8b818b251 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 19:11:51 +0100 Subject: [PATCH 047/101] tests: add tests for SendEvents usecase --- .../parsely/parselyandroid/FlushManager.kt | 8 +- .../parselyandroid/LocalStorageRepository.kt | 2 +- .../parselyandroid/ParselyAPIConnection.kt | 4 +- .../parsely/parselyandroid/SendEventsTest.kt | 206 ++++++++++++++++++ 4 files changed, 213 insertions(+), 7 deletions(-) create mode 100644 parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt index 121b6bf9..d351a181 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt @@ -13,14 +13,14 @@ import kotlinx.coroutines.launch * Handles stopping and starting the flush timer. The flush timer * controls how often we send events to Parse.ly servers. */ -internal class FlushManager( +internal open class FlushManager( private val parselyTracker: ParselyTracker, val intervalMillis: Long, private val coroutineScope: CoroutineScope ) { private var job: Job? = null - fun start() { + open fun start() { if (job?.isActive == true) return job = coroutineScope.launch { @@ -31,8 +31,8 @@ internal class FlushManager( } } - fun stop() = job?.cancel() + open fun stop() = job?.cancel() - val isRunning: Boolean + open val isRunning: Boolean get() = job?.isActive ?: false } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index 16912ea1..be198ef2 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -32,7 +32,7 @@ internal open class LocalStorageRepository(private val context: Context) { } } - suspend fun remove(toRemove: List?>) = mutex.withLock { + open suspend fun remove(toRemove: List?>) = mutex.withLock { persistObject(getStoredQueue() - toRemove.toSet()) } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt index f9f621e5..79ecada8 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt @@ -21,11 +21,11 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -internal class ParselyAPIConnection @JvmOverloads constructor( +internal open class ParselyAPIConnection @JvmOverloads constructor( private val url: String, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) { - suspend fun send(payload: String): Result { + open suspend fun send(payload: String): Result { return withContext(dispatcher) { var connection: HttpURLConnection? = null try { diff --git a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt new file mode 100644 index 00000000..5bc72dd4 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt @@ -0,0 +1,206 @@ +package com.parsely.parselyandroid + +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +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 SendEventsTest { + + private lateinit var sut: SendEvents + + @Test + fun `given empty local storage, when sending events, then do nothing`() = + runTest { + // given + sut = SendEvents( + FakeFlushManager(this), + FakeLocalStorageRepository(), + FakeParselyAPIConnection(), + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(FakeLocalStorageRepository().getStoredQueue()).isEmpty() + } + + @Test + fun `given non-empty local storage and debug mode off, when sending events, then events are sent and removed from local storage`() = + runTest { + // given + val repository = FakeLocalStorageRepository().apply { + insertEvents(listOf(mapOf("test" to 123))) + } + val parselyAPIConnection = FakeParselyAPIConnection().apply { + nextResult = Result.success(Unit) + } + sut = SendEvents( + FakeFlushManager(this), + repository, + parselyAPIConnection, + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(repository.getStoredQueue()).isEmpty() + } + + @Test + fun `given non-empty local storage and debug mode on, when sending events, then events are not sent and removed from local storage`() = + runTest { + // given + val repository = FakeLocalStorageRepository().apply { + insertEvents(listOf(mapOf("test" to 123))) + } + sut = SendEvents( + FakeFlushManager(this), + repository, + FakeParselyAPIConnection(), + this + ) + + // when + sut.invoke(true) + runCurrent() + + // then + assertThat(repository.getStoredQueue()).isEmpty() + } + + @Test + fun `given non-empty local storage and debug mode off, when sending events fails, then events are not removed from local storage`() = + runTest { + // given + val repository = FakeLocalStorageRepository().apply { + insertEvents(listOf(mapOf("test" to 123))) + } + val parselyAPIConnection = FakeParselyAPIConnection().apply { + nextResult = Result.failure(Exception()) + } + sut = SendEvents( + FakeFlushManager(this), + repository, + parselyAPIConnection, + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(repository.getStoredQueue()).isNotEmpty + } + + @Test + fun `given non-empty local storage and debug mode off, when sending events, then flush manager is stopped`() = + runTest { + // given + val flushManager = FakeFlushManager(this) + val repository = FakeLocalStorageRepository().apply { + insertEvents(listOf(mapOf("test" to 123))) + } + val parselyAPIConnection = FakeParselyAPIConnection().apply { + nextResult = Result.success(Unit) + } + sut = SendEvents( + flushManager, + repository, + parselyAPIConnection, + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(flushManager.stopped).isTrue + } + + @Test + fun `given non-empty local storage and debug mode off, when sending events fails, then flush manager is not stopped`() = + runTest { + // given + val flushManager = FakeFlushManager(this) + val repository = FakeLocalStorageRepository().apply { + insertEvents(listOf(mapOf("test" to 123))) + } + val parselyAPIConnection = FakeParselyAPIConnection().apply { + nextResult = Result.failure(Exception()) + } + sut = SendEvents( + flushManager, + repository, + parselyAPIConnection, + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(flushManager.stopped).isFalse + } + + private class FakeFlushManager(scope: CoroutineScope) : FlushManager(FakeTracker(), 10, scope) { + var stopped = false + + override fun stop() { + stopped = true + } + } + + private class FakeTracker : ParselyTracker( + "siteId", 10, ApplicationProvider.getApplicationContext() + ) { + + var flushTimerStopped = false + + override fun stopFlushTimer() { + flushTimerStopped = true + } + } + + private class FakeLocalStorageRepository : + LocalStorageRepository(ApplicationProvider.getApplicationContext()) { + private var storage = emptyList?>() + + override suspend fun insertEvents(toInsert: List?>) { + storage = storage + toInsert + } + + override suspend fun remove(toRemove: List?>) { + storage = storage - toRemove.toSet() + } + + override fun getStoredQueue(): ArrayList?> { + return ArrayList(storage) + } + } + + private class FakeParselyAPIConnection : ParselyAPIConnection("") { + + var nextResult: Result? = null + + override suspend fun send(payload: String): Result { + return nextResult!! + } + } +} From fa73ccd8134f795ee6896190b4fefcc897a3e917 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 19:15:34 +0100 Subject: [PATCH 048/101] refactor: move serialization details to `JsonSerializer` `SendEvents` shouldn't know details about structure of JSON payload. --- .../java/com/parsely/parselyandroid/JsonSerializer.kt | 8 +++++++- .../main/java/com/parsely/parselyandroid/SendEvents.kt | 6 ++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/JsonSerializer.kt b/parsely/src/main/java/com/parsely/parselyandroid/JsonSerializer.kt index 95d6b314..dde232ce 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/JsonSerializer.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/JsonSerializer.kt @@ -5,13 +5,19 @@ import java.io.IOException import java.io.StringWriter internal object JsonSerializer { + + fun toParselyEventsPayload(eventsToSend: List?>): String { + val batchMap: MutableMap = HashMap() + batchMap["events"] = eventsToSend + return toJson(batchMap).orEmpty() + } /** * Encode an event Map as JSON. * * @param map The Map object to encode as JSON. * @return The JSON-encoded value of `map`. */ - fun toJson(map: Map): String? { + private fun toJson(map: Map): String? { val mapper = ObjectMapper() var ret: String? = null try { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt index bb3deaa2..1b1f4c96 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt @@ -1,6 +1,6 @@ package com.parsely.parselyandroid -import com.parsely.parselyandroid.JsonSerializer.toJson +import com.parsely.parselyandroid.JsonSerializer.toParselyEventsPayload import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -20,9 +20,7 @@ internal class SendEvents( } ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) - val batchMap: MutableMap = HashMap() - batchMap["events"] = eventsToSend - val jsonPayload = toJson(batchMap).orEmpty() + val jsonPayload = toParselyEventsPayload(eventsToSend) ParselyTracker.PLog("POST Data %s", jsonPayload) From fab4007aae2588525480a8c4a9a775ff8bcdf7ec Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 19:44:00 +0100 Subject: [PATCH 049/101] fix: do not stop flush manager if local queue is not empty This fixes a possible unwanted stop of flush manager in case that, between querying and successfully sending a event queue, a new event was added. In such case, we should not stop the queue. --- .../com/parsely/parselyandroid/SendEvents.kt | 4 ++- .../parsely/parselyandroid/SendEventsTest.kt | 30 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt index 1b1f4c96..532c0c1f 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt @@ -35,7 +35,9 @@ internal class SendEvents( ParselyTracker.PLog("Pixel request success") localStorageRepository.remove(eventsToSend) ParselyTracker.PLog("Event queue empty, flush timer cleared.") - flushManager.stop() + if (localStorageRepository.getStoredQueue().isEmpty()) { + flushManager.stop() + } }, onFailure = { ParselyTracker.PLog("Pixel request exception") diff --git a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt index 5bc72dd4..12451a70 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt @@ -159,6 +159,34 @@ class SendEventsTest { assertThat(flushManager.stopped).isFalse } + @Test + fun `given non-empty local storage and debug mode off, when storage is not empty after successful event, then flush manager is not stopped`() = + runTest { + // given + val flushManager = FakeFlushManager(this) + val repository = object : FakeLocalStorageRepository() { + override fun getStoredQueue(): ArrayList?> { + return ArrayList(listOf(mapOf("test" to 123))) + } + } + val parselyAPIConnection = FakeParselyAPIConnection().apply { + nextResult = Result.success(Unit) + } + sut = SendEvents( + flushManager, + repository, + parselyAPIConnection, + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(flushManager.stopped).isFalse + } + private class FakeFlushManager(scope: CoroutineScope) : FlushManager(FakeTracker(), 10, scope) { var stopped = false @@ -178,7 +206,7 @@ class SendEventsTest { } } - private class FakeLocalStorageRepository : + private open class FakeLocalStorageRepository : LocalStorageRepository(ApplicationProvider.getApplicationContext()) { private var storage = emptyList?>() From 4237fcdfa59c2b8ed9139f5c170be7eb9b491fe4 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 19:55:18 +0100 Subject: [PATCH 050/101] fix: add mutual execution for `SendEvents#invoke` Make the `SendEvents` run once at a time, to prevent a scenario, when we send multiple requests with the same events. In current state of SDK, it could happen when `FlushManager` counts to next flush interval **and** user moves app to the background at the same time. --- .../com/parsely/parselyandroid/SendEvents.kt | 64 ++++++++++--------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt index 532c0c1f..a28fa940 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt @@ -3,6 +3,8 @@ package com.parsely.parselyandroid import com.parsely.parselyandroid.JsonSerializer.toParselyEventsPayload import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock internal class SendEvents( private val flushManager: FlushManager, @@ -11,39 +13,43 @@ internal class SendEvents( private val scope: CoroutineScope ) { + private val mutex = Mutex() + operator fun invoke(isDebug: Boolean) { scope.launch { - val eventsToSend = localStorageRepository.getStoredQueue() + mutex.withLock { + val eventsToSend = localStorageRepository.getStoredQueue() - if (eventsToSend.isEmpty()) { - return@launch - } - ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) - - val jsonPayload = toParselyEventsPayload(eventsToSend) - - ParselyTracker.PLog("POST Data %s", jsonPayload) - - if (isDebug) { - ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly") - localStorageRepository.remove(eventsToSend) - } else { - ParselyTracker.PLog("Requested %s", ParselyTracker.ROOT_URL) - parselyAPIConnection.send(jsonPayload) - .fold( - onSuccess = { - ParselyTracker.PLog("Pixel request success") - localStorageRepository.remove(eventsToSend) - ParselyTracker.PLog("Event queue empty, flush timer cleared.") - if (localStorageRepository.getStoredQueue().isEmpty()) { - flushManager.stop() + if (eventsToSend.isEmpty()) { + return@launch + } + ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) + + val jsonPayload = toParselyEventsPayload(eventsToSend) + + ParselyTracker.PLog("POST Data %s", jsonPayload) + + if (isDebug) { + ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly") + localStorageRepository.remove(eventsToSend) + } else { + ParselyTracker.PLog("Requested %s", ParselyTracker.ROOT_URL) + parselyAPIConnection.send(jsonPayload) + .fold( + onSuccess = { + ParselyTracker.PLog("Pixel request success") + localStorageRepository.remove(eventsToSend) + ParselyTracker.PLog("Event queue empty, flush timer cleared.") + if (localStorageRepository.getStoredQueue().isEmpty()) { + flushManager.stop() + } + }, + onFailure = { + ParselyTracker.PLog("Pixel request exception") + ParselyTracker.PLog(it.toString()) } - }, - onFailure = { - ParselyTracker.PLog("Pixel request exception") - ParselyTracker.PLog(it.toString()) - } - ) + ) + } } } } From 69f12b3c94598e4b11fd585de5f24b29ea3581d9 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 20:02:52 +0100 Subject: [PATCH 051/101] refactor: remove `FlushQueue` AsyncTask The operations covered by this AsyncTask are no longer needed to run on background thread. The responsibility of checking network state is moved to `flushEvents` and stopping flush timer in case of empty queue - to `SendEvents` --- .../parselyandroid/ParselyTracker.java | 27 ++++--------------- .../com/parsely/parselyandroid/SendEvents.kt | 1 + .../parsely/parselyandroid/SendEventsTest.kt | 19 +++++++++++++ 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index e1ed6205..0df53ffe 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -501,29 +501,12 @@ public int storedEventsCount() { return ar.size(); } - private class FlushQueue extends AsyncTask { - @Override - protected synchronized Void doInBackground(Void... params) { - ArrayList> storedQueue = localStorageRepository.getStoredQueue(); - PLog("%d events in stored queue", storedEventsCount()); - // in case both queues have been flushed and app quits, don't crash - if (storedQueue.isEmpty()) { - stopFlushTimer(); - return null; - } - if (!isReachable()) { - PLog("Network unreachable. Not flushing."); - return null; - } - - PLog("Flushing queue"); - sendBatchRequest(storedQueue); - return null; - } - } - void flushEvents() { - new FlushQueue().execute(); + if (!isReachable()) { + PLog("Network unreachable. Not flushing."); + return; + } + sendEvents.invoke(isDebug); } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt index a28fa940..4172debb 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt @@ -21,6 +21,7 @@ internal class SendEvents( val eventsToSend = localStorageRepository.getStoredQueue() if (eventsToSend.isEmpty()) { + flushManager.stop() return@launch } ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt index 12451a70..485b354a 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt @@ -187,6 +187,25 @@ class SendEventsTest { assertThat(flushManager.stopped).isFalse } + @Test + fun `given empty local storage, when invoked, then flush manager is stopped`() = runTest { + // given + val flushManager = FakeFlushManager(this) + sut = SendEvents( + flushManager, + FakeLocalStorageRepository(), + FakeParselyAPIConnection(), + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(flushManager.stopped).isTrue() + } + private class FakeFlushManager(scope: CoroutineScope) : FlushManager(FakeTracker(), 10, scope) { var stopped = false From ca330abe8cfe674e27cac440e9ac668233f29c89 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 20:05:29 +0100 Subject: [PATCH 052/101] style: remove unused methods BREAKING CHANGE: this commit breaks API contract by removing `stopFlushTimer` but it was never an intention to allow clients to stop flush timer under any conditions. The lifecycle of timer is handled by the SDK internally. --- .../parselyandroid/ParselyTracker.java | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 0df53ffe..1da832fc 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -426,18 +426,6 @@ public void flushEventQueue() { // no-op } - /** - * Send the batched event request to Parsely. - *

- * Creates a POST request containing the JSON encoding of the event queue. - * Sends this request to Parse.ly servers. - * - * @param events The list of event dictionaries to serialize - */ - private void sendBatchRequest(ArrayList> events) { - sendEvents.invoke(isDebug); - } - /** * Returns whether the network is accessible and Parsely is reachable. * @@ -470,13 +458,6 @@ public boolean flushTimerIsActive() { return flushManager.isRunning(); } - /** - * Stop the event queue flush timer. - */ - public void stopFlushTimer() { - flushManager.stop(); - } - @NonNull private String generatePixelId() { return UUID.randomUUID().toString(); From 8d4edd4658889a25a6cbbfacac9fef59cd9974f3 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 21:18:27 +0100 Subject: [PATCH 053/101] fix: make `LocalStorageRepository#getStoredQueue` thread safe and off main thread BREAKING CHANGE: this commit removes `queueSize` and `storedEventsCount` methods. They were never a part of documented public API and added probably only for the need of example app. They were also not safe, as they were triggering I/O operation on the main thread. --- .../main/java/com/example/MainActivity.java | 21 +++------------ example/src/main/res/layout/activity_main.xml | 19 ++------------ .../parselyandroid/LocalStorageRepository.kt | 3 ++- .../parselyandroid/ParselyTracker.java | 26 ++----------------- .../com/parsely/parselyandroid/SdkInit.kt | 18 +++++++++++++ .../parselyandroid/InMemoryBufferTest.kt | 2 +- .../LocalStorageRepositoryTest.kt | 4 +-- .../parsely/parselyandroid/SendEventsTest.kt | 10 ++----- 8 files changed, 32 insertions(+), 71 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/SdkInit.kt diff --git a/example/src/main/java/com/example/MainActivity.java b/example/src/main/java/com/example/MainActivity.java index ee286ef8..6fff5e6c 100644 --- a/example/src/main/java/com/example/MainActivity.java +++ b/example/src/main/java/com/example/MainActivity.java @@ -30,33 +30,18 @@ protected void onCreate(Bundle savedInstanceState) { // Set debugging to true so we don't actually send things to Parse.ly ParselyTracker.sharedInstance().setDebug(true); - final TextView queueView = (TextView) findViewById(R.id.queue_size); - queueView.setText(String.format("Queued events: %d", ParselyTracker.sharedInstance().queueSize())); - - final TextView storedView = (TextView) findViewById(R.id.stored_size); - storedView.setText(String.format("Stored events: %d", ParselyTracker.sharedInstance().storedEventsCount())); - final TextView intervalView = (TextView) findViewById(R.id.interval); - storedView.setText(String.format("Flush interval: %d", ParselyTracker.sharedInstance().getFlushInterval())); updateEngagementStrings(); - final TextView views[] = new TextView[3]; - views[0] = queueView; - views[1] = storedView; - views[2] = intervalView; + final TextView views[] = new TextView[1]; + views[0] = intervalView; final Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { TextView[] v = (TextView[]) msg.obj; - TextView qView = v[0]; - qView.setText(String.format("Queued events: %d", ParselyTracker.sharedInstance().queueSize())); - - TextView sView = v[1]; - sView.setText(String.format("Stored events: %d", ParselyTracker.sharedInstance().storedEventsCount())); - - TextView iView = v[2]; + TextView iView = v[0]; if (ParselyTracker.sharedInstance().flushTimerIsActive()) { iView.setText(String.format("Flush Interval: %d", ParselyTracker.sharedInstance().getFlushInterval())); } else { diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml index ce23ecdd..e86a9223 100644 --- a/example/src/main/res/layout/activity_main.xml +++ b/example/src/main/res/layout/activity_main.xml @@ -68,26 +68,11 @@ android:onClick="trackReset" android:text="@string/button_reset_video" /> - - - - - \ No newline at end of file + diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index be198ef2..2727ba4b 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -41,7 +41,8 @@ internal open class LocalStorageRepository(private val context: Context) { * * @return The stored queue of events. */ - open fun getStoredQueue(): ArrayList?> { + open suspend fun getStoredQueue(): ArrayList?> = mutex.withLock { + var storedQueue: ArrayList?> = ArrayList() try { val fis = context.applicationContext.openFileInput(STORAGE_KEY) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 1da832fc..7ce0ebc0 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -19,7 +19,6 @@ import android.content.Context; import android.net.ConnectivityManager; import android.net.NetworkInfo; -import android.os.AsyncTask; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -27,7 +26,6 @@ import androidx.lifecycle.LifecycleEventObserver; import androidx.lifecycle.ProcessLifecycleOwner; -import java.util.ArrayList; import java.util.Formatter; import java.util.Map; import java.util.Timer; @@ -88,9 +86,8 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { timer = new Timer(); isDebug = false; - if (localStorageRepository.getStoredQueue().size() > 0) { - startFlushTimer(); - } + final SdkInit sdkInit = new SdkInit(ParselyCoroutineScopeKt.getSdkScope(), localStorageRepository, flushManager); + sdkInit.initialize(); ProcessLifecycleOwner.get().getLifecycle().addObserver( (LifecycleEventObserver) (lifecycleOwner, event) -> { @@ -463,25 +460,6 @@ private String generatePixelId() { return UUID.randomUUID().toString(); } - /** - * Get the number of events waiting to be flushed to Parsely. - * - * @return The number of events waiting to be flushed to Parsely. - */ - public int queueSize() { - return localStorageRepository.getStoredQueue().size(); - } - - /** - * Get the number of events stored in persistent storage. - * - * @return The number of events stored in persistent storage. - */ - public int storedEventsCount() { - ArrayList> ar = localStorageRepository.getStoredQueue(); - return ar.size(); - } - void flushEvents() { if (!isReachable()) { PLog("Network unreachable. Not flushing."); diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SdkInit.kt b/parsely/src/main/java/com/parsely/parselyandroid/SdkInit.kt new file mode 100644 index 00000000..6e5de8c1 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/SdkInit.kt @@ -0,0 +1,18 @@ +package com.parsely.parselyandroid + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal class SdkInit( + private val scope: CoroutineScope, + private val localStorageRepository: LocalStorageRepository, + private val flushManager: FlushManager, +) { + fun initialize() { + scope.launch { + if (localStorageRepository.getStoredQueue().isNotEmpty()) { + flushManager.start() + } + } + } +} diff --git a/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt index 66bc9614..71266c46 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt @@ -87,7 +87,7 @@ internal class InMemoryBufferTest { events.addAll(toInsert) } - override fun getStoredQueue(): ArrayList?> { + override suspend fun getStoredQueue(): ArrayList?> { return ArrayList(events) } } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt index 76e5f186..47a60057 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt @@ -38,7 +38,7 @@ class LocalStorageRepositoryTest { } @Test - fun `given no locally stored list, when requesting stored queue, then return an empty list`() { + fun `given no locally stored list, when requesting stored queue, then return an empty list`() = runTest { assertThat(sut.getStoredQueue()).isEmpty() } @@ -79,7 +79,7 @@ class LocalStorageRepositoryTest { } @Test - fun `given stored file with serialized events, when querying the queue, then list has expected events`() { + fun `given stored file with serialized events, when querying the queue, then list has expected events`() = runTest { // given val file = File(context.filesDir.path + "/parsely-events.ser") File(ClassLoader.getSystemResource("valid-java-parsely-events.ser")?.path!!).copyTo(file) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt index 485b354a..f3a0991f 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt @@ -165,7 +165,7 @@ class SendEventsTest { // given val flushManager = FakeFlushManager(this) val repository = object : FakeLocalStorageRepository() { - override fun getStoredQueue(): ArrayList?> { + override suspend fun getStoredQueue(): ArrayList?> { return ArrayList(listOf(mapOf("test" to 123))) } } @@ -217,12 +217,6 @@ class SendEventsTest { private class FakeTracker : ParselyTracker( "siteId", 10, ApplicationProvider.getApplicationContext() ) { - - var flushTimerStopped = false - - override fun stopFlushTimer() { - flushTimerStopped = true - } } private open class FakeLocalStorageRepository : @@ -237,7 +231,7 @@ class SendEventsTest { storage = storage - toRemove.toSet() } - override fun getStoredQueue(): ArrayList?> { + override suspend fun getStoredQueue(): ArrayList?> { return ArrayList(storage) } } From 643defe4c926e8c1db644fc25aa764c1daea1cd0 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 21:20:21 +0100 Subject: [PATCH 054/101] fix: do not create a deadlock As `getStoredQueue` is now using `mutex`, we cannot run it in the scope of the same `Mutex#withLock` as it'll conflict and lock both operations. --- .../parselyandroid/LocalStorageRepository.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index 2727ba4b..038d38d6 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -32,8 +32,12 @@ internal open class LocalStorageRepository(private val context: Context) { } } - open suspend fun remove(toRemove: List?>) = mutex.withLock { - persistObject(getStoredQueue() - toRemove.toSet()) + open suspend fun remove(toRemove: List?>) { + val storedEvents = getStoredQueue() + + mutex.withLock { + persistObject(storedEvents - toRemove.toSet()) + } } /** @@ -67,8 +71,12 @@ internal open class LocalStorageRepository(private val context: Context) { /** * Save the event queue to persistent storage. */ - open suspend fun insertEvents(toInsert: List?>) = mutex.withLock { - persistObject(ArrayList((toInsert + getStoredQueue()).distinct())) + open suspend fun insertEvents(toInsert: List?>){ + val storedEvents = getStoredQueue() + + mutex.withLock { + persistObject(ArrayList((toInsert + storedEvents).distinct())) + } } companion object { From 39f883125ae76ab167446e1eab7ad570b8e809a1 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 21:20:31 +0100 Subject: [PATCH 055/101] fix: do not specify context for `ParselyAPIConnection` `ParselyAPIConnection` will always run in scope of `sdkScope` which has `Dispatchers.IO` as it constant context. --- .../parselyandroid/ParselyAPIConnection.kt | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt index 79ecada8..91d4aa3e 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt @@ -21,28 +21,23 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -internal open class ParselyAPIConnection @JvmOverloads constructor( - private val url: String, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO -) { +internal open class ParselyAPIConnection(private val url: String) { open suspend fun send(payload: String): Result { - return withContext(dispatcher) { - var connection: HttpURLConnection? = null - try { - connection = URL(url).openConnection() as HttpURLConnection - connection.doOutput = true - connection.setRequestProperty("Content-Type", "application/json") - val output = connection.outputStream - output.write(payload.toByteArray()) - output.close() - connection.inputStream - } catch (ex: Exception) { - return@withContext Result.failure(ex) - } finally { - connection?.disconnect() - } - - Result.success(Unit) + var connection: HttpURLConnection? = null + try { + connection = URL(url).openConnection() as HttpURLConnection + connection.doOutput = true + connection.setRequestProperty("Content-Type", "application/json") + val output = connection.outputStream + output.write(payload.toByteArray()) + output.close() + connection.inputStream + } catch (ex: Exception) { + return Result.failure(ex) + } finally { + connection?.disconnect() } + + return Result.success(Unit) } } From dbc73e166e7c9b303988174a4bab7f4e30b4a113 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 21:22:58 +0100 Subject: [PATCH 056/101] tests: fix loading an empty file for `ParselyAPIConnectionTest` --- .../com/parsely/parselyandroid/ParselyAPIConnectionTest.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index 135ca268..497e5d5c 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -55,7 +55,6 @@ class ParselyAPIConnectionTest { runTest { // given mockServer.enqueue(MockResponse().setResponseCode(400)) - val sampleEvents = mapOf("idsite" to "example.com") // when val result = sut.send(pixelPayload) @@ -68,6 +67,8 @@ class ParselyAPIConnectionTest { companion object { val pixelPayload: String = - this::class.java.getResource("pixel_payload.json")?.readText().orEmpty() + ClassLoader.getSystemResource("pixel_payload.json").readText().apply { + assert(isNotBlank()) + } } } From 448c1bd9ce6e74d316cee8a1d2411f0225dc5a42 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 21:16:53 +0100 Subject: [PATCH 057/101] tests: add functional test for engagement session --- .../parsely/parselyandroid/FunctionalTests.kt | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index ead1058c..8a4f591c 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -28,6 +28,7 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.within import org.junit.Test import org.junit.runner.RunWith @@ -190,6 +191,96 @@ class FunctionalTests { } } + /** + * In this scenario consumer app starts an engagement session and after 27150 ms, + * it stops the session. + * + * Intervals: + * With current implementation of `HeartbeatIntervalCalculator`, the next intervals are: + * - 10500ms for the first interval + * - 13650ms for the second interval + * + * So after ~27,2s we should observe + * - 2 `heartbeat` events from `startEngagement` + 1 `heartbeat` event caused by `stopEngagement` which is triggered during engagement interval + * + * Time off-differences in assertions are acceptable, because it's a time-sensitive test + */ + @Test + fun engagementManagerTest() { + val engagementUrl = "engagementUrl" + var startTimestamp = Duration.ZERO + val firstInterval = 10500.milliseconds + val secondInterval = 13650.milliseconds + val pauseInterval = 3.seconds + ActivityScenario.launch(SampleActivity::class.java).use { scenario -> + // given + scenario.onActivity { activity: Activity -> + beforeEach(activity) + server.enqueue(MockResponse().setResponseCode(200)) + parselyTracker = initializeTracker(activity, flushInterval = 30.seconds) + + // when + startTimestamp = System.currentTimeMillis().milliseconds + parselyTracker.startEngagement(engagementUrl, null) + } + + Thread.sleep((firstInterval + secondInterval + pauseInterval).inWholeMilliseconds) + parselyTracker.stopEngagement() + + // then + val request = server.takeRequest(35, TimeUnit.SECONDS)!!.toMap()["events"]!! + + assertThat( + request.sortedBy { it.data.timestamp } + .filter { it.action == "heartbeat" } + ).hasSize(3) + .satisfies({ + val firstEvent = it[0] + val secondEvent = it[1] + val thirdEvent = it[2] + + assertThat(firstEvent.data.timestamp).isCloseTo( + (startTimestamp + firstInterval).inWholeMilliseconds, + within(1.seconds.inWholeMilliseconds) + ) + assertThat(firstEvent.totalTime).isCloseTo( + firstInterval.inWholeMilliseconds, + within(100L) + ) + assertThat(firstEvent.incremental).isCloseTo( + firstInterval.inWholeSeconds, + within(1L) + ) + + assertThat(secondEvent.data.timestamp).isCloseTo( + (startTimestamp + firstInterval + secondInterval).inWholeMilliseconds, + within(1.seconds.inWholeMilliseconds) + ) + assertThat(secondEvent.totalTime).isCloseTo( + (firstInterval + secondInterval).inWholeMilliseconds, + within(100L) + ) + assertThat(secondEvent.incremental).isCloseTo( + secondInterval.inWholeSeconds, + within(1L) + ) + + assertThat(thirdEvent.data.timestamp).isCloseTo( + (startTimestamp + firstInterval + secondInterval + pauseInterval).inWholeMilliseconds, + within(1.seconds.inWholeMilliseconds) + ) + assertThat(thirdEvent.totalTime).isCloseTo( + (firstInterval + secondInterval + pauseInterval).inWholeMilliseconds, + within(100L) + ) + assertThat(thirdEvent.incremental).isCloseTo( + (pauseInterval).inWholeSeconds, + within(1L) + ) + }) + } + } + private fun RecordedRequest.toMap(): Map> { val listType: TypeReference>> = object : TypeReference>>() {} @@ -200,6 +291,15 @@ class FunctionalTests { @JsonIgnoreProperties(ignoreUnknown = true) data class Event( @JsonProperty("idsite") var idsite: String, + @JsonProperty("action") var action: String, + @JsonProperty("data") var data: ExtraData, + @JsonProperty("tt") var totalTime: Long, + @JsonProperty("inc") var incremental: Long, + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + data class ExtraData( + @JsonProperty("ts") var timestamp: Long, ) private val locallyStoredEvents From 868a74dd839f3f3db23ff07c9317ef23359a38fe Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 13:46:57 +0100 Subject: [PATCH 058/101] Rename .java to .kt --- .../{EngagementManager.java => EngagementManager.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename parsely/src/main/java/com/parsely/parselyandroid/{EngagementManager.java => EngagementManager.kt} (100%) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt similarity index 100% rename from parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java rename to parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt From 6dfbf42b56acae41481a90c3cc801b49ba288d0f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 13:46:57 +0100 Subject: [PATCH 059/101] refactor: rewrite `EngagementManager` to Kotlin --- .../parselyandroid/EngagementManager.kt | 152 ++++++++---------- 1 file changed, 66 insertions(+), 86 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt index 182dc407..9b697736 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt @@ -1,120 +1,100 @@ -package com.parsely.parselyandroid; +package com.parsely.parselyandroid -import java.util.Calendar; -import java.util.HashMap; -import java.util.Map; -import java.util.TimeZone; -import java.util.Timer; -import java.util.TimerTask; +import java.util.Calendar +import java.util.TimeZone +import java.util.Timer +import java.util.TimerTask /** * Engagement manager for article and video engagement. - *

+ * + * * Implemented to handle its own queuing of future executions to accomplish * two things: - *

+ * + * * 1. Flushing any engaged time before canceling. * 2. Progressive backoff for long engagements to save data. */ -class EngagementManager { - - private final ParselyTracker parselyTracker; - public Map baseEvent; - private boolean started; - private final Timer parentTimer; - private TimerTask waitingTimerTask; - private long latestDelayMillis, totalTime; - private Calendar startTime; - private final HeartbeatIntervalCalculator intervalCalculator; - - public EngagementManager( - ParselyTracker parselyTracker, - Timer parentTimer, - long intervalMillis, - Map baseEvent, - HeartbeatIntervalCalculator intervalCalculator - ) { - this.parselyTracker = parselyTracker; - this.baseEvent = baseEvent; - this.parentTimer = parentTimer; - this.intervalCalculator = intervalCalculator; - latestDelayMillis = intervalMillis; - totalTime = 0; - startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - } - - public boolean isRunning() { - return started; +internal class EngagementManager( + private val parselyTracker: ParselyTracker, + private val parentTimer: Timer, + private var latestDelayMillis: Long, + var baseEvent: Map, + private val intervalCalculator: HeartbeatIntervalCalculator +) { + var isRunning = false + private set + private var waitingTimerTask: TimerTask? = null + private var totalTime: Long = 0 + private var startTime: Calendar + + init { + startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")) } - public void start() { - scheduleNextExecution(latestDelayMillis); - started = true; - startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + fun start() { + scheduleNextExecution(latestDelayMillis) + isRunning = true + startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")) } - public void stop() { - waitingTimerTask.cancel(); - started = false; + fun stop() { + waitingTimerTask!!.cancel() + isRunning = false } - public boolean isSameVideo(String url, String urlRef, ParselyVideoMetadata metadata) { - Map baseMetadata = (Map) baseEvent.get("metadata"); - return (baseEvent.get("url").equals(url) && - baseEvent.get("urlref").equals(urlRef) && - baseMetadata.get("link").equals(metadata.link) && - (int) (baseMetadata.get("duration")) == metadata.durationSeconds); + fun isSameVideo(url: String, urlRef: String, metadata: ParselyVideoMetadata): Boolean { + val baseMetadata = baseEvent["metadata"] as Map? + return baseEvent["url"] == url && baseEvent["urlref"] == urlRef && baseMetadata!!["link"] == metadata.link && baseMetadata["duration"] as Int == metadata.durationSeconds } - private void scheduleNextExecution(long delay) { - TimerTask task = new TimerTask() { - public void run() { - doEnqueue(scheduledExecutionTime()); - latestDelayMillis = intervalCalculator.calculate(startTime); - scheduleNextExecution(latestDelayMillis); + private fun scheduleNextExecution(delay: Long) { + val task: TimerTask = object : TimerTask() { + override fun run() { + doEnqueue(scheduledExecutionTime()) + latestDelayMillis = intervalCalculator.calculate(startTime) + scheduleNextExecution(latestDelayMillis) } - public boolean cancel() { - boolean output = super.cancel(); + override fun cancel(): Boolean { + val output = super.cancel() // Only enqueue when we actually canceled something. If output is false then // this has already been canceled. if (output) { - doEnqueue(scheduledExecutionTime()); + doEnqueue(scheduledExecutionTime()) } - return output; + return output } - }; - latestDelayMillis = delay; - parentTimer.schedule(task, delay); - waitingTimerTask = task; + } + latestDelayMillis = delay + parentTimer.schedule(task, delay) + waitingTimerTask = task } - private void doEnqueue(long scheduledExecutionTime) { + private fun doEnqueue(scheduledExecutionTime: Long) { // Create a copy of the base event to enqueue - Map event = new HashMap<>(baseEvent); - ParselyTracker.PLog(String.format("Enqueuing %s event.", event.get("action"))); + val event: MutableMap = HashMap( + baseEvent + ) + ParselyTracker.PLog(String.format("Enqueuing %s event.", event["action"])) // Update `ts` for the event since it's happening right now. - Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - @SuppressWarnings("unchecked") - Map baseEventData = (Map) event.get("data"); - assert baseEventData != null; - Map data = new HashMap<>(baseEventData); - data.put("ts", now.getTimeInMillis()); - event.put("data", data); + val now = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + val baseEventData = (event["data"] as Map?)!! + val data: MutableMap = HashMap(baseEventData) + data["ts"] = now.timeInMillis + event["data"] = data // Adjust inc by execution time in case we're late or early. - long executionDiff = (System.currentTimeMillis() - scheduledExecutionTime); - long inc = (latestDelayMillis + executionDiff); - totalTime += inc; - event.put("inc", inc / 1000); - event.put("tt", totalTime); - - parselyTracker.enqueueEvent(event); + val executionDiff = System.currentTimeMillis() - scheduledExecutionTime + val inc = latestDelayMillis + executionDiff + totalTime += inc + event["inc"] = inc / 1000 + event["tt"] = totalTime + parselyTracker.enqueueEvent(event) } - - public double getIntervalMillis() { - return latestDelayMillis; - } + val intervalMillis: Double + get() = latestDelayMillis.toDouble() } From 8279392d81da4b10aa39f4b97c1e60bf8368398c Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 15:40:44 +0100 Subject: [PATCH 060/101] tests: add test for `inc` parameter and `EngagementManager#stop` behavior It has to have longer delays as `inc` is calculated in seconds and test is time-sensitive. --- .../parselyandroid/EngagementManagerTest.kt | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 6b5448c1..30af2730 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -4,6 +4,7 @@ import androidx.test.core.app.ApplicationProvider import java.util.Calendar import java.util.TimeZone import java.util.Timer +import kotlin.time.Duration.Companion.seconds import org.assertj.core.api.AbstractLongAssert import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.within @@ -92,6 +93,37 @@ internal class EngagementManagerTest { ) } + @Test + fun `given started manager, when stopping manager before interval ticks, then schedule an event`() { + // given + sut = EngagementManager( + tracker, + parentTimer, + 5.seconds.inWholeMilliseconds, + baseEvent, + object : FakeIntervalCalculator() { + override fun calculate(startTime: Calendar): Long { + return 5.seconds.inWholeMilliseconds + } + } + ) + sut.start() + + // when + sleep(12.seconds.inWholeMilliseconds) + sut.stop() + + // then + // first tick: after initial delay 5s, incremental addition 5s + // second tick: after regular delay 5s, incremental addition 5s + // third tick: after cancellation after 2s, incremental addition 2s + assertThat(tracker.events).hasSize(3).satisfies({ + assertThat(it[0]).containsEntry("inc", 5L) + assertThat(it[1]).containsEntry("inc", 5L) + assertThat(it[2]).containsEntry("inc", 2L) + }) + } + private fun sleep(millis: Long) = Thread.sleep(millis + THREAD_SLEEPING_THRESHOLD) private fun MapAssert.isCorrectEvent( @@ -130,7 +162,7 @@ internal class EngagementManagerTest { } } - class FakeIntervalCalculator : HeartbeatIntervalCalculator(Clock()) { + open class FakeIntervalCalculator : HeartbeatIntervalCalculator(Clock()) { override fun calculate(startTime: Calendar): Long { return DEFAULT_INTERVAL_MILLIS } @@ -138,6 +170,7 @@ internal class EngagementManagerTest { private companion object { const val DEFAULT_INTERVAL_MILLIS = 100L + // Additional time to wait to ensure that the timer has fired const val THREAD_SLEEPING_THRESHOLD = 50L val testData = mutableMapOf( From 728fd26f6c4c979d5ca3dea8a614efb0ccbdfd7d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 15:09:30 +0100 Subject: [PATCH 061/101] feat: use Coroutines to enqueue events in `EngagementManager` This commits has minimal set of changes that moves implementation towards usage of Kotlin Coroutines with passing unit test. It also makes tests not time-sensitive. --- .../java/com/parsely/parselyandroid/Clock.kt | 3 +- .../parselyandroid/EngagementManager.kt | 34 ++++++++-- .../parselyandroid/ParselyTracker.java | 10 ++- .../parselyandroid/EngagementManagerTest.kt | 68 +++++++++++++------ 4 files changed, 82 insertions(+), 33 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/Clock.kt b/parsely/src/main/java/com/parsely/parselyandroid/Clock.kt index 2db30db8..42d937b3 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/Clock.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/Clock.kt @@ -2,9 +2,10 @@ package com.parsely.parselyandroid import java.util.Calendar import java.util.TimeZone +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds open class Clock { - open val now + open val now: Duration get() = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis.milliseconds } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt index 9b697736..ab69cc7e 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt @@ -4,6 +4,11 @@ import java.util.Calendar import java.util.TimeZone import java.util.Timer import java.util.TimerTask +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch /** * Engagement manager for article and video engagement. @@ -21,26 +26,42 @@ internal class EngagementManager( private val parentTimer: Timer, private var latestDelayMillis: Long, var baseEvent: Map, - private val intervalCalculator: HeartbeatIntervalCalculator + private val intervalCalculator: HeartbeatIntervalCalculator, + private val coroutineScope: CoroutineScope, + private val clock: Clock, ) { var isRunning = false private set private var waitingTimerTask: TimerTask? = null + private var job: Job? = null private var totalTime: Long = 0 private var startTime: Calendar + private var nextScheduledExecution: Long = 0 init { startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")) } fun start() { - scheduleNextExecution(latestDelayMillis) isRunning = true - startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { + timeInMillis = clock.now.inWholeMilliseconds + } + job = coroutineScope.launch { + while (isActive) { + latestDelayMillis = intervalCalculator.calculate(startTime) + nextScheduledExecution = clock.now.inWholeMilliseconds + latestDelayMillis + delay(latestDelayMillis) + doEnqueue(clock.now.inWholeMilliseconds) + } + } } fun stop() { - waitingTimerTask!!.cancel() + job?.let { + it.cancel() + doEnqueue(nextScheduledExecution) + } isRunning = false } @@ -80,14 +101,13 @@ internal class EngagementManager( ParselyTracker.PLog(String.format("Enqueuing %s event.", event["action"])) // Update `ts` for the event since it's happening right now. - val now = Calendar.getInstance(TimeZone.getTimeZone("UTC")) val baseEventData = (event["data"] as Map?)!! val data: MutableMap = HashMap(baseEventData) - data["ts"] = now.timeInMillis + data["ts"] = clock.now.inWholeMilliseconds event["data"] = data // Adjust inc by execution time in case we're late or early. - val executionDiff = System.currentTimeMillis() - scheduledExecutionTime + val executionDiff = clock.now.inWholeMilliseconds - scheduledExecutionTime val inc = latestDelayMillis + executionDiff totalTime += inc event["inc"] = inc / 1000 diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 7ce0ebc0..c87d6d48 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -56,7 +56,9 @@ public class ParselyTracker { @NonNull private final EventsBuilder eventsBuilder; @NonNull - private final HeartbeatIntervalCalculator intervalCalculator = new HeartbeatIntervalCalculator(new Clock()); + private final Clock clock; + @NonNull + private final HeartbeatIntervalCalculator intervalCalculator; @NonNull private final LocalStorageRepository localStorageRepository; @NonNull @@ -81,6 +83,8 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { return Unit.INSTANCE; }); sendEvents = new SendEvents(flushManager, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy"), ParselyCoroutineScopeKt.getSdkScope()); + clock = new Clock(); + intervalCalculator = new HeartbeatIntervalCalculator(clock); // get the adkey straight away on instantiation timer = new Timer(); @@ -284,7 +288,7 @@ public void startEngagement( // Start a new EngagementTask Map event = eventsBuilder.buildEvent(url, urlRef, "heartbeat", null, extraData, lastPageviewUuid); - engagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event, intervalCalculator); + engagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event, intervalCalculator, ParselyCoroutineScopeKt.getSdkScope(), clock ); engagementManager.start(); } @@ -360,7 +364,7 @@ public void trackPlay( // Start a new engagement manager for the video. @NonNull final Map hbEvent = eventsBuilder.buildEvent(url, urlRef, "vheartbeat", videoMetadata, extraData, uuid); // TODO: Can we remove some metadata fields from this request? - videoEngagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent, intervalCalculator); + videoEngagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent, intervalCalculator, ParselyCoroutineScopeKt.getSdkScope(), clock); videoEngagementManager.start(); } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 30af2730..f2135222 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -5,6 +5,14 @@ import java.util.Calendar import java.util.TimeZone import java.util.Timer import kotlin.time.Duration.Companion.seconds +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.assertj.core.api.AbstractLongAssert import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.within @@ -17,6 +25,7 @@ import org.robolectric.RobolectricTestRunner private typealias Event = MutableMap +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) internal class EngagementManagerTest { @@ -28,23 +37,23 @@ internal class EngagementManagerTest { "data" to testData ) - @Before - fun setUp() { + @Test + fun `when starting manager, then record the correct event after interval millis`() = runTest { + // when sut = EngagementManager( tracker, parentTimer, DEFAULT_INTERVAL_MILLIS, baseEvent, - FakeIntervalCalculator() + FakeIntervalCalculator(), + backgroundScope, + FakeClock(testScheduler), ) - } - @Test - fun `when starting manager, then record the correct event after interval millis`() { - // when sut.start() - sleep(DEFAULT_INTERVAL_MILLIS) - val timestamp = now - THREAD_SLEEPING_THRESHOLD + advanceTimeBy(DEFAULT_INTERVAL_MILLIS) + runCurrent() + val timestamp = currentTime // then assertThat(tracker.events[0]).isCorrectEvent( @@ -56,19 +65,29 @@ internal class EngagementManagerTest { } @Test - fun `when starting manager, then schedule task each interval period`() { + fun `when starting manager, then schedule task each interval period`() = runTest { + // when + sut = EngagementManager( + tracker, + parentTimer, + DEFAULT_INTERVAL_MILLIS, + baseEvent, + FakeIntervalCalculator(), + backgroundScope, + FakeClock(testScheduler), + ) sut.start() - sleep(DEFAULT_INTERVAL_MILLIS) - val firstTimestamp = now - THREAD_SLEEPING_THRESHOLD + advanceTimeBy(DEFAULT_INTERVAL_MILLIS) + val firstTimestamp = currentTime - sleep(DEFAULT_INTERVAL_MILLIS) - val secondTimestamp = now - 2 * THREAD_SLEEPING_THRESHOLD + advanceTimeBy(DEFAULT_INTERVAL_MILLIS) + val secondTimestamp = currentTime - sleep(DEFAULT_INTERVAL_MILLIS) - val thirdTimestamp = now - 3 * THREAD_SLEEPING_THRESHOLD + advanceTimeBy(DEFAULT_INTERVAL_MILLIS) + val thirdTimestamp = currentTime - sleep(THREAD_SLEEPING_THRESHOLD) + runCurrent() val firstEvent = tracker.events[0] assertThat(firstEvent).isCorrectEvent( @@ -94,7 +113,7 @@ internal class EngagementManagerTest { } @Test - fun `given started manager, when stopping manager before interval ticks, then schedule an event`() { + fun `given started manager, when stopping manager before interval ticks, then schedule an event`() = runTest { // given sut = EngagementManager( tracker, @@ -105,12 +124,14 @@ internal class EngagementManagerTest { override fun calculate(startTime: Calendar): Long { return 5.seconds.inWholeMilliseconds } - } + }, + this, + FakeClock(testScheduler) ) sut.start() // when - sleep(12.seconds.inWholeMilliseconds) + advanceTimeBy(12.seconds.inWholeMilliseconds) sut.stop() // then @@ -124,8 +145,6 @@ internal class EngagementManagerTest { }) } - private fun sleep(millis: Long) = Thread.sleep(millis + THREAD_SLEEPING_THRESHOLD) - private fun MapAssert.isCorrectEvent( withTotalTime: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, withTimestamp: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, @@ -168,6 +187,11 @@ internal class EngagementManagerTest { } } + class FakeClock(private val scheduler: TestCoroutineScheduler) : Clock() { + override val now: Duration + get() = scheduler.currentTime.milliseconds + } + private companion object { const val DEFAULT_INTERVAL_MILLIS = 100L From 56f1ea62f2e6aa5864bb00625932cc68461c79c8 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 18:49:51 +0100 Subject: [PATCH 062/101] tests: narrow down assertions for `EngagementManager` Now, as tests are no longer time-sensitive, it's possible to narrow down unit tests to check exact expected values. --- .../parselyandroid/EngagementManagerTest.kt | 43 +++++++------------ 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index f2135222..8b724ef9 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -4,9 +4,9 @@ import androidx.test.core.app.ApplicationProvider import java.util.Calendar import java.util.TimeZone import java.util.Timer -import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.advanceTimeBy @@ -15,10 +15,7 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.assertj.core.api.AbstractLongAssert import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.within -import org.assertj.core.api.Assertions.withinPercentage import org.assertj.core.api.MapAssert -import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -39,7 +36,7 @@ internal class EngagementManagerTest { @Test fun `when starting manager, then record the correct event after interval millis`() = runTest { - // when + // given sut = EngagementManager( tracker, parentTimer, @@ -50,23 +47,21 @@ internal class EngagementManagerTest { FakeClock(testScheduler), ) + // when sut.start() advanceTimeBy(DEFAULT_INTERVAL_MILLIS) runCurrent() - val timestamp = currentTime // then assertThat(tracker.events[0]).isCorrectEvent( - // Ideally: totalTime should be equal to DEFAULT_INTERVAL_MILLIS - withTotalTime = { isCloseTo(DEFAULT_INTERVAL_MILLIS, withinPercentage(10)) }, - // Ideally: timestamp should be equal to System.currentTimeMillis() at the time of recording the event - withTimestamp = { isCloseTo(timestamp, within(20L)) } + withTotalTime = { isEqualTo(DEFAULT_INTERVAL_MILLIS) }, + withTimestamp = { isEqualTo(currentTime) } ) } @Test fun `when starting manager, then schedule task each interval period`() = runTest { - // when + // given sut = EngagementManager( tracker, parentTimer, @@ -78,6 +73,7 @@ internal class EngagementManagerTest { ) sut.start() + // when advanceTimeBy(DEFAULT_INTERVAL_MILLIS) val firstTimestamp = currentTime @@ -85,30 +81,24 @@ internal class EngagementManagerTest { val secondTimestamp = currentTime advanceTimeBy(DEFAULT_INTERVAL_MILLIS) - val thirdTimestamp = currentTime - runCurrent() + val thirdTimestamp = currentTime + // then val firstEvent = tracker.events[0] assertThat(firstEvent).isCorrectEvent( - // Ideally: totalTime should be equal to DEFAULT_INTERVAL_MILLIS - withTotalTime = { isCloseTo(DEFAULT_INTERVAL_MILLIS, withinPercentage(10)) }, - // Ideally: timestamp should be equal to `now` at the time of recording the event - withTimestamp = { isCloseTo(firstTimestamp, within(20L)) } + withTotalTime = { isEqualTo(DEFAULT_INTERVAL_MILLIS) }, + withTimestamp = { isEqualTo(firstTimestamp) } ) val secondEvent = tracker.events[1] assertThat(secondEvent).isCorrectEvent( - // Ideally: totalTime should be equal to DEFAULT_INTERVAL_MILLIS * 2 - withTotalTime = { isCloseTo(DEFAULT_INTERVAL_MILLIS * 2, withinPercentage(10)) }, - // Ideally: timestamp should be equal to `now` at the time of recording the event - withTimestamp = { isCloseTo(secondTimestamp, within(20L)) } + withTotalTime = { isEqualTo(DEFAULT_INTERVAL_MILLIS * 2) }, + withTimestamp = { isEqualTo(secondTimestamp) } ) val thirdEvent = tracker.events[2] assertThat(thirdEvent).isCorrectEvent( - // Ideally: totalTime should be equal to DEFAULT_INTERVAL_MILLIS * 3 - withTotalTime = { isCloseTo(DEFAULT_INTERVAL_MILLIS * 3, withinPercentage(10)) }, - // Ideally: timestamp should be equal to `now` at the time of recording the event - withTimestamp = { isCloseTo(thirdTimestamp, within(20L)) } + withTotalTime = { isEqualTo(DEFAULT_INTERVAL_MILLIS * 3) }, + withTimestamp = { isEqualTo(thirdTimestamp) } ) } @@ -194,9 +184,6 @@ internal class EngagementManagerTest { private companion object { const val DEFAULT_INTERVAL_MILLIS = 100L - - // Additional time to wait to ensure that the timer has fired - const val THREAD_SLEEPING_THRESHOLD = 50L val testData = mutableMapOf( "os" to "android", "parsely_site_uuid" to "e8857cbe-5ace-44f4-a85e-7e7475f675c5", From 68ba19ed7e3b2f159e0a173544cec548e9c59709 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 18:59:20 +0100 Subject: [PATCH 063/101] tests: add tests for checking `inc` parameter As tests are now not time-sensitive, there's possibility to verify `inc` parameter as well without making test suite taking a long time. --- .../parselyandroid/EngagementManagerTest.kt | 63 +++++++++---------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 8b724ef9..323fbec3 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -2,7 +2,6 @@ package com.parsely.parselyandroid import androidx.test.core.app.ApplicationProvider import java.util.Calendar -import java.util.TimeZone import java.util.Timer import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -40,7 +39,7 @@ internal class EngagementManagerTest { sut = EngagementManager( tracker, parentTimer, - DEFAULT_INTERVAL_MILLIS, + DEFAULT_INTERVAL.inWholeMilliseconds, baseEvent, FakeIntervalCalculator(), backgroundScope, @@ -49,12 +48,13 @@ internal class EngagementManagerTest { // when sut.start() - advanceTimeBy(DEFAULT_INTERVAL_MILLIS) + advanceTimeBy(DEFAULT_INTERVAL) runCurrent() // then assertThat(tracker.events[0]).isCorrectEvent( - withTotalTime = { isEqualTo(DEFAULT_INTERVAL_MILLIS) }, + withIncrementalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeSeconds)}, + withTotalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeMilliseconds) }, withTimestamp = { isEqualTo(currentTime) } ) } @@ -65,7 +65,7 @@ internal class EngagementManagerTest { sut = EngagementManager( tracker, parentTimer, - DEFAULT_INTERVAL_MILLIS, + DEFAULT_INTERVAL.inWholeMilliseconds, baseEvent, FakeIntervalCalculator(), backgroundScope, @@ -74,30 +74,33 @@ internal class EngagementManagerTest { sut.start() // when - advanceTimeBy(DEFAULT_INTERVAL_MILLIS) + advanceTimeBy(DEFAULT_INTERVAL) val firstTimestamp = currentTime - advanceTimeBy(DEFAULT_INTERVAL_MILLIS) + advanceTimeBy(DEFAULT_INTERVAL) val secondTimestamp = currentTime - advanceTimeBy(DEFAULT_INTERVAL_MILLIS) + advanceTimeBy(DEFAULT_INTERVAL) runCurrent() val thirdTimestamp = currentTime // then val firstEvent = tracker.events[0] assertThat(firstEvent).isCorrectEvent( - withTotalTime = { isEqualTo(DEFAULT_INTERVAL_MILLIS) }, + withIncrementalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeSeconds) }, + withTotalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeMilliseconds) }, withTimestamp = { isEqualTo(firstTimestamp) } ) val secondEvent = tracker.events[1] assertThat(secondEvent).isCorrectEvent( - withTotalTime = { isEqualTo(DEFAULT_INTERVAL_MILLIS * 2) }, + withIncrementalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeSeconds) }, + withTotalTime = { isEqualTo((DEFAULT_INTERVAL * 2).inWholeMilliseconds) }, withTimestamp = { isEqualTo(secondTimestamp) } ) val thirdEvent = tracker.events[2] assertThat(thirdEvent).isCorrectEvent( - withTotalTime = { isEqualTo(DEFAULT_INTERVAL_MILLIS * 3) }, + withIncrementalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeSeconds) }, + withTotalTime = { isEqualTo((DEFAULT_INTERVAL * 3).inWholeMilliseconds) }, withTimestamp = { isEqualTo(thirdTimestamp) } ) } @@ -108,40 +111,39 @@ internal class EngagementManagerTest { sut = EngagementManager( tracker, parentTimer, - 5.seconds.inWholeMilliseconds, + DEFAULT_INTERVAL.inWholeMilliseconds, baseEvent, - object : FakeIntervalCalculator() { - override fun calculate(startTime: Calendar): Long { - return 5.seconds.inWholeMilliseconds - } - }, + FakeIntervalCalculator(), this, FakeClock(testScheduler) ) sut.start() // when - advanceTimeBy(12.seconds.inWholeMilliseconds) + advanceTimeBy(70.seconds.inWholeMilliseconds) sut.stop() // then - // first tick: after initial delay 5s, incremental addition 5s - // second tick: after regular delay 5s, incremental addition 5s - // third tick: after cancellation after 2s, incremental addition 2s + // first tick: after initial delay 30s, incremental addition 30s + // second tick: after regular delay 30s, incremental addition 30s + // third tick: after cancellation after 10s, incremental addition 10s assertThat(tracker.events).hasSize(3).satisfies({ - assertThat(it[0]).containsEntry("inc", 5L) - assertThat(it[1]).containsEntry("inc", 5L) - assertThat(it[2]).containsEntry("inc", 2L) + assertThat(it[0]).containsEntry("inc", 30L) + assertThat(it[1]).containsEntry("inc", 30L) + assertThat(it[2]).containsEntry("inc", 10L) }) } private fun MapAssert.isCorrectEvent( + withIncrementalTime: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, withTotalTime: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, withTimestamp: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, ): MapAssert { return containsEntry("action", "heartbeat") - // Incremental will be always 0 because the interval is lower than 1s - .containsEntry("inc", 0L) + .hasEntrySatisfying("inc") { incrementalTime -> + incrementalTime as Long + assertThat(incrementalTime).withIncrementalTime() + } .hasEntrySatisfying("tt") { totalTime -> totalTime as Long assertThat(totalTime).withTotalTime() @@ -156,9 +158,6 @@ internal class EngagementManagerTest { } } - private val now: Long - get() = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis - class FakeTracker : ParselyTracker( "", 0, @@ -171,9 +170,9 @@ internal class EngagementManagerTest { } } - open class FakeIntervalCalculator : HeartbeatIntervalCalculator(Clock()) { + class FakeIntervalCalculator : HeartbeatIntervalCalculator(Clock()) { override fun calculate(startTime: Calendar): Long { - return DEFAULT_INTERVAL_MILLIS + return DEFAULT_INTERVAL.inWholeMilliseconds } } @@ -183,7 +182,7 @@ internal class EngagementManagerTest { } private companion object { - const val DEFAULT_INTERVAL_MILLIS = 100L + val DEFAULT_INTERVAL = 30.seconds val testData = mutableMapOf( "os" to "android", "parsely_site_uuid" to "e8857cbe-5ace-44f4-a85e-7e7475f675c5", From 7e1d5915e22012babe4d9a503c0d1c3f38d6df5d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 19:03:31 +0100 Subject: [PATCH 064/101] style: remove unused code --- .../parselyandroid/EngagementManager.kt | 27 ------------------- .../parselyandroid/ParselyTracker.java | 7 ++--- .../parselyandroid/EngagementManagerTest.kt | 5 ---- 3 files changed, 2 insertions(+), 37 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt index ab69cc7e..7bed9672 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt @@ -2,8 +2,6 @@ package com.parsely.parselyandroid import java.util.Calendar import java.util.TimeZone -import java.util.Timer -import java.util.TimerTask import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -23,7 +21,6 @@ import kotlinx.coroutines.launch */ internal class EngagementManager( private val parselyTracker: ParselyTracker, - private val parentTimer: Timer, private var latestDelayMillis: Long, var baseEvent: Map, private val intervalCalculator: HeartbeatIntervalCalculator, @@ -32,7 +29,6 @@ internal class EngagementManager( ) { var isRunning = false private set - private var waitingTimerTask: TimerTask? = null private var job: Job? = null private var totalTime: Long = 0 private var startTime: Calendar @@ -70,29 +66,6 @@ internal class EngagementManager( return baseEvent["url"] == url && baseEvent["urlref"] == urlRef && baseMetadata!!["link"] == metadata.link && baseMetadata["duration"] as Int == metadata.durationSeconds } - private fun scheduleNextExecution(delay: Long) { - val task: TimerTask = object : TimerTask() { - override fun run() { - doEnqueue(scheduledExecutionTime()) - latestDelayMillis = intervalCalculator.calculate(startTime) - scheduleNextExecution(latestDelayMillis) - } - - override fun cancel(): Boolean { - val output = super.cancel() - // Only enqueue when we actually canceled something. If output is false then - // this has already been canceled. - if (output) { - doEnqueue(scheduledExecutionTime()) - } - return output - } - } - latestDelayMillis = delay - parentTimer.schedule(task, delay) - waitingTimerTask = task - } - private fun doEnqueue(scheduledExecutionTime: Long) { // Create a copy of the base event to enqueue val event: MutableMap = HashMap( diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index c87d6d48..8c44f8a5 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -28,7 +28,6 @@ import java.util.Formatter; import java.util.Map; -import java.util.Timer; import java.util.UUID; import kotlin.Unit; @@ -48,7 +47,6 @@ public class ParselyTracker { static final String ROOT_URL = "https://p1.parsely.com/".intern(); private boolean isDebug; private final Context context; - private final Timer timer; private final FlushManager flushManager; private EngagementManager engagementManager, videoEngagementManager; @Nullable @@ -87,7 +85,6 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { intervalCalculator = new HeartbeatIntervalCalculator(clock); // get the adkey straight away on instantiation - timer = new Timer(); isDebug = false; final SdkInit sdkInit = new SdkInit(ParselyCoroutineScopeKt.getSdkScope(), localStorageRepository, flushManager); @@ -288,7 +285,7 @@ public void startEngagement( // Start a new EngagementTask Map event = eventsBuilder.buildEvent(url, urlRef, "heartbeat", null, extraData, lastPageviewUuid); - engagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event, intervalCalculator, ParselyCoroutineScopeKt.getSdkScope(), clock ); + engagementManager = new EngagementManager(this, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event, intervalCalculator, ParselyCoroutineScopeKt.getSdkScope(), clock ); engagementManager.start(); } @@ -364,7 +361,7 @@ public void trackPlay( // Start a new engagement manager for the video. @NonNull final Map hbEvent = eventsBuilder.buildEvent(url, urlRef, "vheartbeat", videoMetadata, extraData, uuid); // TODO: Can we remove some metadata fields from this request? - videoEngagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent, intervalCalculator, ParselyCoroutineScopeKt.getSdkScope(), clock); + videoEngagementManager = new EngagementManager(this, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent, intervalCalculator, ParselyCoroutineScopeKt.getSdkScope(), clock); videoEngagementManager.start(); } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 323fbec3..34374fcf 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -2,7 +2,6 @@ package com.parsely.parselyandroid import androidx.test.core.app.ApplicationProvider import java.util.Calendar -import java.util.Timer import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -27,7 +26,6 @@ internal class EngagementManagerTest { private lateinit var sut: EngagementManager private val tracker = FakeTracker() - private val parentTimer = Timer() private val baseEvent: Event = mutableMapOf( "action" to "heartbeat", "data" to testData @@ -38,7 +36,6 @@ internal class EngagementManagerTest { // given sut = EngagementManager( tracker, - parentTimer, DEFAULT_INTERVAL.inWholeMilliseconds, baseEvent, FakeIntervalCalculator(), @@ -64,7 +61,6 @@ internal class EngagementManagerTest { // given sut = EngagementManager( tracker, - parentTimer, DEFAULT_INTERVAL.inWholeMilliseconds, baseEvent, FakeIntervalCalculator(), @@ -110,7 +106,6 @@ internal class EngagementManagerTest { // given sut = EngagementManager( tracker, - parentTimer, DEFAULT_INTERVAL.inWholeMilliseconds, baseEvent, FakeIntervalCalculator(), From 38ef0bba8f950da9c230853c01260f795c321927 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 19:06:48 +0100 Subject: [PATCH 065/101] tests: add unit tests for `EngagementManager#isRunning` --- .../parselyandroid/EngagementManagerTest.kt | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 34374fcf..526cb28b 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -129,6 +129,45 @@ internal class EngagementManagerTest { }) } + @Test + fun `when starting manager, then it should return true for isRunning`() = runTest { + // given + sut = EngagementManager( + tracker, + DEFAULT_INTERVAL.inWholeMilliseconds, + baseEvent, + FakeIntervalCalculator(), + backgroundScope, + FakeClock(testScheduler) + ) + + // when + sut.start() + + // then + assertThat(sut.isRunning).isTrue + } + + @Test + fun `given started manager, when stoping manager, then it should return false for isRunning`() = runTest { + // given + sut = EngagementManager( + tracker, + DEFAULT_INTERVAL.inWholeMilliseconds, + baseEvent, + FakeIntervalCalculator(), + backgroundScope, + FakeClock(testScheduler) + ) + sut.start() + + // when + sut.stop() + + // then + assertThat(sut.isRunning).isFalse + } + private fun MapAssert.isCorrectEvent( withIncrementalTime: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, withTotalTime: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, From d137dc48a2a82b6dadaaa393f1b5727adaa8d0d9 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 19:10:00 +0100 Subject: [PATCH 066/101] refactor: use `Job#isActive` to determine `EngagementManager` state instead of private flag --- .../java/com/parsely/parselyandroid/EngagementManager.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt index 7bed9672..80b694c4 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt @@ -27,8 +27,6 @@ internal class EngagementManager( private val coroutineScope: CoroutineScope, private val clock: Clock, ) { - var isRunning = false - private set private var job: Job? = null private var totalTime: Long = 0 private var startTime: Calendar @@ -38,8 +36,10 @@ internal class EngagementManager( startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")) } + val isRunning: Boolean + get() = job?.isActive ?: false + fun start() { - isRunning = true startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { timeInMillis = clock.now.inWholeMilliseconds } @@ -58,7 +58,6 @@ internal class EngagementManager( it.cancel() doEnqueue(nextScheduledExecution) } - isRunning = false } fun isSameVideo(url: String, urlRef: String, metadata: ParselyVideoMetadata): Boolean { From 47d230360fe7bd0e59846637480a50a5ecf46ba9 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 19:18:17 +0100 Subject: [PATCH 067/101] style: make `baseEvent` private and immutable --- .../main/java/com/parsely/parselyandroid/EngagementManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt index 80b694c4..24f0b780 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt @@ -22,7 +22,7 @@ import kotlinx.coroutines.launch internal class EngagementManager( private val parselyTracker: ParselyTracker, private var latestDelayMillis: Long, - var baseEvent: Map, + private val baseEvent: Map, private val intervalCalculator: HeartbeatIntervalCalculator, private val coroutineScope: CoroutineScope, private val clock: Clock, From 5cbea9f90fdb53a954d2a64131a00429d0ade9db Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 19:23:29 +0100 Subject: [PATCH 068/101] refactor: drop usages of `java.util.Calendar` In favor of timezone agnostic `kotlin.time.Duration`. It simplifies implementation and removes a need to declare a timezone, which might be confusing. --- .../com/parsely/parselyandroid/EngagementManager.kt | 11 ++++------- .../parselyandroid/HeartbeatIntervalCalculator.kt | 9 +++------ .../parsely/parselyandroid/EngagementManagerTest.kt | 3 +-- .../HeartbeatIntervalCalculatorTest.kt | 12 +++--------- 4 files changed, 11 insertions(+), 24 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt index 24f0b780..aae2d419 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt @@ -1,7 +1,6 @@ package com.parsely.parselyandroid -import java.util.Calendar -import java.util.TimeZone +import kotlin.time.Duration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -29,20 +28,18 @@ internal class EngagementManager( ) { private var job: Job? = null private var totalTime: Long = 0 - private var startTime: Calendar + private var startTime: Duration private var nextScheduledExecution: Long = 0 init { - startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + startTime = clock.now } val isRunning: Boolean get() = job?.isActive ?: false fun start() { - startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { - timeInMillis = clock.now.inWholeMilliseconds - } + startTime = clock.now job = coroutineScope.launch { while (isActive) { latestDelayMillis = intervalCalculator.calculate(startTime) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt b/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt index 7e1312f7..d50223ff 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt @@ -1,18 +1,15 @@ package com.parsely.parselyandroid -import java.util.Calendar -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds internal open class HeartbeatIntervalCalculator(private val clock: Clock) { - open fun calculate(startTime: Calendar): Long { - val startTimeDuration = startTime.time.time.milliseconds + open fun calculate(startTime: Duration): Long { val nowDuration = clock.now - val totalTrackedTime = nowDuration - startTimeDuration + val totalTrackedTime = nowDuration - startTime val totalWithOffset = totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL val newInterval = totalWithOffset * BACKOFF_PROPORTION val clampedNewInterval = minOf(MAX_TIME_BETWEEN_HEARTBEATS, newInterval) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 526cb28b..a7e5df9e 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -1,7 +1,6 @@ package com.parsely.parselyandroid import androidx.test.core.app.ApplicationProvider -import java.util.Calendar import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -205,7 +204,7 @@ internal class EngagementManagerTest { } class FakeIntervalCalculator : HeartbeatIntervalCalculator(Clock()) { - override fun calculate(startTime: Calendar): Long { + override fun calculate(startTime: Duration): Long { return DEFAULT_INTERVAL.inWholeMilliseconds } } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt index e0f3ffd7..eb5c420e 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt @@ -22,9 +22,7 @@ internal class HeartbeatIntervalCalculatorTest { fun `given the same time of start and current time, when calculating interval, return offset times backoff proportion`() { // given fakeClock.fakeNow = Duration.ZERO - val startTime = Calendar.getInstance().apply { - timeInMillis = 0 - } + val startTime = Duration.ZERO // when val result = sut.calculate(startTime) @@ -45,9 +43,7 @@ internal class HeartbeatIntervalCalculatorTest { // (15 minutes / 0.3) - 35 seconds = 2965 seconds. Add 1 second to be over the limit val excessiveTime = 2965.seconds + 1.seconds fakeClock.fakeNow = excessiveTime - val startTime = Calendar.getInstance().apply { - timeInMillis = 0 - } + val startTime = Duration.ZERO // when val result = sut.calculate(startTime) @@ -59,9 +55,7 @@ internal class HeartbeatIntervalCalculatorTest { @Test fun `given a specific time point, when updating latest interval, it correctly calculates the interval`() { // given - val startTime = Calendar.getInstance().apply { - timeInMillis = 0 - } + val startTime = Duration.ZERO fakeClock.fakeNow = 2.seconds // when From d90b0d685b76311b1d4e68cab5de5fb766629565 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 11:32:46 +0100 Subject: [PATCH 069/101] refactor: create `deviceInfo` for every `buildEvent` Such flow makes code easier to be extracted --- .../parsely/parselyandroid/EventsBuilder.java | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java index 2f1c1ae8..2f7756b1 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java @@ -30,13 +30,12 @@ class EventsBuilder { private final SharedPreferences settings; private final String siteId; - private Map deviceInfo; + private String adKey = null; public EventsBuilder(@NonNull final Context context, @NonNull final String siteId) { this.context = context; this.siteId = siteId; settings = context.getSharedPreferences("parsely-prefs", 0); - deviceInfo = collectDeviceInfo(null); new GetAdKey(context).execute(); } @@ -74,6 +73,8 @@ Map buildEvent( if (extraData != null) { data.putAll(extraData); } + + final Map deviceInfo = collectDeviceInfo(); data.put("manufacturer", deviceInfo.get("manufacturer")); data.put("os", deviceInfo.get("os")); data.put("os_version", deviceInfo.get("os_version")); @@ -101,13 +102,11 @@ Map buildEvent( *

* Collects info about the device and user to use in Parsely events. */ - private Map collectDeviceInfo(@Nullable final String adKey) { + private Map collectDeviceInfo() { Map dInfo = new HashMap<>(); // TODO: screen dimensions (maybe?) - PLog("adkey is: %s, uuid is %s", adKey, getSiteUuid()); - final String uuid = (adKey != null) ? adKey : getSiteUuid(); - dInfo.put("parsely_site_uuid", uuid); + dInfo.put("parsely_site_uuid", getParselySiteUuid()); dInfo.put("manufacturer", android.os.Build.MANUFACTURER); dInfo.put("os", "android"); dInfo.put("os_version", String.format("%d", android.os.Build.VERSION.SDK_INT)); @@ -119,6 +118,12 @@ private Map collectDeviceInfo(@Nullable final String adKey) { return dInfo; } + private String getParselySiteUuid() { + PLog("adkey is: %s, uuid is %s", adKey, getSiteUuid()); + final String uuid = (adKey != null) ? adKey : getSiteUuid(); + return uuid; + } + /** * Get the UUID for this user. */ @@ -179,7 +184,7 @@ protected String doInBackground(Void... params) { @Override protected void onPostExecute(String advertId) { - deviceInfo = collectDeviceInfo(advertId); + adKey = advertId; } } } From 9a420f0382acf8b4c29e279287cab66e97e881a5 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 11:40:26 +0100 Subject: [PATCH 070/101] refactor: extract creating "device info" to a separate class --- .../parselyandroid/DeviceInfoRepository.java | 124 ++++++++++++++++++ .../parsely/parselyandroid/EventsBuilder.java | 113 +--------------- .../parselyandroid/ParselyTracker.java | 2 +- .../parselyandroid/EventsBuilderTest.kt | 2 +- 4 files changed, 131 insertions(+), 110 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.java diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.java b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.java new file mode 100644 index 00000000..e378189d --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.java @@ -0,0 +1,124 @@ +package com.parsely.parselyandroid; + +import static com.parsely.parselyandroid.ParselyTracker.PLog; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.AsyncTask; +import android.provider.Settings; + +import androidx.annotation.NonNull; + +import com.google.android.gms.ads.identifier.AdvertisingIdClient; +import com.google.android.gms.common.GooglePlayServicesNotAvailableException; +import com.google.android.gms.common.GooglePlayServicesRepairableException; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +class DeviceInfoRepository { + + private static final String UUID_KEY = "parsely-uuid"; + private String adKey = null; + @NonNull + private final Context context; + private final SharedPreferences settings; + + DeviceInfoRepository(@NonNull Context context) { + this.context = context; + settings = context.getSharedPreferences("parsely-prefs", 0); + new GetAdKey(context).execute(); + } + + /** + * Collect device-specific info. + *

+ * Collects info about the device and user to use in Parsely events. + */ + Map collectDeviceInfo() { + Map dInfo = new HashMap<>(); + + // TODO: screen dimensions (maybe?) + dInfo.put("parsely_site_uuid", getParselySiteUuid()); + dInfo.put("manufacturer", android.os.Build.MANUFACTURER); + dInfo.put("os", "android"); + dInfo.put("os_version", String.format("%d", android.os.Build.VERSION.SDK_INT)); + + // FIXME: Not passed in event or used anywhere else. + CharSequence txt = context.getPackageManager().getApplicationLabel(context.getApplicationInfo()); + dInfo.put("appname", txt.toString()); + + return dInfo; + } + + private String getParselySiteUuid() { + PLog("adkey is: %s, uuid is %s", adKey, getSiteUuid()); + final String uuid = (adKey != null) ? adKey : getSiteUuid(); + return uuid; + } + + /** + * Get the UUID for this user. + */ + //TODO: docs about where we get this UUID from and how. + private String getSiteUuid() { + String uuid = ""; + try { + uuid = settings.getString(UUID_KEY, ""); + if (uuid.equals("")) { + uuid = generateSiteUuid(); + } + } catch (Exception ex) { + PLog("Exception caught during site uuid generation: %s", ex.toString()); + } + return uuid; + } + + /** + * Read the Parsely UUID from application context or make a new one. + * + * @return The UUID to use for this user. + */ + private String generateSiteUuid() { + String uuid = Settings.Secure.getString(context.getApplicationContext().getContentResolver(), + Settings.Secure.ANDROID_ID); + PLog(String.format("Generated UUID: %s", uuid)); + return uuid; + } + /** + * Async task to get adKey for this device. + */ + private class GetAdKey extends AsyncTask { + private final Context mContext; + + public GetAdKey(Context context) { + mContext = context; + } + + @Override + protected String doInBackground(Void... params) { + AdvertisingIdClient.Info idInfo = null; + String advertId = null; + try { + idInfo = AdvertisingIdClient.getAdvertisingIdInfo(mContext); + } catch (GooglePlayServicesRepairableException | IOException | + GooglePlayServicesNotAvailableException | IllegalArgumentException e) { + PLog("No Google play services or error! falling back to device uuid"); + // fall back to device uuid on google play errors + advertId = getSiteUuid(); + } + try { + advertId = idInfo.getId(); + } catch (NullPointerException e) { + advertId = getSiteUuid(); + } + return advertId; + } + + @Override + protected void onPostExecute(String advertId) { + adKey = advertId; + } + } +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java index 2f7756b1..af804e7d 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java @@ -3,40 +3,27 @@ import static com.parsely.parselyandroid.ParselyTracker.PLog; import android.content.Context; -import android.content.SharedPreferences; -import android.os.AsyncTask; -import android.provider.Settings; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.gms.ads.identifier.AdvertisingIdClient; -import com.google.android.gms.common.GooglePlayServicesNotAvailableException; -import com.google.android.gms.common.GooglePlayServicesRepairableException; - -import java.io.IOException; import java.util.Calendar; import java.util.HashMap; import java.util.Map; import java.util.TimeZone; class EventsBuilder { - private static final String UUID_KEY = "parsely-uuid"; private static final String VIDEO_START_ID_KEY = "vsid"; private static final String PAGE_VIEW_ID_KEY = "pvid"; - @NonNull - private final Context context; - private final SharedPreferences settings; private final String siteId; - private String adKey = null; + @NonNull + private final DeviceInfoRepository deviceInfoRepository; - public EventsBuilder(@NonNull final Context context, @NonNull final String siteId) { - this.context = context; + public EventsBuilder(@NonNull final DeviceInfoRepository deviceInfoRepository, @NonNull final String siteId) { this.siteId = siteId; - settings = context.getSharedPreferences("parsely-prefs", 0); - new GetAdKey(context).execute(); + this.deviceInfoRepository = deviceInfoRepository; } /** @@ -74,7 +61,7 @@ Map buildEvent( data.putAll(extraData); } - final Map deviceInfo = collectDeviceInfo(); + final Map deviceInfo = deviceInfoRepository.collectDeviceInfo(); data.put("manufacturer", deviceInfo.get("manufacturer")); data.put("os", deviceInfo.get("os")); data.put("os_version", deviceInfo.get("os_version")); @@ -97,94 +84,4 @@ Map buildEvent( return event; } - /** - * Collect device-specific info. - *

- * Collects info about the device and user to use in Parsely events. - */ - private Map collectDeviceInfo() { - Map dInfo = new HashMap<>(); - - // TODO: screen dimensions (maybe?) - dInfo.put("parsely_site_uuid", getParselySiteUuid()); - dInfo.put("manufacturer", android.os.Build.MANUFACTURER); - dInfo.put("os", "android"); - dInfo.put("os_version", String.format("%d", android.os.Build.VERSION.SDK_INT)); - - // FIXME: Not passed in event or used anywhere else. - CharSequence txt = context.getPackageManager().getApplicationLabel(context.getApplicationInfo()); - dInfo.put("appname", txt.toString()); - - return dInfo; - } - - private String getParselySiteUuid() { - PLog("adkey is: %s, uuid is %s", adKey, getSiteUuid()); - final String uuid = (adKey != null) ? adKey : getSiteUuid(); - return uuid; - } - - /** - * Get the UUID for this user. - */ - //TODO: docs about where we get this UUID from and how. - private String getSiteUuid() { - String uuid = ""; - try { - uuid = settings.getString(UUID_KEY, ""); - if (uuid.equals("")) { - uuid = generateSiteUuid(); - } - } catch (Exception ex) { - PLog("Exception caught during site uuid generation: %s", ex.toString()); - } - return uuid; - } - - /** - * Read the Parsely UUID from application context or make a new one. - * - * @return The UUID to use for this user. - */ - private String generateSiteUuid() { - String uuid = Settings.Secure.getString(context.getApplicationContext().getContentResolver(), - Settings.Secure.ANDROID_ID); - PLog(String.format("Generated UUID: %s", uuid)); - return uuid; - } - /** - * Async task to get adKey for this device. - */ - private class GetAdKey extends AsyncTask { - private final Context mContext; - - public GetAdKey(Context context) { - mContext = context; - } - - @Override - protected String doInBackground(Void... params) { - AdvertisingIdClient.Info idInfo = null; - String advertId = null; - try { - idInfo = AdvertisingIdClient.getAdvertisingIdInfo(mContext); - } catch (GooglePlayServicesRepairableException | IOException | - GooglePlayServicesNotAvailableException | IllegalArgumentException e) { - PLog("No Google play services or error! falling back to device uuid"); - // fall back to device uuid on google play errors - advertId = getSiteUuid(); - } - try { - advertId = idInfo.getId(); - } catch (NullPointerException e) { - advertId = getSiteUuid(); - } - return advertId; - } - - @Override - protected void onPostExecute(String advertId) { - adKey = advertId; - } - } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 8c44f8a5..3e08ffba 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -69,7 +69,7 @@ public class ParselyTracker { */ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); - eventsBuilder = new EventsBuilder(context, siteId); + eventsBuilder = new EventsBuilder(new DeviceInfoRepository(context), siteId); localStorageRepository = new LocalStorageRepository(context); flushManager = new FlushManager(this, flushInterval * 1000L, ParselyCoroutineScopeKt.getSdkScope()); diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 5630a8d5..1d7507e9 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -18,7 +18,7 @@ class EventsBuilderTest { fun setUp() { val applicationContext = ApplicationProvider.getApplicationContext() sut = EventsBuilder( - applicationContext, + DeviceInfoRepository(applicationContext), TEST_SITE_ID, ) } From 17c5e8241e5a95ca03c142e6c7251e99b456ddd7 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 11:42:39 +0100 Subject: [PATCH 071/101] Rename .java to .kt --- .../{DeviceInfoRepository.java => DeviceInfoRepository.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename parsely/src/main/java/com/parsely/parselyandroid/{DeviceInfoRepository.java => DeviceInfoRepository.kt} (100%) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.java b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt similarity index 100% rename from parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.java rename to parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt From 65e25dd09537ef9fa77f0a459e074ae73c78a030 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 11:42:39 +0100 Subject: [PATCH 072/101] refactor: move `DeviceInfoRepository` to Kotlin --- .../parselyandroid/DeviceInfoRepository.kt | 176 +++++++++--------- 1 file changed, 87 insertions(+), 89 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt index e378189d..7aa43374 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt @@ -1,124 +1,122 @@ -package com.parsely.parselyandroid; - -import static com.parsely.parselyandroid.ParselyTracker.PLog; - -import android.content.Context; -import android.content.SharedPreferences; -import android.os.AsyncTask; -import android.provider.Settings; - -import androidx.annotation.NonNull; - -import com.google.android.gms.ads.identifier.AdvertisingIdClient; -import com.google.android.gms.common.GooglePlayServicesNotAvailableException; -import com.google.android.gms.common.GooglePlayServicesRepairableException; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -class DeviceInfoRepository { - - private static final String UUID_KEY = "parsely-uuid"; - private String adKey = null; - @NonNull - private final Context context; - private final SharedPreferences settings; - - DeviceInfoRepository(@NonNull Context context) { - this.context = context; - settings = context.getSharedPreferences("parsely-prefs", 0); - new GetAdKey(context).execute(); +package com.parsely.parselyandroid + +import android.content.Context +import android.content.SharedPreferences +import android.os.AsyncTask +import android.os.Build +import android.provider.Settings +import com.google.android.gms.ads.identifier.AdvertisingIdClient +import com.google.android.gms.common.GooglePlayServicesNotAvailableException +import com.google.android.gms.common.GooglePlayServicesRepairableException +import java.io.IOException + +internal class DeviceInfoRepository(private val context: Context) { + private var adKey: String? = null + private val settings: SharedPreferences = context.getSharedPreferences("parsely-prefs", 0) + + init { + GetAdKey(context).execute() } /** * Collect device-specific info. - *

+ * + * * Collects info about the device and user to use in Parsely events. */ - Map collectDeviceInfo() { - Map dInfo = new HashMap<>(); + fun collectDeviceInfo(): Map { + val dInfo: MutableMap = HashMap() // TODO: screen dimensions (maybe?) - dInfo.put("parsely_site_uuid", getParselySiteUuid()); - dInfo.put("manufacturer", android.os.Build.MANUFACTURER); - dInfo.put("os", "android"); - dInfo.put("os_version", String.format("%d", android.os.Build.VERSION.SDK_INT)); + dInfo["parsely_site_uuid"] = parselySiteUuid + dInfo["manufacturer"] = Build.MANUFACTURER + dInfo["os"] = "android" + dInfo["os_version"] = String.format("%d", Build.VERSION.SDK_INT) // FIXME: Not passed in event or used anywhere else. - CharSequence txt = context.getPackageManager().getApplicationLabel(context.getApplicationInfo()); - dInfo.put("appname", txt.toString()); - - return dInfo; + val txt = context.packageManager.getApplicationLabel(context.applicationInfo) + dInfo["appname"] = txt.toString() + return dInfo } - private String getParselySiteUuid() { - PLog("adkey is: %s, uuid is %s", adKey, getSiteUuid()); - final String uuid = (adKey != null) ? adKey : getSiteUuid(); - return uuid; - } + private val parselySiteUuid: String + get() { + ParselyTracker.PLog("adkey is: %s, uuid is %s", adKey, siteUuid) + return if (adKey != null) adKey!! else siteUuid!! + } - /** - * Get the UUID for this user. - */ - //TODO: docs about where we get this UUID from and how. - private String getSiteUuid() { - String uuid = ""; - try { - uuid = settings.getString(UUID_KEY, ""); - if (uuid.equals("")) { - uuid = generateSiteUuid(); + private val siteUuid: String? + /** + * Get the UUID for this user. + */ + get() { + var uuid: String? = "" + try { + uuid = settings.getString(UUID_KEY, "") + if (uuid == "") { + uuid = generateSiteUuid() + } + } catch (ex: Exception) { + ParselyTracker.PLog( + "Exception caught during site uuid generation: %s", + ex.toString() + ) } - } catch (Exception ex) { - PLog("Exception caught during site uuid generation: %s", ex.toString()); + return uuid } - return uuid; - } /** * Read the Parsely UUID from application context or make a new one. * * @return The UUID to use for this user. */ - private String generateSiteUuid() { - String uuid = Settings.Secure.getString(context.getApplicationContext().getContentResolver(), - Settings.Secure.ANDROID_ID); - PLog(String.format("Generated UUID: %s", uuid)); - return uuid; + private fun generateSiteUuid(): String { + val uuid = Settings.Secure.getString( + context.applicationContext.contentResolver, + Settings.Secure.ANDROID_ID + ) + ParselyTracker.PLog(String.format("Generated UUID: %s", uuid)) + return uuid } + /** * Async task to get adKey for this device. */ - private class GetAdKey extends AsyncTask { - private final Context mContext; - - public GetAdKey(Context context) { - mContext = context; - } - - @Override - protected String doInBackground(Void... params) { - AdvertisingIdClient.Info idInfo = null; - String advertId = null; + private inner class GetAdKey(private val mContext: Context) : + AsyncTask() { + protected override fun doInBackground(vararg params: Void?): String? { + var idInfo: AdvertisingIdClient.Info? = null + var advertId: String? = null try { - idInfo = AdvertisingIdClient.getAdvertisingIdInfo(mContext); - } catch (GooglePlayServicesRepairableException | IOException | - GooglePlayServicesNotAvailableException | IllegalArgumentException e) { - PLog("No Google play services or error! falling back to device uuid"); + idInfo = AdvertisingIdClient.getAdvertisingIdInfo(mContext) + } catch (e: GooglePlayServicesRepairableException) { + ParselyTracker.PLog("No Google play services or error! falling back to device uuid") // fall back to device uuid on google play errors - advertId = getSiteUuid(); + advertId = siteUuid + } catch (e: IOException) { + ParselyTracker.PLog("No Google play services or error! falling back to device uuid") + advertId = siteUuid + } catch (e: GooglePlayServicesNotAvailableException) { + ParselyTracker.PLog("No Google play services or error! falling back to device uuid") + advertId = siteUuid + } catch (e: IllegalArgumentException) { + ParselyTracker.PLog("No Google play services or error! falling back to device uuid") + advertId = siteUuid } try { - advertId = idInfo.getId(); - } catch (NullPointerException e) { - advertId = getSiteUuid(); + advertId = idInfo!!.id + } catch (e: NullPointerException) { + advertId = siteUuid } - return advertId; + return advertId } - @Override - protected void onPostExecute(String advertId) { - adKey = advertId; + override fun onPostExecute(advertId: String?) { + adKey = advertId } } + + companion object { + private const val UUID_KEY = "parsely-uuid" + } } From ce1d56ca11fcb5013a816dde8b5f9de7e91bf0d9 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 12:01:56 +0100 Subject: [PATCH 073/101] refactor: make `EventsBuilderTest` not test `DeviceInfoRepository` Introducing an interface, allowed to create `FakeDeviceInfoRepository`. --- .../parselyandroid/DeviceInfoRepository.kt | 9 ++++++-- .../parsely/parselyandroid/EventsBuilder.java | 6 ++--- .../parselyandroid/ParselyTracker.java | 2 +- .../parselyandroid/EventsBuilderTest.kt | 23 ++++++++++--------- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt index 7aa43374..c5510c71 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt @@ -9,8 +9,13 @@ import com.google.android.gms.ads.identifier.AdvertisingIdClient import com.google.android.gms.common.GooglePlayServicesNotAvailableException import com.google.android.gms.common.GooglePlayServicesRepairableException import java.io.IOException +import kotlinx.coroutines.launch -internal class DeviceInfoRepository(private val context: Context) { +internal interface DeviceInfoRepository{ + fun collectDeviceInfo(): Map +} + +internal open class AndroidDeviceInfoRepository(private val context: Context): DeviceInfoRepository { private var adKey: String? = null private val settings: SharedPreferences = context.getSharedPreferences("parsely-prefs", 0) @@ -24,7 +29,7 @@ internal class DeviceInfoRepository(private val context: Context) { * * Collects info about the device and user to use in Parsely events. */ - fun collectDeviceInfo(): Map { + override fun collectDeviceInfo(): Map { val dInfo: MutableMap = HashMap() // TODO: screen dimensions (maybe?) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java index af804e7d..b70c1aec 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java @@ -62,11 +62,9 @@ Map buildEvent( } final Map deviceInfo = deviceInfoRepository.collectDeviceInfo(); - data.put("manufacturer", deviceInfo.get("manufacturer")); - data.put("os", deviceInfo.get("os")); - data.put("os_version", deviceInfo.get("os_version")); data.put("ts", now.getTimeInMillis()); - data.put("parsely_site_uuid", deviceInfo.get("parsely_site_uuid")); + data.putAll(deviceInfo); + event.put("data", data); if (metadata != null) { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 3e08ffba..cb4f399a 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -69,7 +69,7 @@ public class ParselyTracker { */ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); - eventsBuilder = new EventsBuilder(new DeviceInfoRepository(context), siteId); + eventsBuilder = new EventsBuilder(new AndroidDeviceInfoRepository(context), siteId); localStorageRepository = new LocalStorageRepository(context); flushManager = new FlushManager(this, flushInterval * 1000L, ParselyCoroutineScopeKt.getSdkScope()); diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 1d7507e9..f02a8ad3 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -1,7 +1,6 @@ package com.parsely.parselyandroid import android.content.Context -import android.provider.Settings import androidx.test.core.app.ApplicationProvider import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.MapAssert @@ -11,14 +10,13 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) -class EventsBuilderTest { +internal class EventsBuilderTest { private lateinit var sut: EventsBuilder @Before fun setUp() { - val applicationContext = ApplicationProvider.getApplicationContext() sut = EventsBuilder( - DeviceInfoRepository(applicationContext), + FakeDeviceInfoRepository(), TEST_SITE_ID, ) } @@ -116,7 +114,7 @@ class EventsBuilderTest { // then @Suppress("UNCHECKED_CAST") - assertThat(event["data"] as Map).hasSize(5) + assertThat(event["data"] as Map).hasSize(2) } @Test @@ -139,7 +137,7 @@ class EventsBuilderTest { // then @Suppress("UNCHECKED_CAST") - assertThat(event["data"] as Map).hasSize(7) + assertThat(event["data"] as Map).hasSize(4) .containsAllEntriesOf(extraData) } @@ -192,19 +190,22 @@ class EventsBuilderTest { @Suppress("UNCHECKED_CAST") it as Map assertThat(it) - .hasSize(5) - .containsEntry("os", "android") + .hasSize(2) + .containsAllEntriesOf(FAKE_DEVICE_INFO) .hasEntrySatisfying("ts") { timestamp -> assertThat(timestamp as Long).isBetween(1111111111111, 9999999999999) } - .containsEntry("manufacturer", "robolectric") - .containsEntry("os_version", "33") - .containsEntry("parsely_site_uuid", null) } companion object { const val TEST_SITE_ID = "Example" const val TEST_URL = "http://example.com/some-old/article.html" const val TEST_UUID = "123e4567-e89b-12d3-a456-426614174000" + + val FAKE_DEVICE_INFO = mapOf("device" to "info") + } + + class FakeDeviceInfoRepository: DeviceInfoRepository { + override fun collectDeviceInfo(): Map = FAKE_DEVICE_INFO } } From 96acab087ac42285bb953671974147e1d3d42b69 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 12:06:33 +0100 Subject: [PATCH 074/101] refactor: move `GetAdKey` to Coroutines --- .../parselyandroid/DeviceInfoRepository.kt | 22 ++++++++----------- .../parselyandroid/ParselyTracker.java | 2 +- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt index c5510c71..66d63b21 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt @@ -2,25 +2,28 @@ package com.parsely.parselyandroid import android.content.Context import android.content.SharedPreferences -import android.os.AsyncTask import android.os.Build import android.provider.Settings import com.google.android.gms.ads.identifier.AdvertisingIdClient import com.google.android.gms.common.GooglePlayServicesNotAvailableException import com.google.android.gms.common.GooglePlayServicesRepairableException import java.io.IOException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch internal interface DeviceInfoRepository{ fun collectDeviceInfo(): Map } -internal open class AndroidDeviceInfoRepository(private val context: Context): DeviceInfoRepository { +internal open class AndroidDeviceInfoRepository( + private val context: Context, + private val coroutineScope: CoroutineScope +): DeviceInfoRepository { private var adKey: String? = null private val settings: SharedPreferences = context.getSharedPreferences("parsely-prefs", 0) init { - GetAdKey(context).execute() + retrieveAdKey() } /** @@ -84,16 +87,12 @@ internal open class AndroidDeviceInfoRepository(private val context: Context): D return uuid } - /** - * Async task to get adKey for this device. - */ - private inner class GetAdKey(private val mContext: Context) : - AsyncTask() { - protected override fun doInBackground(vararg params: Void?): String? { + private fun retrieveAdKey() { + coroutineScope.launch { var idInfo: AdvertisingIdClient.Info? = null var advertId: String? = null try { - idInfo = AdvertisingIdClient.getAdvertisingIdInfo(mContext) + idInfo = AdvertisingIdClient.getAdvertisingIdInfo(context) } catch (e: GooglePlayServicesRepairableException) { ParselyTracker.PLog("No Google play services or error! falling back to device uuid") // fall back to device uuid on google play errors @@ -113,10 +112,7 @@ internal open class AndroidDeviceInfoRepository(private val context: Context): D } catch (e: NullPointerException) { advertId = siteUuid } - return advertId - } - override fun onPostExecute(advertId: String?) { adKey = advertId } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index cb4f399a..6d59fb60 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -69,7 +69,7 @@ public class ParselyTracker { */ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); - eventsBuilder = new EventsBuilder(new AndroidDeviceInfoRepository(context), siteId); + eventsBuilder = new EventsBuilder(new AndroidDeviceInfoRepository(context, ParselyCoroutineScopeKt.getSdkScope()), siteId); localStorageRepository = new LocalStorageRepository(context); flushManager = new FlushManager(this, flushInterval * 1000L, ParselyCoroutineScopeKt.getSdkScope()); From 2c0cc68417747f9964f58f5ca2b49119cb316538 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 12:08:44 +0100 Subject: [PATCH 075/101] refactor: simplify AdKey retrieval There's no need to differentiate between different type of exceptions. --- .../parselyandroid/DeviceInfoRepository.kt | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt index 66d63b21..cb7cd50e 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt @@ -89,31 +89,13 @@ internal open class AndroidDeviceInfoRepository( private fun retrieveAdKey() { coroutineScope.launch { - var idInfo: AdvertisingIdClient.Info? = null - var advertId: String? = null - try { - idInfo = AdvertisingIdClient.getAdvertisingIdInfo(context) - } catch (e: GooglePlayServicesRepairableException) { - ParselyTracker.PLog("No Google play services or error! falling back to device uuid") - // fall back to device uuid on google play errors - advertId = siteUuid - } catch (e: IOException) { - ParselyTracker.PLog("No Google play services or error! falling back to device uuid") - advertId = siteUuid - } catch (e: GooglePlayServicesNotAvailableException) { - ParselyTracker.PLog("No Google play services or error! falling back to device uuid") - advertId = siteUuid - } catch (e: IllegalArgumentException) { + adKey = try { + val idInfo = AdvertisingIdClient.getAdvertisingIdInfo(context) + idInfo.id + } catch (e: Exception) { ParselyTracker.PLog("No Google play services or error! falling back to device uuid") - advertId = siteUuid + siteUuid } - try { - advertId = idInfo!!.id - } catch (e: NullPointerException) { - advertId = siteUuid - } - - adKey = advertId } } From 25d06f11fbb148f7c7159eb205bcad5fe9ce76fe Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 12:11:15 +0100 Subject: [PATCH 076/101] fix: remove `appname` from device info It was not used. Also, it's not expected that the mobile sdk will send this field. --- .../java/com/parsely/parselyandroid/DeviceInfoRepository.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt index cb7cd50e..493237c9 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt @@ -41,9 +41,6 @@ internal open class AndroidDeviceInfoRepository( dInfo["os"] = "android" dInfo["os_version"] = String.format("%d", Build.VERSION.SDK_INT) - // FIXME: Not passed in event or used anywhere else. - val txt = context.packageManager.getApplicationLabel(context.applicationInfo) - dInfo["appname"] = txt.toString() return dInfo } From c5962bced8009ceeea793bc365ff98f8b6a1aeda Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 12:11:44 +0100 Subject: [PATCH 077/101] style: remove unused imports --- .../java/com/parsely/parselyandroid/DeviceInfoRepository.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt index 493237c9..9fd082e7 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt @@ -5,9 +5,6 @@ import android.content.SharedPreferences import android.os.Build import android.provider.Settings import com.google.android.gms.ads.identifier.AdvertisingIdClient -import com.google.android.gms.common.GooglePlayServicesNotAvailableException -import com.google.android.gms.common.GooglePlayServicesRepairableException -import java.io.IOException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch From 4c9149413b37de32fcc1f12d537ced5ba78f0ab2 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 13:41:53 +0100 Subject: [PATCH 078/101] tests: make `EventsBuilderTest` not Robolectric test As `DeviceInfoRepository` is now faked, the test doesn't have to use Robolectric anymore. --- .../test/java/com/parsely/parselyandroid/EventsBuilderTest.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index f02a8ad3..e76210c2 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -1,7 +1,5 @@ package com.parsely.parselyandroid -import android.content.Context -import androidx.test.core.app.ApplicationProvider import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.MapAssert import org.junit.Before @@ -9,7 +7,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) internal class EventsBuilderTest { private lateinit var sut: EventsBuilder From 9cd6ccec866c4d9074588db5f50283c4ba79a580 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 15:27:59 +0100 Subject: [PATCH 079/101] refactor: extract getting ad key to `AdvertisementIdProvider` It encapsulates all logic related to retrieving an ad key --- .../parselyandroid/AdvertisementIdProvider.kt | 31 ++++++++++++++++++ .../parselyandroid/DeviceInfoRepository.kt | 32 ++++++------------- .../parselyandroid/ParselyTracker.java | 2 +- 3 files changed, 42 insertions(+), 23 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt new file mode 100644 index 00000000..8cb92da3 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt @@ -0,0 +1,31 @@ +package com.parsely.parselyandroid + +import android.content.Context +import com.google.android.gms.ads.identifier.AdvertisingIdClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal class AdvertisementIdProvider( + private val context: Context, + coroutineScope: CoroutineScope +) : IdProvider { + + private var adKey: String? = null + + init { + coroutineScope.launch { + try { + val idInfo = AdvertisingIdClient.getAdvertisingIdInfo(context) + idInfo.id + } catch (e: Exception) { + ParselyTracker.PLog("No Google play services or error!") + } + } + } + + override fun provide(): String? = adKey +} + +internal fun interface IdProvider { + fun provide(): String? +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt index 9fd082e7..7f56eb54 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt @@ -4,9 +4,6 @@ import android.content.Context import android.content.SharedPreferences import android.os.Build import android.provider.Settings -import com.google.android.gms.ads.identifier.AdvertisingIdClient -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch internal interface DeviceInfoRepository{ fun collectDeviceInfo(): Map @@ -14,15 +11,10 @@ internal interface DeviceInfoRepository{ internal open class AndroidDeviceInfoRepository( private val context: Context, - private val coroutineScope: CoroutineScope + private val advertisementIdProvider: IdProvider ): DeviceInfoRepository { - private var adKey: String? = null private val settings: SharedPreferences = context.getSharedPreferences("parsely-prefs", 0) - init { - retrieveAdKey() - } - /** * Collect device-specific info. * @@ -43,8 +35,16 @@ internal open class AndroidDeviceInfoRepository( private val parselySiteUuid: String get() { + val adKey = advertisementIdProvider.provide() + ParselyTracker.PLog("adkey is: %s, uuid is %s", adKey, siteUuid) - return if (adKey != null) adKey!! else siteUuid!! + + return if (adKey != null) { + adKey + } else { + ParselyTracker.PLog("falling back to device uuid") + siteUuid .orEmpty() + } } private val siteUuid: String? @@ -81,18 +81,6 @@ internal open class AndroidDeviceInfoRepository( return uuid } - private fun retrieveAdKey() { - coroutineScope.launch { - adKey = try { - val idInfo = AdvertisingIdClient.getAdvertisingIdInfo(context) - idInfo.id - } catch (e: Exception) { - ParselyTracker.PLog("No Google play services or error! falling back to device uuid") - siteUuid - } - } - } - companion object { private const val UUID_KEY = "parsely-uuid" } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 6d59fb60..c58a93db 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -69,7 +69,7 @@ public class ParselyTracker { */ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); - eventsBuilder = new EventsBuilder(new AndroidDeviceInfoRepository(context, ParselyCoroutineScopeKt.getSdkScope()), siteId); + eventsBuilder = new EventsBuilder(new AndroidDeviceInfoRepository(context, new AdvertisementIdProvider(context, ParselyCoroutineScopeKt.getSdkScope())), siteId); localStorageRepository = new LocalStorageRepository(context); flushManager = new FlushManager(this, flushInterval * 1000L, ParselyCoroutineScopeKt.getSdkScope()); From 6ff145acba14bf1da74863c1ae26e441e6437c0c Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 16:09:39 +0100 Subject: [PATCH 080/101] refactor: extract getting uuid to `UuidProvider` It encapsulates all logic related to retrieving uuid --- .../parselyandroid/AdvertisementIdProvider.kt | 49 +++++++++++++++++++ .../parselyandroid/DeviceInfoRepository.kt | 47 ++---------------- .../parselyandroid/ParselyTracker.java | 6 ++- 3 files changed, 57 insertions(+), 45 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt index 8cb92da3..e0b75c07 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt @@ -1,6 +1,8 @@ package com.parsely.parselyandroid import android.content.Context +import android.content.SharedPreferences +import android.provider.Settings import com.google.android.gms.ads.identifier.AdvertisingIdClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -26,6 +28,53 @@ internal class AdvertisementIdProvider( override fun provide(): String? = adKey } +internal class UuidProvider(private val context: Context) : IdProvider { + private val settings: SharedPreferences = context.getSharedPreferences("parsely-prefs", 0) + + private val siteUuid: String? + /** + * Get the UUID for this user. + */ + get() { + var uuid: String? = "" + try { + uuid = settings.getString(UUID_KEY, "") + if (uuid == "") { + uuid = generateSiteUuid() + } + } catch (ex: Exception) { + ParselyTracker.PLog( + "Exception caught during site uuid generation: %s", + ex.toString() + ) + } + return uuid + } + + /** + * Read the Parsely UUID from application context or make a new one. + * + * @return The UUID to use for this user. + */ + private fun generateSiteUuid(): String { + val uuid = Settings.Secure.getString( + context.applicationContext.contentResolver, + Settings.Secure.ANDROID_ID + ) + ParselyTracker.PLog(String.format("Generated UUID: %s", uuid)) + return uuid + } + + override fun provide(): String? { + return siteUuid + } + + companion object { + private const val UUID_KEY = "parsely-uuid" + } + +} + internal fun interface IdProvider { fun provide(): String? } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt index 7f56eb54..844db97f 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt @@ -1,19 +1,15 @@ package com.parsely.parselyandroid -import android.content.Context -import android.content.SharedPreferences import android.os.Build -import android.provider.Settings internal interface DeviceInfoRepository{ fun collectDeviceInfo(): Map } internal open class AndroidDeviceInfoRepository( - private val context: Context, - private val advertisementIdProvider: IdProvider + private val advertisementIdProvider: IdProvider, + private val uuidProvider: IdProvider, ): DeviceInfoRepository { - private val settings: SharedPreferences = context.getSharedPreferences("parsely-prefs", 0) /** * Collect device-specific info. @@ -36,6 +32,7 @@ internal open class AndroidDeviceInfoRepository( private val parselySiteUuid: String get() { val adKey = advertisementIdProvider.provide() + val siteUuid = uuidProvider.provide() ParselyTracker.PLog("adkey is: %s, uuid is %s", adKey, siteUuid) @@ -46,42 +43,4 @@ internal open class AndroidDeviceInfoRepository( siteUuid .orEmpty() } } - - private val siteUuid: String? - /** - * Get the UUID for this user. - */ - get() { - var uuid: String? = "" - try { - uuid = settings.getString(UUID_KEY, "") - if (uuid == "") { - uuid = generateSiteUuid() - } - } catch (ex: Exception) { - ParselyTracker.PLog( - "Exception caught during site uuid generation: %s", - ex.toString() - ) - } - return uuid - } - - /** - * Read the Parsely UUID from application context or make a new one. - * - * @return The UUID to use for this user. - */ - private fun generateSiteUuid(): String { - val uuid = Settings.Secure.getString( - context.applicationContext.contentResolver, - Settings.Secure.ANDROID_ID - ) - ParselyTracker.PLog(String.format("Generated UUID: %s", uuid)) - return uuid - } - - companion object { - private const val UUID_KEY = "parsely-uuid" - } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index c58a93db..6104d7f8 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -69,7 +69,11 @@ public class ParselyTracker { */ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); - eventsBuilder = new EventsBuilder(new AndroidDeviceInfoRepository(context, new AdvertisementIdProvider(context, ParselyCoroutineScopeKt.getSdkScope())), siteId); + eventsBuilder = new EventsBuilder( + new AndroidDeviceInfoRepository( + new AdvertisementIdProvider(context, ParselyCoroutineScopeKt.getSdkScope()), + new UuidProvider(context) + ), siteId); localStorageRepository = new LocalStorageRepository(context); flushManager = new FlushManager(this, flushInterval * 1000L, ParselyCoroutineScopeKt.getSdkScope()); From 59a83d8c5b216095ea2450a3a4c3a996bddb36a2 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 16:21:38 +0100 Subject: [PATCH 081/101] tests: add unit tests for `UuidProvider` --- .../parselyandroid/UuidProviderTest.kt | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 parsely/src/test/java/com/parsely/parselyandroid/UuidProviderTest.kt diff --git a/parsely/src/test/java/com/parsely/parselyandroid/UuidProviderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/UuidProviderTest.kt new file mode 100644 index 00000000..6804b00e --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/UuidProviderTest.kt @@ -0,0 +1,56 @@ +package com.parsely.parselyandroid + +import android.app.Application +import android.provider.Settings +import androidx.test.core.app.ApplicationProvider +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class UuidProviderTest { + + lateinit var sut: UuidProvider + + @Before + fun setUp() { + sut = UuidProvider(ApplicationProvider.getApplicationContext()) + } + + @Test + fun `given no site uuid is stored, when requesting uuid, then return ANDROID_ID value`() { + // given + val fakeAndroidId = "test id" + Settings.Secure.putString( + ApplicationProvider.getApplicationContext().contentResolver, + Settings.Secure.ANDROID_ID, + fakeAndroidId + ) + + // when + val result= sut.provide() + + // then + assertThat(result).isEqualTo(fakeAndroidId) + } + + @Test + fun `given site uuid already requested, when requesting uuid, then return same uuid`() { + // given + val fakeAndroidId = "test id" + Settings.Secure.putString( + ApplicationProvider.getApplicationContext().contentResolver, + Settings.Secure.ANDROID_ID, + fakeAndroidId + ) + val storedValue = sut.provide() + + // when + val result = sut.provide() + + // then + assertThat(result).isEqualTo(storedValue) + } +} From 3bcbb381781fc9d1d6e7c836da1de8e5f28da9ad Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 16:34:27 +0100 Subject: [PATCH 082/101] refactor: do not query `ANDROID_ID` from `SharedPreferences`. Actually, it was never working as we never were saving any value to those `SharedPreferences`. This change seems fine because we rely in vast majority of cases on AdvertisingId - only in case of lack of Google Play Services we fallback to `ANDROID_ID` which is the same until factory reset. Closes: #59 --- .../parselyandroid/AdvertisementIdProvider.kt | 53 ++++--------------- .../parselyandroid/DeviceInfoRepository.kt | 8 +-- .../parselyandroid/ParselyTracker.java | 2 +- ...oviderTest.kt => AndroidIdProviderTest.kt} | 6 +-- 4 files changed, 18 insertions(+), 51 deletions(-) rename parsely/src/test/java/com/parsely/parselyandroid/{UuidProviderTest.kt => AndroidIdProviderTest.kt} (90%) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt index e0b75c07..f698b55b 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt @@ -1,7 +1,6 @@ package com.parsely.parselyandroid import android.content.Context -import android.content.SharedPreferences import android.provider.Settings import com.google.android.gms.ads.identifier.AdvertisingIdClient import kotlinx.coroutines.CoroutineScope @@ -28,51 +27,19 @@ internal class AdvertisementIdProvider( override fun provide(): String? = adKey } -internal class UuidProvider(private val context: Context) : IdProvider { - private val settings: SharedPreferences = context.getSharedPreferences("parsely-prefs", 0) - - private val siteUuid: String? - /** - * Get the UUID for this user. - */ - get() { - var uuid: String? = "" - try { - uuid = settings.getString(UUID_KEY, "") - if (uuid == "") { - uuid = generateSiteUuid() - } - } catch (ex: Exception) { - ParselyTracker.PLog( - "Exception caught during site uuid generation: %s", - ex.toString() - ) - } - return uuid +internal class AndroidIdProvider(private val context: Context) : IdProvider { + override fun provide(): String? { + val uuid = try { + Settings.Secure.getString( + context.applicationContext.contentResolver, + Settings.Secure.ANDROID_ID + ) + } catch (ex: Exception) { + null } - - /** - * Read the Parsely UUID from application context or make a new one. - * - * @return The UUID to use for this user. - */ - private fun generateSiteUuid(): String { - val uuid = Settings.Secure.getString( - context.applicationContext.contentResolver, - Settings.Secure.ANDROID_ID - ) - ParselyTracker.PLog(String.format("Generated UUID: %s", uuid)) + ParselyTracker.PLog(String.format("Android ID: %s", uuid)) return uuid } - - override fun provide(): String? { - return siteUuid - } - - companion object { - private const val UUID_KEY = "parsely-uuid" - } - } internal fun interface IdProvider { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt index 844db97f..fc899215 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt @@ -8,7 +8,7 @@ internal interface DeviceInfoRepository{ internal open class AndroidDeviceInfoRepository( private val advertisementIdProvider: IdProvider, - private val uuidProvider: IdProvider, + private val androidIdProvider: IdProvider, ): DeviceInfoRepository { /** @@ -32,15 +32,15 @@ internal open class AndroidDeviceInfoRepository( private val parselySiteUuid: String get() { val adKey = advertisementIdProvider.provide() - val siteUuid = uuidProvider.provide() + val androidId = androidIdProvider.provide() - ParselyTracker.PLog("adkey is: %s, uuid is %s", adKey, siteUuid) + ParselyTracker.PLog("adkey is: %s, uuid is %s", adKey, androidId) return if (adKey != null) { adKey } else { ParselyTracker.PLog("falling back to device uuid") - siteUuid .orEmpty() + androidId .orEmpty() } } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 6104d7f8..60b4bf03 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -72,7 +72,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { eventsBuilder = new EventsBuilder( new AndroidDeviceInfoRepository( new AdvertisementIdProvider(context, ParselyCoroutineScopeKt.getSdkScope()), - new UuidProvider(context) + new AndroidIdProvider(context) ), siteId); localStorageRepository = new LocalStorageRepository(context); flushManager = new FlushManager(this, flushInterval * 1000L, diff --git a/parsely/src/test/java/com/parsely/parselyandroid/UuidProviderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/AndroidIdProviderTest.kt similarity index 90% rename from parsely/src/test/java/com/parsely/parselyandroid/UuidProviderTest.kt rename to parsely/src/test/java/com/parsely/parselyandroid/AndroidIdProviderTest.kt index 6804b00e..eaf24427 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/UuidProviderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/AndroidIdProviderTest.kt @@ -10,13 +10,13 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) -internal class UuidProviderTest { +internal class AndroidIdProviderTest { - lateinit var sut: UuidProvider + lateinit var sut: AndroidIdProvider @Before fun setUp() { - sut = UuidProvider(ApplicationProvider.getApplicationContext()) + sut = AndroidIdProvider(ApplicationProvider.getApplicationContext()) } @Test From c6f68bf0a00fdbe38525718490c19127e8878a08 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 16:43:14 +0100 Subject: [PATCH 083/101] tests: add unit tests for `AndroidDeviceInfoRepository` --- .../AndroidDeviceInfoRepositoryTest.kt | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 parsely/src/test/java/com/parsely/parselyandroid/AndroidDeviceInfoRepositoryTest.kt diff --git a/parsely/src/test/java/com/parsely/parselyandroid/AndroidDeviceInfoRepositoryTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/AndroidDeviceInfoRepositoryTest.kt new file mode 100644 index 00000000..0f41f7b6 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/AndroidDeviceInfoRepositoryTest.kt @@ -0,0 +1,78 @@ +package com.parsely.parselyandroid + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowBuild + +private const val SDK_VERSION = 33 +private const val MANUFACTURER = "test manufacturer" + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [SDK_VERSION]) +internal class AndroidDeviceInfoRepositoryTest { + + private lateinit var sut: AndroidDeviceInfoRepository + + @Before + fun setUp() { + ShadowBuild.setManufacturer(MANUFACTURER) + } + + @Test + fun `given the advertisement id exists, when collecting device info, then parsely site uuid is advertisement id`() { + // given + val advertisementId = "ad id" + sut = AndroidDeviceInfoRepository( + advertisementIdProvider = { advertisementId }, + androidIdProvider = { "android id" }) + + // when + val result = sut.collectDeviceInfo() + + // then + assertThat(result).isEqualTo(expectedConstantDeviceInfo + ("parsely_site_uuid" to advertisementId)) + } + + @Test + fun `given the advertisement is null and android id is not, when collecting device info, then parsely id is android id`() { + // given + val androidId = "android id" + sut = AndroidDeviceInfoRepository( + advertisementIdProvider = { null }, + androidIdProvider = { androidId } + ) + + // when + val result = sut.collectDeviceInfo() + + // then + assertThat(result).isEqualTo(expectedConstantDeviceInfo + ("parsely_site_uuid" to androidId)) + } + + @Test + fun `given both advertisement id and android id are null, when collecting device info, then parsely id is empty`() { + // given + sut = AndroidDeviceInfoRepository( + advertisementIdProvider = { null }, + androidIdProvider = { null } + ) + + // when + val result = sut.collectDeviceInfo() + + // then + assertThat(result).isEqualTo(expectedConstantDeviceInfo + ("parsely_site_uuid" to "")) + } + + private companion object { + val expectedConstantDeviceInfo = mapOf( + "manufacturer" to MANUFACTURER, + "os" to "android", + "os_version" to "$SDK_VERSION" + ) + } +} From 366dae5669de3be03dbb73dd4b84180aabe1cf87 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 12:05:48 +0100 Subject: [PATCH 084/101] refactor: rewrite SdkInit to simple function --- .../parsely/parselyandroid/ParselyTracker.java | 7 +++++-- .../java/com/parsely/parselyandroid/SdkInit.kt | 16 +++++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 7ce0ebc0..f8226291 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -86,8 +86,11 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { timer = new Timer(); isDebug = false; - final SdkInit sdkInit = new SdkInit(ParselyCoroutineScopeKt.getSdkScope(), localStorageRepository, flushManager); - sdkInit.initialize(); + SdkInitKt.initialize( + ParselyCoroutineScopeKt.getSdkScope(), + localStorageRepository, + flushManager + ); ProcessLifecycleOwner.get().getLifecycle().addObserver( (LifecycleEventObserver) (lifecycleOwner, event) -> { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SdkInit.kt b/parsely/src/main/java/com/parsely/parselyandroid/SdkInit.kt index 6e5de8c1..07e00cc5 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SdkInit.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/SdkInit.kt @@ -3,16 +3,14 @@ package com.parsely.parselyandroid import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -internal class SdkInit( - private val scope: CoroutineScope, - private val localStorageRepository: LocalStorageRepository, - private val flushManager: FlushManager, +internal fun initialize( + scope: CoroutineScope, + localStorageRepository: LocalStorageRepository, + flushManager: FlushManager, ) { - fun initialize() { - scope.launch { - if (localStorageRepository.getStoredQueue().isNotEmpty()) { - flushManager.start() - } + scope.launch { + if (localStorageRepository.getStoredQueue().isNotEmpty()) { + flushManager.start() } } } From acae5f688b553a94dd21ceb92a73c2a179e17752 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 12:36:35 +0100 Subject: [PATCH 085/101] refactor: introduce `FlushManager` interface Makes creating fake objects less problematic by removing a need to call real object constructor. --- .../parsely/parselyandroid/FlushManager.kt | 21 ++++++++++---- .../parselyandroid/ParselyTracker.java | 2 +- .../parselyandroid/FlushManagerTest.kt | 10 +++---- .../parsely/parselyandroid/SendEventsTest.kt | 29 ++++++++++--------- 4 files changed, 37 insertions(+), 25 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt index d351a181..1a84e7a6 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt @@ -13,14 +13,21 @@ import kotlinx.coroutines.launch * Handles stopping and starting the flush timer. The flush timer * controls how often we send events to Parse.ly servers. */ -internal open class FlushManager( +internal interface FlushManager { + fun start() + fun stop() + val isRunning: Boolean + val intervalMillis: Long +} + +internal class ParselyFlushManager( private val parselyTracker: ParselyTracker, - val intervalMillis: Long, + override val intervalMillis: Long, private val coroutineScope: CoroutineScope -) { +) : FlushManager { private var job: Job? = null - open fun start() { + override fun start() { if (job?.isActive == true) return job = coroutineScope.launch { @@ -31,8 +38,10 @@ internal open class FlushManager( } } - open fun stop() = job?.cancel() + override fun stop() { + job?.cancel() + } - open val isRunning: Boolean + override val isRunning: Boolean get() = job?.isActive ?: false } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index f8226291..eb309c67 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -71,7 +71,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); eventsBuilder = new EventsBuilder(context, siteId); localStorageRepository = new LocalStorageRepository(context); - flushManager = new FlushManager(this, flushInterval * 1000L, + flushManager = new ParselyFlushManager(this, flushInterval * 1000L, ParselyCoroutineScopeKt.getSdkScope()); inMemoryBuffer = new InMemoryBuffer(ParselyCoroutineScopeKt.getSdkScope(), localStorageRepository, () -> { if (!flushTimerIsActive()) { diff --git a/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt index cf2ef157..25f987c3 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt @@ -20,7 +20,7 @@ class FlushManagerTest { @Test fun `when timer starts and interval time passes, then flush queue`() = runTest { - sut = FlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + sut = ParselyFlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(DEFAULT_INTERVAL_MILLIS) @@ -31,7 +31,7 @@ class FlushManagerTest { @Test fun `when timer starts and three interval time passes, then flush queue 3 times`() = runTest { - sut = FlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + sut = ParselyFlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(3 * DEFAULT_INTERVAL_MILLIS) @@ -43,7 +43,7 @@ class FlushManagerTest { @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 = ParselyFlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(2 * DEFAULT_INTERVAL_MILLIS) @@ -58,7 +58,7 @@ class FlushManagerTest { @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 = ParselyFlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) @@ -73,7 +73,7 @@ class FlushManagerTest { @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 = ParselyFlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt index f3a0991f..da5ffdfb 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt @@ -21,7 +21,7 @@ class SendEventsTest { runTest { // given sut = SendEvents( - FakeFlushManager(this), + FakeFlushManager(), FakeLocalStorageRepository(), FakeParselyAPIConnection(), this @@ -46,7 +46,7 @@ class SendEventsTest { nextResult = Result.success(Unit) } sut = SendEvents( - FakeFlushManager(this), + FakeFlushManager(), repository, parselyAPIConnection, this @@ -68,7 +68,7 @@ class SendEventsTest { insertEvents(listOf(mapOf("test" to 123))) } sut = SendEvents( - FakeFlushManager(this), + FakeFlushManager(), repository, FakeParselyAPIConnection(), this @@ -93,7 +93,7 @@ class SendEventsTest { nextResult = Result.failure(Exception()) } sut = SendEvents( - FakeFlushManager(this), + FakeFlushManager(), repository, parselyAPIConnection, this @@ -111,7 +111,7 @@ class SendEventsTest { fun `given non-empty local storage and debug mode off, when sending events, then flush manager is stopped`() = runTest { // given - val flushManager = FakeFlushManager(this) + val flushManager = FakeFlushManager() val repository = FakeLocalStorageRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } @@ -137,7 +137,7 @@ class SendEventsTest { fun `given non-empty local storage and debug mode off, when sending events fails, then flush manager is not stopped`() = runTest { // given - val flushManager = FakeFlushManager(this) + val flushManager = FakeFlushManager() val repository = FakeLocalStorageRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } @@ -163,7 +163,7 @@ class SendEventsTest { fun `given non-empty local storage and debug mode off, when storage is not empty after successful event, then flush manager is not stopped`() = runTest { // given - val flushManager = FakeFlushManager(this) + val flushManager = FakeFlushManager() val repository = object : FakeLocalStorageRepository() { override suspend fun getStoredQueue(): ArrayList?> { return ArrayList(listOf(mapOf("test" to 123))) @@ -190,7 +190,7 @@ class SendEventsTest { @Test fun `given empty local storage, when invoked, then flush manager is stopped`() = runTest { // given - val flushManager = FakeFlushManager(this) + val flushManager = FakeFlushManager() sut = SendEvents( flushManager, FakeLocalStorageRepository(), @@ -206,17 +206,20 @@ class SendEventsTest { assertThat(flushManager.stopped).isTrue() } - private class FakeFlushManager(scope: CoroutineScope) : FlushManager(FakeTracker(), 10, scope) { + private class FakeFlushManager : FlushManager { var stopped = false + override fun start() { + TODO("Not implemented") + } override fun stop() { stopped = true } - } - private class FakeTracker : ParselyTracker( - "siteId", 10, ApplicationProvider.getApplicationContext() - ) { + override val isRunning + get() = TODO("Not implemented") + override val intervalMillis + get() = TODO("Not implemented") } private open class FakeLocalStorageRepository : From 77303c61969bb1418844763f83be7f0a871b9356 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 12:44:37 +0100 Subject: [PATCH 086/101] refactor: introduce `QueueManager` interface Makes creating fake objects less problematic by removing a need to call real object constructor. --- .../com/parsely/parselyandroid/InMemoryBuffer.kt | 2 +- .../parselyandroid/LocalStorageRepository.kt | 14 ++++++++++---- .../java/com/parsely/parselyandroid/SendEvents.kt | 2 +- .../parsely/parselyandroid/InMemoryBufferTest.kt | 8 +++++--- .../com/parsely/parselyandroid/SendEventsTest.kt | 3 +-- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt index c92c0c7a..619e993d 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.sync.withLock internal class InMemoryBuffer( private val coroutineScope: CoroutineScope, - private val localStorageRepository: LocalStorageRepository, + private val localStorageRepository: QueueRepository, private val onEventAddedListener: () -> Unit, ) { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index 038d38d6..dc7d7134 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -8,7 +8,13 @@ import java.io.ObjectOutputStream import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -internal open class LocalStorageRepository(private val context: Context) { +interface QueueRepository { + suspend fun remove(toRemove: List?>) + suspend fun getStoredQueue(): ArrayList?> + suspend fun insertEvents(toInsert: List?>) +} + +internal class LocalStorageRepository(private val context: Context): QueueRepository { private val mutex = Mutex() @@ -32,7 +38,7 @@ internal open class LocalStorageRepository(private val context: Context) { } } - open suspend fun remove(toRemove: List?>) { + override suspend fun remove(toRemove: List?>) { val storedEvents = getStoredQueue() mutex.withLock { @@ -45,7 +51,7 @@ internal open class LocalStorageRepository(private val context: Context) { * * @return The stored queue of events. */ - open suspend fun getStoredQueue(): ArrayList?> = mutex.withLock { + override suspend fun getStoredQueue(): ArrayList?> = mutex.withLock { var storedQueue: ArrayList?> = ArrayList() try { @@ -71,7 +77,7 @@ internal open class LocalStorageRepository(private val context: Context) { /** * Save the event queue to persistent storage. */ - open suspend fun insertEvents(toInsert: List?>){ + override suspend fun insertEvents(toInsert: List?>){ val storedEvents = getStoredQueue() mutex.withLock { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt index 4172debb..1be22b5d 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.sync.withLock internal class SendEvents( private val flushManager: FlushManager, - private val localStorageRepository: LocalStorageRepository, + private val localStorageRepository: QueueRepository, private val parselyAPIConnection: ParselyAPIConnection, private val scope: CoroutineScope ) { diff --git a/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt index 71266c46..e4f354ff 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt @@ -1,6 +1,5 @@ package com.parsely.parselyandroid -import androidx.test.core.app.ApplicationProvider import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel @@ -78,8 +77,7 @@ internal class InMemoryBufferTest { assertThat(repository.getStoredQueue()).containsOnlyOnceElementsOf(events) } - class FakeLocalStorageRepository : - LocalStorageRepository(ApplicationProvider.getApplicationContext()) { + class FakeLocalStorageRepository : QueueRepository { private val events = mutableListOf?>() @@ -87,6 +85,10 @@ internal class InMemoryBufferTest { events.addAll(toInsert) } + override suspend fun remove(toRemove: List?>) { + TODO("Not implemented") + } + override suspend fun getStoredQueue(): ArrayList?> { return ArrayList(events) } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt index da5ffdfb..677d1892 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt @@ -222,8 +222,7 @@ class SendEventsTest { get() = TODO("Not implemented") } - private open class FakeLocalStorageRepository : - LocalStorageRepository(ApplicationProvider.getApplicationContext()) { + private class FakeLocalStorageRepository : QueueRepository { private var storage = emptyList?>() override suspend fun insertEvents(toInsert: List?>) { From be74b2898171be16d0d08491a5cd664e2fb4ef79 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 12:46:15 +0100 Subject: [PATCH 087/101] refactor: introduce `QueueManager` interface Makes creating fake objects less problematic by removing a need to call real object constructor. --- .../parselyandroid/LocalStorageRepository.kt | 2 +- .../com/parsely/parselyandroid/SendEvents.kt | 10 ++++----- .../parsely/parselyandroid/SendEventsTest.kt | 22 +++++++++---------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index dc7d7134..6bc9bd24 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -8,7 +8,7 @@ import java.io.ObjectOutputStream import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -interface QueueRepository { +internal interface QueueRepository { suspend fun remove(toRemove: List?>) suspend fun getStoredQueue(): ArrayList?> suspend fun insertEvents(toInsert: List?>) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt index 1be22b5d..e613f13b 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.sync.withLock internal class SendEvents( private val flushManager: FlushManager, - private val localStorageRepository: QueueRepository, + private val repository: QueueRepository, private val parselyAPIConnection: ParselyAPIConnection, private val scope: CoroutineScope ) { @@ -18,7 +18,7 @@ internal class SendEvents( operator fun invoke(isDebug: Boolean) { scope.launch { mutex.withLock { - val eventsToSend = localStorageRepository.getStoredQueue() + val eventsToSend = repository.getStoredQueue() if (eventsToSend.isEmpty()) { flushManager.stop() @@ -32,16 +32,16 @@ internal class SendEvents( if (isDebug) { ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly") - localStorageRepository.remove(eventsToSend) + repository.remove(eventsToSend) } else { ParselyTracker.PLog("Requested %s", ParselyTracker.ROOT_URL) parselyAPIConnection.send(jsonPayload) .fold( onSuccess = { ParselyTracker.PLog("Pixel request success") - localStorageRepository.remove(eventsToSend) + repository.remove(eventsToSend) ParselyTracker.PLog("Event queue empty, flush timer cleared.") - if (localStorageRepository.getStoredQueue().isEmpty()) { + if (repository.getStoredQueue().isEmpty()) { flushManager.stop() } }, diff --git a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt index 677d1892..cb3a2524 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt @@ -1,7 +1,5 @@ package com.parsely.parselyandroid -import androidx.test.core.app.ApplicationProvider -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -22,7 +20,7 @@ class SendEventsTest { // given sut = SendEvents( FakeFlushManager(), - FakeLocalStorageRepository(), + FakeRepository(), FakeParselyAPIConnection(), this ) @@ -32,14 +30,14 @@ class SendEventsTest { runCurrent() // then - assertThat(FakeLocalStorageRepository().getStoredQueue()).isEmpty() + assertThat(FakeRepository().getStoredQueue()).isEmpty() } @Test fun `given non-empty local storage and debug mode off, when sending events, then events are sent and removed from local storage`() = runTest { // given - val repository = FakeLocalStorageRepository().apply { + val repository = FakeRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } val parselyAPIConnection = FakeParselyAPIConnection().apply { @@ -64,7 +62,7 @@ class SendEventsTest { fun `given non-empty local storage and debug mode on, when sending events, then events are not sent and removed from local storage`() = runTest { // given - val repository = FakeLocalStorageRepository().apply { + val repository = FakeRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } sut = SendEvents( @@ -86,7 +84,7 @@ class SendEventsTest { fun `given non-empty local storage and debug mode off, when sending events fails, then events are not removed from local storage`() = runTest { // given - val repository = FakeLocalStorageRepository().apply { + val repository = FakeRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } val parselyAPIConnection = FakeParselyAPIConnection().apply { @@ -112,7 +110,7 @@ class SendEventsTest { runTest { // given val flushManager = FakeFlushManager() - val repository = FakeLocalStorageRepository().apply { + val repository = FakeRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } val parselyAPIConnection = FakeParselyAPIConnection().apply { @@ -138,7 +136,7 @@ class SendEventsTest { runTest { // given val flushManager = FakeFlushManager() - val repository = FakeLocalStorageRepository().apply { + val repository = FakeRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } val parselyAPIConnection = FakeParselyAPIConnection().apply { @@ -164,7 +162,7 @@ class SendEventsTest { runTest { // given val flushManager = FakeFlushManager() - val repository = object : FakeLocalStorageRepository() { + val repository = object : FakeRepository() { override suspend fun getStoredQueue(): ArrayList?> { return ArrayList(listOf(mapOf("test" to 123))) } @@ -193,7 +191,7 @@ class SendEventsTest { val flushManager = FakeFlushManager() sut = SendEvents( flushManager, - FakeLocalStorageRepository(), + FakeRepository(), FakeParselyAPIConnection(), this ) @@ -222,7 +220,7 @@ class SendEventsTest { get() = TODO("Not implemented") } - private class FakeLocalStorageRepository : QueueRepository { + private open class FakeRepository : QueueRepository { private var storage = emptyList?>() override suspend fun insertEvents(toInsert: List?>) { From a13485cdf98815ab9016de458562bd8d8d23dfde Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 12:53:08 +0100 Subject: [PATCH 088/101] refactor: introduce `RestClient` interface Makes creating fake objects less problematic by removing a need to call real object constructor. --- .../parselyandroid/ParselyAPIConnection.kt | 11 ++++++----- .../com/parsely/parselyandroid/SendEvents.kt | 4 ++-- .../parsely/parselyandroid/SendEventsTest.kt | 18 +++++++++--------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt index 91d4aa3e..c1c1e422 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt @@ -17,12 +17,13 @@ package com.parsely.parselyandroid import java.net.HttpURLConnection import java.net.URL -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -internal open class ParselyAPIConnection(private val url: String) { - open suspend fun send(payload: String): Result { +internal interface RestClient { + suspend fun send(payload: String): Result +} + +internal class ParselyAPIConnection(private val url: String) : RestClient { + override suspend fun send(payload: String): Result { var connection: HttpURLConnection? = null try { connection = URL(url).openConnection() as HttpURLConnection diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt index e613f13b..b1f06e20 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.sync.withLock internal class SendEvents( private val flushManager: FlushManager, private val repository: QueueRepository, - private val parselyAPIConnection: ParselyAPIConnection, + private val restClient: RestClient, private val scope: CoroutineScope ) { @@ -35,7 +35,7 @@ internal class SendEvents( repository.remove(eventsToSend) } else { ParselyTracker.PLog("Requested %s", ParselyTracker.ROOT_URL) - parselyAPIConnection.send(jsonPayload) + restClient.send(jsonPayload) .fold( onSuccess = { ParselyTracker.PLog("Pixel request success") diff --git a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt index cb3a2524..77be99d6 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt @@ -21,7 +21,7 @@ class SendEventsTest { sut = SendEvents( FakeFlushManager(), FakeRepository(), - FakeParselyAPIConnection(), + FakeRestClient(), this ) @@ -40,7 +40,7 @@ class SendEventsTest { val repository = FakeRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } - val parselyAPIConnection = FakeParselyAPIConnection().apply { + val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.success(Unit) } sut = SendEvents( @@ -68,7 +68,7 @@ class SendEventsTest { sut = SendEvents( FakeFlushManager(), repository, - FakeParselyAPIConnection(), + FakeRestClient(), this ) @@ -87,7 +87,7 @@ class SendEventsTest { val repository = FakeRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } - val parselyAPIConnection = FakeParselyAPIConnection().apply { + val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.failure(Exception()) } sut = SendEvents( @@ -113,7 +113,7 @@ class SendEventsTest { val repository = FakeRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } - val parselyAPIConnection = FakeParselyAPIConnection().apply { + val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.success(Unit) } sut = SendEvents( @@ -139,7 +139,7 @@ class SendEventsTest { val repository = FakeRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } - val parselyAPIConnection = FakeParselyAPIConnection().apply { + val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.failure(Exception()) } sut = SendEvents( @@ -167,7 +167,7 @@ class SendEventsTest { return ArrayList(listOf(mapOf("test" to 123))) } } - val parselyAPIConnection = FakeParselyAPIConnection().apply { + val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.success(Unit) } sut = SendEvents( @@ -192,7 +192,7 @@ class SendEventsTest { sut = SendEvents( flushManager, FakeRepository(), - FakeParselyAPIConnection(), + FakeRestClient(), this ) @@ -236,7 +236,7 @@ class SendEventsTest { } } - private class FakeParselyAPIConnection : ParselyAPIConnection("") { + private class FakeRestClient : RestClient { var nextResult: Result? = null From cb5113c8ba298cfedb33813fe0705d5aa8516d2b Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 13:14:04 +0100 Subject: [PATCH 089/101] feat: start `FlushManager` without checking state of stored queue first The `FlushManager` eventually invokes `SendEvents` which checks for the size of stored queue anyways. This change reduces unnecessary complexity and overhead. More context: https://github.com/Parsely/parsely-android/pull/92#discussion_r1396052648 --- .../parsely/parselyandroid/ParselyTracker.java | 6 +----- .../java/com/parsely/parselyandroid/SdkInit.kt | 16 ---------------- 2 files changed, 1 insertion(+), 21 deletions(-) delete mode 100644 parsely/src/main/java/com/parsely/parselyandroid/SdkInit.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index eb309c67..d67a91e0 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -86,11 +86,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { timer = new Timer(); isDebug = false; - SdkInitKt.initialize( - ParselyCoroutineScopeKt.getSdkScope(), - localStorageRepository, - flushManager - ); + flushManager.start(); ProcessLifecycleOwner.get().getLifecycle().addObserver( (LifecycleEventObserver) (lifecycleOwner, event) -> { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SdkInit.kt b/parsely/src/main/java/com/parsely/parselyandroid/SdkInit.kt deleted file mode 100644 index 07e00cc5..00000000 --- a/parsely/src/main/java/com/parsely/parselyandroid/SdkInit.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.parsely.parselyandroid - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -internal fun initialize( - scope: CoroutineScope, - localStorageRepository: LocalStorageRepository, - flushManager: FlushManager, -) { - scope.launch { - if (localStorageRepository.getStoredQueue().isNotEmpty()) { - flushManager.start() - } - } -} From 4b4e47116c8ea5c9e22fedc4cdd7dafbacc3dee6 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 15:21:33 +0100 Subject: [PATCH 090/101] refactor: pass `onFlush` lambda to `ParselyFlushManager` This change decouples `ParselyFlushManager` and `ParselyTracker`. It also makes `FlushManagerTest` resistant to `ParselyTracker` implementation changes. --- .../parsely/parselyandroid/FlushManager.kt | 4 +- .../parselyandroid/ParselyTracker.java | 10 ++++- .../parselyandroid/FlushManagerTest.kt | 43 ++++++++----------- 3 files changed, 27 insertions(+), 30 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt index 1a84e7a6..5026c8d8 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt @@ -21,7 +21,7 @@ internal interface FlushManager { } internal class ParselyFlushManager( - private val parselyTracker: ParselyTracker, + private val onFlush: () -> Unit, override val intervalMillis: Long, private val coroutineScope: CoroutineScope ) : FlushManager { @@ -33,7 +33,7 @@ internal class ParselyFlushManager( job = coroutineScope.launch { while (isActive) { delay(intervalMillis) - parselyTracker.flushEvents() + onFlush.invoke() } } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index d67a91e0..cffa9938 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -32,6 +32,7 @@ import java.util.UUID; import kotlin.Unit; +import kotlin.jvm.functions.Function0; /** * Tracks Parse.ly app views in Android apps @@ -71,7 +72,13 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); eventsBuilder = new EventsBuilder(context, siteId); localStorageRepository = new LocalStorageRepository(context); - flushManager = new ParselyFlushManager(this, flushInterval * 1000L, + flushManager = new ParselyFlushManager(new Function0() { + @Override + public Unit invoke() { + flushEvents(); + return Unit.INSTANCE; + } + }, flushInterval * 1000L, ParselyCoroutineScopeKt.getSdkScope()); inMemoryBuffer = new InMemoryBuffer(ParselyCoroutineScopeKt.getSdkScope(), localStorageRepository, () -> { if (!flushTimerIsActive()) { @@ -466,5 +473,4 @@ void flushEvents() { } sendEvents.invoke(isDebug); } - } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt index 25f987c3..7621b09a 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt @@ -1,6 +1,5 @@ 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 @@ -8,42 +7,42 @@ 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() + private var flushEventsCounter = 0 @Test fun `when timer starts and interval time passes, then flush queue`() = runTest { - sut = ParselyFlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(DEFAULT_INTERVAL_MILLIS) runCurrent() - assertThat(tracker.flushEventsCounter).isEqualTo(1) + assertThat(flushEventsCounter).isEqualTo(1) } @Test fun `when timer starts and three interval time passes, then flush queue 3 times`() = runTest { - sut = ParselyFlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(3 * DEFAULT_INTERVAL_MILLIS) runCurrent() - assertThat(tracker.flushEventsCounter).isEqualTo(3) + assertThat(flushEventsCounter).isEqualTo(3) } @Test fun `when timer starts and is stopped after 2 intervals passes, then flush queue 2 times`() = runTest { - sut = ParselyFlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(2 * DEFAULT_INTERVAL_MILLIS) @@ -52,13 +51,14 @@ class FlushManagerTest { advanceTimeBy(DEFAULT_INTERVAL_MILLIS) runCurrent() - assertThat(tracker.flushEventsCounter).isEqualTo(2) + assertThat(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 = ParselyFlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) @@ -67,13 +67,14 @@ class FlushManagerTest { advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) runCurrent() - assertThat(tracker.flushEventsCounter).isEqualTo(0) + assertThat(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 = ParselyFlushManager(tracker, DEFAULT_INTERVAL_MILLIS, backgroundScope) + sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) @@ -82,25 +83,15 @@ class FlushManagerTest { advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) runCurrent() - assertThat(tracker.flushEventsCounter).isEqualTo(1) + assertThat(flushEventsCounter).isEqualTo(1) advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) runCurrent() - assertThat(tracker.flushEventsCounter).isEqualTo(1) + assertThat(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++ - } - } } From c9872d488a070234975b32b1f605f4f46de2c626 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 15:33:33 +0100 Subject: [PATCH 091/101] refactor: rename `SendEvents` to `FlushQueue` and `isDebug` to `skipSendingEvents` --- .../{SendEvents.kt => FlushQueue.kt} | 6 ++-- .../parselyandroid/ParselyTracker.java | 6 ++-- .../{SendEventsTest.kt => FlushQueueTest.kt} | 32 +++++++++---------- 3 files changed, 22 insertions(+), 22 deletions(-) rename parsely/src/main/java/com/parsely/parselyandroid/{SendEvents.kt => FlushQueue.kt} (94%) rename parsely/src/test/java/com/parsely/parselyandroid/{SendEventsTest.kt => FlushQueueTest.kt} (83%) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt similarity index 94% rename from parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt rename to parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt index b1f06e20..867c536e 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/SendEvents.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -internal class SendEvents( +internal class FlushQueue( private val flushManager: FlushManager, private val repository: QueueRepository, private val restClient: RestClient, @@ -15,7 +15,7 @@ internal class SendEvents( private val mutex = Mutex() - operator fun invoke(isDebug: Boolean) { + operator fun invoke(skipSendingEvents: Boolean) { scope.launch { mutex.withLock { val eventsToSend = repository.getStoredQueue() @@ -30,7 +30,7 @@ internal class SendEvents( ParselyTracker.PLog("POST Data %s", jsonPayload) - if (isDebug) { + if (skipSendingEvents) { ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly") repository.remove(eventsToSend) } else { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index cffa9938..6b341e87 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -63,7 +63,7 @@ public class ParselyTracker { @NonNull private final InMemoryBuffer inMemoryBuffer; @NonNull - private final SendEvents sendEvents; + private final FlushQueue flushQueue; /** * Create a new ParselyTracker instance. @@ -87,7 +87,7 @@ public Unit invoke() { } return Unit.INSTANCE; }); - sendEvents = new SendEvents(flushManager, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy"), ParselyCoroutineScopeKt.getSdkScope()); + flushQueue = new FlushQueue(flushManager, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy"), ParselyCoroutineScopeKt.getSdkScope()); // get the adkey straight away on instantiation timer = new Timer(); @@ -471,6 +471,6 @@ void flushEvents() { PLog("Network unreachable. Not flushing."); return; } - sendEvents.invoke(isDebug); + flushQueue.invoke(isDebug); } } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt similarity index 83% rename from parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt rename to parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt index 77be99d6..75a8fe9b 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/SendEventsTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt @@ -10,15 +10,15 @@ import org.robolectric.RobolectricTestRunner @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) -class SendEventsTest { +class FlushQueueTest { - private lateinit var sut: SendEvents + private lateinit var sut: FlushQueue @Test fun `given empty local storage, when sending events, then do nothing`() = runTest { // given - sut = SendEvents( + sut = FlushQueue( FakeFlushManager(), FakeRepository(), FakeRestClient(), @@ -34,7 +34,7 @@ class SendEventsTest { } @Test - fun `given non-empty local storage and debug mode off, when sending events, then events are sent and removed from local storage`() = + fun `given non-empty local storage, when flushing queue with not skipping sending events, then events are sent and removed from local storage`() = runTest { // given val repository = FakeRepository().apply { @@ -43,7 +43,7 @@ class SendEventsTest { val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.success(Unit) } - sut = SendEvents( + sut = FlushQueue( FakeFlushManager(), repository, parselyAPIConnection, @@ -59,13 +59,13 @@ class SendEventsTest { } @Test - fun `given non-empty local storage and debug mode on, when sending events, then events are not sent and removed from local storage`() = + fun `given non-empty local storage, when flushing queue with skipping sending events, then events are not sent and removed from local storage`() = runTest { // given val repository = FakeRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } - sut = SendEvents( + sut = FlushQueue( FakeFlushManager(), repository, FakeRestClient(), @@ -81,7 +81,7 @@ class SendEventsTest { } @Test - fun `given non-empty local storage and debug mode off, when sending events fails, then events are not removed from local storage`() = + fun `given non-empty local storage, when flushing queue with not skipping sending events fails, then events are not removed from local storage`() = runTest { // given val repository = FakeRepository().apply { @@ -90,7 +90,7 @@ class SendEventsTest { val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.failure(Exception()) } - sut = SendEvents( + sut = FlushQueue( FakeFlushManager(), repository, parselyAPIConnection, @@ -106,7 +106,7 @@ class SendEventsTest { } @Test - fun `given non-empty local storage and debug mode off, when sending events, then flush manager is stopped`() = + fun `given non-empty local storage, when flushing queue with not skipping sending events, then flush manager is stopped`() = runTest { // given val flushManager = FakeFlushManager() @@ -116,7 +116,7 @@ class SendEventsTest { val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.success(Unit) } - sut = SendEvents( + sut = FlushQueue( flushManager, repository, parselyAPIConnection, @@ -132,7 +132,7 @@ class SendEventsTest { } @Test - fun `given non-empty local storage and debug mode off, when sending events fails, then flush manager is not stopped`() = + fun `given non-empty local storage, when flushing queue with not skipping sending events fails, then flush manager is not stopped`() = runTest { // given val flushManager = FakeFlushManager() @@ -142,7 +142,7 @@ class SendEventsTest { val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.failure(Exception()) } - sut = SendEvents( + sut = FlushQueue( flushManager, repository, parselyAPIConnection, @@ -158,7 +158,7 @@ class SendEventsTest { } @Test - fun `given non-empty local storage and debug mode off, when storage is not empty after successful event, then flush manager is not stopped`() = + fun `given non-empty local storage, when storage is not empty after successful flushing queue with not skipping sending events, then flush manager is not stopped`() = runTest { // given val flushManager = FakeFlushManager() @@ -170,7 +170,7 @@ class SendEventsTest { val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.success(Unit) } - sut = SendEvents( + sut = FlushQueue( flushManager, repository, parselyAPIConnection, @@ -189,7 +189,7 @@ class SendEventsTest { fun `given empty local storage, when invoked, then flush manager is stopped`() = runTest { // given val flushManager = FakeFlushManager() - sut = SendEvents( + sut = FlushQueue( flushManager, FakeRepository(), FakeRestClient(), From 269a9d64f3146e23063237b02774916cdee3ce18 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 15:40:34 +0100 Subject: [PATCH 092/101] style: improve position of logging statements in `FlushQueue` --- .../main/java/com/parsely/parselyandroid/FlushQueue.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt index 867c536e..2d880159 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt @@ -24,16 +24,14 @@ internal class FlushQueue( flushManager.stop() return@launch } - ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) - - val jsonPayload = toParselyEventsPayload(eventsToSend) - - ParselyTracker.PLog("POST Data %s", jsonPayload) if (skipSendingEvents) { - ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly") + ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly. Otherwise, would sent ${eventsToSend.size} events") repository.remove(eventsToSend) } else { + ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) + val jsonPayload = toParselyEventsPayload(eventsToSend) + ParselyTracker.PLog("POST Data %s", jsonPayload) ParselyTracker.PLog("Requested %s", ParselyTracker.ROOT_URL) restClient.send(jsonPayload) .fold( From 409c42d89639e1f7139db7487ab3766bff3bf8f9 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 15:44:27 +0100 Subject: [PATCH 093/101] style: return from `FlushQueue` if `skipSendingEvents` To improve readability and align with `eventsToSend.isEmpty()` check --- parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt index 2d880159..54a4f2b8 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt @@ -28,6 +28,7 @@ internal class FlushQueue( if (skipSendingEvents) { ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly. Otherwise, would sent ${eventsToSend.size} events") repository.remove(eventsToSend) + return@launch } else { ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) val jsonPayload = toParselyEventsPayload(eventsToSend) From 95f7a65278cd120895bea01df3e15b3e2dde0d5a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 24 Nov 2023 12:38:03 +0100 Subject: [PATCH 094/101] style: make variables test-local where possible --- .../parsely/parselyandroid/FlushManagerTest.kt | 18 ++++++++++-------- .../parsely/parselyandroid/FlushQueueTest.kt | 18 ++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt index 7621b09a..02842a2c 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt @@ -11,12 +11,10 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class FlushManagerTest { - private lateinit var sut: FlushManager - private var flushEventsCounter = 0 - @Test fun `when timer starts and interval time passes, then flush queue`() = runTest { - sut = + var flushEventsCounter = 0 + val sut = ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() @@ -28,7 +26,8 @@ class FlushManagerTest { @Test fun `when timer starts and three interval time passes, then flush queue 3 times`() = runTest { - sut = + var flushEventsCounter = 0 + val sut = ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() @@ -41,7 +40,8 @@ class FlushManagerTest { @Test fun `when timer starts and is stopped after 2 intervals passes, then flush queue 2 times`() = runTest { - sut = + var flushEventsCounter = 0 + val sut = ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() @@ -57,7 +57,8 @@ class FlushManagerTest { @Test fun `when timer starts, is stopped before end of interval and then time of interval passes, then do not flush queue`() = runTest { - sut = + var flushEventsCounter = 0 + val sut = ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() @@ -73,7 +74,8 @@ class FlushManagerTest { @Test fun `when timer starts, and another timer starts after some time, then flush queue according to the first start`() = runTest { - sut = + var flushEventsCounter = 0 + val sut = ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) sut.start() diff --git a/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt index 75a8fe9b..d92c5705 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt @@ -12,13 +12,11 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class FlushQueueTest { - private lateinit var sut: FlushQueue - @Test fun `given empty local storage, when sending events, then do nothing`() = runTest { // given - sut = FlushQueue( + val sut = FlushQueue( FakeFlushManager(), FakeRepository(), FakeRestClient(), @@ -43,7 +41,7 @@ class FlushQueueTest { val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.success(Unit) } - sut = FlushQueue( + val sut = FlushQueue( FakeFlushManager(), repository, parselyAPIConnection, @@ -65,7 +63,7 @@ class FlushQueueTest { val repository = FakeRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } - sut = FlushQueue( + val sut = FlushQueue( FakeFlushManager(), repository, FakeRestClient(), @@ -90,7 +88,7 @@ class FlushQueueTest { val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.failure(Exception()) } - sut = FlushQueue( + val sut = FlushQueue( FakeFlushManager(), repository, parselyAPIConnection, @@ -116,7 +114,7 @@ class FlushQueueTest { val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.success(Unit) } - sut = FlushQueue( + val sut = FlushQueue( flushManager, repository, parselyAPIConnection, @@ -142,7 +140,7 @@ class FlushQueueTest { val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.failure(Exception()) } - sut = FlushQueue( + val sut = FlushQueue( flushManager, repository, parselyAPIConnection, @@ -170,7 +168,7 @@ class FlushQueueTest { val parselyAPIConnection = FakeRestClient().apply { nextResult = Result.success(Unit) } - sut = FlushQueue( + val sut = FlushQueue( flushManager, repository, parselyAPIConnection, @@ -189,7 +187,7 @@ class FlushQueueTest { fun `given empty local storage, when invoked, then flush manager is stopped`() = runTest { // given val flushManager = FakeFlushManager() - sut = FlushQueue( + val sut = FlushQueue( flushManager, FakeRepository(), FakeRestClient(), From c32302f9094b5eef7342ec065ba3315b5c1a8564 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 24 Nov 2023 13:12:06 +0100 Subject: [PATCH 095/101] feat: update lock logic on local storage repo Now, instead of multiple locks in case of removing or inserting data, we do the same with a single lock. See: https://github.com/Parsely/parsely-android/pull/92#discussion_r1400768099 --- .../parselyandroid/LocalStorageRepository.kt | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index 6bc9bd24..1f1f28fc 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -14,7 +14,7 @@ internal interface QueueRepository { suspend fun insertEvents(toInsert: List?>) } -internal class LocalStorageRepository(private val context: Context): QueueRepository { +internal class LocalStorageRepository(private val context: Context) : QueueRepository { private val mutex = Mutex() @@ -38,21 +38,7 @@ internal class LocalStorageRepository(private val context: Context): QueueReposi } } - override suspend fun remove(toRemove: List?>) { - val storedEvents = getStoredQueue() - - mutex.withLock { - persistObject(storedEvents - toRemove.toSet()) - } - } - - /** - * Get the stored event queue from persistent storage. - * - * @return The stored queue of events. - */ - override suspend fun getStoredQueue(): ArrayList?> = mutex.withLock { - + private fun getInternalStoredQueue(): ArrayList?> { var storedQueue: ArrayList?> = ArrayList() try { val fis = context.applicationContext.openFileInput(STORAGE_KEY) @@ -74,15 +60,26 @@ internal class LocalStorageRepository(private val context: Context): QueueReposi return storedQueue } + override suspend fun remove(toRemove: List?>) = mutex.withLock { + val storedEvents = getInternalStoredQueue() + persistObject(storedEvents - toRemove.toSet()) + } + /** - * Save the event queue to persistent storage. + * Get the stored event queue from persistent storage. + * + * @return The stored queue of events. */ - override suspend fun insertEvents(toInsert: List?>){ - val storedEvents = getStoredQueue() + override suspend fun getStoredQueue(): ArrayList?> = mutex.withLock { + getInternalStoredQueue() + } - mutex.withLock { - persistObject(ArrayList((toInsert + storedEvents).distinct())) - } + /** + * Save the event queue to persistent storage. + */ + override suspend fun insertEvents(toInsert: List?>) = mutex.withLock { + val storedEvents = getInternalStoredQueue() + persistObject(ArrayList((toInsert + storedEvents).distinct())) } companion object { From 2c0ce6e5e726cb2afc4185cf1dfa2fceb5be6a4b Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 24 Nov 2023 14:11:58 +0100 Subject: [PATCH 096/101] style: remove unnecessary `else` from `FlushQueue` --- .../com/parsely/parselyandroid/FlushQueue.kt | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt index 54a4f2b8..b5864e91 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt @@ -29,27 +29,26 @@ internal class FlushQueue( ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly. Otherwise, would sent ${eventsToSend.size} events") repository.remove(eventsToSend) return@launch - } else { - ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) - val jsonPayload = toParselyEventsPayload(eventsToSend) - ParselyTracker.PLog("POST Data %s", jsonPayload) - ParselyTracker.PLog("Requested %s", ParselyTracker.ROOT_URL) - restClient.send(jsonPayload) - .fold( - onSuccess = { - ParselyTracker.PLog("Pixel request success") - repository.remove(eventsToSend) - ParselyTracker.PLog("Event queue empty, flush timer cleared.") - if (repository.getStoredQueue().isEmpty()) { - flushManager.stop() - } - }, - onFailure = { - ParselyTracker.PLog("Pixel request exception") - ParselyTracker.PLog(it.toString()) - } - ) } + ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) + val jsonPayload = toParselyEventsPayload(eventsToSend) + ParselyTracker.PLog("POST Data %s", jsonPayload) + ParselyTracker.PLog("Requested %s", ParselyTracker.ROOT_URL) + restClient.send(jsonPayload) + .fold( + onSuccess = { + ParselyTracker.PLog("Pixel request success") + repository.remove(eventsToSend) + ParselyTracker.PLog("Event queue empty, flush timer cleared.") + if (repository.getStoredQueue().isEmpty()) { + flushManager.stop() + } + }, + onFailure = { + ParselyTracker.PLog("Pixel request exception") + ParselyTracker.PLog(it.toString()) + } + ) } } } From 784a0211aaddccc3ce22287cac3f8927c6463268 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 24 Nov 2023 14:52:41 +0100 Subject: [PATCH 097/101] feat: do not stop FlushManager on successful flush If stored queue will be empty, on next execution of `FlushQueue`, the manager will be stopped anyway. https://github.com/Parsely/parsely-android/pull/92#discussion_r1400718192 --- .../com/parsely/parselyandroid/FlushQueue.kt | 4 --- .../parsely/parselyandroid/FlushQueueTest.kt | 26 ------------------- 2 files changed, 30 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt index b5864e91..4a989b95 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt @@ -39,10 +39,6 @@ internal class FlushQueue( onSuccess = { ParselyTracker.PLog("Pixel request success") repository.remove(eventsToSend) - ParselyTracker.PLog("Event queue empty, flush timer cleared.") - if (repository.getStoredQueue().isEmpty()) { - flushManager.stop() - } }, onFailure = { ParselyTracker.PLog("Pixel request exception") diff --git a/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt index d92c5705..e49605b6 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt @@ -103,32 +103,6 @@ class FlushQueueTest { assertThat(repository.getStoredQueue()).isNotEmpty } - @Test - fun `given non-empty local storage, when flushing queue with not skipping sending events, then flush manager is stopped`() = - runTest { - // given - val flushManager = FakeFlushManager() - val repository = FakeRepository().apply { - insertEvents(listOf(mapOf("test" to 123))) - } - val parselyAPIConnection = FakeRestClient().apply { - nextResult = Result.success(Unit) - } - val sut = FlushQueue( - flushManager, - repository, - parselyAPIConnection, - this - ) - - // when - sut.invoke(false) - runCurrent() - - // then - assertThat(flushManager.stopped).isTrue - } - @Test fun `given non-empty local storage, when flushing queue with not skipping sending events fails, then flush manager is not stopped`() = runTest { From d282f8b1117103d251ce3dc2a213b3a27c72f3b3 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 1 Dec 2023 18:54:09 +0100 Subject: [PATCH 098/101] refactor: make `startTime` a local variable --- .../java/com/parsely/parselyandroid/EngagementManager.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt index aae2d419..2f1dc620 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt @@ -28,18 +28,13 @@ internal class EngagementManager( ) { private var job: Job? = null private var totalTime: Long = 0 - private var startTime: Duration private var nextScheduledExecution: Long = 0 - init { - startTime = clock.now - } - val isRunning: Boolean get() = job?.isActive ?: false fun start() { - startTime = clock.now + val startTime = clock.now job = coroutineScope.launch { while (isActive) { latestDelayMillis = intervalCalculator.calculate(startTime) From 7533f97e0e7575b354ce0ef0a4ba403c6148f7ea Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 5 Dec 2023 17:40:53 +0100 Subject: [PATCH 099/101] fix: assign advertisement id in AdvertisementIdProvider --- .../java/com/parsely/parselyandroid/AdvertisementIdProvider.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt index f698b55b..ba76ca1c 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt @@ -16,8 +16,7 @@ internal class AdvertisementIdProvider( init { coroutineScope.launch { try { - val idInfo = AdvertisingIdClient.getAdvertisingIdInfo(context) - idInfo.id + adKey = AdvertisingIdClient.getAdvertisingIdInfo(context).id } catch (e: Exception) { ParselyTracker.PLog("No Google play services or error!") } From 473dc7e6f65f798f22ae70a6ec54906ec8d2eec1 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 5 Dec 2023 17:47:10 +0100 Subject: [PATCH 100/101] tests: make SUT test-scoped in AndroidDeviceInfoRepositoryTest --- .../parselyandroid/AndroidDeviceInfoRepositoryTest.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/AndroidDeviceInfoRepositoryTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/AndroidDeviceInfoRepositoryTest.kt index 0f41f7b6..08ef47b8 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/AndroidDeviceInfoRepositoryTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/AndroidDeviceInfoRepositoryTest.kt @@ -15,8 +15,6 @@ private const val MANUFACTURER = "test manufacturer" @Config(sdk = [SDK_VERSION]) internal class AndroidDeviceInfoRepositoryTest { - private lateinit var sut: AndroidDeviceInfoRepository - @Before fun setUp() { ShadowBuild.setManufacturer(MANUFACTURER) @@ -26,7 +24,7 @@ internal class AndroidDeviceInfoRepositoryTest { fun `given the advertisement id exists, when collecting device info, then parsely site uuid is advertisement id`() { // given val advertisementId = "ad id" - sut = AndroidDeviceInfoRepository( + val sut = AndroidDeviceInfoRepository( advertisementIdProvider = { advertisementId }, androidIdProvider = { "android id" }) @@ -41,7 +39,7 @@ internal class AndroidDeviceInfoRepositoryTest { fun `given the advertisement is null and android id is not, when collecting device info, then parsely id is android id`() { // given val androidId = "android id" - sut = AndroidDeviceInfoRepository( + val sut = AndroidDeviceInfoRepository( advertisementIdProvider = { null }, androidIdProvider = { androidId } ) @@ -56,7 +54,7 @@ internal class AndroidDeviceInfoRepositoryTest { @Test fun `given both advertisement id and android id are null, when collecting device info, then parsely id is empty`() { // given - sut = AndroidDeviceInfoRepository( + val sut = AndroidDeviceInfoRepository( advertisementIdProvider = { null }, androidIdProvider = { null } ) From 0c3102d82753ebc1c8ec672898b5526be4936b20 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 7 Dec 2023 11:24:01 +0100 Subject: [PATCH 101/101] chore: add a comment for AdvertisementIdProvider#provide --- .../com/parsely/parselyandroid/AdvertisementIdProvider.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt index ba76ca1c..e9f11ce6 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt @@ -23,6 +23,10 @@ internal class AdvertisementIdProvider( } } + /** + * @return advertisement id if the coroutine in the constructor finished executing AdvertisingIdClient#getAdvertisingIdInfo + * null otherwise + */ override fun provide(): String? = adKey }