From 2c74edc54d5c0fb063b17aa5c9e2d4af193d38ba Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 11 Oct 2023 17:43:17 +0200 Subject: [PATCH 001/261] build: add Kotlin Gradle Plugin --- parsely/build.gradle | 1 + settings.gradle | 1 + 2 files changed, 2 insertions(+) diff --git a/parsely/build.gradle b/parsely/build.gradle index 764898d1..f8f02a8e 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -1,5 +1,6 @@ plugins { id 'com.android.library' + id 'org.jetbrains.kotlin.android' } android { diff --git a/settings.gradle b/settings.gradle index e86c25fe..3c3aabc8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,6 +4,7 @@ pluginManagement { plugins { id 'com.android.application' version gradle.ext.agpVersion id 'com.android.library' version gradle.ext.agpVersion + id 'org.jetbrains.kotlin.android' version '1.9.10' } repositories { From abaf9f92bd88f8240707fb2d13dc404498f7b533 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 12 Oct 2023 12:26:28 +0200 Subject: [PATCH 002/261] chore: remove kotlin stdlib version constraints Because now project uses Kotlin Gradle Plugin, those constraints are no longer needed. --- parsely/build.gradle | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/parsely/build.gradle b/parsely/build.gradle index f8f02a8e..74c1c7fb 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -31,24 +31,15 @@ android { } dependencies { - def kotlinVersion = "1.8.10" - implementation 'androidx.appcompat:appcompat:1.4.2' 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' - constraints { - add("implementation", "org.jetbrains.kotlin:kotlin-stdlib-jdk7") { - version { - require(kotlinVersion) - } - } - add("implementation", "org.jetbrains.kotlin:kotlin-stdlib-jdk8") { - version { - require(kotlinVersion) - } - } - } + + testImplementation 'org.robolectric:robolectric:4.9.2' + testImplementation 'androidx.test:core:1.5.0' + testImplementation 'org.assertj:assertj-core:3.24.1' + testImplementation 'junit:junit:4.13.2' } apply from: "${rootProject.projectDir}/publication.gradle" From 8dc0ffcb3eb28acc9f715bc06abfe85f09f40c3b Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 12 Oct 2023 10:42:41 +0200 Subject: [PATCH 003/261] build: add unit tests setup Adding Robolectric to test AsyncTasks. To remove in the future. --- parsely/build.gradle | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/parsely/build.gradle b/parsely/build.gradle index 74c1c7fb..a268e7d4 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -28,6 +28,16 @@ android { withJavadocJar() } } + + testOptions { + unitTests { + includeAndroidResources = true + } + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } } dependencies { From 1c8707748055fa93014dce8a95e3c22c43dc6e3f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 12 Oct 2023 10:49:41 +0200 Subject: [PATCH 004/261] build: add code coverage plugin --- parsely/build.gradle | 1 + settings.gradle | 1 + 2 files changed, 2 insertions(+) diff --git a/parsely/build.gradle b/parsely/build.gradle index a268e7d4..d4b11bd3 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlinx.kover' } android { diff --git a/settings.gradle b/settings.gradle index 3c3aabc8..e42700e3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,6 +5,7 @@ pluginManagement { id 'com.android.application' version gradle.ext.agpVersion id 'com.android.library' version gradle.ext.agpVersion id 'org.jetbrains.kotlin.android' version '1.9.10' + id 'org.jetbrains.kotlinx.kover' version '0.7.4' } repositories { From baa5a56b472dd31cdc431a218a46e1132031ab26 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 12 Oct 2023 12:30:20 +0200 Subject: [PATCH 005/261] ci: run tests and codecoverage report --- .github/workflows/readme.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/readme.yml b/.github/workflows/readme.yml index e369c1a6..a8b90c58 100644 --- a/.github/workflows/readme.yml +++ b/.github/workflows/readme.yml @@ -22,6 +22,13 @@ jobs: run: ./gradlew :example:assembleDebug - name: Android Lint run: ./gradlew lintDebug + - name: Android Unit Tests + run: ./gradlew :parsely:testDebugUnitTest + - name: Generate XML coverage report + run: ./gradlew :parsely:koverXmlReportDebug + - uses: codecov/codecov-action@v3 + with: + files: parsely/build/reports/kover/reportDebug.xml - name: Validate Maven publication run: ./gradlew :parsely:publishReleasePublicationToMavenLocal env: From 7dd5d644869f4443c392d5c2c550aac42f011eaa Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 12 Oct 2023 12:53:37 +0200 Subject: [PATCH 006/261] build: exclude BuildConfig file from code coverage It's file generated by Android build. We don't need to test it. --- parsely/build.gradle | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/parsely/build.gradle b/parsely/build.gradle index d4b11bd3..3b15be5d 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -39,6 +39,16 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() } + + koverReport { + filters { + excludes { + classes( + "*.BuildConfig" + ) + } + } + } } dependencies { From 7f91ca50466eea5572c6e9789b4ce6bf1c742ff6 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 11 Oct 2023 17:56:14 +0200 Subject: [PATCH 007/261] refactor: extract building logic to EventsBuilder --- .../parsely/parselyandroid/EventsBuilder.java | 185 ++++++++++++++++++ .../parselyandroid/ParselyTracker.java | 175 +---------------- 2 files changed, 194 insertions(+), 166 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java new file mode 100644 index 00000000..2f1c1ae8 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java @@ -0,0 +1,185 @@ +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 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 Map deviceInfo; + + 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(); + } + + /** + * Create an event Map + * + * @param url The URL identifying the pageview/heartbeat + * @param action Action to use (e.g. pageview, heartbeat, videostart, vheartbeat) + * @param metadata Metadata to attach to the event. + * @param extraData A Map of additional information to send with the event. + * @return A Map object representing the event to be sent to Parse.ly. + */ + @NonNull + Map buildEvent( + String url, + String urlRef, + String action, + ParselyMetadata metadata, + Map extraData, + @Nullable String uuid + ) { + PLog("buildEvent called for %s/%s", action, url); + + Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + + // Main event info + Map event = new HashMap<>(); + event.put("url", url); + event.put("urlref", urlRef); + event.put("idsite", siteId); + event.put("action", action); + + // Make a copy of extraData and add some things. + Map data = new HashMap<>(); + if (extraData != null) { + data.putAll(extraData); + } + 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")); + event.put("data", data); + + if (metadata != null) { + event.put("metadata", metadata.toMap()); + } + + if (action.equals("videostart") || action.equals("vheartbeat")) { + event.put(VIDEO_START_ID_KEY, uuid); + } + + if (action.equals("pageview") || action.equals("heartbeat")) { + event.put(PAGE_VIEW_ID_KEY, uuid); + } + + return event; + } + + /** + * Collect device-specific info. + *

+ * Collects info about the device and user to use in Parsely events. + */ + private Map collectDeviceInfo(@Nullable final String adKey) { + 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("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; + } + + /** + * 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) { + deviceInfo = collectDeviceInfo(advertId); + } + } +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 8db1cdf0..1412f46c 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -17,11 +17,10 @@ package com.parsely.parselyandroid; import android.content.Context; -import android.content.SharedPreferences; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.os.AsyncTask; -import android.provider.Settings.Secure; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.Lifecycle; @@ -29,9 +28,6 @@ import androidx.lifecycle.ProcessLifecycleOwner; import com.fasterxml.jackson.databind.ObjectMapper; -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.EOFException; import java.io.FileInputStream; @@ -68,33 +64,25 @@ public class ParselyTracker { // emulator localhost // private static final String ROOT_URL = "http://10.0.2.2:5001/"; private static final String ROOT_URL = "https://p1.parsely.com/"; - 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"; - protected ArrayList> eventQueue; - private final String siteId; private boolean isDebug; - private final SharedPreferences settings; - private Map deviceInfo; private final Context context; private final Timer timer; private final FlushManager flushManager; private EngagementManager engagementManager, videoEngagementManager; @Nullable private String lastPageviewUuid = null; + @NonNull + private final EventsBuilder eventsBuilder; /** * Create a new ParselyTracker instance. */ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); - settings = context.getSharedPreferences("parsely-prefs", 0); + this.eventsBuilder = new EventsBuilder(context, siteId); - this.siteId = siteId; // get the adkey straight away on instantiation - deviceInfo = collectDeviceInfo(null); - new GetAdKey(c).execute(); timer = new Timer(); isDebug = false; @@ -156,7 +144,7 @@ public static ParselyTracker sharedInstance(String siteId, int flushInterval, Co /** * Log a message to the console. */ - protected static void PLog(String logString, Object... objects) { + static void PLog(String logString, Object... objects) { if (logString.equals("")) { return; } @@ -259,7 +247,7 @@ public void trackPageview( lastPageviewUuid = generatePixelId(); - enqueueEvent(buildEvent(url, urlRef, "pageview", urlMetadata, extraData, lastPageviewUuid)); + enqueueEvent(eventsBuilder.buildEvent(url, urlRef, "pageview", urlMetadata, extraData, lastPageviewUuid)); } /** @@ -300,7 +288,7 @@ public void startEngagement( stopEngagement(); // Start a new EngagementTask - Map event = buildEvent(url, urlRef, "heartbeat", null, extraData, lastPageviewUuid); + Map event = eventsBuilder.buildEvent(url, urlRef, "heartbeat", null, extraData, lastPageviewUuid); engagementManager = new EngagementManager(timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event); engagementManager.start(); } @@ -371,11 +359,11 @@ public void trackPlay( @NonNull final String uuid = generatePixelId(); // Enqueue the videostart - @NonNull final Map videostartEvent = buildEvent(url, urlRef, "videostart", videoMetadata, extraData, uuid); + @NonNull final Map videostartEvent = eventsBuilder.buildEvent(url, urlRef, "videostart", videoMetadata, extraData, uuid); enqueueEvent(videostartEvent); // Start a new engagement manager for the video. - @NonNull final Map hbEvent = buildEvent(url, urlRef, "vheartbeat", videoMetadata, extraData, uuid); + @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(timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent); videoEngagementManager.start(); @@ -418,62 +406,6 @@ public void resetVideo() { videoEngagementManager = null; } - /** - * Create an event Map - * - * @param url The URL identifying the pageview/heartbeat - * @param action Action to use (e.g. pageview, heartbeat, videostart, vheartbeat) - * @param metadata Metadata to attach to the event. - * @param extraData A Map of additional information to send with the event. - * @return A Map object representing the event to be sent to Parse.ly. - */ - @NonNull - private Map buildEvent( - String url, - String urlRef, - String action, - ParselyMetadata metadata, - Map extraData, - @Nullable String uuid - ) { - PLog("buildEvent called for %s/%s", action, url); - - Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - - // Main event info - Map event = new HashMap<>(); - event.put("url", url); - event.put("urlref", urlRef); - event.put("idsite", siteId); - event.put("action", action); - - // Make a copy of extraData and add some things. - Map data = new HashMap<>(); - if (extraData != null) { - data.putAll(extraData); - } - 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")); - event.put("data", data); - - if (metadata != null) { - event.put("metadata", metadata.toMap()); - } - - if (action.equals("videostart") || action.equals("vheartbeat")) { - event.put(VIDEO_START_ID_KEY, uuid); - } - - if (action.equals("pageview") || action.equals("heartbeat")) { - event.put(PAGE_VIEW_ID_KEY, uuid); - } - - return event; - } - /** * Add an event Map to the queue. *

@@ -671,58 +603,6 @@ private String generatePixelId() { return UUID.randomUUID().toString(); } - /** - * 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 = Secure.getString(context.getApplicationContext().getContentResolver(), - Secure.ANDROID_ID); - PLog(String.format("Generated UUID: %s", uuid)); - 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; - } - - /** - * Collect device-specific info. - *

- * Collects info about the device and user to use in Parsely events. - */ - private Map collectDeviceInfo(@Nullable final String adKey) { - 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("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; - } - /** * Get the number of events waiting to be flushed to Parsely. * @@ -785,43 +665,6 @@ protected synchronized Void doInBackground(Void... params) { } } - /** - * 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) { - deviceInfo = collectDeviceInfo(advertId); - } - - } - - /** * Manager for the event flush timer. *

From 57a207ed10fa1520086637a91721b51c0b5ddc2c Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 12 Oct 2023 10:43:03 +0200 Subject: [PATCH 008/261] tests: pageview event build test --- .../parselyandroid/EventsBuilderTest.kt | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt new file mode 100644 index 00000000..021bd927 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -0,0 +1,71 @@ +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.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class EventsBuilderTest { + + private lateinit var sut: EventsBuilder + + @Before + fun setUp() { + val applicationContext = ApplicationProvider.getApplicationContext() + sut = EventsBuilder( + applicationContext, + TEST_SITE_ID, + ) + Settings.Secure.putString( + applicationContext.contentResolver, + Settings.Secure.ANDROID_ID, + "android_id" + ) + } + + @Test + fun `events builder prepares correct pageview pixel`() { + // when + val event: Map = sut.buildEvent( + TEST_URL, + "", + "pageview", + null, + null, + TEST_UUID, + ) + + // then + assertThat(event) + .hasSize(6) + .containsEntry("action", "pageview") + .containsEntry("url", TEST_URL) + .containsEntry("urlref", "") + .containsEntry("pvid", TEST_UUID) + .containsEntry("idsite", TEST_SITE_ID) + .hasEntrySatisfying("data") { + @Suppress("UNCHECKED_CAST") + it as Map + assertThat(it) + .hasSize(5) + .containsEntry("os", "android") + .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" + } +} \ No newline at end of file From 332173ec0c081c926c6034213ec5f25ea5f78f48 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 17 Oct 2023 15:16:53 +0200 Subject: [PATCH 009/261] build: drop declaration of kotlin JVM target --- parsely/build.gradle | 4 ---- 1 file changed, 4 deletions(-) diff --git a/parsely/build.gradle b/parsely/build.gradle index 3b15be5d..094de3b9 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -36,10 +36,6 @@ android { } } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() - } - koverReport { filters { excludes { From 6d33d75681e9c8499d13c4d19b3187fc13768472 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 17 Oct 2023 15:18:02 +0200 Subject: [PATCH 010/261] build: bump test dependencies to most recent --- parsely/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parsely/build.gradle b/parsely/build.gradle index 094de3b9..e6ed109f 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -53,9 +53,9 @@ dependencies { implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1' implementation 'androidx.lifecycle:lifecycle-process:2.6.2' - testImplementation 'org.robolectric:robolectric:4.9.2' + testImplementation 'org.robolectric:robolectric:4.10.3' testImplementation 'androidx.test:core:1.5.0' - testImplementation 'org.assertj:assertj-core:3.24.1' + testImplementation 'org.assertj:assertj-core:3.24.2' testImplementation 'junit:junit:4.13.2' } From 644cf415a3deb2bb8e71ef83bec56b96624c1c71 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 17 Oct 2023 15:42:28 +0200 Subject: [PATCH 011/261] build: add Codecov token Although open source projects don't require upload with token, CodeCov default token meets limits sometimes, resulting in failed uploads. See more details here: https://github.com/codecov/codecov-action/issues/557#issuecomment-1216749652. The solution is to use our own Codecov token. --- .github/workflows/readme.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/readme.yml b/.github/workflows/readme.yml index a8b90c58..59e66bed 100644 --- a/.github/workflows/readme.yml +++ b/.github/workflows/readme.yml @@ -29,6 +29,7 @@ jobs: - uses: codecov/codecov-action@v3 with: files: parsely/build/reports/kover/reportDebug.xml + token: ${{ secrets.CODECOV_TOKEN }} - name: Validate Maven publication run: ./gradlew :parsely:publishReleasePublicationToMavenLocal env: From 4510d38ddf08204fa9e060468030e8fa65233663 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 18 Oct 2023 14:58:03 +0200 Subject: [PATCH 012/261] tests: heartbeat event build test --- .../parselyandroid/EventsBuilderTest.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 021bd927..02f168a8 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -62,6 +62,41 @@ internal class EventsBuilderTest { .containsEntry("parsely_site_uuid", null) } } + + @Test + fun `events builder prepares correct heartbeat pixel`() { + // when + val event: Map = sut.buildEvent( + TEST_URL, + "", + "heartbeat", + null, + null, + TEST_UUID, + ) + + // then + assertThat(event) + .hasSize(6) + .containsEntry("action", "heartbeat") + .containsEntry("url", TEST_URL) + .containsEntry("urlref", "") + .containsEntry("pvid", TEST_UUID) + .containsEntry("idsite", TEST_SITE_ID) + .hasEntrySatisfying("data") { + @Suppress("UNCHECKED_CAST") + it as Map + assertThat(it) + .hasSize(5) + .containsEntry("os", "android") + .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" From ac43fe4a41b79b2f01d80ebf10b46a5ebed3c61a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 18 Oct 2023 15:14:45 +0200 Subject: [PATCH 013/261] tests: videostart event build test --- .../parselyandroid/EventsBuilderTest.kt | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 02f168a8..0f96cd7f 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -62,7 +62,7 @@ internal class EventsBuilderTest { .containsEntry("parsely_site_uuid", null) } } - + @Test fun `events builder prepares correct heartbeat pixel`() { // when @@ -98,6 +98,41 @@ internal class EventsBuilderTest { } } + @Test + fun `events builder prepares correct videostart pixel`() { + // when + val event: Map = sut.buildEvent( + TEST_URL, + "", + "videostart", + null, + null, + TEST_UUID, + ) + + // then + assertThat(event) + .hasSize(6) + .containsEntry("action", "videostart") + .containsEntry("url", TEST_URL) + .containsEntry("urlref", "") + .containsEntry("vsid", TEST_UUID) + .containsEntry("idsite", TEST_SITE_ID) + .hasEntrySatisfying("data") { + @Suppress("UNCHECKED_CAST") + it as Map + assertThat(it) + .hasSize(5) + .containsEntry("os", "android") + .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" From 5870256ec70c9ce61565474a90429f7fa4bfbf35 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 18 Oct 2023 15:15:17 +0200 Subject: [PATCH 014/261] tests: vheartbeat event build test --- .../parselyandroid/EventsBuilderTest.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 0f96cd7f..9a9649c4 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -133,6 +133,41 @@ internal class EventsBuilderTest { } } + @Test + fun `events builder prepares correct vheartbeat pixel`() { + // when + val event: Map = sut.buildEvent( + TEST_URL, + "", + "vheartbeat", + null, + null, + TEST_UUID, + ) + + // then + assertThat(event) + .hasSize(6) + .containsEntry("action", "vheartbeat") + .containsEntry("url", TEST_URL) + .containsEntry("urlref", "") + .containsEntry("vsid", TEST_UUID) + .containsEntry("idsite", TEST_SITE_ID) + .hasEntrySatisfying("data") { + @Suppress("UNCHECKED_CAST") + it as Map + assertThat(it) + .hasSize(5) + .containsEntry("os", "android") + .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" From 89d066682198d49f6fcaa44df2ecb84922717060 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 18 Oct 2023 15:21:13 +0200 Subject: [PATCH 015/261] refactor: extract shared pixel asserstions To improve readability and reduce boilerplate. --- .../parselyandroid/EventsBuilderTest.kt | 65 ++++--------------- 1 file changed, 11 insertions(+), 54 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 9a9649c4..d977e327 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -4,6 +4,7 @@ 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 import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -42,25 +43,9 @@ internal class EventsBuilderTest { // then assertThat(event) - .hasSize(6) .containsEntry("action", "pageview") - .containsEntry("url", TEST_URL) - .containsEntry("urlref", "") .containsEntry("pvid", TEST_UUID) - .containsEntry("idsite", TEST_SITE_ID) - .hasEntrySatisfying("data") { - @Suppress("UNCHECKED_CAST") - it as Map - assertThat(it) - .hasSize(5) - .containsEntry("os", "android") - .hasEntrySatisfying("ts") { timestamp -> - assertThat(timestamp as Long).isBetween(1111111111111, 9999999999999) - } - .containsEntry("manufacturer", "robolectric") - .containsEntry("os_version", "33") - .containsEntry("parsely_site_uuid", null) - } + .sharedPixelAssertions() } @Test @@ -77,25 +62,9 @@ internal class EventsBuilderTest { // then assertThat(event) - .hasSize(6) .containsEntry("action", "heartbeat") - .containsEntry("url", TEST_URL) - .containsEntry("urlref", "") .containsEntry("pvid", TEST_UUID) - .containsEntry("idsite", TEST_SITE_ID) - .hasEntrySatisfying("data") { - @Suppress("UNCHECKED_CAST") - it as Map - assertThat(it) - .hasSize(5) - .containsEntry("os", "android") - .hasEntrySatisfying("ts") { timestamp -> - assertThat(timestamp as Long).isBetween(1111111111111, 9999999999999) - } - .containsEntry("manufacturer", "robolectric") - .containsEntry("os_version", "33") - .containsEntry("parsely_site_uuid", null) - } + .sharedPixelAssertions() } @Test @@ -112,25 +81,9 @@ internal class EventsBuilderTest { // then assertThat(event) - .hasSize(6) .containsEntry("action", "videostart") - .containsEntry("url", TEST_URL) - .containsEntry("urlref", "") .containsEntry("vsid", TEST_UUID) - .containsEntry("idsite", TEST_SITE_ID) - .hasEntrySatisfying("data") { - @Suppress("UNCHECKED_CAST") - it as Map - assertThat(it) - .hasSize(5) - .containsEntry("os", "android") - .hasEntrySatisfying("ts") { timestamp -> - assertThat(timestamp as Long).isBetween(1111111111111, 9999999999999) - } - .containsEntry("manufacturer", "robolectric") - .containsEntry("os_version", "33") - .containsEntry("parsely_site_uuid", null) - } + .sharedPixelAssertions() } @Test @@ -147,11 +100,15 @@ internal class EventsBuilderTest { // then assertThat(event) - .hasSize(6) .containsEntry("action", "vheartbeat") + .containsEntry("vsid", TEST_UUID) + .sharedPixelAssertions() + } + + private fun MapAssert.sharedPixelAssertions() = + hasSize(6) .containsEntry("url", TEST_URL) .containsEntry("urlref", "") - .containsEntry("vsid", TEST_UUID) .containsEntry("idsite", TEST_SITE_ID) .hasEntrySatisfying("data") { @Suppress("UNCHECKED_CAST") @@ -166,7 +123,7 @@ internal class EventsBuilderTest { .containsEntry("os_version", "33") .containsEntry("parsely_site_uuid", null) } - } + companion object { const val TEST_SITE_ID = "Example" From df80cfa523c52860df8fa35f36f046cdd09789f7 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 18 Oct 2023 15:24:59 +0200 Subject: [PATCH 016/261] tests: add test for null extraData case --- .../parselyandroid/EventsBuilderTest.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index d977e327..8d588967 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -105,6 +105,26 @@ internal class EventsBuilderTest { .sharedPixelAssertions() } + @Test + fun `given extraData is null, when creating a pixel, don't include extraData`() { + // given + val extraData: Map? = null + + // when + val event: Map = sut.buildEvent( + TEST_URL, + "", + "pageview", + null, + extraData, + TEST_UUID, + ) + + // then + @Suppress("UNCHECKED_CAST") + assertThat(event["data"] as Map).hasSize(5) + } + private fun MapAssert.sharedPixelAssertions() = hasSize(6) .containsEntry("url", TEST_URL) From ebeb7b805824b3fd9f3a0f0b273adf958825761d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 18 Oct 2023 15:28:15 +0200 Subject: [PATCH 017/261] tests: add test for not null extraData case --- .../parselyandroid/EventsBuilderTest.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 8d588967..a25eaaa8 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -125,6 +125,30 @@ internal class EventsBuilderTest { assertThat(event["data"] as Map).hasSize(5) } + @Test + fun `given extraData is not null, when creating a pixel, include extraData`() { + // given + val extraData: Map = mapOf( + "extra 1" to "data 1", + "extra 2" to "data 2" + ) + + // when + val event: Map = sut.buildEvent( + TEST_URL, + "", + "pageview", + null, + extraData, + TEST_UUID, + ) + + // then + @Suppress("UNCHECKED_CAST") + assertThat(event["data"] as Map).hasSize(7) + .containsAllEntriesOf(extraData) + } + private fun MapAssert.sharedPixelAssertions() = hasSize(6) .containsEntry("url", TEST_URL) From 0437b89119ac3d7aacf6b55f771314734e06f59e Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 18 Oct 2023 15:34:46 +0200 Subject: [PATCH 018/261] tests: add test for null metadata case --- .../parselyandroid/EventsBuilderTest.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index a25eaaa8..c344027a 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -149,6 +149,26 @@ internal class EventsBuilderTest { .containsAllEntriesOf(extraData) } + @Test + fun `given metadata is null, when creating a pixel, don't include metadata`() { + // given + val metadata: ParselyMetadata? = null + + // when + val event: Map = sut.buildEvent( + TEST_URL, + "", + "pageview", + metadata, + null, + TEST_UUID, + ) + + // then + assertThat(event).doesNotContainKey("metadata") + } + + private fun MapAssert.sharedPixelAssertions() = hasSize(6) .containsEntry("url", TEST_URL) From ca8f2fb36e5860d95209b956878ce9900ce36cf0 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 18 Oct 2023 15:38:07 +0200 Subject: [PATCH 019/261] tests: add test for not null metadata case --- .../parselyandroid/EventsBuilderTest.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index c344027a..f8af344b 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -168,6 +168,25 @@ internal class EventsBuilderTest { assertThat(event).doesNotContainKey("metadata") } + @Test + fun `given metadata is not null, when creating a pixel, include metadata`() { + // given + val metadata = + ParselyMetadata(ArrayList(), "link", "section", null, null, null, null) + + // when + val event: Map = sut.buildEvent( + TEST_URL, + "", + "pageview", + metadata, + null, + TEST_UUID, + ) + + // then + assertThat(event).containsKey("metadata") + } private fun MapAssert.sharedPixelAssertions() = hasSize(6) From 99db4968fdf44b832de5bae8dbcf0cf4b0b9e74a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 18 Oct 2023 15:45:20 +0200 Subject: [PATCH 020/261] tools: disable Codecov Github annotations They don't provide any new data (not covered lines are still visible on Codecov portal) but they shadow diff change of the whole PR, making it more difficult to read. --- codecov.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..77707aa1 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +github_checks: + annotations: false From 1f6d084c922b8cb01fe23be446de932b17a06c47 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 18 Oct 2023 16:20:14 +0200 Subject: [PATCH 021/261] style: remove unnecessary `this` qualifier --- .../main/java/com/parsely/parselyandroid/ParselyTracker.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 1412f46c..5887a77e 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -80,7 +80,7 @@ public class ParselyTracker { */ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); - this.eventsBuilder = new EventsBuilder(context, siteId); + eventsBuilder = new EventsBuilder(context, siteId); // get the adkey straight away on instantiation timer = new Timer(); From 047e6a32a4e77e1c2feaf3c66dc7dc61583827ad Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 18 Oct 2023 16:20:41 +0200 Subject: [PATCH 022/261] style: add empty line at the end of test --- .../test/java/com/parsely/parselyandroid/EventsBuilderTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index f8af344b..33cfe618 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -213,4 +213,4 @@ internal class EventsBuilderTest { const val TEST_URL = "http://example.com/some-old/article.html" const val TEST_UUID = "123e4567-e89b-12d3-a456-426614174000" } -} \ No newline at end of file +} From f10f31a7b15c352f47e9d2aebf12705beb3d8f92 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 19 Oct 2023 10:22:08 +0200 Subject: [PATCH 023/261] refactor: remove unnecessary if check Just above we clear `eventQueue` and purge locally stored events. There's no need to check the size of the queues. --- .../com/parsely/parselyandroid/ParselyAPIConnection.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java index beb225f3..41ad497f 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java @@ -70,10 +70,8 @@ protected void onPostExecute(HttpURLConnection conn) { instance.eventQueue.clear(); instance.purgeStoredQueue(); - if (instance.queueSize() == 0 && instance.storedEventsCount() == 0) { - ParselyTracker.PLog("Event queue empty, flush timer cleared."); - instance.stopFlushTimer(); - } + ParselyTracker.PLog("Event queue empty, flush timer cleared."); + instance.stopFlushTimer(); } } } From 1c11ef7453aff6c344f4038d87d300e1601c382a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 19 Oct 2023 10:40:16 +0200 Subject: [PATCH 024/261] refactor: pass instance of tracker to APIConnection This way, we don't have to check for nullability of the tracker. Also using `ParselyAPIConnection` without first initialising `ParselyTracker` doesn't seem to make any use case, so I think it's a save change. --- .../parselyandroid/ParselyAPIConnection.java | 27 +++++++++---------- .../parselyandroid/ParselyTracker.java | 2 +- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java index 41ad497f..8b6e3825 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java @@ -18,14 +18,22 @@ import android.os.AsyncTask; +import androidx.annotation.NonNull; + import java.io.OutputStream; import java.net.URL; import java.net.HttpURLConnection; public class ParselyAPIConnection extends AsyncTask { + @NonNull + private final ParselyTracker tracker; public Exception exception; + public ParselyAPIConnection(@NonNull ParselyTracker tracker) { + this.tracker = tracker; + } + @Override protected HttpURLConnection doInBackground(String... data) { HttpURLConnection connection = null; @@ -58,21 +66,12 @@ protected void onPostExecute(HttpURLConnection conn) { } else { ParselyTracker.PLog("Pixel request success"); - ParselyTracker instance = null; - try { - instance = ParselyTracker.sharedInstance(); - } catch (NullPointerException ex) { - ParselyTracker.PLog("ParselyTracker is null"); - } - - if (instance != null) { - // only purge the queue if the request was successful - instance.eventQueue.clear(); - instance.purgeStoredQueue(); + // only purge the queue if the request was successful + tracker.eventQueue.clear(); + tracker.purgeStoredQueue(); - ParselyTracker.PLog("Event queue empty, flush timer cleared."); - instance.stopFlushTimer(); - } + 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 5887a77e..0abc4dea 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -458,7 +458,7 @@ private void sendBatchRequest(ArrayList> events) { eventQueue.clear(); purgeStoredQueue(); } else { - new ParselyAPIConnection().execute(ROOT_URL + "mobileproxy", JsonEncode(batchMap)); + new ParselyAPIConnection(this).execute(ROOT_URL + "mobileproxy", JsonEncode(batchMap)); PLog("Requested %s", ROOT_URL); } PLog("POST Data %s", JsonEncode(batchMap)); From 4f7d83332d59ffbb7365e4eddb4d1d08345c6903 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 19 Oct 2023 10:43:56 +0200 Subject: [PATCH 025/261] fix: make `ParselyAPIConnection` package-private BREAKING CHANGE: `ParselyAPIConnection` is no longer accessible for library consumers. This is a deliberate design change. The `ParselyAPIConnection` on its own doesn't provide value for library consumers: it doesn't do anything domain-specific for Parsely, except purging queue after successful events. I think it's completely save to make it accessible only from the library. --- .../java/com/parsely/parselyandroid/ParselyAPIConnection.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java index 8b6e3825..6be3e954 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java @@ -24,13 +24,13 @@ import java.net.URL; import java.net.HttpURLConnection; -public class ParselyAPIConnection extends AsyncTask { +class ParselyAPIConnection extends AsyncTask { @NonNull private final ParselyTracker tracker; public Exception exception; - public ParselyAPIConnection(@NonNull ParselyTracker tracker) { + ParselyAPIConnection(@NonNull ParselyTracker tracker) { this.tracker = tracker; } From 511d7bb3752b5949f5ef0429fead2deff9e77885 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 19 Oct 2023 10:49:00 +0200 Subject: [PATCH 026/261] style: make `ParselyAPIConnection#exception` private No need for this field to be `public` --- .../java/com/parsely/parselyandroid/ParselyAPIConnection.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java index 6be3e954..c146a2da 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java @@ -28,7 +28,7 @@ class ParselyAPIConnection extends AsyncTask Date: Thu, 19 Oct 2023 11:25:28 +0200 Subject: [PATCH 027/261] refactor: extract `purgeEventsQueue` method Every time SDK purges in-memory events queue, it purges stored queue and vice-versa. The extraction of method asserts this behaviour and increases readability --- .../com/parsely/parselyandroid/ParselyAPIConnection.java | 3 +-- .../java/com/parsely/parselyandroid/ParselyTracker.java | 8 ++++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java index c146a2da..b9908192 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java @@ -67,8 +67,7 @@ protected void onPostExecute(HttpURLConnection conn) { ParselyTracker.PLog("Pixel request success"); // only purge the queue if the request was successful - tracker.eventQueue.clear(); - tracker.purgeStoredQueue(); + 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 0abc4dea..397f3dfb 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -455,8 +455,7 @@ private void sendBatchRequest(ArrayList> events) { if (isDebug) { PLog("Debug mode on. Not sending to Parse.ly"); - eventQueue.clear(); - purgeStoredQueue(); + purgeEventsQueue(); } else { new ParselyAPIConnection(this).execute(ROOT_URL + "mobileproxy", JsonEncode(batchMap)); PLog("Requested %s", ROOT_URL); @@ -518,6 +517,11 @@ private ArrayList> getStoredQueue() { return storedQueue; } + void purgeEventsQueue() { + eventQueue.clear(); + purgeStoredQueue(); + } + /** * Delete the stored queue from persistent storage. */ From ce361ec08f3122f731b273d5842fc1f5438c6dcc Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 19 Oct 2023 11:26:07 +0200 Subject: [PATCH 028/261] style: update modifier for `purgeStoredQueue` --- .../main/java/com/parsely/parselyandroid/ParselyTracker.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 397f3dfb..47e848c2 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -525,7 +525,7 @@ void purgeEventsQueue() { /** * Delete the stored queue from persistent storage. */ - protected void purgeStoredQueue() { + private void purgeStoredQueue() { persistObject(new ArrayList>()); } From a52c3688d348b05d8965d892a01146644ae3f146 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 19 Oct 2023 11:58:26 +0200 Subject: [PATCH 029/261] build: add MockWebServer dependency To unit test HTTP communication logic, framework agnostic. --- parsely/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/parsely/build.gradle b/parsely/build.gradle index e6ed109f..b0767cf6 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -57,6 +57,7 @@ dependencies { testImplementation 'androidx.test:core:1.5.0' testImplementation 'org.assertj:assertj-core:3.24.2' testImplementation 'junit:junit:4.13.2' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' } apply from: "${rootProject.projectDir}/publication.gradle" From 06dd9e97ca5d6bae6de66cbd81c257769f49268f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 19 Oct 2023 12:00:19 +0200 Subject: [PATCH 030/261] tests: introduce `FakeTracker` --- .../ParselyAPIConnectionTest.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt new file mode 100644 index 00000000..01205d99 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -0,0 +1,35 @@ +package com.parsely.parselyandroid + +import androidx.test.core.app.ApplicationProvider +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ParselyAPIConnectionTest { + + private lateinit var sut: ParselyAPIConnection + + @Before + fun setUp() { + sut = ParselyAPIConnection(FakeTracker) + } + + object FakeTracker : ParselyTracker( + "siteId", + 10, + ApplicationProvider.getApplicationContext() + ) { + + var flushTimerStopped = false + val events = mutableListOf>() + + override fun purgeEventsQueue() { + events.clear() + } + + override fun stopFlushTimer() { + flushTimerStopped = true + } + } +} \ No newline at end of file From de1eb9e20186bdb61fce16272608592024476173 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 19 Oct 2023 12:02:08 +0200 Subject: [PATCH 031/261] tests: request without pixels case --- .../ParselyAPIConnectionTest.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index 01205d99..2d8dd6fd 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -1,7 +1,12 @@ package com.parsely.parselyandroid +import android.content.Context import androidx.test.core.app.ApplicationProvider +import okhttp3.mockwebserver.MockWebServer +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 @@ -9,12 +14,27 @@ import org.robolectric.RobolectricTestRunner class ParselyAPIConnectionTest { private lateinit var sut: ParselyAPIConnection + private val mockServer = MockWebServer() @Before fun setUp() { sut = ParselyAPIConnection(FakeTracker) } + @Test + fun `when making connection without any events, then make GET request`() { + // when + sut.execute(mockServer.url("/").toString()) + + // then + assertThat(mockServer.takeRequest().method).isEqualTo("GET") + } + + @After + fun tearDown() { + mockServer.shutdown() + } + object FakeTracker : ParselyTracker( "siteId", 10, From 1fec8bd22e826062c45ea14b3126d05a3ef02bf9 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 19 Oct 2023 12:25:00 +0200 Subject: [PATCH 032/261] tests: request with pixels case --- .../ParselyAPIConnectionTest.kt | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index 2d8dd6fd..91982ad5 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -30,15 +30,43 @@ class ParselyAPIConnectionTest { assertThat(mockServer.takeRequest().method).isEqualTo("GET") } + @Test + fun `when making connection with events, then make POST request with JSON Content-Type header`() { + // when + sut.execute( + mockServer.url("/").toString(), pixelPayload + ) + + // then + assertThat(mockServer.takeRequest()).satisfies({ + assertThat(it.method).isEqualTo("POST") + assertThat(it.headers["Content-Type"]).isEqualTo("application/json") + assertThat(it.body.readUtf8()).isEqualTo(pixelPayload) + }) + } + @After fun tearDown() { mockServer.shutdown() } + companion object { + val pixelPayload = """ +{ + "events": [ + { + "idsite": "example.com" + }, + { + "idsite": "example2.com" + } + ] +} +""".trimIndent() + } + object FakeTracker : ParselyTracker( - "siteId", - 10, - ApplicationProvider.getApplicationContext() + "siteId", 10, ApplicationProvider.getApplicationContext() ) { var flushTimerStopped = false From f490db8be67ffacec19695f995c420659c9d3806 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 11:16:05 +0200 Subject: [PATCH 033/261] style: remove unnecessary secure id assignment --- .../java/com/parsely/parselyandroid/EventsBuilderTest.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 33cfe618..9a0cce4d 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -22,11 +22,6 @@ internal class EventsBuilderTest { applicationContext, TEST_SITE_ID, ) - Settings.Secure.putString( - applicationContext.contentResolver, - Settings.Secure.ANDROID_ID, - "android_id" - ) } @Test From 7c23c66a35c09980cb641dee697bac0cb49e2dea Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 11:26:13 +0200 Subject: [PATCH 034/261] style: minor code-style improvements --- .../java/com/parsely/parselyandroid/EventsBuilderTest.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 9a0cce4d..54831982 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -11,8 +11,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) -internal class EventsBuilderTest { - +class EventsBuilderTest { private lateinit var sut: EventsBuilder @Before @@ -166,8 +165,9 @@ internal class EventsBuilderTest { @Test fun `given metadata is not null, when creating a pixel, include metadata`() { // given - val metadata = - ParselyMetadata(ArrayList(), "link", "section", null, null, null, null) + val metadata = ParselyMetadata( + ArrayList(), "link", "section", null, null, null, null + ) // when val event: Map = sut.buildEvent( @@ -202,7 +202,6 @@ internal class EventsBuilderTest { .containsEntry("parsely_site_uuid", null) } - companion object { const val TEST_SITE_ID = "Example" const val TEST_URL = "http://example.com/some-old/article.html" From d23c26bc9d97c5577653b121c0665fde86d64a73 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 11:54:33 +0200 Subject: [PATCH 035/261] tests: update test names to match when-then schema --- .../java/com/parsely/parselyandroid/EventsBuilderTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 54831982..5630a8d5 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -24,7 +24,7 @@ class EventsBuilderTest { } @Test - fun `events builder prepares correct pageview pixel`() { + fun `when building pageview event, then build the correct one`() { // when val event: Map = sut.buildEvent( TEST_URL, @@ -43,7 +43,7 @@ class EventsBuilderTest { } @Test - fun `events builder prepares correct heartbeat pixel`() { + fun `when building heartbeat event, then build the correct one`() { // when val event: Map = sut.buildEvent( TEST_URL, @@ -62,7 +62,7 @@ class EventsBuilderTest { } @Test - fun `events builder prepares correct videostart pixel`() { + fun `when building videostart event, then build the correct one`() { // when val event: Map = sut.buildEvent( TEST_URL, @@ -81,7 +81,7 @@ class EventsBuilderTest { } @Test - fun `events builder prepares correct vheartbeat pixel`() { + fun `when building vheartbeat event, then build the correct one`() { // when val event: Map = sut.buildEvent( TEST_URL, From 9cf12c7258bc5332bd46db2eb920c60982623ffc Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 12:07:23 +0200 Subject: [PATCH 036/261] tests: update GET request test to wait for task to finish Previously, the `ParselyAPIConnection` task was not finishing execution before the test finished. Only because the task's `onBackground` was fast, the test was passing. To remove this flakiness, the introduced change uses LooperMode.Mode.PAUSED to wait for AsyncTask execution to finish, before running assertions. --- .../parselyandroid/ParselyAPIConnectionTest.kt | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index 91982ad5..ed45f76b 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -1,7 +1,7 @@ package com.parsely.parselyandroid -import android.content.Context import androidx.test.core.app.ApplicationProvider +import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.assertj.core.api.Assertions.assertThat import org.junit.After @@ -9,8 +9,11 @@ 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) +@LooperMode(LooperMode.Mode.PAUSED) class ParselyAPIConnectionTest { private lateinit var sut: ParselyAPIConnection @@ -23,11 +26,20 @@ class ParselyAPIConnectionTest { @Test fun `when making connection without any events, then make GET request`() { + // given + mockServer.enqueue(MockResponse().setResponseCode(200)) + // when - sut.execute(mockServer.url("/").toString()) + val url = mockServer.url("").toString() + sut.execute(url).get() + shadowMainLooper().idle(); // then - assertThat(mockServer.takeRequest().method).isEqualTo("GET") + val request = mockServer.takeRequest() + assertThat(request).satisfies({ + assertThat(it.method).isEqualTo("GET") + assertThat(it.failure).isNull() + }) } @Test From 85e93edf83e86db31b8a13262ca047a5995edd0d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 12:09:25 +0200 Subject: [PATCH 037/261] tests: update POST request test to wait for task to finish See ab4fe3a for details. --- .../parsely/parselyandroid/ParselyAPIConnectionTest.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index ed45f76b..edc9d677 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -44,10 +44,13 @@ class ParselyAPIConnectionTest { @Test fun `when making connection with events, then make POST request with JSON Content-Type header`() { + // given + mockServer.enqueue(MockResponse().setResponseCode(200)) + // when - sut.execute( - mockServer.url("/").toString(), pixelPayload - ) + val url = mockServer.url("/").toString() + sut.execute(url, pixelPayload).get() + shadowMainLooper().idle(); // then assertThat(mockServer.takeRequest()).satisfies({ From 2cc22a9ab8988f2eda97796cbd50f5a4626473a1 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 12:09:58 +0200 Subject: [PATCH 038/261] style: move url declaration to "given" --- .../com/parsely/parselyandroid/ParselyAPIConnectionTest.kt | 4 ++-- 1 file changed, 2 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 edc9d677..0221f689 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -28,9 +28,9 @@ class ParselyAPIConnectionTest { fun `when making connection without any events, then make GET request`() { // given mockServer.enqueue(MockResponse().setResponseCode(200)) + val url = mockServer.url("").toString() // when - val url = mockServer.url("").toString() sut.execute(url).get() shadowMainLooper().idle(); @@ -46,9 +46,9 @@ class ParselyAPIConnectionTest { fun `when making connection with events, then make POST request with JSON Content-Type header`() { // given mockServer.enqueue(MockResponse().setResponseCode(200)) + val url = mockServer.url("/").toString() // when - val url = mockServer.url("/").toString() sut.execute(url, pixelPayload).get() shadowMainLooper().idle(); From 80e50fcccb2ea1e7f0f9c4d535f975e97b33bbdd Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 12:12:21 +0200 Subject: [PATCH 039/261] tests: add test for purging events queue when successful request is made --- .../parselyandroid/ParselyAPIConnectionTest.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index 0221f689..a56b3ceb 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -60,6 +60,21 @@ class ParselyAPIConnectionTest { }) } + @Test + fun `given successful response, when request is made, purge events queue`() { + // given + mockServer.enqueue(MockResponse().setResponseCode(200)) + FakeTracker.events.add(mapOf("idsite" to "example.com")) + val url = mockServer.url("/").toString() + + // when + sut.execute(url).get() + shadowMainLooper().idle(); + + // then + assertThat(FakeTracker.events).isEmpty() + } + @After fun tearDown() { mockServer.shutdown() From 6e0496ce4eeaf36fa63556a7d61c3238bc0cb007 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 12:14:04 +0200 Subject: [PATCH 040/261] tests: add test for not purging events queue when unsuccessful request is made --- .../parselyandroid/ParselyAPIConnectionTest.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index a56b3ceb..d2072d85 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -75,6 +75,22 @@ class ParselyAPIConnectionTest { assertThat(FakeTracker.events).isEmpty() } + @Test + fun `given unsuccessful response, when request is made, do not purge events queue`() { + // given + mockServer.enqueue(MockResponse().setResponseCode(400)) + val sampleEvents = mapOf("idsite" to "example.com") + FakeTracker.events.add(sampleEvents) + val url = mockServer.url("/").toString() + + // when + sut.execute(url).get() + shadowMainLooper().idle(); + + // then + assertThat(FakeTracker.events).containsExactly(sampleEvents) + } + @After fun tearDown() { mockServer.shutdown() From b83d9a7b7d4ab61baf838504b030658b23cf58ce Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 12:15:56 +0200 Subject: [PATCH 041/261] style: make `url` definition test-wide --- .../com/parsely/parselyandroid/ParselyAPIConnectionTest.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index d2072d85..a9e05870 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -18,6 +18,7 @@ class ParselyAPIConnectionTest { private lateinit var sut: ParselyAPIConnection private val mockServer = MockWebServer() + private val url = mockServer.url("").toString() @Before fun setUp() { @@ -28,7 +29,6 @@ class ParselyAPIConnectionTest { fun `when making connection without any events, then make GET request`() { // given mockServer.enqueue(MockResponse().setResponseCode(200)) - val url = mockServer.url("").toString() // when sut.execute(url).get() @@ -46,7 +46,6 @@ class ParselyAPIConnectionTest { fun `when making connection with events, then make POST request with JSON Content-Type header`() { // given mockServer.enqueue(MockResponse().setResponseCode(200)) - val url = mockServer.url("/").toString() // when sut.execute(url, pixelPayload).get() @@ -65,7 +64,6 @@ class ParselyAPIConnectionTest { // given mockServer.enqueue(MockResponse().setResponseCode(200)) FakeTracker.events.add(mapOf("idsite" to "example.com")) - val url = mockServer.url("/").toString() // when sut.execute(url).get() @@ -81,7 +79,6 @@ class ParselyAPIConnectionTest { mockServer.enqueue(MockResponse().setResponseCode(400)) val sampleEvents = mapOf("idsite" to "example.com") FakeTracker.events.add(sampleEvents) - val url = mockServer.url("/").toString() // when sut.execute(url).get() @@ -126,4 +123,4 @@ class ParselyAPIConnectionTest { flushTimerStopped = true } } -} \ No newline at end of file +} From 4fd33d5d3b9d55c8d56f465d71e1940ef42a904c Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 12:16:54 +0200 Subject: [PATCH 042/261] tests: update test names to add missing given condition --- .../com/parsely/parselyandroid/ParselyAPIConnectionTest.kt | 4 ++-- 1 file changed, 2 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 a9e05870..80b7537d 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -26,7 +26,7 @@ class ParselyAPIConnectionTest { } @Test - fun `when making connection without any events, then make GET request`() { + fun `given successful response, when making connection without any events, then make GET request`() { // given mockServer.enqueue(MockResponse().setResponseCode(200)) @@ -43,7 +43,7 @@ class ParselyAPIConnectionTest { } @Test - fun `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`() { // given mockServer.enqueue(MockResponse().setResponseCode(200)) From 1db601cf2aebdd0a2e3a7a00f1c05b19c40f69f6 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 12:18:02 +0200 Subject: [PATCH 043/261] tests: verify that flush timer is stopped after succesful response --- .../com/parsely/parselyandroid/ParselyAPIConnectionTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index 80b7537d..437dc4d1 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -60,7 +60,7 @@ class ParselyAPIConnectionTest { } @Test - fun `given successful response, when request is made, purge events queue`() { + fun `given successful response, when request is made, then purge events queue and stop flush timer`() { // given mockServer.enqueue(MockResponse().setResponseCode(200)) FakeTracker.events.add(mapOf("idsite" to "example.com")) @@ -71,6 +71,7 @@ class ParselyAPIConnectionTest { // then assertThat(FakeTracker.events).isEmpty() + assertThat(FakeTracker.flushTimerStopped).isTrue } @Test From 2aa0c873f91374305911dd0b633a7d3464e42ed0 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 12:19:06 +0200 Subject: [PATCH 044/261] tests: verify that flush timer is not stopped after unsuccessful response --- .../com/parsely/parselyandroid/ParselyAPIConnectionTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index 437dc4d1..ba2f8694 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -75,7 +75,7 @@ class ParselyAPIConnectionTest { } @Test - fun `given unsuccessful response, when request is made, do not purge events queue`() { + fun `given unsuccessful response, when request is made, then do not purge events queue and do not stop flush timer`() { // given mockServer.enqueue(MockResponse().setResponseCode(400)) val sampleEvents = mapOf("idsite" to "example.com") @@ -87,6 +87,7 @@ class ParselyAPIConnectionTest { // then assertThat(FakeTracker.events).containsExactly(sampleEvents) + assertThat(FakeTracker.flushTimerStopped).isFalse } @After From fd9e883fed2f20e5df6b1bad9ae46207b82a1a8a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 12:24:52 +0200 Subject: [PATCH 045/261] tests: change FakeTracker from object to class The `object` state persisted between tests, what influenced results depending on order of tests execution. --- .../parselyandroid/ParselyAPIConnectionTest.kt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index ba2f8694..f240c436 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -19,10 +19,11 @@ 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(FakeTracker) + sut = ParselyAPIConnection(tracker) } @Test @@ -63,15 +64,15 @@ class ParselyAPIConnectionTest { fun `given successful response, when request is made, then purge events queue and stop flush timer`() { // given mockServer.enqueue(MockResponse().setResponseCode(200)) - FakeTracker.events.add(mapOf("idsite" to "example.com")) + tracker.events.add(mapOf("idsite" to "example.com")) // when sut.execute(url).get() shadowMainLooper().idle(); // then - assertThat(FakeTracker.events).isEmpty() - assertThat(FakeTracker.flushTimerStopped).isTrue + assertThat(tracker.events).isEmpty() + assertThat(tracker.flushTimerStopped).isTrue } @Test @@ -79,15 +80,15 @@ class ParselyAPIConnectionTest { // given mockServer.enqueue(MockResponse().setResponseCode(400)) val sampleEvents = mapOf("idsite" to "example.com") - FakeTracker.events.add(sampleEvents) + tracker.events.add(sampleEvents) // when sut.execute(url).get() shadowMainLooper().idle(); // then - assertThat(FakeTracker.events).containsExactly(sampleEvents) - assertThat(FakeTracker.flushTimerStopped).isFalse + assertThat(tracker.events).containsExactly(sampleEvents) + assertThat(tracker.flushTimerStopped).isFalse } @After @@ -110,7 +111,7 @@ class ParselyAPIConnectionTest { """.trimIndent() } - object FakeTracker : ParselyTracker( + private class FakeTracker : ParselyTracker( "siteId", 10, ApplicationProvider.getApplicationContext() ) { From e938e6e68b554503c1157cf023b24014b09cf372 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 12:25:38 +0200 Subject: [PATCH 046/261] refactor: change result type of `ParselyAPIConnection` to `Void` There's no need to return `HttpURLConnection`. This change simplifies implementation. --- .../parsely/parselyandroid/ParselyAPIConnection.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java index b9908192..992fb973 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java @@ -24,7 +24,7 @@ import java.net.URL; import java.net.HttpURLConnection; -class ParselyAPIConnection extends AsyncTask { +class ParselyAPIConnection extends AsyncTask { @NonNull private final ParselyTracker tracker; @@ -35,7 +35,7 @@ class ParselyAPIConnection extends AsyncTask Date: Fri, 20 Oct 2023 14:18:46 +0200 Subject: [PATCH 047/261] refactor: extract EngagementManager --- .../parselyandroid/EngagementManager.java | 125 ++++++++++++++++++ .../parselyandroid/ParselyTracker.java | 120 +---------------- 2 files changed, 127 insertions(+), 118 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java new file mode 100644 index 00000000..2ad9daad --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java @@ -0,0 +1,125 @@ +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; + +/** + * 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 static final long MAX_TIME_BETWEEN_HEARTBEATS = 60 * 60; + private static final long OFFSET_MATCHING_BASE_INTERVAL = 35; + private static final double BACKOFF_PROPORTION = 0.3; + + + public EngagementManager(ParselyTracker parselyTracker, Timer parentTimer, long intervalMillis, Map baseEvent) { + this.parselyTracker = parselyTracker; + this.baseEvent = baseEvent; + this.parentTimer = parentTimer; + latestDelayMillis = intervalMillis; + totalTime = 0; + startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + } + + public boolean isRunning() { + return started; + } + + public void start() { + scheduleNextExecution(latestDelayMillis); + started = true; + startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + } + + public void stop() { + waitingTimerTask.cancel(); + started = 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); + } + + private void scheduleNextExecution(long delay) { + TimerTask task = new TimerTask() { + public void run() { + doEnqueue(scheduledExecutionTime()); + updateLatestInterval(); + scheduleNextExecution(latestDelayMillis); + } + + public boolean cancel() { + boolean 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 void doEnqueue(long scheduledExecutionTime) { + // Create a copy of the base event to enqueue + Map event = new HashMap<>(baseEvent); + ParselyTracker.PLog(String.format("Enqueuing %s event.", event.get("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<>((Map) baseEventData); + data.put("ts", now.getTimeInMillis()); + event.put("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); + } + + private void updateLatestInterval() { + Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + long totalTrackedTime = (now.getTime().getTime() - startTime.getTime().getTime()) / 1000; + double totalWithOffset = totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL; + double newInterval = totalWithOffset * BACKOFF_PROPORTION; + long clampedNewInterval = (long) Math.min(MAX_TIME_BETWEEN_HEARTBEATS, newInterval); + latestDelayMillis = clampedNewInterval * 1000; + } + + public double getIntervalMillis() { + return latestDelayMillis; + } +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 47e848c2..decf915a 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -38,12 +38,10 @@ import java.io.ObjectOutputStream; import java.io.StringWriter; import java.util.ArrayList; -import java.util.Calendar; import java.util.Formatter; import java.util.HashMap; import java.util.HashSet; import java.util.Map; -import java.util.TimeZone; import java.util.Timer; import java.util.TimerTask; import java.util.UUID; @@ -289,7 +287,7 @@ public void startEngagement( // Start a new EngagementTask Map event = eventsBuilder.buildEvent(url, urlRef, "heartbeat", null, extraData, lastPageviewUuid); - engagementManager = new EngagementManager(timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event); + engagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event); engagementManager.start(); } @@ -365,7 +363,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(timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent); + videoEngagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent); videoEngagementManager.start(); } @@ -722,118 +720,4 @@ private void flushEvents() { new FlushQueue().execute(); } - /** - * 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. - */ - private class EngagementManager { - - public Map baseEvent; - private boolean started; - private final Timer parentTimer; - private TimerTask waitingTimerTask; - private long latestDelayMillis, totalTime; - private Calendar startTime; - - private static final long MAX_TIME_BETWEEN_HEARTBEATS = 60 * 60; - private static final long OFFSET_MATCHING_BASE_INTERVAL = 35; - private static final double BACKOFF_PROPORTION = 0.3; - - - public EngagementManager(Timer parentTimer, long intervalMillis, Map baseEvent) { - this.baseEvent = baseEvent; - this.parentTimer = parentTimer; - latestDelayMillis = intervalMillis; - totalTime = 0; - startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - } - - public boolean isRunning() { - return started; - } - - public void start() { - scheduleNextExecution(latestDelayMillis); - started = true; - startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - } - - public void stop() { - waitingTimerTask.cancel(); - started = 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); - } - - private void scheduleNextExecution(long delay) { - TimerTask task = new TimerTask() { - public void run() { - doEnqueue(scheduledExecutionTime()); - updateLatestInterval(); - scheduleNextExecution(latestDelayMillis); - } - - public boolean cancel() { - boolean 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 void doEnqueue(long scheduledExecutionTime) { - // Create a copy of the base event to enqueue - Map event = new HashMap<>(baseEvent); - PLog(String.format("Enqueuing %s event.", event.get("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<>((Map) baseEventData); - data.put("ts", now.getTimeInMillis()); - event.put("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); - - enqueueEvent(event); - } - - private void updateLatestInterval() { - Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - long totalTrackedTime = (now.getTime().getTime() - startTime.getTime().getTime()) / 1000; - double totalWithOffset = totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL; - double newInterval = totalWithOffset * BACKOFF_PROPORTION; - long clampedNewInterval = (long)Math.min(MAX_TIME_BETWEEN_HEARTBEATS, newInterval); - latestDelayMillis = clampedNewInterval * 1000; - } - - public double getIntervalMillis() { - return latestDelayMillis; - } - } } From 2b3b07dfa624cc93a116c5e2d4111fab8740ec02 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 14:20:28 +0200 Subject: [PATCH 048/261] feat: make `enqueueEvent` package-visible So this method can be accessed by extracted `EngagementManager` --- .../main/java/com/parsely/parselyandroid/ParselyTracker.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index decf915a..fe4ec3af 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -414,7 +414,7 @@ public void resetVideo() { * * @param event The event Map to enqueue. */ - private void enqueueEvent(Map event) { + void enqueueEvent(Map event) { // Push it onto the queue eventQueue.add(event); new QueueManager().execute(); From f197f13ae1afdf0a15598ee4f14af06da2b5ab06 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 14:21:36 +0200 Subject: [PATCH 049/261] refactor: use getters instead of field values --- .../main/java/com/parsely/parselyandroid/ParselyTracker.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index fe4ec3af..0002cfae 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -174,7 +174,7 @@ public double getVideoEngagementInterval() { * @return Whether the engagement tracker is running. */ public boolean engagementIsActive() { - return engagementManager != null && engagementManager.started; + return engagementManager != null && engagementManager.isRunning(); } /** @@ -183,7 +183,7 @@ public boolean engagementIsActive() { * @return Whether video tracking is active. */ public boolean videoIsActive() { - return videoEngagementManager != null && videoEngagementManager.started; + return videoEngagementManager != null && videoEngagementManager.isRunning(); } /** From 663d5c7251be8a2efc99af37d1a4e57d47ec83ca Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 16:10:56 +0200 Subject: [PATCH 050/261] tests: record first event after given interval --- .../parselyandroid/EngagementManagerTest.kt | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt new file mode 100644 index 00000000..f46e9108 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -0,0 +1,63 @@ +package com.parsely.parselyandroid + +import androidx.test.core.app.ApplicationProvider +import java.util.Timer +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) +class EngagementManagerTest { + + private lateinit var sut: EngagementManager + private val tracker = FakeTracker() + private val parentTimer = Timer() + private val baseEvent = mapOf( + "data" to mapOf( + "os" to "android", + "parsely_site_uuid" to "e8857cbe-5ace-44f4-a85e-7e7475f675c5", + "os_version" to "34", + "manufacturer" to "Google", + "ts" to 1697638552181 + ) + ) + + @Before + fun setUp() { + sut = EngagementManager( + tracker, + parentTimer, + DEFAULT_INTERVAL_MILLIS, + baseEvent, + ) + } + + @Test + fun `when starting manager, then record next event execution after interval millis`() { + // when + sut.start() + Thread.sleep(DEFAULT_INTERVAL_MILLIS) + + // then + assertThat(tracker.events).hasSize(1) + } + + class FakeTracker : ParselyTracker( + "", + 0, + ApplicationProvider.getApplicationContext() + ) { + val events = mutableListOf>() + + override fun enqueueEvent(event: MutableMap) { + events += event + } + + } + + companion object { + private const val DEFAULT_INTERVAL_MILLIS = 1 * 1000L + } +} \ No newline at end of file From b437f8afb3b7a522a06edea9842026323f435e9f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 16:52:03 +0200 Subject: [PATCH 051/261] refactor: extract `updateLatestInterval` The logic itself is complex and should be unit tested. Also, the production implementation will return long intervals (like 10s). Because `EngagementManager` uses TimerTask we have to use `Thread#sleep` in unit tests. All in all - with default implementation of `updateLatestInterval` the unit tests would last very long, hence this extraction, which will allow to inject custom intervals. --- .../parselyandroid/EngagementManager.java | 11 --------- .../UpdateEngagementIntervalCalculator.java | 23 +++++++++++++++++++ 2 files changed, 23 insertions(+), 11 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java index 2ad9daad..2b68d2ab 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java @@ -26,9 +26,6 @@ class EngagementManager { private long latestDelayMillis, totalTime; private Calendar startTime; - private static final long MAX_TIME_BETWEEN_HEARTBEATS = 60 * 60; - private static final long OFFSET_MATCHING_BASE_INTERVAL = 35; - private static final double BACKOFF_PROPORTION = 0.3; public EngagementManager(ParselyTracker parselyTracker, Timer parentTimer, long intervalMillis, Map baseEvent) { @@ -110,14 +107,6 @@ private void doEnqueue(long scheduledExecutionTime) { parselyTracker.enqueueEvent(event); } - private void updateLatestInterval() { - Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - long totalTrackedTime = (now.getTime().getTime() - startTime.getTime().getTime()) / 1000; - double totalWithOffset = totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL; - double newInterval = totalWithOffset * BACKOFF_PROPORTION; - long clampedNewInterval = (long) Math.min(MAX_TIME_BETWEEN_HEARTBEATS, newInterval); - latestDelayMillis = clampedNewInterval * 1000; - } public double getIntervalMillis() { return latestDelayMillis; diff --git a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java new file mode 100644 index 00000000..7fadbacc --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java @@ -0,0 +1,23 @@ +package com.parsely.parselyandroid; + +import androidx.annotation.NonNull; + +import java.util.Calendar; +import java.util.TimeZone; + +class UpdateEngagementIntervalCalculator { + + private static final long MAX_TIME_BETWEEN_HEARTBEATS = 60 * 60; + private static final long OFFSET_MATCHING_BASE_INTERVAL = 35; + private static final double BACKOFF_PROPORTION = 0.3; + + long updateLatestInterval(@NonNull final Calendar startTime) { + Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + long totalTrackedTime = (now.getTime().getTime() - startTime.getTime().getTime()) / 1000; + double totalWithOffset = totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL; + double newInterval = totalWithOffset * BACKOFF_PROPORTION; + long clampedNewInterval = (long) Math.min(MAX_TIME_BETWEEN_HEARTBEATS, newInterval); + System.out.println("New interval: " + clampedNewInterval*1000); + return clampedNewInterval * 1000; + } +} From 29b80215179a4527d4c89a50d1f0ff753f484c69 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 16:54:45 +0200 Subject: [PATCH 052/261] refactor: update tracker and manager to use interval calculator --- .../com/parsely/parselyandroid/EngagementManager.java | 8 ++++---- .../java/com/parsely/parselyandroid/ParselyTracker.java | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java index 2b68d2ab..85769238 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java @@ -25,13 +25,13 @@ class EngagementManager { private TimerTask waitingTimerTask; private long latestDelayMillis, totalTime; private Calendar startTime; + private final UpdateEngagementIntervalCalculator intervalCalculator; - - - public EngagementManager(ParselyTracker parselyTracker, Timer parentTimer, long intervalMillis, Map baseEvent) { + public EngagementManager(ParselyTracker parselyTracker, Timer parentTimer, long intervalMillis, Map baseEvent, UpdateEngagementIntervalCalculator intervalCalculator) { this.parselyTracker = parselyTracker; this.baseEvent = baseEvent; this.parentTimer = parentTimer; + this.intervalCalculator = intervalCalculator; latestDelayMillis = intervalMillis; totalTime = 0; startTime = Calendar.getInstance(TimeZone.getTimeZone("UTC")); @@ -64,7 +64,7 @@ private void scheduleNextExecution(long delay) { TimerTask task = new TimerTask() { public void run() { doEnqueue(scheduledExecutionTime()); - updateLatestInterval(); + latestDelayMillis = intervalCalculator.updateLatestInterval(startTime); scheduleNextExecution(latestDelayMillis); } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 0002cfae..49feabaa 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -72,6 +72,7 @@ public class ParselyTracker { private String lastPageviewUuid = null; @NonNull private final EventsBuilder eventsBuilder; + @NonNull final UpdateEngagementIntervalCalculator intervalCalculator = new UpdateEngagementIntervalCalculator(); /** * Create a new ParselyTracker instance. @@ -287,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); + engagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event, intervalCalculator); engagementManager.start(); } @@ -363,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); + videoEngagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent, intervalCalculator); videoEngagementManager.start(); } From 65c95138da5d2884c07419a614a7e2d5dd8676a8 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 20 Oct 2023 19:50:01 +0200 Subject: [PATCH 053/261] tests: record event after each interval --- .../parselyandroid/EngagementManagerTest.kt | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index f46e9108..8c8bb008 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -1,6 +1,7 @@ package com.parsely.parselyandroid import androidx.test.core.app.ApplicationProvider +import java.util.Calendar import java.util.Timer import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -9,12 +10,13 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) -class EngagementManagerTest { +internal class EngagementManagerTest { private lateinit var sut: EngagementManager private val tracker = FakeTracker() private val parentTimer = Timer() private val baseEvent = mapOf( + "action" to "heartbeat", "data" to mapOf( "os" to "android", "parsely_site_uuid" to "e8857cbe-5ace-44f4-a85e-7e7475f675c5", @@ -31,6 +33,7 @@ class EngagementManagerTest { parentTimer, DEFAULT_INTERVAL_MILLIS, baseEvent, + FakeIntervalCalculator() ) } @@ -44,6 +47,20 @@ class EngagementManagerTest { assertThat(tracker.events).hasSize(1) } + @Test + fun `when starting manager, then schedule task each interval period`() { + sut.start() + + Thread.sleep(DEFAULT_INTERVAL_MILLIS) + assertThat(tracker.events).hasSize(1) + + Thread.sleep(DEFAULT_INTERVAL_MILLIS) + assertThat(tracker.events).hasSize(2) + + Thread.sleep(DEFAULT_INTERVAL_MILLIS) + assertThat(tracker.events).hasSize(3) + } + class FakeTracker : ParselyTracker( "", 0, @@ -57,7 +74,13 @@ class EngagementManagerTest { } + class FakeIntervalCalculator : UpdateEngagementIntervalCalculator() { + override fun updateLatestInterval(startTime: Calendar): Long { + return DEFAULT_INTERVAL_MILLIS + } + } + companion object { - private const val DEFAULT_INTERVAL_MILLIS = 1 * 1000L + private const val DEFAULT_INTERVAL_MILLIS = 1 * 100L } } \ No newline at end of file From 11cb57faa77f9ffee42569cb122d93a6c687db1e Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 24 Oct 2023 13:26:11 +0200 Subject: [PATCH 054/261] tests: introduce `typealias Event` To improve readability in tests. --- .../parsely/parselyandroid/EngagementManagerTest.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 8c8bb008..c8bb5bf9 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -9,15 +9,17 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +private typealias Event = MutableMap + @RunWith(RobolectricTestRunner::class) internal class EngagementManagerTest { private lateinit var sut: EngagementManager private val tracker = FakeTracker() private val parentTimer = Timer() - private val baseEvent = mapOf( + private val baseEvent: Event = mutableMapOf( "action" to "heartbeat", - "data" to mapOf( + "data" to mutableMapOf( "os" to "android", "parsely_site_uuid" to "e8857cbe-5ace-44f4-a85e-7e7475f675c5", "os_version" to "34", @@ -66,9 +68,9 @@ internal class EngagementManagerTest { 0, ApplicationProvider.getApplicationContext() ) { - val events = mutableListOf>() + val events = mutableListOf() - override fun enqueueEvent(event: MutableMap) { + override fun enqueueEvent(event: Event) { events += event } From 0c42ea3771b4cbe2926ba3cef1ff475b24f688ad Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 24 Oct 2023 13:28:47 +0200 Subject: [PATCH 055/261] tests: improve "start manager" test case To validate that the tracked event is correct. --- .../parselyandroid/EngagementManagerTest.kt | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index c8bb5bf9..c1fcc64f 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.Timer import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.withinPercentage import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -40,13 +41,40 @@ internal class EngagementManagerTest { } @Test - fun `when starting manager, then record next event execution after interval millis`() { + fun `when starting manager, then record the correct event after interval millis`() { // when sut.start() - Thread.sleep(DEFAULT_INTERVAL_MILLIS) + sleep(DEFAULT_INTERVAL_MILLIS) // then - assertThat(tracker.events).hasSize(1) + + val trackedEvent = tracker.events[0] + + assertThat(trackedEvent) + .containsEntry("action", "heartbeat") + .hasEntrySatisfying("inc") { incremental -> + incremental as Long + // Ideally: incremental should be 0 + assertThat(incremental).isLessThan(5) + } + .hasEntrySatisfying("tt") { totalTime -> + totalTime as Long + // Ideally: totalTime should be equal to DEFAULT_INTERVAL_MILLIS + assertThat(totalTime).isCloseTo(DEFAULT_INTERVAL_MILLIS, withinPercentage(10)) + } + .hasEntrySatisfying("data") { data -> + data as Map + assertThat(data).hasEntrySatisfying("ts") { timestamp -> + timestamp as Long + assertThat(timestamp).isCloseTo( + System.currentTimeMillis() + DEFAULT_INTERVAL_MILLIS, + withinPercentage(5) + ) + }.containsEntry("os", "android") + .containsEntry("parsely_site_uuid", "e8857cbe-5ace-44f4-a85e-7e7475f675c5") + .containsEntry("os_version", "34") + .containsEntry("manufacturer", "Google") + } } @Test @@ -82,7 +110,12 @@ internal class EngagementManagerTest { } } + private fun sleep(millis: Long) = Thread.sleep(millis + THREAD_SLEEPING_THRESHOLD) + companion object { private const val DEFAULT_INTERVAL_MILLIS = 1 * 100L + + // Additional time to wait to ensure that the timer has fired + private const val THREAD_SLEEPING_THRESHOLD = 100L } } \ No newline at end of file From 1f3c1b6698ce055219e0b12b7594745edec14f3f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 24 Oct 2023 13:29:54 +0200 Subject: [PATCH 056/261] style: suppress `UNCHECKED_CAST` on `EngagementManagerTest` The test will contain many unchecked casts, but they're safe - they'll always be . --- .../java/com/parsely/parselyandroid/EngagementManagerTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index c1fcc64f..933bb3e0 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -13,6 +13,7 @@ import org.robolectric.RobolectricTestRunner private typealias Event = MutableMap @RunWith(RobolectricTestRunner::class) +@Suppress("UNCHECKED_CAST") internal class EngagementManagerTest { private lateinit var sut: EngagementManager From 4c3b5e9e475733206b3a9eba7624e15afb3e213b Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 24 Oct 2023 13:57:52 +0200 Subject: [PATCH 057/261] style: extract event assertions --- .../parselyandroid/EngagementManagerTest.kt | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 933bb3e0..ed043d8b 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -3,8 +3,12 @@ package com.parsely.parselyandroid import androidx.test.core.app.ApplicationProvider import java.util.Calendar import java.util.Timer +import org.assertj.core.api.AbstractLongAssert import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.byLessThan import org.assertj.core.api.Assertions.withinPercentage +import org.assertj.core.api.LongAssert +import org.assertj.core.api.MapAssert import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -48,29 +52,47 @@ internal class EngagementManagerTest { sleep(DEFAULT_INTERVAL_MILLIS) // then + assertThat(tracker.events[0]).isCorrectEvent( + withInc = { + // Ideally: incremental should be 0 + isLessThan(10) + }, + withTotalTime = { + // Ideally: totalTime should be equal to DEFAULT_INTERVAL_MILLIS + isCloseTo(DEFAULT_INTERVAL_MILLIS, withinPercentage(10)) + }, + withTimestamp = { + // Ideally: timestamp should be equal to System.currentTimeMillis() + DEFAULT_INTERVAL_MILLIS + isCloseTo( + System.currentTimeMillis() + DEFAULT_INTERVAL_MILLIS, + withinPercentage(5) + ) + } + ) + } - val trackedEvent = tracker.events[0] - - assertThat(trackedEvent) - .containsEntry("action", "heartbeat") + private fun MapAssert.isCorrectEvent( + withInc: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, + withTotalTime: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, + withTimestamp: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, + ): MapAssert { + return containsEntry("action", "heartbeat") .hasEntrySatisfying("inc") { incremental -> incremental as Long - // Ideally: incremental should be 0 - assertThat(incremental).isLessThan(5) + val assertThat = assertThat(incremental) + assertThat.withInc() + assertThat(incremental).withInc() + } .hasEntrySatisfying("tt") { totalTime -> totalTime as Long - // Ideally: totalTime should be equal to DEFAULT_INTERVAL_MILLIS - assertThat(totalTime).isCloseTo(DEFAULT_INTERVAL_MILLIS, withinPercentage(10)) + assertThat(totalTime).withTotalTime() } .hasEntrySatisfying("data") { data -> data as Map assertThat(data).hasEntrySatisfying("ts") { timestamp -> timestamp as Long - assertThat(timestamp).isCloseTo( - System.currentTimeMillis() + DEFAULT_INTERVAL_MILLIS, - withinPercentage(5) - ) + assertThat(timestamp).withTimestamp() }.containsEntry("os", "android") .containsEntry("parsely_site_uuid", "e8857cbe-5ace-44f4-a85e-7e7475f675c5") .containsEntry("os_version", "34") From a4bf8009613cc427224390d4da1c953423000454 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 24 Oct 2023 14:15:43 +0200 Subject: [PATCH 058/261] tests: narrow down timestamp condition in single event record case --- .../parsely/parselyandroid/EngagementManagerTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index ed043d8b..95bc5520 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -5,9 +5,8 @@ import java.util.Calendar import java.util.Timer import org.assertj.core.api.AbstractLongAssert import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.byLessThan +import org.assertj.core.api.Assertions.within import org.assertj.core.api.Assertions.withinPercentage -import org.assertj.core.api.LongAssert import org.assertj.core.api.MapAssert import org.junit.Before import org.junit.Test @@ -50,6 +49,7 @@ internal class EngagementManagerTest { // when sut.start() sleep(DEFAULT_INTERVAL_MILLIS) + val timestamp = System.currentTimeMillis() // then assertThat(tracker.events[0]).isCorrectEvent( @@ -62,10 +62,10 @@ internal class EngagementManagerTest { isCloseTo(DEFAULT_INTERVAL_MILLIS, withinPercentage(10)) }, withTimestamp = { - // Ideally: timestamp should be equal to System.currentTimeMillis() + DEFAULT_INTERVAL_MILLIS + // Ideally: timestamp should be equal to System.currentTimeMillis() at the time of recording the event isCloseTo( - System.currentTimeMillis() + DEFAULT_INTERVAL_MILLIS, - withinPercentage(5) + timestamp, + within(100L) ) } ) From 203a5c8f51f0008225bf3547b08bec5f0d275d39 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 24 Oct 2023 16:02:30 +0200 Subject: [PATCH 059/261] tests: simplify single event test case As per comment: `inc` will always be 0 because the interval between events is lower than 1s --- .../parselyandroid/EngagementManagerTest.kt | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 95bc5520..a5cab58b 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -2,6 +2,7 @@ package com.parsely.parselyandroid import androidx.test.core.app.ApplicationProvider import java.util.Calendar +import java.util.TimeZone import java.util.Timer import org.assertj.core.api.AbstractLongAssert import org.assertj.core.api.Assertions.assertThat @@ -49,14 +50,10 @@ internal class EngagementManagerTest { // when sut.start() sleep(DEFAULT_INTERVAL_MILLIS) - val timestamp = System.currentTimeMillis() + val timestamp = now - THREAD_SLEEPING_THRESHOLD // then assertThat(tracker.events[0]).isCorrectEvent( - withInc = { - // Ideally: incremental should be 0 - isLessThan(10) - }, withTotalTime = { // Ideally: totalTime should be equal to DEFAULT_INTERVAL_MILLIS isCloseTo(DEFAULT_INTERVAL_MILLIS, withinPercentage(10)) @@ -72,18 +69,12 @@ internal class EngagementManagerTest { } private fun MapAssert.isCorrectEvent( - withInc: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, withTotalTime: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, withTimestamp: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, ): MapAssert { return containsEntry("action", "heartbeat") - .hasEntrySatisfying("inc") { incremental -> - incremental as Long - val assertThat = assertThat(incremental) - assertThat.withInc() - assertThat(incremental).withInc() - - } + // Incremental will be always 0 because the interval is lower than 1s + .containsEntry("inc", 0L) .hasEntrySatisfying("tt") { totalTime -> totalTime as Long assertThat(totalTime).withTotalTime() @@ -133,12 +124,15 @@ internal class EngagementManagerTest { } } + private val now: Long + get() = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis + private fun sleep(millis: Long) = Thread.sleep(millis + THREAD_SLEEPING_THRESHOLD) companion object { - private const val DEFAULT_INTERVAL_MILLIS = 1 * 100L + private const val DEFAULT_INTERVAL_MILLIS = 100L // Additional time to wait to ensure that the timer has fired - private const val THREAD_SLEEPING_THRESHOLD = 100L + private const val THREAD_SLEEPING_THRESHOLD = 50L } } \ No newline at end of file From 496b18055f41a273ea9e8f3efb0dea59b2baea97 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 24 Oct 2023 16:04:25 +0200 Subject: [PATCH 060/261] tests: add test case for validity of subsequent events --- .../parselyandroid/EngagementManagerTest.kt | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index a5cab58b..30b3c984 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -95,14 +95,38 @@ internal class EngagementManagerTest { fun `when starting manager, then schedule task each interval period`() { sut.start() - Thread.sleep(DEFAULT_INTERVAL_MILLIS) - assertThat(tracker.events).hasSize(1) + sleep(DEFAULT_INTERVAL_MILLIS) + val firstTimestamp = now - THREAD_SLEEPING_THRESHOLD + + sleep(DEFAULT_INTERVAL_MILLIS) + val secondTimestamp = now - 2 * THREAD_SLEEPING_THRESHOLD + + sleep(DEFAULT_INTERVAL_MILLIS) + val thirdTimestamp = now - 3 * THREAD_SLEEPING_THRESHOLD - Thread.sleep(DEFAULT_INTERVAL_MILLIS) - assertThat(tracker.events).hasSize(2) + sleep(THREAD_SLEEPING_THRESHOLD) - Thread.sleep(DEFAULT_INTERVAL_MILLIS) - assertThat(tracker.events).hasSize(3) + 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)) } + ) + 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)) } + ) + 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)) } + ) } class FakeTracker : ParselyTracker( From 320adb60fdb0f457dbf6c6da8e092c0c57e0ca09 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 23 Oct 2023 13:30:21 +0200 Subject: [PATCH 061/261] style: move tearDown below setUp --- .../parsely/parselyandroid/ParselyAPIConnectionTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index f240c436..bc725fee 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -26,6 +26,11 @@ class ParselyAPIConnectionTest { sut = ParselyAPIConnection(tracker) } + @After + fun tearDown() { + mockServer.shutdown() + } + @Test fun `given successful response, when making connection without any events, then make GET request`() { // given @@ -91,11 +96,6 @@ class ParselyAPIConnectionTest { assertThat(tracker.flushTimerStopped).isFalse } - @After - fun tearDown() { - mockServer.shutdown() - } - companion object { val pixelPayload = """ { From 73e22b9002b458878fb7cc8d8884caf25ac0eb13 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 23 Oct 2023 13:35:15 +0200 Subject: [PATCH 062/261] refactor: move JSON pixel payload to a file --- .../parselyandroid/ParselyAPIConnectionTest.kt | 14 ++------------ parsely/src/test/resources/pixel_payload.json | 10 ++++++++++ 2 files changed, 12 insertions(+), 12 deletions(-) create mode 100644 parsely/src/test/resources/pixel_payload.json diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index bc725fee..815abc93 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -97,18 +97,8 @@ class ParselyAPIConnectionTest { } companion object { - val pixelPayload = """ -{ - "events": [ - { - "idsite": "example.com" - }, - { - "idsite": "example2.com" - } - ] -} -""".trimIndent() + val pixelPayload: String = + this::class.java.getResource("pixel_payload.json")?.readText().orEmpty() } private class FakeTracker : ParselyTracker( diff --git a/parsely/src/test/resources/pixel_payload.json b/parsely/src/test/resources/pixel_payload.json new file mode 100644 index 00000000..803b4930 --- /dev/null +++ b/parsely/src/test/resources/pixel_payload.json @@ -0,0 +1,10 @@ +{ + "events": [ + { + "idsite": "example.com" + }, + { + "idsite": "example2.com" + } + ] +} From 1c10d70f5d1db02bdce3b3f90c6afad60c5d909a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 25 Oct 2023 11:23:06 +0200 Subject: [PATCH 063/261] tests: extract `data` to variable --- .../parselyandroid/EngagementManagerTest.kt | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 30b3c984..12ac0b9f 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -25,13 +25,7 @@ internal class EngagementManagerTest { private val parentTimer = Timer() private val baseEvent: Event = mutableMapOf( "action" to "heartbeat", - "data" to mutableMapOf( - "os" to "android", - "parsely_site_uuid" to "e8857cbe-5ace-44f4-a85e-7e7475f675c5", - "os_version" to "34", - "manufacturer" to "Google", - "ts" to 1697638552181 - ) + "data" to testData ) @Before @@ -84,10 +78,7 @@ internal class EngagementManagerTest { assertThat(data).hasEntrySatisfying("ts") { timestamp -> timestamp as Long assertThat(timestamp).withTimestamp() - }.containsEntry("os", "android") - .containsEntry("parsely_site_uuid", "e8857cbe-5ace-44f4-a85e-7e7475f675c5") - .containsEntry("os_version", "34") - .containsEntry("manufacturer", "Google") + }.containsAllEntriesOf(testData.minus("ts")) } } @@ -139,7 +130,6 @@ internal class EngagementManagerTest { override fun enqueueEvent(event: Event) { events += event } - } class FakeIntervalCalculator : UpdateEngagementIntervalCalculator() { @@ -153,10 +143,16 @@ internal class EngagementManagerTest { private fun sleep(millis: Long) = Thread.sleep(millis + THREAD_SLEEPING_THRESHOLD) - companion object { - private const val DEFAULT_INTERVAL_MILLIS = 100L - + private companion object { + const val DEFAULT_INTERVAL_MILLIS = 100L // Additional time to wait to ensure that the timer has fired - private const val THREAD_SLEEPING_THRESHOLD = 50L + const val THREAD_SLEEPING_THRESHOLD = 50L + val testData = mutableMapOf( + "os" to "android", + "parsely_site_uuid" to "e8857cbe-5ace-44f4-a85e-7e7475f675c5", + "os_version" to "34", + "manufacturer" to "Google", + "ts" to 123L + ) } } \ No newline at end of file From e591c93436f469bdb7bd6a6f30ac32bf9f760d7d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 25 Oct 2023 11:24:26 +0200 Subject: [PATCH 064/261] style: move helper method below test cases --- .../parselyandroid/EngagementManagerTest.kt | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 12ac0b9f..63405fa3 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -62,26 +62,6 @@ internal class EngagementManagerTest { ) } - private fun MapAssert.isCorrectEvent( - 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("tt") { totalTime -> - totalTime as Long - assertThat(totalTime).withTotalTime() - } - .hasEntrySatisfying("data") { data -> - data as Map - assertThat(data).hasEntrySatisfying("ts") { timestamp -> - timestamp as Long - assertThat(timestamp).withTimestamp() - }.containsAllEntriesOf(testData.minus("ts")) - } - } - @Test fun `when starting manager, then schedule task each interval period`() { sut.start() @@ -120,6 +100,26 @@ internal class EngagementManagerTest { ) } + private fun MapAssert.isCorrectEvent( + 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("tt") { totalTime -> + totalTime as Long + assertThat(totalTime).withTotalTime() + } + .hasEntrySatisfying("data") { data -> + data as Map + assertThat(data).hasEntrySatisfying("ts") { timestamp -> + timestamp as Long + assertThat(timestamp).withTimestamp() + }.containsAllEntriesOf(testData.minus("ts")) + } + } + class FakeTracker : ParselyTracker( "", 0, From d287a5398e8fa105b175d0bb7bb1a4acd4405d02 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 11:29:43 +0200 Subject: [PATCH 065/261] refactor: introduce `Clock` So `UpdateEngagementIntervalCalculator` can be injected with constant value of "now" in tests. Otherwise, the test will be time-sensitive. --- .../src/main/java/com/parsely/parselyandroid/Clock.kt | 9 +++++++++ .../com/parsely/parselyandroid/ParselyTracker.java | 2 +- .../UpdateEngagementIntervalCalculator.java | 10 +++++++--- .../parsely/parselyandroid/EngagementManagerTest.kt | 4 ++-- 4 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/Clock.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/Clock.kt b/parsely/src/main/java/com/parsely/parselyandroid/Clock.kt new file mode 100644 index 00000000..4554b4d5 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/Clock.kt @@ -0,0 +1,9 @@ +package com.parsely.parselyandroid + +import java.util.Calendar +import java.util.TimeZone + +open class Clock { + open val now + get() = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 49feabaa..ec39d3c3 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 @@ public class ParselyTracker { private String lastPageviewUuid = null; @NonNull private final EventsBuilder eventsBuilder; - @NonNull final UpdateEngagementIntervalCalculator intervalCalculator = new UpdateEngagementIntervalCalculator(); + @NonNull final UpdateEngagementIntervalCalculator intervalCalculator = new UpdateEngagementIntervalCalculator(new Clock()); /** * Create a new ParselyTracker instance. diff --git a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java index 7fadbacc..3ac04ff0 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java @@ -3,7 +3,6 @@ import androidx.annotation.NonNull; import java.util.Calendar; -import java.util.TimeZone; class UpdateEngagementIntervalCalculator { @@ -11,9 +10,14 @@ class UpdateEngagementIntervalCalculator { private static final long OFFSET_MATCHING_BASE_INTERVAL = 35; private static final double BACKOFF_PROPORTION = 0.3; + @NonNull private final Clock clock; + + public UpdateEngagementIntervalCalculator(@NonNull Clock clock) { + this.clock = clock; + } + long updateLatestInterval(@NonNull final Calendar startTime) { - Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - long totalTrackedTime = (now.getTime().getTime() - startTime.getTime().getTime()) / 1000; + long totalTrackedTime = (clock.getNow() - startTime.getTime().getTime()) / 1000; double totalWithOffset = totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL; double newInterval = totalWithOffset * BACKOFF_PROPORTION; long clampedNewInterval = (long) Math.min(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 63405fa3..9af6973d 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -132,7 +132,7 @@ internal class EngagementManagerTest { } } - class FakeIntervalCalculator : UpdateEngagementIntervalCalculator() { + class FakeIntervalCalculator : UpdateEngagementIntervalCalculator(Clock()) { override fun updateLatestInterval(startTime: Calendar): Long { return DEFAULT_INTERVAL_MILLIS } @@ -155,4 +155,4 @@ internal class EngagementManagerTest { "ts" to 123L ) } -} \ No newline at end of file +} From f717089c9ee14130244a803de7abcfff307f19b3 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 12:06:01 +0200 Subject: [PATCH 066/261] style: move assertions to single-line For better readability. --- .../parselyandroid/EngagementManagerTest.kt | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 9af6973d..09b55fe1 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -48,17 +48,10 @@ internal class EngagementManagerTest { // then assertThat(tracker.events[0]).isCorrectEvent( - withTotalTime = { - // Ideally: totalTime should be equal to DEFAULT_INTERVAL_MILLIS - isCloseTo(DEFAULT_INTERVAL_MILLIS, withinPercentage(10)) - }, - withTimestamp = { - // Ideally: timestamp should be equal to System.currentTimeMillis() at the time of recording the event - isCloseTo( - timestamp, - within(100L) - ) - } + // 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(100L)) } ) } From 54e3239f7b00ad45a4766857acaa53444c0587c3 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 12:06:38 +0200 Subject: [PATCH 067/261] fix: remove unwanted log from interval calculator --- .../parselyandroid/UpdateEngagementIntervalCalculator.java | 1 - 1 file changed, 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java index 3ac04ff0..a0f1877f 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java @@ -21,7 +21,6 @@ long updateLatestInterval(@NonNull final Calendar startTime) { double totalWithOffset = totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL; double newInterval = totalWithOffset * BACKOFF_PROPORTION; long clampedNewInterval = (long) Math.min(MAX_TIME_BETWEEN_HEARTBEATS, newInterval); - System.out.println("New interval: " + clampedNewInterval*1000); return clampedNewInterval * 1000; } } From 4d0b3e1656f292c829f17c6d48bc60ab0eb8ea55 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 12:08:49 +0200 Subject: [PATCH 068/261] tests: decrease the timestamp threshold on single-event case --- .../java/com/parsely/parselyandroid/EngagementManagerTest.kt | 2 +- 1 file changed, 1 insertion(+), 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 09b55fe1..755e780f 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -51,7 +51,7 @@ internal class EngagementManagerTest { // 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(100L)) } + withTimestamp = { isCloseTo(timestamp, within(20L)) } ) } From 7877a39db6be92442d794e77d2bc4d58ca78d997 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 12:20:21 +0200 Subject: [PATCH 069/261] style: move unchecked cast suppress closer to warning --- .../java/com/parsely/parselyandroid/EngagementManagerTest.kt | 2 +- 1 file changed, 1 insertion(+), 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 755e780f..64d397b2 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -17,7 +17,6 @@ import org.robolectric.RobolectricTestRunner private typealias Event = MutableMap @RunWith(RobolectricTestRunner::class) -@Suppress("UNCHECKED_CAST") internal class EngagementManagerTest { private lateinit var sut: EngagementManager @@ -105,6 +104,7 @@ internal class EngagementManagerTest { assertThat(totalTime).withTotalTime() } .hasEntrySatisfying("data") { data -> + @Suppress("UNCHECKED_CAST") data as Map assertThat(data).hasEntrySatisfying("ts") { timestamp -> timestamp as Long From e0c108b5bf80b4af9c80f3642416871f3f69c283 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 12:47:48 +0200 Subject: [PATCH 070/261] style: move private methods closer in test --- .../parsely/parselyandroid/EngagementManagerTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 64d397b2..7dc06005 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -92,6 +92,8 @@ internal class EngagementManagerTest { ) } + private fun sleep(millis: Long) = Thread.sleep(millis + THREAD_SLEEPING_THRESHOLD) + private fun MapAssert.isCorrectEvent( withTotalTime: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, withTimestamp: AbstractLongAssert<*>.() -> AbstractLongAssert<*>, @@ -113,6 +115,9 @@ internal class EngagementManagerTest { } } + private val now: Long + get() = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis + class FakeTracker : ParselyTracker( "", 0, @@ -131,11 +136,6 @@ internal class EngagementManagerTest { } } - private val now: Long - get() = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis - - private fun sleep(millis: Long) = Thread.sleep(millis + THREAD_SLEEPING_THRESHOLD) - private companion object { const val DEFAULT_INTERVAL_MILLIS = 100L // Additional time to wait to ensure that the timer has fired From 2c7222a24a36f5f4baf5319586248b8c28930a5c Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 12:49:26 +0200 Subject: [PATCH 071/261] style: static code readability improvements --- .../com/parsely/parselyandroid/EngagementManager.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java index 85769238..a72e7374 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java @@ -27,7 +27,13 @@ class EngagementManager { private Calendar startTime; private final UpdateEngagementIntervalCalculator intervalCalculator; - public EngagementManager(ParselyTracker parselyTracker, Timer parentTimer, long intervalMillis, Map baseEvent, UpdateEngagementIntervalCalculator intervalCalculator) { + public EngagementManager( + ParselyTracker parselyTracker, + Timer parentTimer, + long intervalMillis, + Map baseEvent, + UpdateEngagementIntervalCalculator intervalCalculator + ) { this.parselyTracker = parselyTracker; this.baseEvent = baseEvent; this.parentTimer = parentTimer; From 0237e3574d17842cfc17b96ce929c33713708ee4 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 12:50:19 +0200 Subject: [PATCH 072/261] style: remove redundant casting of `baseEventData` --- .../main/java/com/parsely/parselyandroid/EngagementManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java index a72e7374..fc470bd5 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java @@ -99,7 +99,7 @@ private void doEnqueue(long scheduledExecutionTime) { @SuppressWarnings("unchecked") Map baseEventData = (Map) event.get("data"); assert baseEventData != null; - Map data = new HashMap<>((Map) baseEventData); + Map data = new HashMap<>(baseEventData); data.put("ts", now.getTimeInMillis()); event.put("data", data); From c997778ac5898245c920edd3709bd714bde3a7dd Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 13:33:16 +0200 Subject: [PATCH 073/261] tests: make interval calculator constants package-private So they can be used in unit tests --- .../parselyandroid/UpdateEngagementIntervalCalculator.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java index a0f1877f..5973bf65 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java @@ -6,9 +6,9 @@ class UpdateEngagementIntervalCalculator { - private static final long MAX_TIME_BETWEEN_HEARTBEATS = 60 * 60; - private static final long OFFSET_MATCHING_BASE_INTERVAL = 35; - private static final double BACKOFF_PROPORTION = 0.3; + static final long MAX_TIME_BETWEEN_HEARTBEATS = 60 * 60; + static final long OFFSET_MATCHING_BASE_INTERVAL = 35; + static final double BACKOFF_PROPORTION = 0.3; @NonNull private final Clock clock; From 7b45029212b900b2e7ab7b2566ac0928b0183600 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 13:34:18 +0200 Subject: [PATCH 074/261] tests: add test setup of `UpdateEngagementIntervalCalculator` --- .../UpdateEngagementIntervalCalculatorTest.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt diff --git a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt new file mode 100644 index 00000000..5a843ac8 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt @@ -0,0 +1,21 @@ +package com.parsely.parselyandroid + +import org.junit.Before + +internal class UpdateEngagementIntervalCalculatorTest { + + private lateinit var sut: UpdateEngagementIntervalCalculator + val fakeClock = FakeClock() + + @Before + fun setUp() { + sut = UpdateEngagementIntervalCalculator(fakeClock) + } + + class FakeClock : Clock() { + var fakeNow = 0L + + override val now: Long + get() = fakeNow + } +} \ No newline at end of file From 93a13df2d5dfda5d317a571589d6d0fab6064930 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 13:34:54 +0200 Subject: [PATCH 075/261] tests: add test case for the same start and current time --- .../UpdateEngagementIntervalCalculatorTest.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt index 5a843ac8..740fea1c 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt @@ -1,6 +1,12 @@ package com.parsely.parselyandroid +import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.BACKOFF_PROPORTION +import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.OFFSET_MATCHING_BASE_INTERVAL +import java.util.Calendar +import java.util.TimeZone +import org.assertj.core.api.Assertions.assertThat import org.junit.Before +import org.junit.Test internal class UpdateEngagementIntervalCalculatorTest { @@ -12,6 +18,24 @@ internal class UpdateEngagementIntervalCalculatorTest { sut = UpdateEngagementIntervalCalculator(fakeClock) } + @Test + fun `given the same time of start and current time, when calculating interval, return offset times backoff proportion`() { + // given + fakeClock.fakeNow = 0L + val startTime = Calendar.getInstance().apply { + timeInMillis = 0 + } + + // when + val result = sut.updateLatestInterval(startTime) + + // then + // ((currentTimeInMillis + OFFSET_MATCHING_BASE_INTERVAL) * BACKOFF_PROPORTION) * 1000 + // (0 + 35) * 0.3 * 1000 = 10500 but the result is 10000 because newInterval + // is casted from double to long - instead of 10.5 seconds, it's 10 seconds + assertThat(result).isEqualTo(10000) + } + class FakeClock : Clock() { var fakeNow = 0L From ea6c00de2f662ea09ec824617d50fbef1e8ffea5 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 13:35:20 +0200 Subject: [PATCH 076/261] tests: add test case for interval > max interval time --- .../UpdateEngagementIntervalCalculatorTest.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt index 740fea1c..f5f06201 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt @@ -1,6 +1,7 @@ package com.parsely.parselyandroid import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.BACKOFF_PROPORTION +import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.MAX_TIME_BETWEEN_HEARTBEATS import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.OFFSET_MATCHING_BASE_INTERVAL import java.util.Calendar import java.util.TimeZone @@ -36,6 +37,24 @@ internal class UpdateEngagementIntervalCalculatorTest { assertThat(result).isEqualTo(10000) } + @Test + fun `given a time that will cause the interval to surpass the MAX_TIME_BETWEEN_HEARTBEATS, when calculating interval, then return the MAX_TIME_BETWEEN_HEARTBEATS`() { + // given + // "excessiveTime" is a calculated point in time where the resulting interval would + // naturally surpass MAX_TIME_BETWEEN_HEARTBEATS + val excessiveTime = ((MAX_TIME_BETWEEN_HEARTBEATS / BACKOFF_PROPORTION) - OFFSET_MATCHING_BASE_INTERVAL) * 1000 + fakeClock.fakeNow = excessiveTime.toLong() + 1 + val startTime = Calendar.getInstance().apply { + timeInMillis = 0 + } + + // when + val result = sut.updateLatestInterval(startTime) + + // then + assertThat(result).isEqualTo(MAX_TIME_BETWEEN_HEARTBEATS * 1000) + } + class FakeClock : Clock() { var fakeNow = 0L From e0b55036922bca9e284827c0de0b109e65ec0180 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 14:33:58 +0200 Subject: [PATCH 077/261] tests: add test case for interval calculations after some time since the start time --- .../UpdateEngagementIntervalCalculatorTest.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt index f5f06201..6b2793b2 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt @@ -42,7 +42,8 @@ internal class UpdateEngagementIntervalCalculatorTest { // given // "excessiveTime" is a calculated point in time where the resulting interval would // naturally surpass MAX_TIME_BETWEEN_HEARTBEATS - val excessiveTime = ((MAX_TIME_BETWEEN_HEARTBEATS / BACKOFF_PROPORTION) - OFFSET_MATCHING_BASE_INTERVAL) * 1000 + val excessiveTime = + ((MAX_TIME_BETWEEN_HEARTBEATS / BACKOFF_PROPORTION) - OFFSET_MATCHING_BASE_INTERVAL) * 1000 fakeClock.fakeNow = excessiveTime.toLong() + 1 val startTime = Calendar.getInstance().apply { timeInMillis = 0 @@ -55,6 +56,26 @@ internal class UpdateEngagementIntervalCalculatorTest { assertThat(result).isEqualTo(MAX_TIME_BETWEEN_HEARTBEATS * 1000) } + @Test + fun `given a specific time point, when updating latest interval, it correctly calculates the interval`() { + // given + val timePoint = 2000L + val startTime = Calendar.getInstance().apply { + timeInMillis = 0 + } + fakeClock.fakeNow = timePoint + + // when + val result = sut.updateLatestInterval(startTime) + + // then + // The formula is + // ((currentTimeInMillis + OFFSET_MATCHING_BASE_INTERVAL) * BACKOFF_PROPORTION) * 1000 + // (2 + 35) * 0.3 * 1000 = 11100 but the result is 11000 because newInterval + // is casted from double to long - instead of 11.1 seconds, it's 11 seconds + assertThat(result).isEqualTo(11000L) + } + class FakeClock : Clock() { var fakeNow = 0L From 8f05805d09cddb679e7517a5ebb4536ddedb0782 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 14:38:59 +0200 Subject: [PATCH 078/261] refactor: automated rewrite to Kotlin UpdateEngagementIntervalCalculator class --- .../UpdateEngagementIntervalCalculator.java | 26 ------------------- .../UpdateEngagementIntervalCalculator.kt | 21 +++++++++++++++ .../UpdateEngagementIntervalCalculatorTest.kt | 6 ++--- 3 files changed, 24 insertions(+), 29 deletions(-) delete mode 100644 parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java deleted file mode 100644 index 5973bf65..00000000 --- a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.parsely.parselyandroid; - -import androidx.annotation.NonNull; - -import java.util.Calendar; - -class UpdateEngagementIntervalCalculator { - - static final long MAX_TIME_BETWEEN_HEARTBEATS = 60 * 60; - static final long OFFSET_MATCHING_BASE_INTERVAL = 35; - static final double BACKOFF_PROPORTION = 0.3; - - @NonNull private final Clock clock; - - public UpdateEngagementIntervalCalculator(@NonNull Clock clock) { - this.clock = clock; - } - - long updateLatestInterval(@NonNull final Calendar startTime) { - long totalTrackedTime = (clock.getNow() - startTime.getTime().getTime()) / 1000; - double totalWithOffset = totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL; - double newInterval = totalWithOffset * BACKOFF_PROPORTION; - long clampedNewInterval = (long) Math.min(MAX_TIME_BETWEEN_HEARTBEATS, newInterval); - return clampedNewInterval * 1000; - } -} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt new file mode 100644 index 00000000..39bcf898 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt @@ -0,0 +1,21 @@ +package com.parsely.parselyandroid + +import java.util.Calendar + +internal open class UpdateEngagementIntervalCalculator(private val clock: Clock) { + + open fun updateLatestInterval(startTime: Calendar): Long { + val totalTrackedTime = (clock.now - startTime.time.time) / 1000 + val totalWithOffset = (totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL).toDouble() + val newInterval = totalWithOffset * BACKOFF_PROPORTION + val clampedNewInterval = + Math.min(MAX_TIME_BETWEEN_HEARTBEATS.toDouble(), newInterval).toLong() + return clampedNewInterval * 1000 + } + + companion object { + const val MAX_TIME_BETWEEN_HEARTBEATS = (60 * 60).toLong() + const val OFFSET_MATCHING_BASE_INTERVAL: Long = 35 + const val BACKOFF_PROPORTION = 0.3 + } +} \ No newline at end of file diff --git a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt index 6b2793b2..26f16fc9 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt @@ -1,8 +1,8 @@ package com.parsely.parselyandroid -import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.BACKOFF_PROPORTION -import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.MAX_TIME_BETWEEN_HEARTBEATS -import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.OFFSET_MATCHING_BASE_INTERVAL +import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.BACKOFF_PROPORTION +import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.MAX_TIME_BETWEEN_HEARTBEATS +import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.OFFSET_MATCHING_BASE_INTERVAL import java.util.Calendar import java.util.TimeZone import org.assertj.core.api.Assertions.assertThat From 42f6981fef1450509f101f2f0406f4d41542d8cd Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 14:58:32 +0200 Subject: [PATCH 079/261] fix: incorrect interval calculation Refactor part: interval calculator now uses `kotlin.time.Duration` Thanks to this change we: - fix the bugs of unwanted rounding of `double` values to `long` - makes class easier to work, as we don't have to think about units with all the operation - makes implementation more similar to iOS, which uses `TimeInterval` (see: https://github.com/Parsely/AnalyticsSDK-iOS/blob/b1db4b05dcd5af6feb4710ec30d13fa704305692/Sources/Sampler.swift#L136) --- .../UpdateEngagementIntervalCalculator.kt | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt index 39bcf898..0629f43b 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt @@ -1,21 +1,29 @@ package com.parsely.parselyandroid import java.util.Calendar +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds internal open class UpdateEngagementIntervalCalculator(private val clock: Clock) { open fun updateLatestInterval(startTime: Calendar): Long { - val totalTrackedTime = (clock.now - startTime.time.time) / 1000 - val totalWithOffset = (totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL).toDouble() + val startTimeDuration = startTime.time.time.milliseconds + val nowDuration = clock.now.milliseconds + + val totalTrackedTime = nowDuration - startTimeDuration + val totalWithOffset = totalTrackedTime + offset val newInterval = totalWithOffset * BACKOFF_PROPORTION - val clampedNewInterval = - Math.min(MAX_TIME_BETWEEN_HEARTBEATS.toDouble(), newInterval).toLong() - return clampedNewInterval * 1000 + val clampedNewInterval = minOf(maxTimeBetweenHeartbeats, newInterval) + return clampedNewInterval.inWholeMilliseconds } companion object { const val MAX_TIME_BETWEEN_HEARTBEATS = (60 * 60).toLong() const val OFFSET_MATCHING_BASE_INTERVAL: Long = 35 const val BACKOFF_PROPORTION = 0.3 + + val offset = 35.seconds + val maxTimeBetweenHeartbeats = 1.hours } } \ No newline at end of file From 11daa5e3d8beebcea9128761fb6dcc8f3ef6247d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 15:02:22 +0200 Subject: [PATCH 080/261] tests: update test cases to assert on the correct values --- .../UpdateEngagementIntervalCalculatorTest.kt | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt index 26f16fc9..8c1cced5 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt @@ -4,7 +4,6 @@ import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.B import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.MAX_TIME_BETWEEN_HEARTBEATS import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.OFFSET_MATCHING_BASE_INTERVAL import java.util.Calendar -import java.util.TimeZone import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test @@ -31,10 +30,9 @@ internal class UpdateEngagementIntervalCalculatorTest { val result = sut.updateLatestInterval(startTime) // then - // ((currentTimeInMillis + OFFSET_MATCHING_BASE_INTERVAL) * BACKOFF_PROPORTION) * 1000 - // (0 + 35) * 0.3 * 1000 = 10500 but the result is 10000 because newInterval - // is casted from double to long - instead of 10.5 seconds, it's 10 seconds - assertThat(result).isEqualTo(10000) + // ((currentTime + offset) * BACKOFF_PROPORTION) * 1000 + // (0 + 35) * 0.3 * 1000 = 10500 + assertThat(result).isEqualTo(10500) } @Test @@ -61,7 +59,7 @@ internal class UpdateEngagementIntervalCalculatorTest { // given val timePoint = 2000L val startTime = Calendar.getInstance().apply { - timeInMillis = 0 + timeInMillis = 0 } fakeClock.fakeNow = timePoint @@ -69,11 +67,9 @@ internal class UpdateEngagementIntervalCalculatorTest { val result = sut.updateLatestInterval(startTime) // then - // The formula is - // ((currentTimeInMillis + OFFSET_MATCHING_BASE_INTERVAL) * BACKOFF_PROPORTION) * 1000 - // (2 + 35) * 0.3 * 1000 = 11100 but the result is 11000 because newInterval - // is casted from double to long - instead of 11.1 seconds, it's 11 seconds - assertThat(result).isEqualTo(11000L) + // ((currentTime + offset) * BACKOFF_PROPORTION) * 1000 + // (2 + 35) * 0.3 * 1000 = 11100 + assertThat(result).isEqualTo(11100) } class FakeClock : Clock() { From 4276974e6a0eef6ba2108b81a2543b6f05ebb644 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 15:09:21 +0200 Subject: [PATCH 081/261] refactor: Clock uses `kotlin.time.Duration` now --- .../main/java/com/parsely/parselyandroid/Clock.kt | 3 ++- .../UpdateEngagementIntervalCalculator.kt | 2 +- .../UpdateEngagementIntervalCalculatorTest.kt | 14 ++++++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/Clock.kt b/parsely/src/main/java/com/parsely/parselyandroid/Clock.kt index 4554b4d5..2db30db8 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/Clock.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/Clock.kt @@ -2,8 +2,9 @@ package com.parsely.parselyandroid import java.util.Calendar import java.util.TimeZone +import kotlin.time.Duration.Companion.milliseconds open class Clock { open val now - get() = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis + get() = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis.milliseconds } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt index 0629f43b..19c3865b 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt @@ -9,7 +9,7 @@ internal open class UpdateEngagementIntervalCalculator(private val clock: Clock) open fun updateLatestInterval(startTime: Calendar): Long { val startTimeDuration = startTime.time.time.milliseconds - val nowDuration = clock.now.milliseconds + val nowDuration = clock.now val totalTrackedTime = nowDuration - startTimeDuration val totalWithOffset = totalTrackedTime + offset diff --git a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt index 8c1cced5..908c5319 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt @@ -4,6 +4,9 @@ import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.B import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.MAX_TIME_BETWEEN_HEARTBEATS import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.OFFSET_MATCHING_BASE_INTERVAL import java.util.Calendar +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test @@ -21,7 +24,7 @@ internal class UpdateEngagementIntervalCalculatorTest { @Test fun `given the same time of start and current time, when calculating interval, return offset times backoff proportion`() { // given - fakeClock.fakeNow = 0L + fakeClock.fakeNow = Duration.ZERO val startTime = Calendar.getInstance().apply { timeInMillis = 0 } @@ -42,7 +45,7 @@ internal class UpdateEngagementIntervalCalculatorTest { // naturally surpass MAX_TIME_BETWEEN_HEARTBEATS val excessiveTime = ((MAX_TIME_BETWEEN_HEARTBEATS / BACKOFF_PROPORTION) - OFFSET_MATCHING_BASE_INTERVAL) * 1000 - fakeClock.fakeNow = excessiveTime.toLong() + 1 + fakeClock.fakeNow = excessiveTime.milliseconds + 1.seconds val startTime = Calendar.getInstance().apply { timeInMillis = 0 } @@ -57,11 +60,10 @@ internal class UpdateEngagementIntervalCalculatorTest { @Test fun `given a specific time point, when updating latest interval, it correctly calculates the interval`() { // given - val timePoint = 2000L val startTime = Calendar.getInstance().apply { timeInMillis = 0 } - fakeClock.fakeNow = timePoint + fakeClock.fakeNow = 2.seconds // when val result = sut.updateLatestInterval(startTime) @@ -73,9 +75,9 @@ internal class UpdateEngagementIntervalCalculatorTest { } class FakeClock : Clock() { - var fakeNow = 0L + var fakeNow = Duration.ZERO - override val now: Long + override val now: Duration get() = fakeNow } } \ No newline at end of file From 5bd2b261b91cdc25dda53a9e042760b735b8a8af Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 15:13:31 +0200 Subject: [PATCH 082/261] refactor: do not use plain number fields for calculator constants anymore --- .../UpdateEngagementIntervalCalculator.kt | 11 ++++------- .../UpdateEngagementIntervalCalculatorTest.kt | 8 +++----- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt index 19c3865b..51a03fca 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt @@ -12,18 +12,15 @@ internal open class UpdateEngagementIntervalCalculator(private val clock: Clock) val nowDuration = clock.now val totalTrackedTime = nowDuration - startTimeDuration - val totalWithOffset = totalTrackedTime + offset + val totalWithOffset = totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL val newInterval = totalWithOffset * BACKOFF_PROPORTION - val clampedNewInterval = minOf(maxTimeBetweenHeartbeats, newInterval) + val clampedNewInterval = minOf(MAX_TIME_BETWEEN_HEARTBEATS, newInterval) return clampedNewInterval.inWholeMilliseconds } companion object { - const val MAX_TIME_BETWEEN_HEARTBEATS = (60 * 60).toLong() - const val OFFSET_MATCHING_BASE_INTERVAL: Long = 35 const val BACKOFF_PROPORTION = 0.3 - - val offset = 35.seconds - val maxTimeBetweenHeartbeats = 1.hours + val OFFSET_MATCHING_BASE_INTERVAL = 35.seconds + val MAX_TIME_BETWEEN_HEARTBEATS = 1.hours } } \ No newline at end of file diff --git a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt index 908c5319..dc0ff3c6 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt @@ -5,7 +5,6 @@ import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.M import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.OFFSET_MATCHING_BASE_INTERVAL import java.util.Calendar import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import org.assertj.core.api.Assertions.assertThat import org.junit.Before @@ -43,9 +42,8 @@ internal class UpdateEngagementIntervalCalculatorTest { // given // "excessiveTime" is a calculated point in time where the resulting interval would // naturally surpass MAX_TIME_BETWEEN_HEARTBEATS - val excessiveTime = - ((MAX_TIME_BETWEEN_HEARTBEATS / BACKOFF_PROPORTION) - OFFSET_MATCHING_BASE_INTERVAL) * 1000 - fakeClock.fakeNow = excessiveTime.milliseconds + 1.seconds + val excessiveTime = ((MAX_TIME_BETWEEN_HEARTBEATS / BACKOFF_PROPORTION) - OFFSET_MATCHING_BASE_INTERVAL) + fakeClock.fakeNow = excessiveTime + 1.seconds val startTime = Calendar.getInstance().apply { timeInMillis = 0 } @@ -54,7 +52,7 @@ internal class UpdateEngagementIntervalCalculatorTest { val result = sut.updateLatestInterval(startTime) // then - assertThat(result).isEqualTo(MAX_TIME_BETWEEN_HEARTBEATS * 1000) + assertThat(result).isEqualTo(MAX_TIME_BETWEEN_HEARTBEATS.inWholeMilliseconds) } @Test From 2eef6cadbc50f870453f5e3926930099f9daa576 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 15:14:17 +0200 Subject: [PATCH 083/261] style: simplify calculator name --- .../parselyandroid/EngagementManager.java | 6 +++--- ...ator.kt => HeartbeatIntervalCalculator.kt} | 4 ++-- .../parselyandroid/ParselyTracker.java | 2 +- .../parselyandroid/EngagementManagerTest.kt | 4 ++-- ....kt => HeartbeatIntervalCalculatorTest.kt} | 20 +++++++++---------- 5 files changed, 18 insertions(+), 18 deletions(-) rename parsely/src/main/java/com/parsely/parselyandroid/{UpdateEngagementIntervalCalculator.kt => HeartbeatIntervalCalculator.kt} (85%) rename parsely/src/test/java/com/parsely/parselyandroid/{UpdateEngagementIntervalCalculatorTest.kt => HeartbeatIntervalCalculatorTest.kt} (74%) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java index fc470bd5..182dc407 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java @@ -25,14 +25,14 @@ class EngagementManager { private TimerTask waitingTimerTask; private long latestDelayMillis, totalTime; private Calendar startTime; - private final UpdateEngagementIntervalCalculator intervalCalculator; + private final HeartbeatIntervalCalculator intervalCalculator; public EngagementManager( ParselyTracker parselyTracker, Timer parentTimer, long intervalMillis, Map baseEvent, - UpdateEngagementIntervalCalculator intervalCalculator + HeartbeatIntervalCalculator intervalCalculator ) { this.parselyTracker = parselyTracker; this.baseEvent = baseEvent; @@ -70,7 +70,7 @@ private void scheduleNextExecution(long delay) { TimerTask task = new TimerTask() { public void run() { doEnqueue(scheduledExecutionTime()); - latestDelayMillis = intervalCalculator.updateLatestInterval(startTime); + latestDelayMillis = intervalCalculator.calculate(startTime); scheduleNextExecution(latestDelayMillis); } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt b/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt similarity index 85% rename from parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt rename to parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt index 51a03fca..ed79e15e 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt @@ -5,9 +5,9 @@ import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -internal open class UpdateEngagementIntervalCalculator(private val clock: Clock) { +internal open class HeartbeatIntervalCalculator(private val clock: Clock) { - open fun updateLatestInterval(startTime: Calendar): Long { + open fun calculate(startTime: Calendar): Long { val startTimeDuration = startTime.time.time.milliseconds val nowDuration = clock.now diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index ec39d3c3..ffcb109b 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 @@ public class ParselyTracker { private String lastPageviewUuid = null; @NonNull private final EventsBuilder eventsBuilder; - @NonNull final UpdateEngagementIntervalCalculator intervalCalculator = new UpdateEngagementIntervalCalculator(new Clock()); + @NonNull final HeartbeatIntervalCalculator intervalCalculator = new HeartbeatIntervalCalculator(new Clock()); /** * Create a new ParselyTracker instance. diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 7dc06005..6b5448c1 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -130,8 +130,8 @@ internal class EngagementManagerTest { } } - class FakeIntervalCalculator : UpdateEngagementIntervalCalculator(Clock()) { - override fun updateLatestInterval(startTime: Calendar): Long { + class FakeIntervalCalculator : HeartbeatIntervalCalculator(Clock()) { + override fun calculate(startTime: Calendar): Long { return DEFAULT_INTERVAL_MILLIS } } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt similarity index 74% rename from parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt rename to parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt index dc0ff3c6..3a323fb2 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculatorTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt @@ -1,8 +1,8 @@ package com.parsely.parselyandroid -import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.BACKOFF_PROPORTION -import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.MAX_TIME_BETWEEN_HEARTBEATS -import com.parsely.parselyandroid.UpdateEngagementIntervalCalculator.Companion.OFFSET_MATCHING_BASE_INTERVAL +import com.parsely.parselyandroid.HeartbeatIntervalCalculator.Companion.BACKOFF_PROPORTION +import com.parsely.parselyandroid.HeartbeatIntervalCalculator.Companion.MAX_TIME_BETWEEN_HEARTBEATS +import com.parsely.parselyandroid.HeartbeatIntervalCalculator.Companion.OFFSET_MATCHING_BASE_INTERVAL import java.util.Calendar import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -10,14 +10,14 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test -internal class UpdateEngagementIntervalCalculatorTest { +internal class HeartbeatIntervalCalculatorTest { - private lateinit var sut: UpdateEngagementIntervalCalculator - val fakeClock = FakeClock() + private lateinit var sut: HeartbeatIntervalCalculator + private val fakeClock = FakeClock() @Before fun setUp() { - sut = UpdateEngagementIntervalCalculator(fakeClock) + sut = HeartbeatIntervalCalculator(fakeClock) } @Test @@ -29,7 +29,7 @@ internal class UpdateEngagementIntervalCalculatorTest { } // when - val result = sut.updateLatestInterval(startTime) + val result = sut.calculate(startTime) // then // ((currentTime + offset) * BACKOFF_PROPORTION) * 1000 @@ -49,7 +49,7 @@ internal class UpdateEngagementIntervalCalculatorTest { } // when - val result = sut.updateLatestInterval(startTime) + val result = sut.calculate(startTime) // then assertThat(result).isEqualTo(MAX_TIME_BETWEEN_HEARTBEATS.inWholeMilliseconds) @@ -64,7 +64,7 @@ internal class UpdateEngagementIntervalCalculatorTest { fakeClock.fakeNow = 2.seconds // when - val result = sut.updateLatestInterval(startTime) + val result = sut.calculate(startTime) // then // ((currentTime + offset) * BACKOFF_PROPORTION) * 1000 From cf7a4ccac02c0baf5cd2c9531be50c0123685310 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 15:15:42 +0200 Subject: [PATCH 084/261] style: minor test comments improvements --- .../parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt index 3a323fb2..c575fdb2 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt @@ -32,7 +32,7 @@ internal class HeartbeatIntervalCalculatorTest { val result = sut.calculate(startTime) // then - // ((currentTime + offset) * BACKOFF_PROPORTION) * 1000 + // ((currentTime + offset) * backoff) and then in milliseconds // (0 + 35) * 0.3 * 1000 = 10500 assertThat(result).isEqualTo(10500) } @@ -67,7 +67,7 @@ internal class HeartbeatIntervalCalculatorTest { val result = sut.calculate(startTime) // then - // ((currentTime + offset) * BACKOFF_PROPORTION) * 1000 + // ((currentTime + offset) * backoff) and then in milliseconds // (2 + 35) * 0.3 * 1000 = 11100 assertThat(result).isEqualTo(11100) } From cd4a8df3164d5f885246014292967ea0e77c0f31 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 15:16:51 +0200 Subject: [PATCH 085/261] style: add empty lines at the end of files --- .../com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt | 2 +- .../parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt b/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt index ed79e15e..a99b287e 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt @@ -23,4 +23,4 @@ internal open class HeartbeatIntervalCalculator(private val clock: Clock) { val OFFSET_MATCHING_BASE_INTERVAL = 35.seconds val MAX_TIME_BETWEEN_HEARTBEATS = 1.hours } -} \ No newline at end of file +} diff --git a/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt index c575fdb2..41467e7b 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt @@ -78,4 +78,4 @@ internal class HeartbeatIntervalCalculatorTest { override val now: Duration get() = fakeNow } -} \ No newline at end of file +} From 4ebb3fd7ee2c1c884f346a7c0875b726b5ef17a7 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 15:36:01 +0200 Subject: [PATCH 086/261] tests: use static values (not calculated) in max time between heartbeats test case --- .../parselyandroid/HeartbeatIntervalCalculatorTest.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt index 41467e7b..baaeabda 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt @@ -1,8 +1,6 @@ package com.parsely.parselyandroid -import com.parsely.parselyandroid.HeartbeatIntervalCalculator.Companion.BACKOFF_PROPORTION import com.parsely.parselyandroid.HeartbeatIntervalCalculator.Companion.MAX_TIME_BETWEEN_HEARTBEATS -import com.parsely.parselyandroid.HeartbeatIntervalCalculator.Companion.OFFSET_MATCHING_BASE_INTERVAL import java.util.Calendar import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -41,9 +39,12 @@ internal class HeartbeatIntervalCalculatorTest { fun `given a time that will cause the interval to surpass the MAX_TIME_BETWEEN_HEARTBEATS, when calculating interval, then return the MAX_TIME_BETWEEN_HEARTBEATS`() { // given // "excessiveTime" is a calculated point in time where the resulting interval would - // naturally surpass MAX_TIME_BETWEEN_HEARTBEATS - val excessiveTime = ((MAX_TIME_BETWEEN_HEARTBEATS / BACKOFF_PROPORTION) - OFFSET_MATCHING_BASE_INTERVAL) - fakeClock.fakeNow = excessiveTime + 1.seconds + // surpass MAX_TIME_BETWEEN_HEARTBEATS + // (currentTime + offset) * backoff = max + // currentTime = (max / backoff) - offset, so + // (1 hour / 0.3) - 35 seconds = 11965 seconds. Add 1 second to be over the limit. + val excessiveTime = 11965.seconds + 1.seconds + fakeClock.fakeNow = excessiveTime val startTime = Calendar.getInstance().apply { timeInMillis = 0 } From f1bec2b0df1b773a11588c7a7c12c8c1c08a40bc Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 15:37:02 +0200 Subject: [PATCH 087/261] fix: align max time between heartbeats with iOS: 15 minutes iOS source: https://github.com/Parsely/AnalyticsSDK-iOS/blob/b1db4b05dcd5af6feb4710ec30d13fa704305692/Sources/Sampler.swift#L7 --- .../com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt b/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt index a99b287e..7e1312f7 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt @@ -3,6 +3,7 @@ package com.parsely.parselyandroid import java.util.Calendar import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds internal open class HeartbeatIntervalCalculator(private val clock: Clock) { @@ -21,6 +22,6 @@ internal open class HeartbeatIntervalCalculator(private val clock: Clock) { companion object { const val BACKOFF_PROPORTION = 0.3 val OFFSET_MATCHING_BASE_INTERVAL = 35.seconds - val MAX_TIME_BETWEEN_HEARTBEATS = 1.hours + val MAX_TIME_BETWEEN_HEARTBEATS = 15.minutes } } From 2d60e5c89c6c9a73f4e9781b1bb9a1cc2e54fa2d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 26 Oct 2023 15:39:03 +0200 Subject: [PATCH 088/261] tests: update the test case to match new max value between heartbeats --- .../parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt index baaeabda..e0f3ffd7 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt @@ -42,8 +42,8 @@ internal class HeartbeatIntervalCalculatorTest { // surpass MAX_TIME_BETWEEN_HEARTBEATS // (currentTime + offset) * backoff = max // currentTime = (max / backoff) - offset, so - // (1 hour / 0.3) - 35 seconds = 11965 seconds. Add 1 second to be over the limit. - val excessiveTime = 11965.seconds + 1.seconds + // (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 From fc703607aed4af9e99a635e42e155b99010e2e2f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 27 Oct 2023 22:09:40 +0200 Subject: [PATCH 089/261] tests: add setup for functional tests - add dependencies - add test scaffold - introduce an empty, test-only activity --- parsely/build.gradle | 9 +++++++ .../parsely/parselyandroid/FunctionalTests.kt | 24 +++++++++++++++++++ parsely/src/debug/AndroidManifest.xml | 11 +++++++++ 3 files changed, 44 insertions(+) create mode 100644 parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt create mode 100644 parsely/src/debug/AndroidManifest.xml diff --git a/parsely/build.gradle b/parsely/build.gradle index b0767cf6..71d13a01 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -10,6 +10,8 @@ android { defaultConfig { minSdkVersion 21 targetSdkVersion 33 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { @@ -22,6 +24,7 @@ android { exclude 'META-INF/LICENSE' exclude 'META-INF/NOTICE' } + testBuildType "debug" publishing { singleVariant('release') { @@ -58,6 +61,12 @@ dependencies { testImplementation 'org.assertj:assertj-core:3.24.2' testImplementation 'junit:junit:4.13.2' testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test:rules:1.5.0' + androidTestImplementation 'org.assertj:assertj-core:3.24.2' + androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + androidTestImplementation 'androidx.test:runner:1.5.2' } apply from: "${rootProject.projectDir}/publication.gradle" diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt new file mode 100644 index 00000000..c2ab8312 --- /dev/null +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -0,0 +1,24 @@ +package com.parsely.parselyandroid + +import android.app.Activity +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith + + +@RunWith(AndroidJUnit4::class) +class FunctionalTests { + + private lateinit var parselyTracker: ParselyTracker + + @Test + fun appTracksEventsAboveQueueSizeLimit() { + ActivityScenario.launch(SampleActivity::class.java).use { scenario -> + scenario.onActivity { activity: Activity -> + } + } + } + + class SampleActivity : Activity() +} diff --git a/parsely/src/debug/AndroidManifest.xml b/parsely/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..e1811077 --- /dev/null +++ b/parsely/src/debug/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + From 4a1f1fadc25545992321a218d283cffc11fa0483 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 27 Oct 2023 22:18:18 +0200 Subject: [PATCH 090/261] tests: initialize ParselyTracker in functional test With address of mocked server as `ROOT_URL`, applied via reflection. `ROOT_URL` *must not* be a plain declaration, because otherwise the compiler will *inline* the value of the constant, and we won't be able to change it, even via reflection, for the functional test. --- .../parsely/parselyandroid/FunctionalTests.kt | 27 +++++++++++++++++++ .../parselyandroid/ParselyTracker.java | 5 +++- 2 files changed, 31 insertions(+), 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 c2ab8312..6903ed3e 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -3,6 +3,10 @@ package com.parsely.parselyandroid import android.app.Activity import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.lang.reflect.Field +import kotlin.time.Duration.Companion.seconds +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer import org.junit.Test import org.junit.runner.RunWith @@ -11,14 +15,37 @@ import org.junit.runner.RunWith class FunctionalTests { private lateinit var parselyTracker: ParselyTracker + private val server = MockWebServer() + private val url = server.url("/").toString() @Test fun appTracksEventsAboveQueueSizeLimit() { ActivityScenario.launch(SampleActivity::class.java).use { scenario -> scenario.onActivity { activity: Activity -> + server.enqueue(MockResponse().setResponseCode(200)) + parselyTracker = initializeTracker(activity) + + parselyTracker.trackPageview("url", null, null, null) + + server.takeRequest() } } } + private fun initializeTracker(activity: Activity): ParselyTracker { + 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 { + const val siteId = "123" + val flushInterval = 10.seconds + } + class SampleActivity : Activity() } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index ffcb109b..39506938 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -61,7 +61,10 @@ public class ParselyTracker { private static final String STORAGE_KEY = "parsely-events.ser"; // emulator localhost // private static final String ROOT_URL = "http://10.0.2.2:5001/"; - private static final String ROOT_URL = "https://p1.parsely.com/"; + /** + * @noinspection StringOperationCanBeSimplified + */ + private static final String ROOT_URL = "https://p1.parsely.com/".intern(); protected ArrayList> eventQueue; private boolean isDebug; private final Context context; From eb31361f3ccc47d0a014da2fc8a1993baceec345 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 27 Oct 2023 22:27:53 +0200 Subject: [PATCH 091/261] tests: assert lack of local storage file when running test By using AndroidX test orchestrator with `clearPackageData` flag. Each test should run in homogeneous environment. This change asserts, that the file used to store events locally won't be shared between tests. --- parsely/build.gradle | 5 +++++ .../com/parsely/parselyandroid/FunctionalTests.kt | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/parsely/build.gradle b/parsely/build.gradle index 71d13a01..ef2ab843 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -12,6 +12,10 @@ android { targetSdkVersion 33 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments clearPackageData: 'true' + } + testOptions { + execution 'ANDROIDX_TEST_ORCHESTRATOR' } buildTypes { release { @@ -67,6 +71,7 @@ dependencies { androidTestImplementation 'org.assertj:assertj-core:3.24.2' androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestUtil 'androidx.test:orchestrator:1.4.2' } apply from: "${rootProject.projectDir}/publication.gradle" diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index 6903ed3e..4abb0e5e 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -3,7 +3,10 @@ package com.parsely.parselyandroid import android.app.Activity import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.io.File import java.lang.reflect.Field +import java.nio.file.Path +import kotlin.io.path.Path import kotlin.time.Duration.Companion.seconds import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -17,11 +20,21 @@ class FunctionalTests { private lateinit var parselyTracker: ParselyTracker private val server = MockWebServer() private val url = server.url("/").toString() + private lateinit var appsFiles: Path + + private fun beforeEach(activity: Activity) { + appsFiles = Path(activity.filesDir.path) + + if (File("$appsFiles/parsely-events.ser").exists()) { + throw RuntimeException("Local storage file exists. Something went wrong with orchestrating the tests.") + } + } @Test fun appTracksEventsAboveQueueSizeLimit() { ActivityScenario.launch(SampleActivity::class.java).use { scenario -> scenario.onActivity { activity: Activity -> + beforeEach(activity) server.enqueue(MockResponse().setResponseCode(200)) parselyTracker = initializeTracker(activity) From 729c40e3f35205d27cd086017bba8c9466d3ec91 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 27 Oct 2023 22:34:08 +0200 Subject: [PATCH 092/261] tests: test case for "app tracks events above queue size limit" This commit adds test that checks if sending events over the limit of 50 events, works as intended. To not use flaky `Thread#sleep` approach, this test checks for: - local storage file changes via `java.nio.file.WatchService` API - finish of HTTP requests via `okhttp3.mockwebserver.takeRequest` API The `waitForFileEvents` method works in a way as `app.cash.turbine`, without a timeout though. --- .../parsely/parselyandroid/FunctionalTests.kt | 71 ++++++++++++++++++- 1 file changed, 69 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 4abb0e5e..f4d80511 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -4,12 +4,19 @@ import android.app.Activity import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import java.io.File +import java.io.FileInputStream +import java.io.ObjectInputStream import java.lang.reflect.Field import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds.ENTRY_CREATE +import java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY +import java.nio.file.WatchEvent +import java.nio.file.WatchService import kotlin.io.path.Path import kotlin.time.Duration.Companion.seconds import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import org.assertj.core.api.Assertions.assertThat import org.junit.Test import org.junit.runner.RunWith @@ -30,6 +37,11 @@ class FunctionalTests { } } + /** + * In this scenario, the consumer application tracks more than 50 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() { ActivityScenario.launch(SampleActivity::class.java).use { scenario -> @@ -38,11 +50,66 @@ class FunctionalTests { server.enqueue(MockResponse().setResponseCode(200)) parselyTracker = initializeTracker(activity) - parselyTracker.trackPageview("url", null, null, null) + repeat(51) { + parselyTracker.trackPageview("url", null, null, null) + } - server.takeRequest() + // Waits for the SDK to save events to disk + val createLocalStorageEvents = appsFiles.waitForFileEvents(2) + assertThat(createLocalStorageEvents).satisfiesExactly( + // Checks for local storage file creation + { event -> assertThat(event.kind()).isEqualTo(ENTRY_CREATE) }, + // Checks if local storage file was modified + { event -> assertThat(event.kind()).isEqualTo(ENTRY_MODIFY) }, + ) + assertThat(locallyStoredEvents).hasSize(51) + } + + val dropLocalStorageEvent = appsFiles.waitForFileEvents(1) + + // Waits for the SDK to send events (flush interval passes) + server.takeRequest() + + assertThat(dropLocalStorageEvent).satisfiesExactly( + { event -> assertThat(event.kind()).isEqualTo(ENTRY_MODIFY) }, + ) + assertThat(locallyStoredEvents).hasSize(0) + } + } + + private val locallyStoredEvents + get() = FileInputStream(File("$appsFiles/parsely-events.ser")).use { + ObjectInputStream(it).use { objectInputStream -> + @Suppress("UNCHECKED_CAST") + objectInputStream.readObject() as ArrayList> } } + + + private fun Path.waitForFileEvents(numberOfEvents: Int): List> { + val service = watch() + val events = LinkedHashSet>() + while (true) { + val key = service.poll() + val polledEvents = + key?.pollEvents()?.filter { it.context().toString() == "parsely-events.ser" } + .orEmpty() + events.addAll(polledEvents) + println("[Parsely] Caught ${events.size} file events") + if (events.size == numberOfEvents) { + key?.reset() + break + } + Thread.sleep(500) + } + return events.toList() + } + + private fun Path.watch(): WatchService { + val watchService = this.fileSystem.newWatchService() + register(watchService, ENTRY_CREATE, ENTRY_MODIFY) + + return watchService } private fun initializeTracker(activity: Activity): ParselyTracker { From e7d53d4891b2849a52f352bec7344013687ad395 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 27 Oct 2023 23:20:08 +0200 Subject: [PATCH 093/261] tests: do not wait for file changes in functional tests This commit changes the approach for functional tests to more blackbox style, which seems to be more suitable here. As we don't care for implementation details (e.g. how or if events are stored locally), we no longer observe file changes. --- .../parsely/parselyandroid/FunctionalTests.kt | 63 +++++-------------- 1 file changed, 16 insertions(+), 47 deletions(-) diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index f4d80511..0545345e 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -8,15 +8,16 @@ import java.io.FileInputStream import java.io.ObjectInputStream import java.lang.reflect.Field import java.nio.file.Path -import java.nio.file.StandardWatchEventKinds.ENTRY_CREATE -import java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY -import java.nio.file.WatchEvent -import java.nio.file.WatchService import kotlin.io.path.Path +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.yield import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.fail import org.junit.Test import org.junit.runner.RunWith @@ -53,27 +54,22 @@ class FunctionalTests { repeat(51) { parselyTracker.trackPageview("url", null, null, null) } - - // Waits for the SDK to save events to disk - val createLocalStorageEvents = appsFiles.waitForFileEvents(2) - assertThat(createLocalStorageEvents).satisfiesExactly( - // Checks for local storage file creation - { event -> assertThat(event.kind()).isEqualTo(ENTRY_CREATE) }, - // Checks if local storage file was modified - { event -> assertThat(event.kind()).isEqualTo(ENTRY_MODIFY) }, - ) - assertThat(locallyStoredEvents).hasSize(51) } - val dropLocalStorageEvent = appsFiles.waitForFileEvents(1) - // Waits for the SDK to send events (flush interval passes) server.takeRequest() - assertThat(dropLocalStorageEvent).satisfiesExactly( - { event -> assertThat(event.kind()).isEqualTo(ENTRY_MODIFY) }, - ) - assertThat(locallyStoredEvents).hasSize(0) + runBlocking { + withTimeoutOrNull(500.milliseconds) { + while (true) { + yield() + if (locallyStoredEvents.size == 0) { + break + } + } + } ?: fail("Local storage file is not empty!") + } + } } @@ -85,33 +81,6 @@ class FunctionalTests { } } - - private fun Path.waitForFileEvents(numberOfEvents: Int): List> { - val service = watch() - val events = LinkedHashSet>() - while (true) { - val key = service.poll() - val polledEvents = - key?.pollEvents()?.filter { it.context().toString() == "parsely-events.ser" } - .orEmpty() - events.addAll(polledEvents) - println("[Parsely] Caught ${events.size} file events") - if (events.size == numberOfEvents) { - key?.reset() - break - } - Thread.sleep(500) - } - return events.toList() - } - - private fun Path.watch(): WatchService { - val watchService = this.fileSystem.newWatchService() - register(watchService, ENTRY_CREATE, ENTRY_MODIFY) - - return watchService - } - private fun initializeTracker(activity: Activity): ParselyTracker { return ParselyTracker.sharedInstance( siteId, flushInterval.inWholeSeconds.toInt(), activity.application From 6ac717d789c6647b445fad71085ffa0019b58226 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 27 Oct 2023 23:55:25 +0200 Subject: [PATCH 094/261] tests: assert the correct payload size of triggered HTTP request. --- .../parsely/parselyandroid/FunctionalTests.kt | 21 +++++++++++++++++-- 1 file changed, 19 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 0545345e..52d592d2 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -3,6 +3,10 @@ package com.parsely.parselyandroid import android.app.Activity import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper import java.io.File import java.io.FileInputStream import java.io.ObjectInputStream @@ -16,6 +20,7 @@ import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.yield 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.fail import org.junit.Test @@ -57,7 +62,8 @@ class FunctionalTests { } // Waits for the SDK to send events (flush interval passes) - server.takeRequest() + val requestPayload = server.takeRequest().toMap() + assertThat(requestPayload["events"]).hasSize(51) runBlocking { withTimeoutOrNull(500.milliseconds) { @@ -69,10 +75,21 @@ class FunctionalTests { } } ?: fail("Local storage file is not empty!") } - } } + private fun RecordedRequest.toMap(): Map> { + val listType: TypeReference>> = + object : TypeReference>>() {} + + return ObjectMapper().readValue(body.readUtf8(), listType) + } + + @JsonIgnoreProperties(ignoreUnknown = true) + data class Event ( + @JsonProperty("idsite") var idsite: String, + ) + private val locallyStoredEvents get() = FileInputStream(File("$appsFiles/parsely-events.ser")).use { ObjectInputStream(it).use { objectInputStream -> From 5b25e59eee0681b61c91c0a9cdcdefac5160b472 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 28 Oct 2023 00:01:50 +0200 Subject: [PATCH 095/261] ci: run functional tests on CI On a separate job, as it has to run on macOS. --- .github/workflows/readme.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/workflows/readme.yml b/.github/workflows/readme.yml index 59e66bed..347dbb75 100644 --- a/.github/workflows/readme.yml +++ b/.github/workflows/readme.yml @@ -43,3 +43,33 @@ jobs: with: name: artifact path: ~/.m2/repository/com/parsely/parsely/* + functional-tests: + runs-on: macos-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: set up JDK + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + - name: Functional Tests + uses: reactivecircus/android-emulator-runner@v2.28.0 + with: + working-directory: . + api-level: 31 + profile: Nexus 6 + arch: x86_64 + force-avd-creation: false + avd-name: macOS-avd-x86_64-31 + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew :parsely:connectedCheck + - name: Publish build artifacts + uses: actions/upload-artifact@v3 + if: always() + with: + name: artifact + path: ./parsely/build/reports/* From 328143e9f46e7056446ca289225566b46be16aa0 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sun, 29 Oct 2023 13:04:25 +0100 Subject: [PATCH 096/261] build: extract dependencies version of unit and instrumentation tests --- parsely/build.gradle | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/parsely/build.gradle b/parsely/build.gradle index ef2ab843..3fbfade1 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -4,6 +4,11 @@ plugins { id 'org.jetbrains.kotlinx.kover' } +ext { + assertJVersion = '3.24.2' + mockWebServerVersion = '4.12.0' +} + android { compileSdkVersion 33 @@ -59,17 +64,17 @@ 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' - + testImplementation 'org.robolectric:robolectric:4.10.3' testImplementation 'androidx.test:core:1.5.0' - testImplementation 'org.assertj:assertj-core:3.24.2' + testImplementation "org.assertj:assertj-core:$assertJVersion" testImplementation 'junit:junit:4.13.2' - testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + testImplementation "com.squareup.okhttp3:mockwebserver:$mockWebServerVersion" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test:rules:1.5.0' - androidTestImplementation 'org.assertj:assertj-core:3.24.2' - androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + androidTestImplementation "org.assertj:assertj-core:$assertJVersion" + androidTestImplementation "com.squareup.okhttp3:mockwebserver:$mockWebServerVersion" androidTestImplementation 'androidx.test:runner:1.5.2' androidTestUtil 'androidx.test:orchestrator:1.4.2' } From e20610755c741fdf6d0ff9ff5bd3965a093e1611 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sun, 29 Oct 2023 15:12:45 +0100 Subject: [PATCH 097/261] style: extract name of local storage file to val --- .../java/com/parsely/parselyandroid/FunctionalTests.kt | 5 +++-- 1 file changed, 3 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 52d592d2..ef4646c5 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -38,7 +38,7 @@ class FunctionalTests { private fun beforeEach(activity: Activity) { appsFiles = Path(activity.filesDir.path) - if (File("$appsFiles/parsely-events.ser").exists()) { + if (File("$appsFiles/$localStorageFileName").exists()) { throw RuntimeException("Local storage file exists. Something went wrong with orchestrating the tests.") } } @@ -91,7 +91,7 @@ class FunctionalTests { ) private val locallyStoredEvents - get() = FileInputStream(File("$appsFiles/parsely-events.ser")).use { + get() = FileInputStream(File("$appsFiles/$localStorageFileName")).use { ObjectInputStream(it).use { objectInputStream -> @Suppress("UNCHECKED_CAST") objectInputStream.readObject() as ArrayList> @@ -110,6 +110,7 @@ class FunctionalTests { private companion object { const val siteId = "123" + const val localStorageFileName = "parsely-events.ser" val flushInterval = 10.seconds } From 7edf70d02491ec4bc640f7ee29048670a16a454b Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sun, 29 Oct 2023 15:27:37 +0100 Subject: [PATCH 098/261] refactor: simplify code with waitFor function for locally stored events --- .../parsely/parselyandroid/FunctionalTests.kt | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index ef4646c5..e82fd24e 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -22,7 +22,6 @@ 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.fail import org.junit.Test import org.junit.runner.RunWith @@ -65,16 +64,9 @@ class FunctionalTests { val requestPayload = server.takeRequest().toMap() assertThat(requestPayload["events"]).hasSize(51) - runBlocking { - withTimeoutOrNull(500.milliseconds) { - while (true) { - yield() - if (locallyStoredEvents.size == 0) { - break - } - } - } ?: fail("Local storage file is not empty!") - } + // Wait a moment to give SDK time to delete the content of local storage file + waitFor { locallyStoredEvents.isEmpty() } + assertThat(locallyStoredEvents).isEmpty() } } @@ -86,7 +78,7 @@ class FunctionalTests { } @JsonIgnoreProperties(ignoreUnknown = true) - data class Event ( + data class Event( @JsonProperty("idsite") var idsite: String, ) @@ -115,4 +107,15 @@ class FunctionalTests { } class SampleActivity : Activity() + + private fun waitFor(condition: () -> Boolean) = runBlocking { + withTimeoutOrNull(500.milliseconds) { + while (true) { + yield() + if (condition()) { + break + } + } + } + } } From dba68eb4c7778d133b1ea7651301e956a0e3ff29 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 13:07:49 +0100 Subject: [PATCH 099/261] style: use `@SuppressWarnings` instead of a comment --- .../main/java/com/parsely/parselyandroid/ParselyTracker.java | 4 +--- 1 file changed, 1 insertion(+), 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 39506938..467f219f 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -61,9 +61,7 @@ public class ParselyTracker { private static final String STORAGE_KEY = "parsely-events.ser"; // emulator localhost // private static final String ROOT_URL = "http://10.0.2.2:5001/"; - /** - * @noinspection StringOperationCanBeSimplified - */ + @SuppressWarnings("StringOperationCanBeSimplified") private static final String ROOT_URL = "https://p1.parsely.com/".intern(); protected ArrayList> eventQueue; private boolean isDebug; From d3e2487c26cc137629e9cd655ca0718c8502de56 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 13:08:49 +0100 Subject: [PATCH 100/261] fix: add `intern` to `ROOT_URL` of emulator localhost --- .../main/java/com/parsely/parselyandroid/ParselyTracker.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 467f219f..7784970e 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -59,9 +59,8 @@ public class ParselyTracker { private static final int QUEUE_SIZE_LIMIT = 50; private static final int STORAGE_SIZE_LIMIT = 100; private static final String STORAGE_KEY = "parsely-events.ser"; -// emulator localhost -// private static final String ROOT_URL = "http://10.0.2.2:5001/"; @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(); protected ArrayList> eventQueue; private boolean isDebug; From 609f701bc60c0c151862e0ed532e2c181293ffa6 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 13:12:22 +0100 Subject: [PATCH 101/261] ci: use `connectedDebugAndroidTest` to run functional tests --- .github/workflows/readme.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/readme.yml b/.github/workflows/readme.yml index 347dbb75..257d0df3 100644 --- a/.github/workflows/readme.yml +++ b/.github/workflows/readme.yml @@ -66,7 +66,7 @@ jobs: avd-name: macOS-avd-x86_64-31 emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: ./gradlew :parsely:connectedCheck + script: ./gradlew :parsely:connectedDebugAndroidTest - name: Publish build artifacts uses: actions/upload-artifact@v3 if: always() From e506df73a55a6e88f556fcbd6da359dfa8cf7d2f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 13:32:18 +0100 Subject: [PATCH 102/261] chore: remove unused `testBuildType` config Because we only now run instrumentation tests in the `debug` build type by default, we no longer need to configure `debug` as the `testBuildType`. --- parsely/build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/parsely/build.gradle b/parsely/build.gradle index 3fbfade1..c1206c88 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -33,7 +33,6 @@ android { exclude 'META-INF/LICENSE' exclude 'META-INF/NOTICE' } - testBuildType "debug" publishing { singleVariant('release') { From ec0f30a0b24e40ebe4138b92a9fdce2bc6d7fd35 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 1 Nov 2023 18:58:06 +0100 Subject: [PATCH 103/261] refactor: extract local storage related code To LocalStorageRepository.java --- .../LocalStorageRepository.java | 104 ++++++++++++++++++ .../parselyandroid/ParselyTracker.java | 101 ++--------------- 2 files changed, 115 insertions(+), 90 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.java diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.java b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.java new file mode 100644 index 00000000..b15028e4 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.java @@ -0,0 +1,104 @@ +package com.parsely.parselyandroid; + +import static com.parsely.parselyandroid.ParselyTracker.PLog; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import java.io.EOFException; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +class LocalStorageRepository { + private static final String STORAGE_KEY = "parsely-events.ser"; + + private final Context context; + + public LocalStorageRepository(Context context) { + this.context = context; + } + + /** + * Persist an object to storage. + * + * @param o Object to store. + */ + void persistObject(Object o) { + try { + FileOutputStream fos = context.getApplicationContext().openFileOutput( + STORAGE_KEY, + android.content.Context.MODE_PRIVATE + ); + ObjectOutputStream oos = new ObjectOutputStream(fos); + oos.writeObject(o); + oos.close(); + } catch (Exception ex) { + PLog("Exception thrown during queue serialization: %s", ex.toString()); + } + } + + /** + * Delete the stored queue from persistent storage. + */ + void purgeStoredQueue() { + persistObject(new ArrayList>()); + } + + /** + * Get the stored event queue from persistent storage. + * + * @return The stored queue of events. + */ + @NonNull + ArrayList> getStoredQueue() { + ArrayList> storedQueue = null; + try { + FileInputStream fis = context.getApplicationContext().openFileInput(STORAGE_KEY); + ObjectInputStream ois = new ObjectInputStream(fis); + //noinspection unchecked + storedQueue = (ArrayList>) ois.readObject(); + ois.close(); + } catch (EOFException ex) { + // Nothing to do here. + } catch (FileNotFoundException ex) { + // Nothing to do here. Means there was no saved queue. + } catch (Exception ex) { + PLog("Exception thrown during queue deserialization: %s", ex.toString()); + } + + if (storedQueue == null) { + storedQueue = new ArrayList<>(); + } + return storedQueue; + } + + /** + * Delete an event from the stored queue. + */ + void expelStoredEvent() { + ArrayList> storedQueue = getStoredQueue(); + storedQueue.remove(0); + } + + /** + * Save the event queue to persistent storage. + */ + synchronized void persistQueue(@NonNull final List> inMemoryQueue) { + PLog("Persisting event queue"); + ArrayList> storedQueue = getStoredQueue(); + HashSet> hs = new HashSet<>(); + hs.addAll(storedQueue); + hs.addAll(inMemoryQueue); + storedQueue.clear(); + storedQueue.addAll(hs); + persistObject(storedQueue); + } +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 7784970e..ec75cbd5 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -29,13 +29,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.EOFException; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; import java.io.IOException; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; import java.io.StringWriter; import java.util.ArrayList; import java.util.Formatter; @@ -58,7 +52,6 @@ public class ParselyTracker { private static final int DEFAULT_ENGAGEMENT_INTERVAL_MILLIS = 10500; private static final int QUEUE_SIZE_LIMIT = 50; private static final int STORAGE_SIZE_LIMIT = 100; - private static final String STORAGE_KEY = "parsely-events.ser"; @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(); @@ -72,7 +65,10 @@ public class ParselyTracker { private String lastPageviewUuid = null; @NonNull private final EventsBuilder eventsBuilder; - @NonNull final HeartbeatIntervalCalculator intervalCalculator = new HeartbeatIntervalCalculator(new Clock()); + @NonNull + private final HeartbeatIntervalCalculator intervalCalculator = new HeartbeatIntervalCalculator(new Clock()); + @NonNull + private final LocalStorageRepository localStorageRepository; /** * Create a new ParselyTracker instance. @@ -80,6 +76,7 @@ public class ParselyTracker { protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); eventsBuilder = new EventsBuilder(context, siteId); + localStorageRepository = new LocalStorageRepository(context); // get the adkey straight away on instantiation timer = new Timer(); @@ -89,7 +86,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { flushManager = new FlushManager(timer, flushInterval * 1000L); - if (getStoredQueue().size() > 0) { + if (localStorageRepository.getStoredQueue().size() > 0) { startFlushTimer(); } @@ -474,85 +471,9 @@ private boolean isReachable() { return netInfo != null && netInfo.isConnectedOrConnecting(); } - /** - * Save the event queue to persistent storage. - */ - private synchronized void persistQueue() { - PLog("Persisting event queue"); - ArrayList> storedQueue = getStoredQueue(); - HashSet> hs = new HashSet<>(); - hs.addAll(storedQueue); - hs.addAll(eventQueue); - storedQueue.clear(); - storedQueue.addAll(hs); - persistObject(storedQueue); - } - - /** - * Get the stored event queue from persistent storage. - * - * @return The stored queue of events. - */ - @NonNull - private ArrayList> getStoredQueue() { - ArrayList> storedQueue = null; - try { - FileInputStream fis = context.getApplicationContext().openFileInput(STORAGE_KEY); - ObjectInputStream ois = new ObjectInputStream(fis); - //noinspection unchecked - storedQueue = (ArrayList>) ois.readObject(); - ois.close(); - } catch (EOFException ex) { - // Nothing to do here. - } catch (FileNotFoundException ex) { - // Nothing to do here. Means there was no saved queue. - } catch (Exception ex) { - PLog("Exception thrown during queue deserialization: %s", ex.toString()); - } - - if (storedQueue == null) { - storedQueue = new ArrayList<>(); - } - return storedQueue; - } - void purgeEventsQueue() { eventQueue.clear(); - purgeStoredQueue(); - } - - /** - * Delete the stored queue from persistent storage. - */ - private void purgeStoredQueue() { - persistObject(new ArrayList>()); - } - - /** - * Delete an event from the stored queue. - */ - private void expelStoredEvent() { - ArrayList> storedQueue = getStoredQueue(); - storedQueue.remove(0); - } - - /** - * Persist an object to storage. - * - * @param o Object to store. - */ - private void persistObject(Object o) { - try { - FileOutputStream fos = context.getApplicationContext().openFileOutput( - STORAGE_KEY, - android.content.Context.MODE_PRIVATE - ); - ObjectOutputStream oos = new ObjectOutputStream(fos); - oos.writeObject(o); - oos.close(); - } catch (Exception ex) { - PLog("Exception thrown during queue serialization: %s", ex.toString()); - } + localStorageRepository.purgeStoredQueue(); } /** @@ -621,7 +542,7 @@ public int queueSize() { * @return The number of events stored in persistent storage. */ public int storedEventsCount() { - ArrayList> ar = getStoredQueue(); + ArrayList> ar = localStorageRepository.getStoredQueue(); return ar.size(); } @@ -631,11 +552,11 @@ protected Void doInBackground(Void... params) { // if event queue is too big, push to persisted storage if (eventQueue.size() > QUEUE_SIZE_LIMIT) { PLog("Queue size exceeded, expelling oldest event to persistent memory"); - persistQueue(); + localStorageRepository.persistQueue(eventQueue); eventQueue.remove(0); // if persisted storage is too big, expel one if (storedEventsCount() > STORAGE_SIZE_LIMIT) { - expelStoredEvent(); + localStorageRepository.expelStoredEvent(); } } return null; @@ -645,7 +566,7 @@ protected Void doInBackground(Void... params) { private class FlushQueue extends AsyncTask { @Override protected synchronized Void doInBackground(Void... params) { - ArrayList> storedQueue = getStoredQueue(); + ArrayList> storedQueue = localStorageRepository.getStoredQueue(); PLog("%d events in queue, %d stored events", eventQueue.size(), storedEventsCount()); // in case both queues have been flushed and app quits, don't crash if ((eventQueue == null || eventQueue.size() == 0) && storedQueue.size() == 0) { From 07079ddcb44cc960692f7a0fd1d02d994b1a996e Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 2 Nov 2023 10:06:30 +0100 Subject: [PATCH 104/261] tests: test that `expelStoredEvent` doesn't have an effect The added test proves, that method `expelStoredEvent` doesn't have any affect on the stored queue. Removing it is safe, as it asserts the previous behavior. --- .../LocalStorageRepositoryTest.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt diff --git a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt new file mode 100644 index 00000000..5145ece2 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt @@ -0,0 +1,31 @@ +package com.parsely.parselyandroid + +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) +class LocalStorageRepositoryTest { + + private lateinit var sut: LocalStorageRepository + + @Before + fun setUp() { + sut = LocalStorageRepository(ApplicationProvider.getApplicationContext()) + } + + @Test + fun `when expelling stored event, then assert that it has no effect`() { + // given + sut.persistQueue((1..100).map { mapOf("index" to it) }) + + // when + sut.expelStoredEvent() + + // then + assertThat(sut.storedQueue).hasSize(100) + } +} From d2066e3f0d0df8ceb1d2298f6b98e61b774dc223 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 2 Nov 2023 10:28:05 +0100 Subject: [PATCH 105/261] tests: add unit tests coverage for LocalStorageRepository --- .../LocalStorageRepositoryTest.kt | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt index 5145ece2..18b95f2e 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt @@ -28,4 +28,49 @@ class LocalStorageRepositoryTest { // then assertThat(sut.storedQueue).hasSize(100) } + + @Test + fun `given the list of events, when persisting the list, then querying the list returns the same result`() { + // given + val eventsList = (1..10).map { mapOf("index" to it) } + + // when + sut.persistQueue(eventsList) + + // then + assertThat(sut.storedQueue).hasSize(10).containsExactlyInAnyOrderElementsOf(eventsList) + } + + @Test + fun `given no locally stored list, when requesting stored queue, then return an empty list`() { + assertThat(sut.storedQueue).isEmpty() + } + + @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`() { + // given + val storedQueue = (1..5).map { mapOf("index" to it) } + val inMemoryQueue = (3..10).map { mapOf("index" to it) } + sut.persistQueue(storedQueue) + + // when + sut.persistQueue(inMemoryQueue) + + // then + val expectedQueue = (1..10).map { mapOf("index" to it) } + assertThat(sut.storedQueue).hasSize(10).containsExactlyInAnyOrderElementsOf(expectedQueue) + } + + @Test + fun `given stored queue, when purging stored queue, then assert queue is purged`() { + // given + val eventsList = (1..10).map { mapOf("index" to it) } + sut.persistQueue(eventsList) + + // when + sut.purgeStoredQueue() + + // then + assertThat(sut.storedQueue).isEmpty() + } } From 63eb02cf7862a5e72a72947a66ff98dac4abae4f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 2 Nov 2023 10:33:37 +0100 Subject: [PATCH 106/261] fix: add the correct visibility modifier to `persistObject` method --- .../com/parsely/parselyandroid/LocalStorageRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.java b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.java index b15028e4..6e9897a5 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.java @@ -22,7 +22,7 @@ class LocalStorageRepository { private final Context context; - public LocalStorageRepository(Context context) { + LocalStorageRepository(Context context) { this.context = context; } @@ -31,7 +31,7 @@ public LocalStorageRepository(Context context) { * * @param o Object to store. */ - void persistObject(Object o) { + private void persistObject(Object o) { try { FileOutputStream fos = context.getApplicationContext().openFileOutput( STORAGE_KEY, From 675508b933edeb0e43890303954a1e3f22cb93f4 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 2 Nov 2023 11:28:40 +0100 Subject: [PATCH 107/261] tests: assert correct serialization of the Java file This test is added to assert correctness of serialization after migrating LocalStorageRepository from Java to Kotlin. Two languages might have some differences in how they serialize objects, so this test will assert that older storage files (created by Java) will have compatible content with new version of LocalStorageRepository, written in Kotlin. --- .../LocalStorageRepositoryTest.kt | 49 +++++++++++++++++- .../resources/valid-java-parsely-events.ser | Bin 0 -> 881 bytes 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 parsely/src/test/resources/valid-java-parsely-events.ser diff --git a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt index 18b95f2e..09d255d8 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt @@ -1,6 +1,8 @@ package com.parsely.parselyandroid +import android.content.Context import androidx.test.core.app.ApplicationProvider +import java.io.File import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test @@ -11,10 +13,11 @@ import org.robolectric.RobolectricTestRunner class LocalStorageRepositoryTest { private lateinit var sut: LocalStorageRepository + private val context = ApplicationProvider.getApplicationContext() @Before fun setUp() { - sut = LocalStorageRepository(ApplicationProvider.getApplicationContext()) + sut = LocalStorageRepository(context) } @Test @@ -73,4 +76,48 @@ class LocalStorageRepositoryTest { // then assertThat(sut.storedQueue).isEmpty() } + + @Test + fun `given stored file with serialized events, when querying the queue, then list has expected events`() { + // given + val file = File(context.filesDir.path + "/parsely-events.ser") + File(ClassLoader.getSystemResource("valid-java-parsely-events.ser")?.path!!).copyTo(file) + + // when + val queue = sut.storedQueue + + // then + assertThat(queue).isEqualTo( + listOf( + mapOf( + "idsite" to "example.com", + "urlref" to "http://example.com/", + "data" to mapOf( + "manufacturer" to "Google", + "os" to "android", + "os_version" to "33", + "parsely_site_uuid" to "b325e2c9-498c-4331-a967-2d6049317c77", + "ts" to 1698918720863L + ), + "pvid" to "272cc2b8-5acc-4a70-80c7-20bb6eb843e4", + "action" to "pageview", + "url" to "http://example.com/article1.html" + ), + mapOf( + "idsite" to "example.com", + "urlref" to "http://example.com/", + "data" to mapOf( + "manufacturer" to "Google", + "os" to "android", + "os_version" to "33", + "parsely_site_uuid" to "b325e2c9-498c-4331-a967-2d6049317c77", + "ts" to 1698918742375L + ), + "pvid" to "e94567ec-3459-498c-bf2e-6a1b85ed5a82", + "action" to "pageview", + "url" to "http://example.com/article1.html" + ) + ) + ) + } } diff --git a/parsely/src/test/resources/valid-java-parsely-events.ser b/parsely/src/test/resources/valid-java-parsely-events.ser new file mode 100644 index 0000000000000000000000000000000000000000..2d514a8c616fb3d20524d50a9fde13961c85fd12 GIT binary patch literal 881 zcmds#y>1gh5P-*LI|)gI5?P{)gaqyR=l%^vpa}#8Uct=mjqO8g@3A{)Unr2$(6|T% z6coHcUVsu&(9l3h0|hPbz}g@s5>U}m>=d&*v)_EXXWyY|G_)S!4BI8=vVB`?JU+?| zS0|tLUcJZHb*LRe!{kq>DgeNlHm=)?~&?W>v9&%QgVmo^RN=W7itPTArDliD8Q>oD`DULQQPC)Kb%sp*3bMZge^q zb~_A>3>nR1cnZ#MO`8mkG7RfD&9urhhTQ_SA$dGl-ZUu7W$t?Fdm(wE?*{!uxPkBY zTt#_l==;4`#4*FBGJ_duldGw1>-!Ab6P%VqJ6)+sThKd7jilvw*jP1}S860q zN9`ju9o<}fd;a0{+7}0E_n&4fF1F%$GTbybdRCdf_ zwqo^SPY5qf+z^Fj#klJxU13?fX&O~8#X_w(HwqllET=g`-FjrW`Ui=qITw<8 z?J-Ye^%s}_+XF3sOzWrMH=_%JsUL(haR~R_R=JzEX2h7H^J2ppCSdv literal 0 HcmV?d00001 From b59244decf8acecc29299a12619ee12dd98471c5 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 2 Nov 2023 11:30:39 +0100 Subject: [PATCH 108/261] Rename .java to .kt --- .../{LocalStorageRepository.java => LocalStorageRepository.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename parsely/src/main/java/com/parsely/parselyandroid/{LocalStorageRepository.java => LocalStorageRepository.kt} (100%) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.java b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt similarity index 100% rename from parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.java rename to parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt From a9d9631ab9e40a82a68389a6d904fd63a6ab74e6 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 2 Nov 2023 11:30:39 +0100 Subject: [PATCH 109/261] refactor: move `LocalStorageRepository` to Kotlin --- .../parselyandroid/LocalStorageRepository.kt | 141 ++++++++---------- 1 file changed, 64 insertions(+), 77 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index 6e9897a5..1b0973ee 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -1,104 +1,91 @@ -package com.parsely.parselyandroid; +package com.parsely.parselyandroid -import static com.parsely.parselyandroid.ParselyTracker.PLog; - -import android.content.Context; - -import androidx.annotation.NonNull; - -import java.io.EOFException; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; - -class LocalStorageRepository { - private static final String STORAGE_KEY = "parsely-events.ser"; - - private final Context context; - - LocalStorageRepository(Context context) { - this.context = context; - } +import android.content.Context +import java.io.EOFException +import java.io.FileNotFoundException +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +internal class LocalStorageRepository(private val context: Context) { /** * Persist an object to storage. * * @param o Object to store. */ - private void persistObject(Object o) { + private fun persistObject(o: Any) { try { - FileOutputStream fos = context.getApplicationContext().openFileOutput( - STORAGE_KEY, - android.content.Context.MODE_PRIVATE - ); - ObjectOutputStream oos = new ObjectOutputStream(fos); - oos.writeObject(o); - oos.close(); - } catch (Exception ex) { - PLog("Exception thrown during queue serialization: %s", ex.toString()); + val fos = context.applicationContext.openFileOutput( + STORAGE_KEY, + Context.MODE_PRIVATE + ) + val oos = ObjectOutputStream(fos) + oos.writeObject(o) + oos.close() + } catch (ex: Exception) { + ParselyTracker.PLog("Exception thrown during queue serialization: %s", ex.toString()) } } /** * Delete the stored queue from persistent storage. */ - void purgeStoredQueue() { - persistObject(new ArrayList>()); + fun purgeStoredQueue() { + persistObject(ArrayList>()) } - /** - * Get the stored event queue from persistent storage. - * - * @return The stored queue of events. - */ - @NonNull - ArrayList> getStoredQueue() { - ArrayList> storedQueue = null; - try { - FileInputStream fis = context.getApplicationContext().openFileInput(STORAGE_KEY); - ObjectInputStream ois = new ObjectInputStream(fis); - //noinspection unchecked - storedQueue = (ArrayList>) ois.readObject(); - ois.close(); - } catch (EOFException ex) { - // Nothing to do here. - } catch (FileNotFoundException ex) { - // Nothing to do here. Means there was no saved queue. - } catch (Exception ex) { - PLog("Exception thrown during queue deserialization: %s", ex.toString()); - } - - if (storedQueue == null) { - storedQueue = new ArrayList<>(); + val storedQueue: ArrayList?> + /** + * Get the stored event queue from persistent storage. + * + * @return The stored queue of events. + */ + get() { + var storedQueue: ArrayList?>? = null + try { + val fis = context.applicationContext.openFileInput(STORAGE_KEY) + val ois = ObjectInputStream(fis) + storedQueue = ois.readObject() as ArrayList?> + ois.close() + } catch (ex: EOFException) { + // Nothing to do here. + } catch (ex: FileNotFoundException) { + // Nothing to do here. Means there was no saved queue. + } catch (ex: Exception) { + ParselyTracker.PLog( + "Exception thrown during queue deserialization: %s", + ex.toString() + ) + } + if (storedQueue == null) { + storedQueue = ArrayList() + } + return storedQueue } - return storedQueue; - } /** * Delete an event from the stored queue. */ - void expelStoredEvent() { - ArrayList> storedQueue = getStoredQueue(); - storedQueue.remove(0); + fun expelStoredEvent() { + val storedQueue = storedQueue + storedQueue.removeAt(0) } /** * Save the event queue to persistent storage. */ - synchronized void persistQueue(@NonNull final List> inMemoryQueue) { - PLog("Persisting event queue"); - ArrayList> storedQueue = getStoredQueue(); - HashSet> hs = new HashSet<>(); - hs.addAll(storedQueue); - hs.addAll(inMemoryQueue); - storedQueue.clear(); - storedQueue.addAll(hs); - persistObject(storedQueue); + @Synchronized + fun persistQueue(inMemoryQueue: List?>) { + ParselyTracker.PLog("Persisting event queue") + val storedQueue = storedQueue + val hs = HashSet?>() + hs.addAll(storedQueue) + hs.addAll(inMemoryQueue) + storedQueue.clear() + storedQueue.addAll(hs) + persistObject(storedQueue) + } + + companion object { + private const val STORAGE_KEY = "parsely-events.ser" } -} +} \ No newline at end of file From 49713d8223d4b1940ca29e9d21487b4d0fceea33 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 2 Nov 2023 11:32:55 +0100 Subject: [PATCH 110/261] refactor: make queues keys non-nullable --- .../parsely/parselyandroid/LocalStorageRepository.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index 1b0973ee..ce785439 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -33,18 +33,18 @@ internal class LocalStorageRepository(private val context: Context) { persistObject(ArrayList>()) } - val storedQueue: ArrayList?> + val storedQueue: ArrayList?> /** * Get the stored event queue from persistent storage. * * @return The stored queue of events. */ get() { - var storedQueue: ArrayList?>? = null + var storedQueue: ArrayList?>? = null try { val fis = context.applicationContext.openFileInput(STORAGE_KEY) val ois = ObjectInputStream(fis) - storedQueue = ois.readObject() as ArrayList?> + storedQueue = ois.readObject() as ArrayList?> ois.close() } catch (ex: EOFException) { // Nothing to do here. @@ -74,10 +74,10 @@ internal class LocalStorageRepository(private val context: Context) { * Save the event queue to persistent storage. */ @Synchronized - fun persistQueue(inMemoryQueue: List?>) { + fun persistQueue(inMemoryQueue: List?>) { ParselyTracker.PLog("Persisting event queue") val storedQueue = storedQueue - val hs = HashSet?>() + val hs = HashSet?>() hs.addAll(storedQueue) hs.addAll(inMemoryQueue) storedQueue.clear() From fac1f77caf99001911c1b6ddf39f8f8eb972707a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 2 Nov 2023 11:46:28 +0100 Subject: [PATCH 111/261] refactor: extract QueueManager to a separate class --- .../parselyandroid/ParselyTracker.java | 21 +--------- .../parsely/parselyandroid/QueueManager.java | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/QueueManager.java diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index ec75cbd5..11c13b15 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -50,8 +50,6 @@ public class ParselyTracker { private static ParselyTracker instance = null; private static final int DEFAULT_FLUSH_INTERVAL_SECS = 60; private static final int DEFAULT_ENGAGEMENT_INTERVAL_MILLIS = 10500; - private static final int QUEUE_SIZE_LIMIT = 50; - private static final int STORAGE_SIZE_LIMIT = 100; @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(); @@ -415,7 +413,7 @@ public void resetVideo() { void enqueueEvent(Map event) { // Push it onto the queue eventQueue.add(event); - new QueueManager().execute(); + new QueueManager(this, localStorageRepository).execute(); if (!flushTimerIsActive()) { startFlushTimer(); PLog("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000)); @@ -546,23 +544,6 @@ public int storedEventsCount() { return ar.size(); } - private class QueueManager extends AsyncTask { - @Override - protected Void doInBackground(Void... params) { - // if event queue is too big, push to persisted storage - if (eventQueue.size() > QUEUE_SIZE_LIMIT) { - PLog("Queue size exceeded, expelling oldest event to persistent memory"); - localStorageRepository.persistQueue(eventQueue); - eventQueue.remove(0); - // if persisted storage is too big, expel one - if (storedEventsCount() > STORAGE_SIZE_LIMIT) { - localStorageRepository.expelStoredEvent(); - } - } - return null; - } - } - private class FlushQueue extends AsyncTask { @Override protected synchronized Void doInBackground(Void... params) { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.java b/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.java new file mode 100644 index 00000000..7a9a38ee --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.java @@ -0,0 +1,38 @@ +package com.parsely.parselyandroid; + +import android.os.AsyncTask; + +import androidx.annotation.NonNull; + +class QueueManager extends AsyncTask { + private static final int QUEUE_SIZE_LIMIT = 50; + private static final int STORAGE_SIZE_LIMIT = 100; + + @NonNull + private final ParselyTracker parselyTracker; + @NonNull + private final LocalStorageRepository localStorageRepository; + + public QueueManager( + @NonNull ParselyTracker parselyTracker, + @NonNull LocalStorageRepository localStorageRepository + ) { + this.parselyTracker = parselyTracker; + this.localStorageRepository = localStorageRepository; + } + + @Override + protected Void doInBackground(Void... params) { + // if event queue is too big, push to persisted storage + if (parselyTracker.eventQueue.size() > QUEUE_SIZE_LIMIT) { + ParselyTracker.PLog("Queue size exceeded, expelling oldest event to persistent memory"); + localStorageRepository.persistQueue(parselyTracker.eventQueue); + parselyTracker.eventQueue.remove(0); + // if persisted storage is too big, expel one + if (parselyTracker.storedEventsCount() > STORAGE_SIZE_LIMIT) { + localStorageRepository.expelStoredEvent(); + } + } + return null; + } +} From 93657a77b3d33d55f4e50beba16dd7989b177746 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 3 Nov 2023 15:39:24 +0100 Subject: [PATCH 112/261] tests: add unit test coverage for QueueManager --- .../parselyandroid/LocalStorageRepository.kt | 8 +- .../parselyandroid/ParselyTracker.java | 7 +- .../parsely/parselyandroid/QueueManager.java | 10 +- .../parselyandroid/QueueManagerTest.kt | 109 ++++++++++++++++++ 4 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index ce785439..63c695ac 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -6,7 +6,7 @@ import java.io.FileNotFoundException import java.io.ObjectInputStream import java.io.ObjectOutputStream -internal class LocalStorageRepository(private val context: Context) { +internal open class LocalStorageRepository(private val context: Context) { /** * Persist an object to storage. * @@ -33,7 +33,7 @@ internal class LocalStorageRepository(private val context: Context) { persistObject(ArrayList>()) } - val storedQueue: ArrayList?> + open val storedQueue: ArrayList?> /** * Get the stored event queue from persistent storage. * @@ -65,7 +65,7 @@ internal class LocalStorageRepository(private val context: Context) { /** * Delete an event from the stored queue. */ - fun expelStoredEvent() { + open fun expelStoredEvent() { val storedQueue = storedQueue storedQueue.removeAt(0) } @@ -74,7 +74,7 @@ internal class LocalStorageRepository(private val context: Context) { * Save the event queue to persistent storage. */ @Synchronized - fun persistQueue(inMemoryQueue: List?>) { + open fun persistQueue(inMemoryQueue: List?>) { ParselyTracker.PLog("Persisting event queue") val storedQueue = storedQueue val hs = HashSet?>() diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 11c13b15..e4d61c2f 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -35,6 +35,7 @@ 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.TimerTask; @@ -53,7 +54,7 @@ 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(); - protected ArrayList> eventQueue; + private final ArrayList> eventQueue; private boolean isDebug; private final Context context; private final Timer timer; @@ -97,6 +98,10 @@ 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)} * diff --git a/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.java b/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.java index 7a9a38ee..c05d1fe2 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.java @@ -5,8 +5,8 @@ import androidx.annotation.NonNull; class QueueManager extends AsyncTask { - private static final int QUEUE_SIZE_LIMIT = 50; - private static final int STORAGE_SIZE_LIMIT = 100; + static final int QUEUE_SIZE_LIMIT = 50; + static final int STORAGE_SIZE_LIMIT = 100; @NonNull private final ParselyTracker parselyTracker; @@ -24,10 +24,10 @@ public QueueManager( @Override protected Void doInBackground(Void... params) { // if event queue is too big, push to persisted storage - if (parselyTracker.eventQueue.size() > QUEUE_SIZE_LIMIT) { + if (parselyTracker.getInMemoryQueue().size() > QUEUE_SIZE_LIMIT) { ParselyTracker.PLog("Queue size exceeded, expelling oldest event to persistent memory"); - localStorageRepository.persistQueue(parselyTracker.eventQueue); - parselyTracker.eventQueue.remove(0); + localStorageRepository.persistQueue(parselyTracker.getInMemoryQueue()); + parselyTracker.getInMemoryQueue().remove(0); // if persisted storage is too big, expel one if (parselyTracker.storedEventsCount() > STORAGE_SIZE_LIMIT) { localStorageRepository.expelStoredEvent(); diff --git a/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt new file mode 100644 index 00000000..d73bbd3f --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt @@ -0,0 +1,109 @@ +package com.parsely.parselyandroid + +import androidx.test.core.app.ApplicationProvider +import com.parsely.parselyandroid.QueueManager.QUEUE_SIZE_LIMIT +import com.parsely.parselyandroid.QueueManager.STORAGE_SIZE_LIMIT +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 + +@RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +internal class QueueManagerTest { + + private lateinit var sut: QueueManager + + 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`() { + // given + val initialInMemoryQueue = listOf(mapOf("test" to "test")) + tracker.applyFakeQueue(initialInMemoryQueue) + + // when + sut.execute().get() + shadowMainLooper().idle(); + + // then + assertThat(tracker.inMemoryQueue).isEqualTo(initialInMemoryQueue) + assertThat(repository.storedQueue).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`() { + // given + val initialInMemoryQueue = (1..QUEUE_SIZE_LIMIT + 1).map { mapOf("test" to it) } + tracker.applyFakeQueue(initialInMemoryQueue) + + // when + sut.execute().get() + shadowMainLooper().idle(); + + // then + assertThat(repository.storedQueue).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`() { + // given + 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(); + + // 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.storedQueue.size + } + } + + class FakeLocalRepository : + LocalStorageRepository(ApplicationProvider.getApplicationContext()) { + + private var localFileQueue = emptyList?>() + var wasEventExpelled = false + + override fun persistQueue(inMemoryQueue: List?>) { + this.localFileQueue += inMemoryQueue + } + + override val storedQueue: ArrayList?> + get() = ArrayList(localFileQueue) + + + override fun expelStoredEvent() { + wasEventExpelled = true + } + } +} From 1fe87722cd9cc8a20faf77bdedc354d230d170a2 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 3 Nov 2023 15:48:44 +0100 Subject: [PATCH 113/261] style: make getStoredQueue a method --- .../parselyandroid/LocalStorageRepository.kt | 19 +++++++++---------- .../LocalStorageRepositoryTest.kt | 12 ++++++------ .../parselyandroid/QueueManagerTest.kt | 10 ++++------ 3 files 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 63c695ac..ac5ef795 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -33,13 +33,12 @@ internal open class LocalStorageRepository(private val context: Context) { persistObject(ArrayList>()) } - open val storedQueue: ArrayList?> - /** - * Get the stored event queue from persistent storage. - * - * @return The stored queue of events. - */ - get() { + /** + * Get the stored event queue from persistent storage. + * + * @return The stored queue of events. + */ + open fun getStoredQueue(): ArrayList?> { var storedQueue: ArrayList?>? = null try { val fis = context.applicationContext.openFileInput(STORAGE_KEY) @@ -66,7 +65,7 @@ internal open class LocalStorageRepository(private val context: Context) { * Delete an event from the stored queue. */ open fun expelStoredEvent() { - val storedQueue = storedQueue + val storedQueue = getStoredQueue() storedQueue.removeAt(0) } @@ -76,7 +75,7 @@ internal open class LocalStorageRepository(private val context: Context) { @Synchronized open fun persistQueue(inMemoryQueue: List?>) { ParselyTracker.PLog("Persisting event queue") - val storedQueue = storedQueue + val storedQueue = getStoredQueue() val hs = HashSet?>() hs.addAll(storedQueue) hs.addAll(inMemoryQueue) @@ -88,4 +87,4 @@ internal open class LocalStorageRepository(private val context: Context) { companion object { private const val STORAGE_KEY = "parsely-events.ser" } -} \ No newline at end of file +} diff --git a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt index 09d255d8..2879fdab 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt @@ -29,7 +29,7 @@ class LocalStorageRepositoryTest { sut.expelStoredEvent() // then - assertThat(sut.storedQueue).hasSize(100) + assertThat(sut.getStoredQueue()).hasSize(100) } @Test @@ -41,12 +41,12 @@ class LocalStorageRepositoryTest { sut.persistQueue(eventsList) // then - assertThat(sut.storedQueue).hasSize(10).containsExactlyInAnyOrderElementsOf(eventsList) + assertThat(sut.getStoredQueue()).hasSize(10).containsExactlyInAnyOrderElementsOf(eventsList) } @Test fun `given no locally stored list, when requesting stored queue, then return an empty list`() { - assertThat(sut.storedQueue).isEmpty() + assertThat(sut.getStoredQueue()).isEmpty() } @Test @@ -61,7 +61,7 @@ class LocalStorageRepositoryTest { // then val expectedQueue = (1..10).map { mapOf("index" to it) } - assertThat(sut.storedQueue).hasSize(10).containsExactlyInAnyOrderElementsOf(expectedQueue) + assertThat(sut.getStoredQueue()).hasSize(10).containsExactlyInAnyOrderElementsOf(expectedQueue) } @Test @@ -74,7 +74,7 @@ class LocalStorageRepositoryTest { sut.purgeStoredQueue() // then - assertThat(sut.storedQueue).isEmpty() + assertThat(sut.getStoredQueue()).isEmpty() } @Test @@ -84,7 +84,7 @@ class LocalStorageRepositoryTest { File(ClassLoader.getSystemResource("valid-java-parsely-events.ser")?.path!!).copyTo(file) // when - val queue = sut.storedQueue + val queue = sut.getStoredQueue() // then assertThat(queue).isEqualTo( diff --git a/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt index d73bbd3f..309b52e6 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt @@ -37,7 +37,7 @@ internal class QueueManagerTest { // then assertThat(tracker.inMemoryQueue).isEqualTo(initialInMemoryQueue) - assertThat(repository.storedQueue).isEmpty() + assertThat(repository.getStoredQueue()).isEmpty() } @Test @@ -51,7 +51,7 @@ internal class QueueManagerTest { shadowMainLooper().idle(); // then - assertThat(repository.storedQueue).isEqualTo(initialInMemoryQueue) + assertThat(repository.getStoredQueue()).isEqualTo(initialInMemoryQueue) assertThat(tracker.inMemoryQueue).hasSize(QUEUE_SIZE_LIMIT) } @@ -84,7 +84,7 @@ internal class QueueManagerTest { } override fun storedEventsCount(): Int { - return repository.storedQueue.size + return repository.getStoredQueue().size } } @@ -98,9 +98,7 @@ internal class QueueManagerTest { this.localFileQueue += inMemoryQueue } - override val storedQueue: ArrayList?> - get() = ArrayList(localFileQueue) - + override fun getStoredQueue() = ArrayList(localFileQueue) override fun expelStoredEvent() { wasEventExpelled = true From 5a5f7c368d3cf6909186c21e3f1497ea081f4590 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 3 Nov 2023 16:15:14 +0100 Subject: [PATCH 114/261] refactor: make getStoredQueue more concise --- .../parselyandroid/LocalStorageRepository.kt | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index ac5ef795..dcabe195 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -39,27 +39,25 @@ internal open class LocalStorageRepository(private val context: Context) { * @return The stored queue of events. */ open fun getStoredQueue(): ArrayList?> { - var storedQueue: ArrayList?>? = null - try { - val fis = context.applicationContext.openFileInput(STORAGE_KEY) - val ois = ObjectInputStream(fis) - storedQueue = ois.readObject() as ArrayList?> - ois.close() - } catch (ex: EOFException) { - // Nothing to do here. - } catch (ex: FileNotFoundException) { - // Nothing to do here. Means there was no saved queue. - } catch (ex: Exception) { - ParselyTracker.PLog( - "Exception thrown during queue deserialization: %s", - ex.toString() - ) - } - if (storedQueue == null) { - storedQueue = ArrayList() - } - return storedQueue + var storedQueue: ArrayList?> = ArrayList() + try { + val fis = context.applicationContext.openFileInput(STORAGE_KEY) + val ois = ObjectInputStream(fis) + @Suppress("UNCHECKED_CAST") + storedQueue = ois.readObject() as ArrayList?> + ois.close() + } catch (ex: EOFException) { + // Nothing to do here. + } catch (ex: FileNotFoundException) { + // Nothing to do here. Means there was no saved queue. + } catch (ex: Exception) { + ParselyTracker.PLog( + "Exception thrown during queue deserialization: %s", + ex.toString() + ) } + return storedQueue + } /** * Delete an event from the stored queue. From 7700af91bc6d68f3a65610cc6220537417e09535 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 3 Nov 2023 16:29:53 +0100 Subject: [PATCH 115/261] Rename .java to .kt --- .../parsely/parselyandroid/{QueueManager.java => QueueManager.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename parsely/src/main/java/com/parsely/parselyandroid/{QueueManager.java => QueueManager.kt} (100%) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.java b/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt similarity index 100% rename from parsely/src/main/java/com/parsely/parselyandroid/QueueManager.java rename to parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt From fbe86d4b823ebf4d1f2a9d760b2180a5c51999e2 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 3 Nov 2023 16:29:53 +0100 Subject: [PATCH 116/261] tests: add unit test coverage for QueueManager --- .../parsely/parselyandroid/QueueManager.kt | 46 ++++++++----------- .../parselyandroid/QueueManagerTest.kt | 4 +- 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt index c05d1fe2..6a786cc1 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt @@ -1,38 +1,28 @@ -package com.parsely.parselyandroid; +package com.parsely.parselyandroid -import android.os.AsyncTask; +import android.os.AsyncTask -import androidx.annotation.NonNull; +internal class QueueManager( + private val parselyTracker: ParselyTracker, + private val localStorageRepository: LocalStorageRepository +) : AsyncTask() { -class QueueManager extends AsyncTask { - static final int QUEUE_SIZE_LIMIT = 50; - static final int STORAGE_SIZE_LIMIT = 100; - - @NonNull - private final ParselyTracker parselyTracker; - @NonNull - private final LocalStorageRepository localStorageRepository; - - public QueueManager( - @NonNull ParselyTracker parselyTracker, - @NonNull LocalStorageRepository localStorageRepository - ) { - this.parselyTracker = parselyTracker; - this.localStorageRepository = localStorageRepository; - } - - @Override - protected Void doInBackground(Void... params) { + override fun doInBackground(vararg params: Void?): Void? { // if event queue is too big, push to persisted storage - if (parselyTracker.getInMemoryQueue().size() > QUEUE_SIZE_LIMIT) { - ParselyTracker.PLog("Queue size exceeded, expelling oldest event to persistent memory"); - localStorageRepository.persistQueue(parselyTracker.getInMemoryQueue()); - parselyTracker.getInMemoryQueue().remove(0); + 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(); + localStorageRepository.expelStoredEvent() } } - return null; + return null + } + + 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 index 309b52e6..2bb00887 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt @@ -1,8 +1,8 @@ package com.parsely.parselyandroid import androidx.test.core.app.ApplicationProvider -import com.parsely.parselyandroid.QueueManager.QUEUE_SIZE_LIMIT -import com.parsely.parselyandroid.QueueManager.STORAGE_SIZE_LIMIT +import com.parsely.parselyandroid.QueueManager.Companion.QUEUE_SIZE_LIMIT +import com.parsely.parselyandroid.QueueManager.Companion.STORAGE_SIZE_LIMIT import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test From 16fce92a494d7c4ed16ed83510573420bae895a2 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 6 Nov 2023 15:11:28 +0100 Subject: [PATCH 117/261] refactor: simplify method --- .../com/parsely/parselyandroid/LocalStorageRepository.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index dcabe195..1f4f903b 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -73,13 +73,7 @@ internal open class LocalStorageRepository(private val context: Context) { @Synchronized open fun persistQueue(inMemoryQueue: List?>) { ParselyTracker.PLog("Persisting event queue") - val storedQueue = getStoredQueue() - val hs = HashSet?>() - hs.addAll(storedQueue) - hs.addAll(inMemoryQueue) - storedQueue.clear() - storedQueue.addAll(hs) - persistObject(storedQueue) + persistObject((inMemoryQueue + getStoredQueue()).distinct()) } companion object { From 0a3bcc3bef15ae52c9150b8e146ab7e0e830193a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 6 Nov 2023 15:22:03 +0100 Subject: [PATCH 118/261] style: add deprecation annotations --- .../src/main/java/com/parsely/parselyandroid/QueueManager.kt | 2 ++ .../test/java/com/parsely/parselyandroid/QueueManagerTest.kt | 1 + 2 files changed, 3 insertions(+) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt index 6a786cc1..59d5401b 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt @@ -2,11 +2,13 @@ package com.parsely.parselyandroid import android.os.AsyncTask +@Suppress("DEPRECATION") internal class QueueManager( private val parselyTracker: ParselyTracker, private val localStorageRepository: LocalStorageRepository ) : AsyncTask() { + @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) { diff --git a/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt index 2bb00887..86613295 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt @@ -11,6 +11,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.LooperMode import org.robolectric.shadows.ShadowLooper.shadowMainLooper +@Suppress("DEPRECATION") @RunWith(RobolectricTestRunner::class) @LooperMode(LooperMode.Mode.PAUSED) internal class QueueManagerTest { From b3fcdbd6c68f712267822e8f668ad5542c9b7a1a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 6 Nov 2023 15:46:41 +0100 Subject: [PATCH 119/261] docs: update coordinates to STORAGE_SIZE_LIMIT const --- .../main/java/com/parsely/parselyandroid/ParselyTracker.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index e4d61c2f..609ed087 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -411,7 +411,7 @@ 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 #STORAGE_SIZE_LIMIT}. + * store exceeds {@link QueueManager#STORAGE_SIZE_LIMIT}. * * @param event The event Map to enqueue. */ From a5554a70d95eb63fbf5afcf0e3e44aafe96c5ef5 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 6 Nov 2023 17:05:26 +0100 Subject: [PATCH 120/261] Rename .java to .kt --- .../{ParselyAPIConnection.java => ParselyAPIConnection.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename parsely/src/main/java/com/parsely/parselyandroid/{ParselyAPIConnection.java => ParselyAPIConnection.kt} (100%) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt similarity index 100% rename from parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java rename to parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt From 0fbf5841f8fd36a397632a822c1463f107ff2f85 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 6 Nov 2023 17:05:27 +0100 Subject: [PATCH 121/261] refactor: move `ParselyAPIConnection` to Kotlin --- .../parselyandroid/ParselyAPIConnection.kt | 78 +++++++------------ 1 file changed, 30 insertions(+), 48 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt index 992fb973..b9e14069 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt @@ -13,64 +13,46 @@ See the License for the specific language governing permissions and limitations under the License. */ +package com.parsely.parselyandroid -package com.parsely.parselyandroid; +import android.os.AsyncTask +import java.net.HttpURLConnection +import java.net.URL -import android.os.AsyncTask; - -import androidx.annotation.NonNull; - -import java.io.OutputStream; -import java.net.URL; -import java.net.HttpURLConnection; - -class ParselyAPIConnection extends AsyncTask { - - @NonNull - private final ParselyTracker tracker; - private Exception exception; - - ParselyAPIConnection(@NonNull ParselyTracker tracker) { - this.tracker = tracker; - } - - @Override - protected Void doInBackground(String... data) { - HttpURLConnection connection = null; +internal class ParselyAPIConnection(private val tracker: ParselyTracker) : AsyncTask() { + private var exception: Exception? = null + protected override fun doInBackground(vararg data: String?): Void? { + var connection: HttpURLConnection? = null try { - if (data.length == 1) { // non-batched (since no post data is included) - connection = (HttpURLConnection) new URL(data[0]).openConnection(); - connection.getInputStream(); - } else if (data.length == 2) { // batched (post data included) - connection = (HttpURLConnection) new URL(data[0]).openConnection(); - connection.setDoOutput(true); // Triggers POST (aka silliest interface ever) - connection.setRequestProperty("Content-Type", "application/json"); - - OutputStream output = connection.getOutputStream(); - output.write(data[1].getBytes()); - output.close(); - connection.getInputStream(); + 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) + connection.setRequestProperty("Content-Type", "application/json") + val output = connection.outputStream + output.write(data[1]?.toByteArray()) + output.close() + connection.inputStream } - - } catch (Exception ex) { - this.exception = ex; + } catch (ex: Exception) { + exception = ex } - return null; + return null } - @Override - protected void onPostExecute(Void result) { - if (this.exception != null) { - ParselyTracker.PLog("Pixel request exception"); - ParselyTracker.PLog(this.exception.toString()); + override fun onPostExecute(result: Void?) { + 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(); + tracker.purgeEventsQueue() + ParselyTracker.PLog("Event queue empty, flush timer cleared.") + tracker.stopFlushTimer() } } } From 3645773bedd16b4d93bb4d06b38570b19998ee3d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 6 Nov 2023 17:14:53 +0100 Subject: [PATCH 122/261] style: add deprecation annotations --- .../java/com/parsely/parselyandroid/ParselyAPIConnection.kt | 6 +++++- 1 file changed, 5 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 b9e14069..6934defe 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt @@ -19,9 +19,12 @@ import android.os.AsyncTask import java.net.HttpURLConnection import java.net.URL +@Suppress("DEPRECATION") internal class ParselyAPIConnection(private val tracker: ParselyTracker) : AsyncTask() { private var exception: Exception? = null - protected override fun doInBackground(vararg data: String?): Void? { + + @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) @@ -42,6 +45,7 @@ internal class ParselyAPIConnection(private val tracker: ParselyTracker) : Async return null } + @Deprecated("Deprecated in Java") override fun onPostExecute(result: Void?) { if (exception != null) { ParselyTracker.PLog("Pixel request exception") From 7c16775b2341a935d01707ca793d56f46a895824 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 6 Nov 2023 17:21:04 +0100 Subject: [PATCH 123/261] ci: do not fail PR if code coverage lowered We don't need such check in this SDK. Also, it can be false positive when moving from Java to Kotlin --- codecov.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codecov.yml b/codecov.yml index 77707aa1..7e29772e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,2 +1,7 @@ github_checks: annotations: false + +coverage: + status: + project: off + patch: off From 44c575742f328a5640e2362186e14ff693aa4fc1 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 19:30:16 +0100 Subject: [PATCH 124/261] tests: add functional test for validating correct behavior on app on stop I had to use UI Automator as I couldn't find any other way to make the test app go to ON_STOP state. --- parsely/build.gradle | 1 + .../parsely/parselyandroid/FunctionalTests.kt | 45 ++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/parsely/build.gradle b/parsely/build.gradle index c1206c88..0d1dc896 100644 --- a/parsely/build.gradle +++ b/parsely/build.gradle @@ -75,6 +75,7 @@ dependencies { androidTestImplementation "org.assertj:assertj-core:$assertJVersion" androidTestImplementation "com.squareup.okhttp3:mockwebserver:$mockWebServerVersion" androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' androidTestUtil 'androidx.test:orchestrator:1.4.2' } diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index e82fd24e..beb60e94 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -3,6 +3,9 @@ package com.parsely.parselyandroid import android.app.Activity import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.core.type.TypeReference @@ -12,7 +15,10 @@ 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 +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.runBlocking @@ -70,6 +76,38 @@ class FunctionalTests { } } + /** + * In this scenario, the consumer application goes to the background, re-launches the app, + * and moves to the background again. It asserts, that only one payload has been sent. + */ + @Test + fun appSendsEventsWhenMovedToBackgroundAndDoesntSendDuplicatedRequestWhenItsMovedToBackgroundAgainQuickly() { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + ActivityScenario.launch(SampleActivity::class.java).use { scenario -> + scenario.onActivity { activity: Activity -> + beforeEach(activity) + server.enqueue(MockResponse().setResponseCode(200)) + server.enqueue(MockResponse().setResponseCode(200)) + parselyTracker = initializeTracker(activity, flushInterval = 1.hours) + + repeat(20) { + parselyTracker.trackPageview("url", null, null, null) + } + } + + device.pressHome() + device.pressRecentApps() + device.findObject(UiSelector().descriptionContains("com.parsely")).click() + device.pressHome() + + val firstRequest = server.takeRequest(10000, TimeUnit.MILLISECONDS)?.toMap() + val secondRequest = server.takeRequest(10000, TimeUnit.MILLISECONDS)?.toMap() + + assertThat(firstRequest!!["events"]).hasSize(20) + assertThat(secondRequest).isNull() + } + } + private fun RecordedRequest.toMap(): Map> { val listType: TypeReference>> = object : TypeReference>>() {} @@ -90,7 +128,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 { @@ -103,7 +144,7 @@ class FunctionalTests { private companion object { const val siteId = "123" const val localStorageFileName = "parsely-events.ser" - val flushInterval = 10.seconds + val defaultFlushInterval = 10.seconds } class SampleActivity : Activity() From 45000d5a46135dca0b3682cc0f9e9cabde97118c Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 8 Nov 2023 08:12:44 +0100 Subject: [PATCH 125/261] style: improve test scenario comment readability --- .../java/com/parsely/parselyandroid/FunctionalTests.kt | 9 +++++++-- 1 file changed, 7 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 beb60e94..e2248069 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -77,8 +77,13 @@ class FunctionalTests { } /** - * In this scenario, the consumer application goes to the background, re-launches the app, - * and moves to the background again. It asserts, that only one payload has been sent. + * In this scenario, the consumer application: + * 1. Goes to the background + * 2. Is re-launched + * This pattern occurs twice, which allows us to confirm the following assertions: + * 1. The event request is triggered when the consumer application is moved to the background + * 2. If the consumer application is sent to the background again within a short interval, + * the request is not duplicated. */ @Test fun appSendsEventsWhenMovedToBackgroundAndDoesntSendDuplicatedRequestWhenItsMovedToBackgroundAgainQuickly() { From 7d3237cd44032f211d74f1d4766c8987cba090ff Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 8 Nov 2023 15:29:31 +0100 Subject: [PATCH 126/261] style: move suppression to file level --- .../java/com/parsely/parselyandroid/ParselyAPIConnection.kt | 2 +- 1 file changed, 1 insertion(+), 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 6934defe..8d0634bd 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt @@ -13,13 +13,13 @@ 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 java.net.HttpURLConnection import java.net.URL -@Suppress("DEPRECATION") internal class ParselyAPIConnection(private val tracker: ParselyTracker) : AsyncTask() { private var exception: Exception? = null From 65af01e998c9c0b8eaa41fe19c4c1f8116dd3735 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 29 Nov 2023 18:40:56 +0100 Subject: [PATCH 127/261] tests: add unit test for `ParselyMetaData#toMap` method --- .../parselyandroid/ParselyMetadataTest.kt | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt new file mode 100644 index 00000000..30381d42 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt @@ -0,0 +1,45 @@ +package com.parsely.parselyandroid + +import java.util.Calendar +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class ParselyMetadataTest { + + @Test + fun `given metadata with complete set of data, when converting to map, then the map is as expected`() { + // given + val authors = arrayListOf("first author", "second author") + val link = "sample link" + val section = "sample section" + val tags = arrayListOf("first tag", "second tag") + val thumbUrl = "sample thumb url" + val title = "sample title" + val pubDate = Calendar.getInstance().apply { set(2023, 0, 1) } + val sut = ParselyMetadata( + authors, + link, + section, + tags, + thumbUrl, + title, + pubDate + ) + + // when + val map = sut.toMap() + + // then + assertThat(map).isEqualTo( + mapOf( + "authors" to authors, + "link" to link, + "section" to section, + "tags" to tags, + "thumb_url" to thumbUrl, + "title" to title, + "pub_date_tmsp" to pubDate.timeInMillis / 1000 + ) + ) + } +} From cec7295ee801ba1e844b06fcaa3edf61d4f5bc77 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 29 Nov 2023 18:46:12 +0100 Subject: [PATCH 128/261] tests: add unit test for `ParselyVideoMetaData#toMap` method --- .../parselyandroid/ParselyMetadataTest.kt | 59 +++++++++++++------ 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt index 30381d42..99937086 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt @@ -9,13 +9,6 @@ class ParselyMetadataTest { @Test fun `given metadata with complete set of data, when converting to map, then the map is as expected`() { // given - val authors = arrayListOf("first author", "second author") - val link = "sample link" - val section = "sample section" - val tags = arrayListOf("first tag", "second tag") - val thumbUrl = "sample thumb url" - val title = "sample title" - val pubDate = Calendar.getInstance().apply { set(2023, 0, 1) } val sut = ParselyMetadata( authors, link, @@ -30,16 +23,48 @@ class ParselyMetadataTest { val map = sut.toMap() // then - assertThat(map).isEqualTo( - mapOf( - "authors" to authors, - "link" to link, - "section" to section, - "tags" to tags, - "thumb_url" to thumbUrl, - "title" to title, - "pub_date_tmsp" to pubDate.timeInMillis / 1000 - ) + assertThat(map).isEqualTo(expectedParselyMetadataMap) + } + + @Test + fun `given video metadata with complete set of data, when converting to map, then the map is as expected`() { + // given + val duration = 12 + val sut = ParselyVideoMetadata( + authors, + link, + section, + tags, + thumbUrl, + title, + pubDate, + duration + ) + + // when + val map = sut.toMap() + + // then + assertThat(map).isEqualTo(expectedParselyMetadataMap + ("duration" to duration)) + } + + companion object { + val authors = arrayListOf("first author", "second author") + val link = "sample link" + val section = "sample section" + val tags = arrayListOf("first tag", "second tag") + val thumbUrl = "sample thumb url" + val title = "sample title" + val pubDate = Calendar.getInstance().apply { set(2023, 0, 1) } + + val expectedParselyMetadataMap = mapOf( + "authors" to authors, + "link" to link, + "section" to section, + "tags" to tags, + "thumb_url" to thumbUrl, + "title" to title, + "pub_date_tmsp" to pubDate.timeInMillis / 1000 ) } } From 7c77733fcb1c457c1a1adb3906be75e03b9b4589 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 29 Nov 2023 18:49:55 +0100 Subject: [PATCH 129/261] fix: set `toMap` methods visibility to package-private BREAKING CHANGE: `toMap` were previously exposed to SDK consumers, but from SDK point of view, this was not necessary. To simplify the contract, those method are moved to be internal only. Also, the properties don't have to be `public` for the same reason. --- .../java/com/parsely/parselyandroid/ParselyMetadata.java | 8 ++++---- .../com/parsely/parselyandroid/ParselyVideoMetadata.java | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.java index 805902ab..44fd1142 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.java @@ -16,9 +16,9 @@ * Otherwise, metadata will be gathered by Parse.ly's crawling infrastructure. */ public class ParselyMetadata { - public ArrayList authors, tags; - public String link, section, thumbUrl, title; - public Calendar pubDate; + ArrayList authors, tags; + String link, section, thumbUrl, title; + Calendar pubDate; /** * Create a new ParselyMetadata object. @@ -54,7 +54,7 @@ public ParselyMetadata( * * @return a Map object representing the metadata. */ - public Map toMap() { + Map toMap() { Map output = new HashMap<>(); if (this.authors != null) { output.put("authors", this.authors); diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java index 874ead34..63aa6910 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java @@ -12,7 +12,7 @@ */ public class ParselyVideoMetadata extends ParselyMetadata { - public int durationSeconds; + int durationSeconds; /** * Create a new ParselyVideoMetadata object. @@ -48,7 +48,7 @@ public ParselyVideoMetadata( * * @return a Map object representing the metadata. */ - public Map toMap() { + Map toMap() { Map output = super.toMap(); output.put("duration", this.durationSeconds); return output; From a6225bfc0277641694ce4d85206d892b5a5b95e4 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 29 Nov 2023 18:50:38 +0100 Subject: [PATCH 130/261] style: address lint warnings `int` cannot be `null`, and `videoId` is annotated as `NonNull` --- .../com/parsely/parselyandroid/ParselyVideoMetadata.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java index 63aa6910..32cfcd2e 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java @@ -34,12 +34,9 @@ public ParselyVideoMetadata( @Nullable String thumbUrl, @Nullable String title, @Nullable Calendar pubDate, - @NonNull int durationSeconds + int durationSeconds ) { super(authors, videoId, section, tags, thumbUrl, title, pubDate); - if (videoId == null) { - throw new NullPointerException("videoId cannot be null"); - } this.durationSeconds = durationSeconds; } From 5a053d7d4d57386fd258d59d77680532b55c8465 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 29 Nov 2023 19:04:57 +0100 Subject: [PATCH 131/261] refactor: change type of publication date from `Calendar` to long BREAKING CHANGE: instead of `java.utils.Calendar`, consumers now have to provide timestamp (in milliseconds) of publication date. --- example/src/main/java/com/example/MainActivity.java | 2 +- .../com/parsely/parselyandroid/ParselyMetadata.java | 13 +++++-------- .../parselyandroid/ParselyVideoMetadata.java | 6 +++--- .../com/parsely/parselyandroid/EventsBuilderTest.kt | 2 +- .../parsely/parselyandroid/ParselyMetadataTest.kt | 9 +++++---- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/example/src/main/java/com/example/MainActivity.java b/example/src/main/java/com/example/MainActivity.java index ee286ef8..8ba56ebd 100644 --- a/example/src/main/java/com/example/MainActivity.java +++ b/example/src/main/java/com/example/MainActivity.java @@ -131,7 +131,7 @@ public void trackPlay(View view) { new ArrayList(), "http://example.com/thumbs/video-1234", "Awesome Video #1234", - Calendar.getInstance(), + System.currentTimeMillis(), 90 ); // NOTE: For videos embedded in an article, "url" should be the URL for that article. diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.java index 44fd1142..031bd14a 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.java @@ -3,7 +3,6 @@ import androidx.annotation.Nullable; import java.util.ArrayList; -import java.util.Calendar; import java.util.HashMap; import java.util.Map; @@ -18,7 +17,7 @@ public class ParselyMetadata { ArrayList authors, tags; String link, section, thumbUrl, title; - Calendar pubDate; + long publicationDateMilliseconds; /** * Create a new ParselyMetadata object. @@ -29,7 +28,7 @@ public class ParselyMetadata { * @param tags User-defined tags for the content. Up to 20 are allowed. * @param thumbUrl URL at which the main image for this content is located. * @param title The title of the content. - * @param pubDate The date this piece of content was published. + * @param publicationDateMilliseconds The date this piece of content was published. */ public ParselyMetadata( @Nullable ArrayList authors, @@ -38,7 +37,7 @@ public ParselyMetadata( @Nullable ArrayList tags, @Nullable String thumbUrl, @Nullable String title, - @Nullable Calendar pubDate + long publicationDateMilliseconds ) { this.authors = authors; this.link = link; @@ -46,7 +45,7 @@ public ParselyMetadata( this.tags = tags; this.thumbUrl = thumbUrl; this.title = title; - this.pubDate = pubDate; + this.publicationDateMilliseconds = publicationDateMilliseconds; } /** @@ -74,9 +73,7 @@ Map toMap() { if (this.title != null) { output.put("title", this.title); } - if (this.pubDate != null) { - output.put("pub_date_tmsp", this.pubDate.getTimeInMillis() / 1000); - } + output.put("pub_date_tmsp", publicationDateMilliseconds / 1000); return output; } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java index 32cfcd2e..0718ffba 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java @@ -23,7 +23,7 @@ public class ParselyVideoMetadata extends ParselyMetadata { * @param tags User-defined tags for the video. Up to 20 are allowed. * @param thumbUrl URL at which the main image for this video is located. * @param title The title of the video. - * @param pubDate The date this video was published. + * @param publicationDateMilliseconds The timestamp in milliseconds this video was published. * @param durationSeconds Duration of the video in seconds. Required. */ public ParselyVideoMetadata( @@ -33,10 +33,10 @@ public ParselyVideoMetadata( @Nullable ArrayList tags, @Nullable String thumbUrl, @Nullable String title, - @Nullable Calendar pubDate, + long publicationDateMilliseconds, int durationSeconds ) { - super(authors, videoId, section, tags, thumbUrl, title, pubDate); + super(authors, videoId, section, tags, thumbUrl, title, publicationDateMilliseconds); this.durationSeconds = durationSeconds; } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 5630a8d5..998548f6 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -166,7 +166,7 @@ class EventsBuilderTest { fun `given metadata is not null, when creating a pixel, include metadata`() { // given val metadata = ParselyMetadata( - ArrayList(), "link", "section", null, null, null, null + ArrayList(), "link", "section", null, null, null, 0 ) // when diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt index 99937086..290d7c59 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt @@ -1,6 +1,7 @@ package com.parsely.parselyandroid import java.util.Calendar +import kotlin.time.Duration.Companion.seconds import org.assertj.core.api.Assertions.assertThat import org.junit.Test @@ -16,7 +17,7 @@ class ParselyMetadataTest { tags, thumbUrl, title, - pubDate + publicationDate.inWholeMilliseconds ) // when @@ -37,7 +38,7 @@ class ParselyMetadataTest { tags, thumbUrl, title, - pubDate, + publicationDate.inWholeMilliseconds, duration ) @@ -55,7 +56,7 @@ class ParselyMetadataTest { val tags = arrayListOf("first tag", "second tag") val thumbUrl = "sample thumb url" val title = "sample title" - val pubDate = Calendar.getInstance().apply { set(2023, 0, 1) } + val publicationDate = 100.seconds val expectedParselyMetadataMap = mapOf( "authors" to authors, @@ -64,7 +65,7 @@ class ParselyMetadataTest { "tags" to tags, "thumb_url" to thumbUrl, "title" to title, - "pub_date_tmsp" to pubDate.timeInMillis / 1000 + "pub_date_tmsp" to publicationDate.inWholeSeconds ) } } From e35a7c3d1a8ffa29245c009a7ee4779be483ce82 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 29 Nov 2023 19:11:13 +0100 Subject: [PATCH 132/261] Rename .java to .kt --- .../parselyandroid/{ParselyMetadata.java => ParselyMetadata.kt} | 0 .../{ParselyVideoMetadata.java => ParselyVideoMetadata.kt} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename parsely/src/main/java/com/parsely/parselyandroid/{ParselyMetadata.java => ParselyMetadata.kt} (100%) rename parsely/src/main/java/com/parsely/parselyandroid/{ParselyVideoMetadata.java => ParselyVideoMetadata.kt} (100%) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt similarity index 100% rename from parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.java rename to parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt similarity index 100% rename from parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java rename to parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt From 8ae5bdb57eb949a3295a7704712bf5e1d7332624 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 29 Nov 2023 19:11:14 +0100 Subject: [PATCH 133/261] refactor: migrate `ParselyMetadata` and `ParselyVideoMetadata` to kotlin --- .../parsely/parselyandroid/ParselyMetadata.kt | 98 ++++++++----------- .../parselyandroid/ParselyVideoMetadata.kt | 69 +++++-------- 2 files changed, 66 insertions(+), 101 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt index 031bd14a..7f024881 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt @@ -1,80 +1,60 @@ -package com.parsely.parselyandroid; - -import androidx.annotation.Nullable; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; +package com.parsely.parselyandroid /** * Represents post metadata to be passed to Parsely tracking. - *

+ * + * * This class is used to attach a metadata block to a Parse.ly pageview * request. Pageview metadata is only required for URLs not accessible over the * internet (i.e. app-only content) or if the customer is using an "in-pixel" integration. * Otherwise, metadata will be gathered by Parse.ly's crawling infrastructure. */ -public class ParselyMetadata { - ArrayList authors, tags; - String link, section, thumbUrl, title; - long publicationDateMilliseconds; - - /** - * Create a new ParselyMetadata object. - * - * @param authors The names of the authors of the content. Up to 10 authors are accepted. - * @param link A post's canonical url. - * @param section The category or vertical to which this content belongs. - * @param tags User-defined tags for the content. Up to 20 are allowed. - * @param thumbUrl URL at which the main image for this content is located. - * @param title The title of the content. - * @param publicationDateMilliseconds The date this piece of content was published. - */ - public ParselyMetadata( - @Nullable ArrayList authors, - @Nullable String link, - @Nullable String section, - @Nullable ArrayList tags, - @Nullable String thumbUrl, - @Nullable String title, - long publicationDateMilliseconds - ) { - this.authors = authors; - this.link = link; - this.section = section; - this.tags = tags; - this.thumbUrl = thumbUrl; - this.title = title; - this.publicationDateMilliseconds = publicationDateMilliseconds; - } - +open class ParselyMetadata +/** + * Create a new ParselyMetadata object. + * + * @param authors The names of the authors of the content. Up to 10 authors are accepted. + * @param link A post's canonical url. + * @param section The category or vertical to which this content belongs. + * @param tags User-defined tags for the content. Up to 20 are allowed. + * @param thumbUrl URL at which the main image for this content is located. + * @param title The title of the content. + * @param publicationDateMilliseconds The date this piece of content was published. + */( + private val authors: ArrayList?, + @JvmField internal val link: String?, + private val section: String?, + private val tags: ArrayList?, + private val thumbUrl: String?, + private val title: String?, + private val publicationDateMilliseconds: Long +) { /** * Turn this object into a Map * * @return a Map object representing the metadata. */ - Map toMap() { - Map output = new HashMap<>(); - if (this.authors != null) { - output.put("authors", this.authors); + open fun toMap(): Map? { + val output: MutableMap = HashMap() + if (authors != null) { + output["authors"] = authors } - if (this.link != null) { - output.put("link", this.link); + if (link != null) { + output["link"] = link } - if (this.section != null) { - output.put("section", this.section); + if (section != null) { + output["section"] = section } - if (this.tags != null) { - output.put("tags", this.tags); + if (tags != null) { + output["tags"] = tags } - if (this.thumbUrl != null) { - output.put("thumb_url", this.thumbUrl); + if (thumbUrl != null) { + output["thumb_url"] = thumbUrl } - if (this.title != null) { - output.put("title", this.title); + if (title != null) { + output["title"] = title } - output.put("pub_date_tmsp", publicationDateMilliseconds / 1000); - return output; + output["pub_date_tmsp"] = publicationDateMilliseconds / 1000 + return output } } - diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt index 0718ffba..3e02be83 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt @@ -1,53 +1,38 @@ -package com.parsely.parselyandroid; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Map; +package com.parsely.parselyandroid /** * ParselyMetadata for video content. */ -public class ParselyVideoMetadata extends ParselyMetadata { - - int durationSeconds; - - /** - * Create a new ParselyVideoMetadata object. - * - * @param authors The names of the authors of the video. Up to 10 authors are accepted. - * @param videoId Unique identifier for the video. Required. - * @param section The category or vertical to which this video belongs. - * @param tags User-defined tags for the video. Up to 20 are allowed. - * @param thumbUrl URL at which the main image for this video is located. - * @param title The title of the video. - * @param publicationDateMilliseconds The timestamp in milliseconds this video was published. - * @param durationSeconds Duration of the video in seconds. Required. - */ - public ParselyVideoMetadata( - @Nullable ArrayList authors, - @NonNull String videoId, - @Nullable String section, - @Nullable ArrayList tags, - @Nullable String thumbUrl, - @Nullable String title, - long publicationDateMilliseconds, - int durationSeconds - ) { - super(authors, videoId, section, tags, thumbUrl, title, publicationDateMilliseconds); - this.durationSeconds = durationSeconds; - } - +class ParselyVideoMetadata +/** + * Create a new ParselyVideoMetadata object. + * + * @param authors The names of the authors of the video. Up to 10 authors are accepted. + * @param videoId Unique identifier for the video. Required. + * @param section The category or vertical to which this video belongs. + * @param tags User-defined tags for the video. Up to 20 are allowed. + * @param thumbUrl URL at which the main image for this video is located. + * @param title The title of the video. + * @param publicationDateMilliseconds The timestamp in milliseconds this video was published. + * @param durationSeconds Duration of the video in seconds. Required. + */( + authors: ArrayList?, + videoId: String, + section: String?, + tags: ArrayList?, + thumbUrl: String?, + title: String?, + publicationDateMilliseconds: Long, + @JvmField internal val durationSeconds: Int +) : ParselyMetadata(authors, videoId, section, tags, thumbUrl, title, publicationDateMilliseconds) { /** * Turn this object into a Map * * @return a Map object representing the metadata. */ - Map toMap() { - Map output = super.toMap(); - output.put("duration", this.durationSeconds); - return output; + override fun toMap(): Map? { + val output = super.toMap()?.toMutableMap() + output?.put("duration", durationSeconds) + return output } } From 5e3a95890fc49986c3889545e394c8f93a9bbffb Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 31 Oct 2023 17:06:00 +0100 Subject: [PATCH 134/261] tests: add functional test for validating flushing queue --- .../parsely/parselyandroid/FunctionalTests.kt | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index e2248069..8e3b9b2c 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -77,39 +77,43 @@ class FunctionalTests { } /** - * In this scenario, the consumer application: - * 1. Goes to the background - * 2. Is re-launched - * This pattern occurs twice, which allows us to confirm the following assertions: - * 1. The event request is triggered when the consumer application is moved to the background - * 2. If the consumer application is sent to the background again within a short interval, - * the request is not duplicated. + * 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 appSendsEventsWhenMovedToBackgroundAndDoesntSendDuplicatedRequestWhenItsMovedToBackgroundAgainQuickly() { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + fun appFlushesEventsAfterFlushInterval() { ActivityScenario.launch(SampleActivity::class.java).use { scenario -> scenario.onActivity { activity: Activity -> beforeEach(activity) server.enqueue(MockResponse().setResponseCode(200)) - server.enqueue(MockResponse().setResponseCode(200)) - parselyTracker = initializeTracker(activity, flushInterval = 1.hours) + parselyTracker = initializeTracker(activity) - repeat(20) { - parselyTracker.trackPageview("url", null, null, null) - } + parselyTracker.trackPageview("url", null, null, null) + } + + Thread.sleep((flushInterval / 2).inWholeMilliseconds) + + scenario.onActivity { + parselyTracker.trackPageview("url", null, null, null) } - device.pressHome() - device.pressRecentApps() - device.findObject(UiSelector().descriptionContains("com.parsely")).click() - device.pressHome() + 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) + } - val firstRequest = server.takeRequest(10000, TimeUnit.MILLISECONDS)?.toMap() - val secondRequest = server.takeRequest(10000, TimeUnit.MILLISECONDS)?.toMap() + Thread.sleep(flushInterval.inWholeMilliseconds) - assertThat(firstRequest!!["events"]).hasSize(20) - assertThat(secondRequest).isNull() + val secondRequestPayload = server.takeRequest(100, TimeUnit.MILLISECONDS)?.toMap() + assertThat(secondRequestPayload!!["events"]).hasSize(1) } } From b5768fdb82ef11e76c1513335d4dad0db553e2bc Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 10:57:24 +0100 Subject: [PATCH 135/261] 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 609ed087..0f72c043 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -38,7 +38,6 @@ import java.util.List; import java.util.Map; import java.util.Timer; -import java.util.TimerTask; import java.util.UUID; /** @@ -83,7 +82,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 (localStorageRepository.getStoredQueue().size() > 0) { startFlushTimer(); @@ -575,56 +574,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 24bbacc5b6b86ebd590636588ab47a8d03faada9 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 11:08:07 +0100 Subject: [PATCH 136/261] 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 45affad92e1eb03cc2263ae13964216a71be969f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 11:08:07 +0100 Subject: [PATCH 137/261] 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 0f72c043..1bdd80d3 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(this, localStorageRepository).execute(); if (!flushTimerIsActive()) { startFlushTimer(); - PLog("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000)); + PLog("Flush flushTimer set to %ds", (flushManager.intervalMillis / 1000)); } } From aca3321acb6b289fb6ea6b4d4b07197baacf52d3 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 14:03:09 +0100 Subject: [PATCH 138/261] 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 1077e6e6c9a69dfa48973a35ba3a957bed16c4f6 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 18:08:09 +0100 Subject: [PATCH 139/261] build: add coroutines dependency --- parsely/build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/parsely/build.gradle b/parsely/build.gradle index 0d1dc896..811a02af 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 0ac02cb6fe03c38e44bea02be87261d32acd9f19 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 18:09:09 +0100 Subject: [PATCH 140/261] 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 1bdd80d3..76869f87 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -82,7 +82,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 (localStorageRepository.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(this, localStorageRepository).execute(); if (!flushTimerIsActive()) { startFlushTimer(); - PLog("Flush flushTimer set to %ds", (flushManager.intervalMillis / 1000)); + PLog("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000)); } } From 01c285f796b63cc3bb5ecb3c239be903ef91a605 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 18:09:31 +0100 Subject: [PATCH 141/261] 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 bc4a7d602827009ef3843dbf58b1dfec892d88fd Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 18:23:49 +0100 Subject: [PATCH 142/261] 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 d1eebf96196fa3b9d313e69d259c50283ee00292 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 30 Oct 2023 18:24:17 +0100 Subject: [PATCH 143/261] 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 8b3a2c56442768e7344cda10c1b0d37ab55a4de6 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 31 Oct 2023 17:08:30 +0100 Subject: [PATCH 144/261] 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 8e3b9b2c..541d0206 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -153,7 +153,7 @@ class FunctionalTests { private companion object { const val siteId = "123" const val localStorageFileName = "parsely-events.ser" - val defaultFlushInterval = 10.seconds + val flushInterval = 5.seconds } class SampleActivity : Activity() From c9dfcc415a24ce82519e96b2bba15c0a0bd89377 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 31 Oct 2023 17:24:37 +0100 Subject: [PATCH 145/261] 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 541d0206..29c1cac0 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -103,7 +103,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 { @@ -112,7 +112,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 8cac90860afe364875080bfb0ef2ec83f29b4442 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 3 Nov 2023 14:33:29 +0100 Subject: [PATCH 146/261] 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 81ce844ccd5cc65a187610c3c7c4ac8ff74ca382 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 6 Nov 2023 12:05:44 +0100 Subject: [PATCH 147/261] tests: add functional stress test --- .../parsely/parselyandroid/FunctionalTests.kt | 44 ++++++++++++++++--- 1 file changed, 39 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 29c1cac0..20bb7516 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -18,9 +18,9 @@ import java.nio.file.Path import java.util.concurrent.TimeUnit import kotlin.io.path.Path import kotlin.time.Duration -import kotlin.time.Duration.Companion.hours 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 @@ -95,13 +95,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) @@ -110,13 +110,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>>() {} @@ -153,7 +187,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 d02f7bdba06f6799ed33047f84967815e0c8d100 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 6 Nov 2023 14:09:27 +0100 Subject: [PATCH 148/261] 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 62e27bf75df7b2e1eb64fd7704decc21477524a8 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 6 Nov 2023 15:00:01 +0100 Subject: [PATCH 149/261] 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 00f182d3b3e0bab631c835f005646630e4c4e5e7 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 13:42:50 +0100 Subject: [PATCH 150/261] 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 44d0a2ccf8cd2696b05ac85c5bd30cb24d87de15 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 17:47:47 +0100 Subject: [PATCH 151/261] 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 18b23393d69d443ffa913454ea601419151886ad Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 18:02:06 +0100 Subject: [PATCH 152/261] 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 63d4e3f0d933ea8910347624fce27258abaa6b42 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 18:03:42 +0100 Subject: [PATCH 153/261] 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 8496ab7f94bea658f7dd33970a968f06a9bb37a2 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 19:22:44 +0100 Subject: [PATCH 154/261] 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 c7287a38229fbd461c87c01185ccf49d34fa27bb Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 19:23:00 +0100 Subject: [PATCH 155/261] 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 45bd81537f3f353c2a5c1d6efc3a62fd8fb2a508 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 7 Nov 2023 19:38:24 +0100 Subject: [PATCH 156/261] 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 958a4e3ef77a34acdd1878c2132bd8b5b402509d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 8 Nov 2023 08:15:53 +0100 Subject: [PATCH 157/261] 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 d21b391854143bd26e5a51aa55997cc9e60b9f03 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 8 Nov 2023 08:59:17 +0100 Subject: [PATCH 158/261] 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 20bb7516..4a6a9560 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -125,8 +125,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 -> @@ -134,20 +133,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 c61a2cefd651b6defaac22e98cdf45aff2d90b8f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 8 Nov 2023 09:00:15 +0100 Subject: [PATCH 159/261] 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 4a6a9560..b507cbd2 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -20,7 +20,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 @@ -49,12 +48,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 6ca98d5f66897afe273509260661ede2a0fe41da Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 8 Nov 2023 09:36:06 +0100 Subject: [PATCH 160/261] 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 b3e7f45c1638332d8ee44da08ec609bd69c8cded Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 8 Nov 2023 16:56:13 +0100 Subject: [PATCH 161/261] 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 cc67a711d8dc454d0eb163556fdc164f0ade4171 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 09:23:14 +0100 Subject: [PATCH 162/261] 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 ca4b951b24e736586560bad5c59780058aab24a6 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 09:59:01 +0100 Subject: [PATCH 163/261] 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 70c8d7d1a396e5410e92646b48ec54a5fcf9a522 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 10:25:00 +0100 Subject: [PATCH 164/261] 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 2d8fa28191b5d83d846a3e1b7aca8f647cce0b47 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 10:34:34 +0100 Subject: [PATCH 165/261] 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 2e9d1d4d560ff2a4293539dcdb62ae7ca2e9d1d1 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 10:47:34 +0100 Subject: [PATCH 166/261] 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 f1f3395216a2637709bad14aebb06e56eb93b922 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 10:59:09 +0100 Subject: [PATCH 167/261] 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 b507cbd2..0ab947c1 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -176,13 +176,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 c3f71db78d5854e372a45f9588b22ac40d9dbcc1 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 11:04:59 +0100 Subject: [PATCH 168/261] 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 a11b192b15eed3d78edf6c82b587fc98f947792d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 11:12:00 +0100 Subject: [PATCH 169/261] 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 d7679a9f104f7f07ba68933e6785a0835ce32721 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 12:28:07 +0100 Subject: [PATCH 170/261] 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 c494f85042cc7e5b527b145b74f9b6aceafa6895 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 12:41:00 +0100 Subject: [PATCH 171/261] 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 2c835b877d0ddb599bc0aad2c6071b8bdf67781b Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 16:48:14 +0100 Subject: [PATCH 172/261] 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 ddcca6ffa7f3036411253b96ae34fc4f0eaace18 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 20:06:37 +0100 Subject: [PATCH 173/261] 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 33bc808dd83dc94502e21fff3e45dec1781fed9e Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 20:09:12 +0100 Subject: [PATCH 174/261] 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 0ee130d8fd25e4872dd5ac371a424543719e833a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 9 Nov 2023 20:35:01 +0100 Subject: [PATCH 175/261] 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 fdf826c40e00a65a62a1dfd32926ded41f2b50ed Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 10 Nov 2023 13:57:10 +0100 Subject: [PATCH 176/261] 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 ed7acf9d481e337eb3fa72b865592f076b7e6936 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 18:29:46 +0100 Subject: [PATCH 177/261] 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 807b9a74e6f1f5a3c26b19685bdf0a18dcdaf2e3 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 18:38:56 +0100 Subject: [PATCH 178/261] 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 649758f08f0d3b02ca4b67ff26229b70930eb555 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 18:41:05 +0100 Subject: [PATCH 179/261] 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 854cd2789a90c9a5f8c179d269a32efa6e0be7d6 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 19:11:51 +0100 Subject: [PATCH 180/261] 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 268b0374b7c3a712464e21e78437f51e29f49985 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 19:15:34 +0100 Subject: [PATCH 181/261] 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 914d343bf6544ca785f0e5391176a1f318523c0d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 19:44:00 +0100 Subject: [PATCH 182/261] 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 c41f0e663507fb6165078cbe182e67f16490ce78 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 19:55:18 +0100 Subject: [PATCH 183/261] 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 00bae74c2dc03f77987a60ccd5901b2e9aba8869 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 20:02:52 +0100 Subject: [PATCH 184/261] 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 c3428d613c35fac81bd1fafabc1de22e7b245782 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 20:05:29 +0100 Subject: [PATCH 185/261] 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 7ff07b67ee96bf5f64fce16c07d8f9f7f20dfd3e Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 21:18:27 +0100 Subject: [PATCH 186/261] 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 8ba56ebd..b349d22a 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 c0369ed514e5210ab287b7619aff52c97cbbcc00 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Sat, 11 Nov 2023 21:20:21 +0100 Subject: [PATCH 187/261] 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 eb57e1df46da6eab5fd02514560ee8072d6a6efd Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 21:20:31 +0100 Subject: [PATCH 188/261] 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 1c297438f7202180511095ed1469a2c6726935bd Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 21:22:58 +0100 Subject: [PATCH 189/261] 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 494521aa2aa004a3e157cc7885c4a21aa5d6282e Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 21:16:53 +0100 Subject: [PATCH 190/261] 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 0ab947c1..95450804 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -27,6 +27,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 @@ -152,6 +153,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>>() {} @@ -162,6 +253,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 0dea1b1891674d89826934acae11ee90fb6b3f05 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 13:46:57 +0100 Subject: [PATCH 191/261] 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 ac6298ab6d80483d51cda3cf526e6cf5c437d0f7 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 13:46:57 +0100 Subject: [PATCH 192/261] 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 cacfb3c0ff68664395c40cff92f87a6c76f76c22 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 15:40:44 +0100 Subject: [PATCH 193/261] 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 153e160fd13a99ccc9f62c46fca2076a32764749 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 15:09:30 +0100 Subject: [PATCH 194/261] 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 2c6a50daed0b40017dcf08a43e3ef7708ca4a347 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 18:49:51 +0100 Subject: [PATCH 195/261] 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 22d8b261d56ca1cb256de693a7370d694c721c99 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 18:59:20 +0100 Subject: [PATCH 196/261] 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 26ec6dfd8be5f10a102206901b129938ae21ec44 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 19:03:31 +0100 Subject: [PATCH 197/261] 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 6ba60617707c2c1bf9db67637d1ca35d40201d16 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 19:06:48 +0100 Subject: [PATCH 198/261] 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 06531d401a21db8392dc0dafe6a9f28a5d5bf120 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 19:10:00 +0100 Subject: [PATCH 199/261] 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 29a5cea8e910bc16881001293188e42b370a2a6a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 19:18:17 +0100 Subject: [PATCH 200/261] 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 2ccb951bbe925ccc598c50c6102ede2bc8b6e1ea Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Mon, 13 Nov 2023 19:23:29 +0100 Subject: [PATCH 201/261] 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 263f4974f52a12328a8131d7a587b4724de9ebb5 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 11:32:46 +0100 Subject: [PATCH 202/261] 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 be20409e0d7fbcfae09d4e14ba477ba37e3b7792 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 11:40:26 +0100 Subject: [PATCH 203/261] 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 998548f6..92c2c9fd 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 65da336660fcde86a7bb8cae15233cabdcee0d8d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 11:42:39 +0100 Subject: [PATCH 204/261] 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 19cd5c8738826c2c4121354a99d15519a06ece76 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 11:42:39 +0100 Subject: [PATCH 205/261] 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 a67f4a867b8173e158564ba79aaa422752062eaa Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 12:01:56 +0100 Subject: [PATCH 206/261] 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 92c2c9fd..80c88b0b 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 ab4a05bc88ce97edbf68f9a42605340108be92af Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 12:06:33 +0100 Subject: [PATCH 207/261] 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 837811f6d4180da94e6227ec90dce003da72aa94 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 12:08:44 +0100 Subject: [PATCH 208/261] 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 0bea04e50e70cfed1df2e46975ae9f8812ea93c0 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 12:11:15 +0100 Subject: [PATCH 209/261] 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 2d50a740be3504bc1d0a46801c51703d6fd9fad1 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 12:11:44 +0100 Subject: [PATCH 210/261] 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 6c402f7e1febddf7adb4930d3894a077e5aed847 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 13:41:53 +0100 Subject: [PATCH 211/261] 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 80c88b0b..9cc989b5 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 ce4a755a985520af2dc2915a73d94f8d86325b1d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 15:27:59 +0100 Subject: [PATCH 212/261] 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 5c037592f61641bfa23ec6dabb17ca44177cbb07 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 16:09:39 +0100 Subject: [PATCH 213/261] 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 256302f7071e3a67c2ef9100c024595baab92113 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 16:21:38 +0100 Subject: [PATCH 214/261] 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 ec061f51e044338c411ddd6cae388e97d6d90c20 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 16:34:27 +0100 Subject: [PATCH 215/261] 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 b74ff1d9263a98272db48ac9affce30a5a98ad7d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 14 Nov 2023 16:43:14 +0100 Subject: [PATCH 216/261] 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 ff2b218ac92be916a022097ba953ce48a19b722e Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 12:05:48 +0100 Subject: [PATCH 217/261] 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 60b4bf03..629c7822 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -91,8 +91,11 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { // get the adkey straight away on instantiation 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 73e558cc15400e7af92c743963f5513259b558ed Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 12:36:35 +0100 Subject: [PATCH 218/261] 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 629c7822..0ef0083f 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -75,7 +75,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { new AndroidIdProvider(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 211bc8797de87b8c5a17320de1c6dfce62570252 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 12:44:37 +0100 Subject: [PATCH 219/261] 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 16a1501283b22277f2d9270206510744acf0280a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 12:46:15 +0100 Subject: [PATCH 220/261] 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 fb4fc8e6d3b1c4ffabd2688863892b33ca55b635 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 12:53:08 +0100 Subject: [PATCH 221/261] 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 16fd3bf4a2b84b736830242431e067a6ec56b55a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 13:14:04 +0100 Subject: [PATCH 222/261] 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 0ef0083f..09b1e378 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -91,11 +91,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { // get the adkey straight away on instantiation 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 c7858a3406d9165feb7d4e9963ba2ab743ca0a6f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 15:21:33 +0100 Subject: [PATCH 223/261] 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 09b1e378..a4cfbd1f 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -31,6 +31,7 @@ import java.util.UUID; import kotlin.Unit; +import kotlin.jvm.functions.Function0; /** * Tracks Parse.ly app views in Android apps @@ -75,7 +76,13 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { new AndroidIdProvider(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()) { @@ -471,5 +478,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 1a19e45a2dc386ff8035995f532478397807a1ab Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 15:33:33 +0100 Subject: [PATCH 224/261] refactor: rename `SendEvents` to `FlushQueue` and `isDebug` to `skipSendingEvents` --- .../{SendEvents.kt => FlushQueue.kt} | 6 ++-- .../parselyandroid/ParselyTracker.java | 8 ++--- .../{SendEventsTest.kt => FlushQueueTest.kt} | 32 +++++++++---------- 3 files changed, 22 insertions(+), 24 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 a4cfbd1f..3f605ab7 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. @@ -91,9 +91,7 @@ public Unit invoke() { } return Unit.INSTANCE; }); - sendEvents = new SendEvents(flushManager, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy"), ParselyCoroutineScopeKt.getSdkScope()); - clock = new Clock(); - intervalCalculator = new HeartbeatIntervalCalculator(clock); + flushQueue = new FlushQueue(flushManager, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy"), ParselyCoroutineScopeKt.getSdkScope()); // get the adkey straight away on instantiation isDebug = false; @@ -476,6 +474,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 c976a1c45048227dfdfd0ce3e4e7be5edff6792a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 15:40:34 +0100 Subject: [PATCH 225/261] 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 dd1f4ddf028aac83e02e58a085e22c482b1caf47 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 17 Nov 2023 15:44:27 +0100 Subject: [PATCH 226/261] 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 cdb9fb3c28d4af21539c8716ccab9c3d0baa413f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 24 Nov 2023 12:38:03 +0100 Subject: [PATCH 227/261] 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 b4c3b920fb7d4e3edbc3e4dca01dccaaf9865a0f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 24 Nov 2023 13:12:06 +0100 Subject: [PATCH 228/261] 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 1dfdc93a0f9e48da83181484fce33631d4193f8a Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 24 Nov 2023 14:11:58 +0100 Subject: [PATCH 229/261] 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 14bc4d79578aa9b6544caaab93daee86411c9772 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 24 Nov 2023 14:52:41 +0100 Subject: [PATCH 230/261] 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 5b0f61a55f41aae8b851ae28cf5dba1d6afd2246 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Fri, 1 Dec 2023 18:54:09 +0100 Subject: [PATCH 231/261] 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 89ffada84e60455a9ae27fdaa1363bb5e572eb96 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 5 Dec 2023 17:40:53 +0100 Subject: [PATCH 232/261] 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 fd8f9992f6473f968f45f71f5a767fab8882fded Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 5 Dec 2023 17:47:10 +0100 Subject: [PATCH 233/261] 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 6b567becd2a049c8e2d4ac7c278faa45270e9714 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 7 Dec 2023 11:24:01 +0100 Subject: [PATCH 234/261] 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 } From 1a0026cb6fc575d876ca54c3b1720a41528a9c21 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 12 Dec 2023 16:19:32 +0100 Subject: [PATCH 235/261] refactor: accept `List` in metadata's constructors There's no need to force API consumers to use `ArrayList` - any `List` will be just fine. --- .../main/java/com/parsely/parselyandroid/ParselyMetadata.kt | 4 ++-- .../java/com/parsely/parselyandroid/ParselyVideoMetadata.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt index 7f024881..56cb3cd9 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt @@ -21,10 +21,10 @@ open class ParselyMetadata * @param title The title of the content. * @param publicationDateMilliseconds The date this piece of content was published. */( - private val authors: ArrayList?, + private val authors: List?, @JvmField internal val link: String?, private val section: String?, - private val tags: ArrayList?, + private val tags: List?, private val thumbUrl: String?, private val title: String?, private val publicationDateMilliseconds: Long diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt index 3e02be83..3744381e 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt @@ -16,10 +16,10 @@ class ParselyVideoMetadata * @param publicationDateMilliseconds The timestamp in milliseconds this video was published. * @param durationSeconds Duration of the video in seconds. Required. */( - authors: ArrayList?, + authors: List?, videoId: String, section: String?, - tags: ArrayList?, + tags: List?, thumbUrl: String?, title: String?, publicationDateMilliseconds: Long, From 20dac5405713de04dfc1faec74a5a56aef34650f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 12 Dec 2023 16:35:34 +0100 Subject: [PATCH 236/261] fix: make `publicationDateMilliseconds` nullable In Java implementation, `pubDate` was `@Nullable` so there's no reason to change it in Kotlin implementation. --- .../main/java/com/parsely/parselyandroid/ParselyMetadata.kt | 6 ++++-- .../java/com/parsely/parselyandroid/ParselyVideoMetadata.kt | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt index 56cb3cd9..86db7773 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt @@ -27,7 +27,7 @@ open class ParselyMetadata private val tags: List?, private val thumbUrl: String?, private val title: String?, - private val publicationDateMilliseconds: Long + private val publicationDateMilliseconds: Long? ) { /** * Turn this object into a Map @@ -54,7 +54,9 @@ open class ParselyMetadata if (title != null) { output["title"] = title } - output["pub_date_tmsp"] = publicationDateMilliseconds / 1000 + if (publicationDateMilliseconds != null) { + output["pub_date_tmsp"] = publicationDateMilliseconds / 1000 + } return output } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt index 3744381e..c556f98d 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt @@ -22,7 +22,7 @@ class ParselyVideoMetadata tags: List?, thumbUrl: String?, title: String?, - publicationDateMilliseconds: Long, + publicationDateMilliseconds: Long?, @JvmField internal val durationSeconds: Int ) : ParselyMetadata(authors, videoId, section, tags, thumbUrl, title, publicationDateMilliseconds) { /** From 3ccba426eccc3234cdc54b83392a9948da4a4103 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Tue, 12 Dec 2023 16:37:38 +0100 Subject: [PATCH 237/261] feat: add `null` default arguments for nullable fields --- .../com/parsely/parselyandroid/ParselyMetadata.kt | 14 +++++++------- .../parsely/parselyandroid/ParselyVideoMetadata.kt | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt index 86db7773..9c43ee07 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt @@ -21,13 +21,13 @@ open class ParselyMetadata * @param title The title of the content. * @param publicationDateMilliseconds The date this piece of content was published. */( - private val authors: List?, - @JvmField internal val link: String?, - private val section: String?, - private val tags: List?, - private val thumbUrl: String?, - private val title: String?, - private val publicationDateMilliseconds: Long? + private val authors: List? = null, + @JvmField internal val link: String? = null, + private val section: String? = null, + private val tags: List? = null, + private val thumbUrl: String? = null, + private val title: String? = null, + private val publicationDateMilliseconds: Long? = null ) { /** * Turn this object into a Map diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt index c556f98d..7d4857e6 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt @@ -16,13 +16,13 @@ class ParselyVideoMetadata * @param publicationDateMilliseconds The timestamp in milliseconds this video was published. * @param durationSeconds Duration of the video in seconds. Required. */( - authors: List?, + authors: List? = null, videoId: String, - section: String?, - tags: List?, - thumbUrl: String?, - title: String?, - publicationDateMilliseconds: Long?, + section: String? = null, + tags: List? = null, + thumbUrl: String? = null, + title: String? = null, + publicationDateMilliseconds: Long? = null, @JvmField internal val durationSeconds: Int ) : ParselyMetadata(authors, videoId, section, tags, thumbUrl, title, publicationDateMilliseconds) { /** From e9c25b7fc41d999e0bc9cf8b377dcbddebba6d9f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 17:21:25 +0100 Subject: [PATCH 238/261] fix: bring back `java.util.Calendar` to metadata constructors --- example/src/main/java/com/example/MainActivity.java | 2 +- .../java/com/parsely/parselyandroid/ParselyMetadata.kt | 10 ++++++---- .../com/parsely/parselyandroid/ParselyVideoMetadata.kt | 8 +++++--- .../com/parsely/parselyandroid/EventsBuilderTest.kt | 4 +--- .../com/parsely/parselyandroid/ParselyMetadataTest.kt | 8 ++++---- 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/example/src/main/java/com/example/MainActivity.java b/example/src/main/java/com/example/MainActivity.java index b349d22a..6fff5e6c 100644 --- a/example/src/main/java/com/example/MainActivity.java +++ b/example/src/main/java/com/example/MainActivity.java @@ -116,7 +116,7 @@ public void trackPlay(View view) { new ArrayList(), "http://example.com/thumbs/video-1234", "Awesome Video #1234", - System.currentTimeMillis(), + Calendar.getInstance(), 90 ); // NOTE: For videos embedded in an article, "url" should be the URL for that article. diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt index 9c43ee07..201b4eb5 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt @@ -1,5 +1,7 @@ package com.parsely.parselyandroid +import java.util.Calendar + /** * Represents post metadata to be passed to Parsely tracking. * @@ -19,7 +21,7 @@ open class ParselyMetadata * @param tags User-defined tags for the content. Up to 20 are allowed. * @param thumbUrl URL at which the main image for this content is located. * @param title The title of the content. - * @param publicationDateMilliseconds The date this piece of content was published. + * @param pubDate The date this piece of content was published. */( private val authors: List? = null, @JvmField internal val link: String? = null, @@ -27,7 +29,7 @@ open class ParselyMetadata private val tags: List? = null, private val thumbUrl: String? = null, private val title: String? = null, - private val publicationDateMilliseconds: Long? = null + private val pubDate: Calendar? = null ) { /** * Turn this object into a Map @@ -54,8 +56,8 @@ open class ParselyMetadata if (title != null) { output["title"] = title } - if (publicationDateMilliseconds != null) { - output["pub_date_tmsp"] = publicationDateMilliseconds / 1000 + if (pubDate != null) { + output["pub_date_tmsp"] = pubDate.timeInMillis / 1000 } return output } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt index 7d4857e6..4a877e31 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt @@ -1,5 +1,7 @@ package com.parsely.parselyandroid +import java.util.Calendar + /** * ParselyMetadata for video content. */ @@ -13,7 +15,7 @@ class ParselyVideoMetadata * @param tags User-defined tags for the video. Up to 20 are allowed. * @param thumbUrl URL at which the main image for this video is located. * @param title The title of the video. - * @param publicationDateMilliseconds The timestamp in milliseconds this video was published. + * @param pubDate The timestamp in milliseconds this video was published. * @param durationSeconds Duration of the video in seconds. Required. */( authors: List? = null, @@ -22,9 +24,9 @@ class ParselyVideoMetadata tags: List? = null, thumbUrl: String? = null, title: String? = null, - publicationDateMilliseconds: Long? = null, + pubDate: Calendar? = null, @JvmField internal val durationSeconds: Int -) : ParselyMetadata(authors, videoId, section, tags, thumbUrl, title, publicationDateMilliseconds) { +) : ParselyMetadata(authors, videoId, section, tags, thumbUrl, title, pubDate) { /** * Turn this object into a Map * diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 9cc989b5..8f57335b 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -4,8 +4,6 @@ import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.MapAssert import org.junit.Before import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner internal class EventsBuilderTest { private lateinit var sut: EventsBuilder @@ -161,7 +159,7 @@ internal class EventsBuilderTest { fun `given metadata is not null, when creating a pixel, include metadata`() { // given val metadata = ParselyMetadata( - ArrayList(), "link", "section", null, null, null, 0 + ArrayList(), "link", "section", null, null, null, null ) // when diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt index 290d7c59..3bf8b61e 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt @@ -17,7 +17,7 @@ class ParselyMetadataTest { tags, thumbUrl, title, - publicationDate.inWholeMilliseconds + pubDate ) // when @@ -38,7 +38,7 @@ class ParselyMetadataTest { tags, thumbUrl, title, - publicationDate.inWholeMilliseconds, + pubDate, duration ) @@ -56,7 +56,7 @@ class ParselyMetadataTest { val tags = arrayListOf("first tag", "second tag") val thumbUrl = "sample thumb url" val title = "sample title" - val publicationDate = 100.seconds + val pubDate = Calendar.getInstance().apply { set(2023, 0, 1) } val expectedParselyMetadataMap = mapOf( "authors" to authors, @@ -65,7 +65,7 @@ class ParselyMetadataTest { "tags" to tags, "thumb_url" to thumbUrl, "title" to title, - "pub_date_tmsp" to publicationDate.inWholeSeconds + "pub_date_tmsp" to pubDate.timeInMillis / 1000 ) } } From a991948727990ff810ae4a7d517338d0b2e86ac1 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 14:02:19 +0100 Subject: [PATCH 239/261] refactor: extract `ConnectivityStatusProvider` to a separate class Reasons: to reduce responsibilities of `ParselyTracker` and introduce unit tests coverage for "no internet connection" case --- .../ConnectivityStatusProvider.kt | 22 ++++++++ .../com/parsely/parselyandroid/FlushQueue.kt | 7 ++- .../parselyandroid/ParselyTracker.java | 20 ++----- .../parsely/parselyandroid/FlushQueueTest.kt | 52 ++++++++++++++++--- 4 files changed, 75 insertions(+), 26 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/ConnectivityStatusProvider.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ConnectivityStatusProvider.kt b/parsely/src/main/java/com/parsely/parselyandroid/ConnectivityStatusProvider.kt new file mode 100644 index 00000000..f7d90842 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/ConnectivityStatusProvider.kt @@ -0,0 +1,22 @@ +package com.parsely.parselyandroid + +import android.content.Context +import android.net.ConnectivityManager + +internal interface ConnectivityStatusProvider { + /** + * @return Whether the network is accessible. + */ + fun isReachable(): Boolean +} + +internal class AndroidConnectivityStatusProvider(private val context: Context): ConnectivityStatusProvider { + + override fun isReachable(): Boolean { + val cm = context.getSystemService( + Context.CONNECTIVITY_SERVICE + ) as ConnectivityManager + val netInfo = cm.activeNetworkInfo + return netInfo != null && netInfo.isConnectedOrConnecting + } +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt index 4a989b95..79e2a811 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt @@ -10,12 +10,17 @@ internal class FlushQueue( private val flushManager: FlushManager, private val repository: QueueRepository, private val restClient: RestClient, - private val scope: CoroutineScope + private val scope: CoroutineScope, + private val connectivityStatusProvider: ConnectivityStatusProvider ) { private val mutex = Mutex() operator fun invoke(skipSendingEvents: Boolean) { + if (!connectivityStatusProvider.isReachable()) { + ParselyTracker.PLog("Network unreachable. Not flushing.") + return + } scope.launch { mutex.withLock { val eventsToSend = repository.getStoredQueue() diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 3f605ab7..ef4b03fb 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -91,7 +91,9 @@ public Unit invoke() { } return Unit.INSTANCE; }); - flushQueue = new FlushQueue(flushManager, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy"), ParselyCoroutineScopeKt.getSdkScope()); + flushQueue = new FlushQueue(flushManager, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy"), ParselyCoroutineScopeKt.getSdkScope(), new AndroidConnectivityStatusProvider(context)); + clock = new Clock(); + intervalCalculator = new HeartbeatIntervalCalculator(clock); // get the adkey straight away on instantiation isDebug = false; @@ -432,18 +434,6 @@ public void flushEventQueue() { // no-op } - /** - * Returns whether the network is accessible and Parsely is reachable. - * - * @return Whether the network is accessible and Parsely is reachable. - */ - private boolean isReachable() { - ConnectivityManager cm = (ConnectivityManager) context.getSystemService( - Context.CONNECTIVITY_SERVICE); - NetworkInfo netInfo = cm.getActiveNetworkInfo(); - return netInfo != null && netInfo.isConnectedOrConnecting(); - } - /** * Start the timer to flush events to Parsely. *

@@ -470,10 +460,6 @@ private String generatePixelId() { } void flushEvents() { - if (!isReachable()) { - PLog("Network unreachable. Not flushing."); - return; - } flushQueue.invoke(isDebug); } } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt index e49605b6..c3a48f21 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt @@ -20,7 +20,8 @@ class FlushQueueTest { FakeFlushManager(), FakeRepository(), FakeRestClient(), - this + this, + FakeConnectivityStatusProvider() ) // when @@ -45,7 +46,8 @@ class FlushQueueTest { FakeFlushManager(), repository, parselyAPIConnection, - this + this, + FakeConnectivityStatusProvider() ) // when @@ -59,7 +61,7 @@ class FlushQueueTest { @Test 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 + // give val repository = FakeRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } @@ -67,7 +69,8 @@ class FlushQueueTest { FakeFlushManager(), repository, FakeRestClient(), - this + this, + FakeConnectivityStatusProvider() ) // when @@ -92,7 +95,8 @@ class FlushQueueTest { FakeFlushManager(), repository, parselyAPIConnection, - this + this, + FakeConnectivityStatusProvider() ) // when @@ -118,7 +122,8 @@ class FlushQueueTest { flushManager, repository, parselyAPIConnection, - this + this, + FakeConnectivityStatusProvider() ) // when @@ -146,7 +151,8 @@ class FlushQueueTest { flushManager, repository, parselyAPIConnection, - this + this, + FakeConnectivityStatusProvider() ) // when @@ -165,7 +171,8 @@ class FlushQueueTest { flushManager, FakeRepository(), FakeRestClient(), - this + this, + FakeConnectivityStatusProvider() ) // when @@ -176,6 +183,30 @@ class FlushQueueTest { assertThat(flushManager.stopped).isTrue() } + @Test + fun `given non-empty local storage, when flushing queue with no internet connection, then events are not sent and not removed from local storage`() = + runTest { + // given + val repository = FakeRepository().apply { + insertEvents(listOf(mapOf("test" to 123))) + } + val sut = FlushQueue( + FakeFlushManager(), + repository, + FakeRestClient(), + this, + FakeConnectivityStatusProvider().apply { reachable = false } + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(repository.getStoredQueue()).isNotEmpty + } + + private class FakeFlushManager : FlushManager { var stopped = false override fun start() { @@ -216,4 +247,9 @@ class FlushQueueTest { return nextResult!! } } + + private class FakeConnectivityStatusProvider : ConnectivityStatusProvider { + var reachable = true + override fun isReachable() = reachable + } } From ed64c5cc6a48ccb0e7c8b9dabe132b39b3e317ef Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 14:05:01 +0100 Subject: [PATCH 240/261] refactor: make returned intervals nullable fields Instead of returning `-1` in case of missing interval --- .../com/parsely/parselyandroid/ParselyTracker.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index ef4b03fb..2aacf896 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -162,16 +162,18 @@ static void PLog(String logString, Object... objects) { * * @return The base engagement tracking interval. */ - public double getEngagementInterval() { + @Nullable + public Double getEngagementInterval() { if (engagementManager == null) { - return -1; + return null; } return engagementManager.getIntervalMillis(); } - public double getVideoEngagementInterval() { + @Nullable + public Double getVideoEngagementInterval() { if (videoEngagementManager == null) { - return -1; + return null; } return videoEngagementManager.getIntervalMillis(); } From 16a398147413710cd474a706ba874dabb4831ac4 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 14:06:03 +0100 Subject: [PATCH 241/261] refactor: remove `getDebug` method BREAKING CHANGE: this method was part of the public contract but I can't find a justified value of it --- .../java/com/parsely/parselyandroid/ParselyTracker.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 2aacf896..4f102dd4 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -205,15 +205,6 @@ public long getFlushInterval() { return flushManager.getIntervalMillis() / 1000; } - /** - * Getter for isDebug - * - * @return Whether debug mode is active. - */ - public boolean getDebug() { - return isDebug; - } - /** * Set a debug flag which will prevent data from being sent to Parse.ly *

From 0db75029c8638f55ed54dbe23d6bd85fb125180b Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 15:42:27 +0100 Subject: [PATCH 242/261] refactor: move `PLog` to a separate class --- .../parselyandroid/AdvertisementIdProvider.kt | 5 +++-- .../parselyandroid/DeviceInfoRepository.kt | 5 +++-- .../parsely/parselyandroid/EngagementManager.kt | 3 ++- .../parsely/parselyandroid/EventsBuilder.java | 2 +- .../com/parsely/parselyandroid/FlushQueue.kt | 17 +++++++++-------- .../parsely/parselyandroid/InMemoryBuffer.kt | 5 +++-- .../parselyandroid/LocalStorageRepository.kt | 5 +++-- .../java/com/parsely/parselyandroid/Logging.kt | 17 +++++++++++++++++ .../parsely/parselyandroid/ParselyTracker.java | 15 ++------------- 9 files changed, 43 insertions(+), 31 deletions(-) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/Logging.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt index e9f11ce6..5620811c 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt @@ -3,6 +3,7 @@ package com.parsely.parselyandroid import android.content.Context import android.provider.Settings import com.google.android.gms.ads.identifier.AdvertisingIdClient +import com.parsely.parselyandroid.Logging.PLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -18,7 +19,7 @@ internal class AdvertisementIdProvider( try { adKey = AdvertisingIdClient.getAdvertisingIdInfo(context).id } catch (e: Exception) { - ParselyTracker.PLog("No Google play services or error!") + PLog("No Google play services or error!") } } } @@ -40,7 +41,7 @@ internal class AndroidIdProvider(private val context: Context) : IdProvider { } catch (ex: Exception) { null } - ParselyTracker.PLog(String.format("Android ID: %s", uuid)) + PLog(String.format("Android ID: %s", uuid)) return uuid } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt index fc899215..77898643 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt @@ -1,6 +1,7 @@ package com.parsely.parselyandroid import android.os.Build +import com.parsely.parselyandroid.Logging.PLog internal interface DeviceInfoRepository{ fun collectDeviceInfo(): Map @@ -34,12 +35,12 @@ internal open class AndroidDeviceInfoRepository( val adKey = advertisementIdProvider.provide() val androidId = androidIdProvider.provide() - ParselyTracker.PLog("adkey is: %s, uuid is %s", adKey, androidId) + PLog("adkey is: %s, uuid is %s", adKey, androidId) return if (adKey != null) { adKey } else { - ParselyTracker.PLog("falling back to device uuid") + PLog("falling back to device uuid") androidId .orEmpty() } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt index 2f1dc620..e59757a6 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt @@ -1,5 +1,6 @@ package com.parsely.parselyandroid +import com.parsely.parselyandroid.Logging.PLog import kotlin.time.Duration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -62,7 +63,7 @@ internal class EngagementManager( val event: MutableMap = HashMap( baseEvent ) - ParselyTracker.PLog(String.format("Enqueuing %s event.", event["action"])) + PLog(String.format("Enqueuing %s event.", event["action"])) // Update `ts` for the event since it's happening right now. val baseEventData = (event["data"] as Map?)!! diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java index b70c1aec..795f60e5 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java @@ -1,6 +1,6 @@ package com.parsely.parselyandroid; -import static com.parsely.parselyandroid.ParselyTracker.PLog; +import static com.parsely.parselyandroid.Logging.PLog; import android.content.Context; diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt index 79e2a811..5465afc1 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt @@ -1,6 +1,7 @@ package com.parsely.parselyandroid import com.parsely.parselyandroid.JsonSerializer.toParselyEventsPayload +import com.parsely.parselyandroid.Logging.PLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -18,7 +19,7 @@ internal class FlushQueue( operator fun invoke(skipSendingEvents: Boolean) { if (!connectivityStatusProvider.isReachable()) { - ParselyTracker.PLog("Network unreachable. Not flushing.") + PLog("Network unreachable. Not flushing.") return } scope.launch { @@ -31,23 +32,23 @@ internal class FlushQueue( } if (skipSendingEvents) { - ParselyTracker.PLog("Debug mode on. Not sending to Parse.ly. Otherwise, would sent ${eventsToSend.size} events") + PLog("Debug mode on. Not sending to Parse.ly. Otherwise, would sent ${eventsToSend.size} events") repository.remove(eventsToSend) return@launch } - ParselyTracker.PLog("Sending request with %d events", eventsToSend.size) + 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) + PLog("POST Data %s", jsonPayload) + PLog("Requested %s", ParselyTracker.ROOT_URL) restClient.send(jsonPayload) .fold( onSuccess = { - ParselyTracker.PLog("Pixel request success") + PLog("Pixel request success") repository.remove(eventsToSend) }, onFailure = { - ParselyTracker.PLog("Pixel request exception") - ParselyTracker.PLog(it.toString()) + PLog("Pixel request exception") + PLog(it.toString()) } ) } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt index 619e993d..e12f00a2 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt @@ -1,5 +1,6 @@ package com.parsely.parselyandroid +import com.parsely.parselyandroid.Logging.PLog import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -22,7 +23,7 @@ internal class InMemoryBuffer( while (isActive) { mutex.withLock { if (buffer.isNotEmpty()) { - ParselyTracker.PLog("Persisting ${buffer.size} events") + PLog("Persisting ${buffer.size} events") localStorageRepository.insertEvents(buffer) buffer.clear() } @@ -35,7 +36,7 @@ internal class InMemoryBuffer( fun add(event: Map) { coroutineScope.launch { mutex.withLock { - ParselyTracker.PLog("Event added to buffer") + 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 1f1f28fc..ee55d560 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -1,6 +1,7 @@ package com.parsely.parselyandroid import android.content.Context +import com.parsely.parselyandroid.Logging.PLog import java.io.EOFException import java.io.FileNotFoundException import java.io.ObjectInputStream @@ -34,7 +35,7 @@ internal class LocalStorageRepository(private val context: Context) : QueueRepos oos.close() fos.close() } catch (ex: Exception) { - ParselyTracker.PLog("Exception thrown during queue serialization: %s", ex.toString()) + PLog("Exception thrown during queue serialization: %s", ex.toString()) } } @@ -52,7 +53,7 @@ internal class LocalStorageRepository(private val context: Context) : QueueRepos } catch (ex: FileNotFoundException) { // Nothing to do here. Means there was no saved queue. } catch (ex: Exception) { - ParselyTracker.PLog( + PLog( "Exception thrown during queue deserialization: %s", ex.toString() ) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt b/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt new file mode 100644 index 00000000..63968d03 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt @@ -0,0 +1,17 @@ +package com.parsely.parselyandroid + +import java.util.Formatter + +object Logging { + + /** + * Log a message to the console. + */ + @JvmStatic + fun PLog(logString: String, vararg objects: Any?) { + if (logString == "") { + return + } + println(Formatter().format("[Parsely] $logString", *objects).toString()) + } +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 4f102dd4..8f2db6e6 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -16,9 +16,9 @@ package com.parsely.parselyandroid; +import static com.parsely.parselyandroid.Logging.PLog; + import android.content.Context; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -26,7 +26,6 @@ import androidx.lifecycle.LifecycleEventObserver; import androidx.lifecycle.ProcessLifecycleOwner; -import java.util.Formatter; import java.util.Map; import java.util.UUID; @@ -147,16 +146,6 @@ public static ParselyTracker sharedInstance(String siteId, int flushInterval, Co return instance; } - /** - * Log a message to the console. - */ - static void PLog(String logString, Object... objects) { - if (logString.equals("")) { - return; - } - System.out.println(new Formatter().format("[Parsely] " + logString, objects).toString()); - } - /** * Get the heartbeat interval * From fa9717be0c2b2decf186f86310f6da0bccc9cecc Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 15:46:07 +0100 Subject: [PATCH 243/261] refactor: convert `context` to a local variable --- .../main/java/com/parsely/parselyandroid/ParselyTracker.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 8f2db6e6..b9c5cc15 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -46,7 +46,6 @@ public class ParselyTracker { // 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 FlushManager flushManager; private EngagementManager engagementManager, videoEngagementManager; @Nullable @@ -68,7 +67,7 @@ public class ParselyTracker { * Create a new ParselyTracker instance. */ protected ParselyTracker(String siteId, int flushInterval, Context c) { - context = c.getApplicationContext(); + Context context = c.getApplicationContext(); eventsBuilder = new EventsBuilder( new AndroidDeviceInfoRepository( new AdvertisementIdProvider(context, ParselyCoroutineScopeKt.getSdkScope()), From 6362dd37a5197f61bf3af30b2f2dbc938f037d90 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 15:48:14 +0100 Subject: [PATCH 244/261] refactor: convert `localStorageRepository` to a local variable --- .../main/java/com/parsely/parselyandroid/ParselyTracker.java | 4 +--- 1 file changed, 1 insertion(+), 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 b9c5cc15..b0cb7b1e 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -57,8 +57,6 @@ public class ParselyTracker { @NonNull private final HeartbeatIntervalCalculator intervalCalculator; @NonNull - private final LocalStorageRepository localStorageRepository; - @NonNull private final InMemoryBuffer inMemoryBuffer; @NonNull private final FlushQueue flushQueue; @@ -73,7 +71,7 @@ protected ParselyTracker(String siteId, int flushInterval, Context c) { new AdvertisementIdProvider(context, ParselyCoroutineScopeKt.getSdkScope()), new AndroidIdProvider(context) ), siteId); - localStorageRepository = new LocalStorageRepository(context); + LocalStorageRepository localStorageRepository = new LocalStorageRepository(context); flushManager = new ParselyFlushManager(new Function0() { @Override public Unit invoke() { From ee91fde3758e3177fd9fc6c8144974b6c4fd296b Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 16:01:31 +0100 Subject: [PATCH 245/261] refactor: rename `PLog` to `log` --- .../parselyandroid/AdvertisementIdProvider.kt | 6 +++--- .../parselyandroid/DeviceInfoRepository.kt | 6 +++--- .../parselyandroid/EngagementManager.kt | 5 ++--- .../parsely/parselyandroid/EventsBuilder.java | 6 ++---- .../com/parsely/parselyandroid/FlushQueue.kt | 18 +++++++++--------- .../parsely/parselyandroid/InMemoryBuffer.kt | 6 +++--- .../parselyandroid/LocalStorageRepository.kt | 6 +++--- .../java/com/parsely/parselyandroid/Logging.kt | 2 +- .../parsely/parselyandroid/ParselyTracker.java | 6 +++--- 9 files changed, 29 insertions(+), 32 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt index 5620811c..87e93a84 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt @@ -3,7 +3,7 @@ package com.parsely.parselyandroid import android.content.Context import android.provider.Settings import com.google.android.gms.ads.identifier.AdvertisingIdClient -import com.parsely.parselyandroid.Logging.PLog +import com.parsely.parselyandroid.Logging.log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -19,7 +19,7 @@ internal class AdvertisementIdProvider( try { adKey = AdvertisingIdClient.getAdvertisingIdInfo(context).id } catch (e: Exception) { - PLog("No Google play services or error!") + log("No Google play services or error!") } } } @@ -41,7 +41,7 @@ internal class AndroidIdProvider(private val context: Context) : IdProvider { } catch (ex: Exception) { null } - PLog(String.format("Android ID: %s", uuid)) + log(String.format("Android ID: %s", uuid)) return uuid } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt index 77898643..61abdc4f 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt @@ -1,7 +1,7 @@ package com.parsely.parselyandroid import android.os.Build -import com.parsely.parselyandroid.Logging.PLog +import com.parsely.parselyandroid.Logging.log internal interface DeviceInfoRepository{ fun collectDeviceInfo(): Map @@ -35,12 +35,12 @@ internal open class AndroidDeviceInfoRepository( val adKey = advertisementIdProvider.provide() val androidId = androidIdProvider.provide() - PLog("adkey is: %s, uuid is %s", adKey, androidId) + log("adkey is: %s, uuid is %s", adKey, androidId) return if (adKey != null) { adKey } else { - PLog("falling back to device uuid") + log("falling back to device uuid") androidId .orEmpty() } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt index e59757a6..750b2a65 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 com.parsely.parselyandroid.Logging.PLog -import kotlin.time.Duration +import com.parsely.parselyandroid.Logging.log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -63,7 +62,7 @@ internal class EngagementManager( val event: MutableMap = HashMap( baseEvent ) - PLog(String.format("Enqueuing %s event.", event["action"])) + log(String.format("Enqueuing %s event.", event["action"])) // Update `ts` for the event since it's happening right now. val baseEventData = (event["data"] as Map?)!! diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java index 795f60e5..c4bfab2a 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java @@ -1,8 +1,6 @@ package com.parsely.parselyandroid; -import static com.parsely.parselyandroid.Logging.PLog; - -import android.content.Context; +import static com.parsely.parselyandroid.Logging.log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -44,7 +42,7 @@ Map buildEvent( Map extraData, @Nullable String uuid ) { - PLog("buildEvent called for %s/%s", action, url); + log("buildEvent called for %s/%s", action, url); Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt index 5465afc1..01de870c 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt @@ -1,7 +1,7 @@ package com.parsely.parselyandroid import com.parsely.parselyandroid.JsonSerializer.toParselyEventsPayload -import com.parsely.parselyandroid.Logging.PLog +import com.parsely.parselyandroid.Logging.log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -19,7 +19,7 @@ internal class FlushQueue( operator fun invoke(skipSendingEvents: Boolean) { if (!connectivityStatusProvider.isReachable()) { - PLog("Network unreachable. Not flushing.") + log("Network unreachable. Not flushing.") return } scope.launch { @@ -32,23 +32,23 @@ internal class FlushQueue( } if (skipSendingEvents) { - PLog("Debug mode on. Not sending to Parse.ly. Otherwise, would sent ${eventsToSend.size} events") + log("Debug mode on. Not sending to Parse.ly. Otherwise, would sent ${eventsToSend.size} events") repository.remove(eventsToSend) return@launch } - PLog("Sending request with %d events", eventsToSend.size) + log("Sending request with %d events", eventsToSend.size) val jsonPayload = toParselyEventsPayload(eventsToSend) - PLog("POST Data %s", jsonPayload) - PLog("Requested %s", ParselyTracker.ROOT_URL) + log("POST Data %s", jsonPayload) + log("Requested %s", ParselyTracker.ROOT_URL) restClient.send(jsonPayload) .fold( onSuccess = { - PLog("Pixel request success") + log("Pixel request success") repository.remove(eventsToSend) }, onFailure = { - PLog("Pixel request exception") - PLog(it.toString()) + log("Pixel request exception") + log(it.toString()) } ) } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt index e12f00a2..63f6e70a 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt @@ -1,6 +1,6 @@ package com.parsely.parselyandroid -import com.parsely.parselyandroid.Logging.PLog +import com.parsely.parselyandroid.Logging.log import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -23,7 +23,7 @@ internal class InMemoryBuffer( while (isActive) { mutex.withLock { if (buffer.isNotEmpty()) { - PLog("Persisting ${buffer.size} events") + log("Persisting ${buffer.size} events") localStorageRepository.insertEvents(buffer) buffer.clear() } @@ -36,7 +36,7 @@ internal class InMemoryBuffer( fun add(event: Map) { coroutineScope.launch { mutex.withLock { - PLog("Event added to buffer") + log("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 ee55d560..f8dc30fe 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -1,7 +1,7 @@ package com.parsely.parselyandroid import android.content.Context -import com.parsely.parselyandroid.Logging.PLog +import com.parsely.parselyandroid.Logging.log import java.io.EOFException import java.io.FileNotFoundException import java.io.ObjectInputStream @@ -35,7 +35,7 @@ internal class LocalStorageRepository(private val context: Context) : QueueRepos oos.close() fos.close() } catch (ex: Exception) { - PLog("Exception thrown during queue serialization: %s", ex.toString()) + log("Exception thrown during queue serialization: %s", ex.toString()) } } @@ -53,7 +53,7 @@ internal class LocalStorageRepository(private val context: Context) : QueueRepos } catch (ex: FileNotFoundException) { // Nothing to do here. Means there was no saved queue. } catch (ex: Exception) { - PLog( + log( "Exception thrown during queue deserialization: %s", ex.toString() ) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt b/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt index 63968d03..4ded5788 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt @@ -8,7 +8,7 @@ object Logging { * Log a message to the console. */ @JvmStatic - fun PLog(logString: String, vararg objects: Any?) { + fun log(logString: String, vararg objects: Any?) { if (logString == "") { return } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index b0cb7b1e..a2d4027c 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -16,7 +16,7 @@ package com.parsely.parselyandroid; -import static com.parsely.parselyandroid.Logging.PLog; +import static com.parsely.parselyandroid.Logging.log; import android.content.Context; @@ -83,7 +83,7 @@ public Unit invoke() { inMemoryBuffer = new InMemoryBuffer(ParselyCoroutineScopeKt.getSdkScope(), localStorageRepository, () -> { if (!flushTimerIsActive()) { startFlushTimer(); - PLog("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000)); + log("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000)); } return Unit.INSTANCE; }); @@ -201,7 +201,7 @@ public long getFlushInterval() { */ public void setDebug(boolean debug) { isDebug = debug; - PLog("Debugging is now set to " + isDebug); + log("Debugging is now set to " + isDebug); } /** From b61ba544ffe1506358d41ccd2a1d6014b40438bf Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 16:40:01 +0100 Subject: [PATCH 246/261] fix: do not throw exception in case of empty `url` In case of an empty `url` parameter, the SDK should not crash the application. The logging SDK is likely not as critical to the consumer business flow to the degree, that providing an incorrect argument would kill the whole process. --- .../java/com/parsely/parselyandroid/ParselyTracker.java | 9 ++++++--- 1 file changed, 6 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 a2d4027c..5a741d8d 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -222,7 +222,8 @@ public void trackPageview( @Nullable ParselyMetadata urlMetadata, @Nullable Map extraData) { if (url.equals("")) { - throw new IllegalArgumentException("url cannot be null or empty."); + log("url cannot be empty"); + return; } // Blank urlref is better than null @@ -262,7 +263,8 @@ public void startEngagement( final @Nullable Map extraData ) { if (url.equals("")) { - throw new IllegalArgumentException("url cannot be null or empty."); + log("url cannot be empty"); + return; } // Blank urlref is better than null @@ -320,7 +322,8 @@ public void trackPlay( @NonNull ParselyVideoMetadata videoMetadata, @Nullable Map extraData) { if (url.equals("")) { - throw new IllegalArgumentException("url cannot be null or empty."); + log("url cannot be empty"); + return; } // Blank urlref is better than null From 06965a3e7e3597559c8460c16942244d1635ce6e Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 17:26:36 +0100 Subject: [PATCH 247/261] style: fix minor typo in tests --- .../src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt index c3a48f21..0dea4b1a 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt @@ -61,7 +61,7 @@ class FlushQueueTest { @Test fun `given non-empty local storage, when flushing queue with skipping sending events, then events are not sent and removed from local storage`() = runTest { - // give + // given val repository = FakeRepository().apply { insertEvents(listOf(mapOf("test" to 123))) } From 173c76451b7b949f5c5c8b61ca8b8c37bc691a2f Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 18:38:24 +0100 Subject: [PATCH 248/261] Rename .java to .kt --- .../parselyandroid/{EventsBuilder.java => EventsBuilder.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename parsely/src/main/java/com/parsely/parselyandroid/{EventsBuilder.java => EventsBuilder.kt} (100%) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt similarity index 100% rename from parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java rename to parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt From b8d31049d042298a13aa35daed8293a227abaffa Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 18:38:24 +0100 Subject: [PATCH 249/261] refactor: convert `EventsBuilder` to Kotlin --- .../parsely/parselyandroid/EventsBuilder.kt | 98 ++++++++----------- 1 file changed, 39 insertions(+), 59 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt index c4bfab2a..a2cb451c 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt @@ -1,29 +1,13 @@ -package com.parsely.parselyandroid; +package com.parsely.parselyandroid -import static com.parsely.parselyandroid.Logging.log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.Calendar; -import java.util.HashMap; -import java.util.Map; -import java.util.TimeZone; - -class EventsBuilder { - private static final String VIDEO_START_ID_KEY = "vsid"; - private static final String PAGE_VIEW_ID_KEY = "pvid"; - - private final String siteId; - - @NonNull - private final DeviceInfoRepository deviceInfoRepository; - - public EventsBuilder(@NonNull final DeviceInfoRepository deviceInfoRepository, @NonNull final String siteId) { - this.siteId = siteId; - this.deviceInfoRepository = deviceInfoRepository; - } +import com.parsely.parselyandroid.Logging.log +import java.util.Calendar +import java.util.TimeZone +internal class EventsBuilder( + private val deviceInfoRepository: DeviceInfoRepository, + private val siteId: String +) { /** * Create an event Map * @@ -33,51 +17,47 @@ class EventsBuilder { * @param extraData A Map of additional information to send with the event. * @return A Map object representing the event to be sent to Parse.ly. */ - @NonNull - Map buildEvent( - String url, - String urlRef, - String action, - ParselyMetadata metadata, - Map extraData, - @Nullable String uuid - ) { - log("buildEvent called for %s/%s", action, url); - - Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + fun buildEvent( + url: String?, + urlRef: String?, + action: String, + metadata: ParselyMetadata?, + extraData: Map?, + uuid: String? + ): Map { + log("buildEvent called for %s/%s", action, url) + val now = Calendar.getInstance(TimeZone.getTimeZone("UTC")) // Main event info - Map event = new HashMap<>(); - event.put("url", url); - event.put("urlref", urlRef); - event.put("idsite", siteId); - event.put("action", action); + val event: MutableMap = HashMap() + event["url"] = url + event["urlref"] = urlRef + event["idsite"] = siteId + event["action"] = action // Make a copy of extraData and add some things. - Map data = new HashMap<>(); + val data: MutableMap = HashMap() if (extraData != null) { - data.putAll(extraData); + data.putAll(extraData) } - - final Map deviceInfo = deviceInfoRepository.collectDeviceInfo(); - data.put("ts", now.getTimeInMillis()); - data.putAll(deviceInfo); - - event.put("data", data); - + val deviceInfo = deviceInfoRepository.collectDeviceInfo() + data["ts"] = now.timeInMillis + data.putAll(deviceInfo) + event["data"] = data if (metadata != null) { - event.put("metadata", metadata.toMap()); + event["metadata"] = metadata.toMap() } - - if (action.equals("videostart") || action.equals("vheartbeat")) { - event.put(VIDEO_START_ID_KEY, uuid); + if (action == "videostart" || action == "vheartbeat") { + event[VIDEO_START_ID_KEY] = uuid } - - if (action.equals("pageview") || action.equals("heartbeat")) { - event.put(PAGE_VIEW_ID_KEY, uuid); + if (action == "pageview" || action == "heartbeat") { + event[PAGE_VIEW_ID_KEY] = uuid } - - return event; + return event } + companion object { + private const val VIDEO_START_ID_KEY = "vsid" + private const val PAGE_VIEW_ID_KEY = "pvid" + } } From caf65951ffce1552298a232d6a22c8aeb2055d36 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 18:50:26 +0100 Subject: [PATCH 250/261] fix: do not allow users to start engagement session without tracking page first --- .../java/com/parsely/parselyandroid/FunctionalTests.kt | 1 + .../main/java/com/parsely/parselyandroid/ParselyTracker.java | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index 95450804..eaeed64e 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -183,6 +183,7 @@ class FunctionalTests { // when startTimestamp = System.currentTimeMillis().milliseconds + parselyTracker.trackPageview("url", null, null, null) parselyTracker.startEngagement(engagementUrl, null) } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 5a741d8d..af7abee0 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -266,6 +266,10 @@ public void startEngagement( log("url cannot be empty"); return; } + if (lastPageviewUuid == null) { + log("engagement session cannot start without calling trackPageview first"); + return; + } // Blank urlref is better than null if (urlRef == null) { From 688fdf6d14615f0a5ee736923596d39b588ebe30 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 18:51:33 +0100 Subject: [PATCH 251/261] fix: update signature of `ParselyMetadata#toMap` methods `toMap` will never produce `null` because it creates a new map each time --- .../main/java/com/parsely/parselyandroid/ParselyMetadata.kt | 2 +- .../java/com/parsely/parselyandroid/ParselyVideoMetadata.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt index 201b4eb5..8b551587 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt @@ -36,7 +36,7 @@ open class ParselyMetadata * * @return a Map object representing the metadata. */ - open fun toMap(): Map? { + open fun toMap(): Map { val output: MutableMap = HashMap() if (authors != null) { output["authors"] = authors diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt index 4a877e31..36c6381a 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt @@ -32,9 +32,9 @@ class ParselyVideoMetadata * * @return a Map object representing the metadata. */ - override fun toMap(): Map? { - val output = super.toMap()?.toMutableMap() - output?.put("duration", durationSeconds) + override fun toMap(): Map { + val output = super.toMap().toMutableMap() + output["duration"] = durationSeconds return output } } From fb1afc7ef91653727bf1dddb581151b720cc8cf8 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 18:51:55 +0100 Subject: [PATCH 252/261] fix: update signature of `buildEvent` method --- .../com/parsely/parselyandroid/EventsBuilder.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt index a2cb451c..514847d6 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt @@ -18,18 +18,18 @@ internal class EventsBuilder( * @return A Map object representing the event to be sent to Parse.ly. */ fun buildEvent( - url: String?, - urlRef: String?, + url: String, + urlRef: String, action: String, metadata: ParselyMetadata?, extraData: Map?, - uuid: String? - ): Map { + uuid: String + ): Map { log("buildEvent called for %s/%s", action, url) val now = Calendar.getInstance(TimeZone.getTimeZone("UTC")) // Main event info - val event: MutableMap = HashMap() + val event: MutableMap = HashMap() event["url"] = url event["urlref"] = urlRef event["idsite"] = siteId @@ -44,8 +44,8 @@ internal class EventsBuilder( data["ts"] = now.timeInMillis data.putAll(deviceInfo) event["data"] = data - if (metadata != null) { - event["metadata"] = metadata.toMap() + metadata?.let { + event["metadata"] = it.toMap() } if (action == "videostart" || action == "vheartbeat") { event[VIDEO_START_ID_KEY] = uuid From 5ce8239714957d73391e81788683c3faf36cfa18 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Wed, 13 Dec 2023 18:56:27 +0100 Subject: [PATCH 253/261] fix: make Logging object internal --- parsely/src/main/java/com/parsely/parselyandroid/Logging.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt b/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt index 4ded5788..cdee406a 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt @@ -2,7 +2,7 @@ package com.parsely.parselyandroid import java.util.Formatter -object Logging { +internal object Logging { /** * Log a message to the console. From 51d0aded8f5ab479a331ef0c5ee4dc0fb7750669 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 14 Dec 2023 12:52:22 +0100 Subject: [PATCH 254/261] feat: use `Clock` for getting time in `EventsBuilder` --- .../parsely/parselyandroid/EventsBuilder.kt | 6 +++--- .../parselyandroid/ParselyTracker.java | 4 ++-- .../parselyandroid/EventsBuilderTest.kt | 19 ++++++++++++++----- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt index 514847d6..945c8b33 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt @@ -6,7 +6,8 @@ import java.util.TimeZone internal class EventsBuilder( private val deviceInfoRepository: DeviceInfoRepository, - private val siteId: String + private val siteId: String, + private val clock: Clock, ) { /** * Create an event Map @@ -26,7 +27,6 @@ internal class EventsBuilder( uuid: String ): Map { log("buildEvent called for %s/%s", action, url) - val now = Calendar.getInstance(TimeZone.getTimeZone("UTC")) // Main event info val event: MutableMap = HashMap() @@ -41,7 +41,7 @@ internal class EventsBuilder( data.putAll(extraData) } val deviceInfo = deviceInfoRepository.collectDeviceInfo() - data["ts"] = now.timeInMillis + data["ts"] = clock.now.inWholeMilliseconds data.putAll(deviceInfo) event["data"] = data metadata?.let { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index af7abee0..17682e90 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java @@ -66,11 +66,12 @@ public class ParselyTracker { */ protected ParselyTracker(String siteId, int flushInterval, Context c) { Context context = c.getApplicationContext(); + clock = new Clock(); eventsBuilder = new EventsBuilder( new AndroidDeviceInfoRepository( new AdvertisementIdProvider(context, ParselyCoroutineScopeKt.getSdkScope()), new AndroidIdProvider(context) - ), siteId); + ), siteId, clock); LocalStorageRepository localStorageRepository = new LocalStorageRepository(context); flushManager = new ParselyFlushManager(new Function0() { @Override @@ -88,7 +89,6 @@ public Unit invoke() { return Unit.INSTANCE; }); flushQueue = new FlushQueue(flushManager, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy"), ParselyCoroutineScopeKt.getSdkScope(), new AndroidConnectivityStatusProvider(context)); - clock = new Clock(); intervalCalculator = new HeartbeatIntervalCalculator(clock); // get the adkey straight away on instantiation diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 8f57335b..c37a92bb 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -1,5 +1,10 @@ package com.parsely.parselyandroid +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.MapAssert import org.junit.Before @@ -7,17 +12,19 @@ import org.junit.Test internal class EventsBuilderTest { private lateinit var sut: EventsBuilder + private val clock = FakeClock() @Before fun setUp() { sut = EventsBuilder( FakeDeviceInfoRepository(), TEST_SITE_ID, + clock ) } @Test - fun `when building pageview event, then build the correct one`() { + fun `when building pageview event, then build the correct one`() = runTest { // when val event: Map = sut.buildEvent( TEST_URL, @@ -187,9 +194,6 @@ internal class EventsBuilderTest { assertThat(it) .hasSize(2) .containsAllEntriesOf(FAKE_DEVICE_INFO) - .hasEntrySatisfying("ts") { timestamp -> - assertThat(timestamp as Long).isBetween(1111111111111, 9999999999999) - } } companion object { @@ -197,10 +201,15 @@ internal class EventsBuilderTest { 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") + val FAKE_NOW = 15.hours + val FAKE_DEVICE_INFO = mapOf("device" to "info", "ts" to FAKE_NOW.inWholeMilliseconds.toString()) } class FakeDeviceInfoRepository: DeviceInfoRepository { override fun collectDeviceInfo(): Map = FAKE_DEVICE_INFO } + + class FakeClock() : Clock() { + override val now: Duration = FAKE_NOW + } } From a86c207103fb274177c4dc48a503f18422730299 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 14 Dec 2023 12:54:18 +0100 Subject: [PATCH 255/261] style: remove unused imports and runTest --- .../java/com/parsely/parselyandroid/EventsBuilderTest.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index c37a92bb..05af5efb 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -2,9 +2,6 @@ package com.parsely.parselyandroid import kotlin.time.Duration import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.milliseconds -import kotlinx.coroutines.test.TestCoroutineScheduler -import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.MapAssert import org.junit.Before @@ -24,7 +21,7 @@ internal class EventsBuilderTest { } @Test - fun `when building pageview event, then build the correct one`() = runTest { + fun `when building pageview event, then build the correct one`() { // when val event: Map = sut.buildEvent( TEST_URL, From 919778f0768eeea53a8283499a65d325fbee057e Mon Sep 17 00:00:00 2001 From: martinakram Date: Wed, 17 Jan 2024 11:50:09 -0500 Subject: [PATCH 256/261] Add optional pageType parameter to ParselyMetadata class in Android SDK --- .../parsely/parselyandroid/ParselyMetadata.kt | 19 ++++++++++++------- .../parselyandroid/ParselyMetadataTest.kt | 10 +++++++--- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt index 8b551587..0314d2af 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt @@ -15,13 +15,14 @@ open class ParselyMetadata /** * Create a new ParselyMetadata object. * - * @param authors The names of the authors of the content. Up to 10 authors are accepted. - * @param link A post's canonical url. - * @param section The category or vertical to which this content belongs. - * @param tags User-defined tags for the content. Up to 20 are allowed. - * @param thumbUrl URL at which the main image for this content is located. - * @param title The title of the content. - * @param pubDate The date this piece of content was published. + * @param authors The names of the authors of the content. Up to 10 authors are accepted. + * @param link A post's canonical url. + * @param section The category or vertical to which this content belongs. + * @param tags User-defined tags for the content. Up to 20 are allowed. + * @param thumbUrl URL at which the main image for this content is located. + * @param title The title of the content. + * @param pubDate The date this piece of content was published. + * @param pageType The type of page being tracked */( private val authors: List? = null, @JvmField internal val link: String? = null, @@ -30,6 +31,7 @@ open class ParselyMetadata private val thumbUrl: String? = null, private val title: String? = null, private val pubDate: Calendar? = null + private val pageType: String? = null ) { /** * Turn this object into a Map @@ -59,6 +61,9 @@ open class ParselyMetadata if (pubDate != null) { output["pub_date_tmsp"] = pubDate.timeInMillis / 1000 } + if (pageType != null) { + output["page_type"] = pageType + } return output } } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt index 3bf8b61e..0af3c71f 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt @@ -17,7 +17,8 @@ class ParselyMetadataTest { tags, thumbUrl, title, - pubDate + pubDate, + pageType ) // when @@ -39,7 +40,8 @@ class ParselyMetadataTest { thumbUrl, title, pubDate, - duration + duration, + pageType ) // when @@ -57,6 +59,7 @@ class ParselyMetadataTest { val thumbUrl = "sample thumb url" val title = "sample title" val pubDate = Calendar.getInstance().apply { set(2023, 0, 1) } + val pageType = "post" val expectedParselyMetadataMap = mapOf( "authors" to authors, @@ -65,7 +68,8 @@ class ParselyMetadataTest { "tags" to tags, "thumb_url" to thumbUrl, "title" to title, - "pub_date_tmsp" to pubDate.timeInMillis / 1000 + "pub_date_tmsp" to pubDate.timeInMillis / 1000, + "page_type" to pageType ) } } From dae740f8ea7f7af9146cb485ef5b9ee881fd3640 Mon Sep 17 00:00:00 2001 From: martinakram Date: Wed, 17 Jan 2024 11:54:30 -0500 Subject: [PATCH 257/261] Add missing comma to ParselyMetadata object --- .../src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt index 0314d2af..f7ad0d40 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt @@ -30,7 +30,7 @@ open class ParselyMetadata private val tags: List? = null, private val thumbUrl: String? = null, private val title: String? = null, - private val pubDate: Calendar? = null + private val pubDate: Calendar? = null, private val pageType: String? = null ) { /** From 0e943363ff74bd89576c5e2728739b58cd97417a Mon Sep 17 00:00:00 2001 From: martinakram Date: Wed, 17 Jan 2024 12:00:05 -0500 Subject: [PATCH 258/261] Fix broken test in ParselyMetadataTest --- .../test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt index 0af3c71f..0210445e 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt @@ -41,7 +41,6 @@ class ParselyMetadataTest { title, pubDate, duration, - pageType ) // when From d981f2ef05435e3605a3bc50a8be786a0b2478c2 Mon Sep 17 00:00:00 2001 From: martinakram Date: Wed, 17 Jan 2024 12:11:18 -0500 Subject: [PATCH 259/261] Remove missed comma in ParselyMetadataTest --- .../test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt index 0210445e..524aaf04 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt @@ -40,7 +40,7 @@ class ParselyMetadataTest { thumbUrl, title, pubDate, - duration, + duration ) // when From 1adfb441ae4b88f4c5ce9a37144ed66cf92dba9e Mon Sep 17 00:00:00 2001 From: martinakram Date: Wed, 17 Jan 2024 12:31:53 -0500 Subject: [PATCH 260/261] Actually fix the tests for real this time --- .../com/parsely/parselyandroid/ParselyMetadataTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt index 524aaf04..181f419c 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt @@ -10,6 +10,7 @@ class ParselyMetadataTest { @Test fun `given metadata with complete set of data, when converting to map, then the map is as expected`() { // given + val pageType = "post" val sut = ParselyMetadata( authors, link, @@ -25,7 +26,8 @@ class ParselyMetadataTest { val map = sut.toMap() // then - assertThat(map).isEqualTo(expectedParselyMetadataMap) + val expectedMapWithPageType = expectedBaseParselyMetadataMap + ("page_type" to pageType) + assertThat(map).isEqualTo(expectedMapWithPageType) } @Test @@ -58,7 +60,6 @@ class ParselyMetadataTest { val thumbUrl = "sample thumb url" val title = "sample title" val pubDate = Calendar.getInstance().apply { set(2023, 0, 1) } - val pageType = "post" val expectedParselyMetadataMap = mapOf( "authors" to authors, @@ -67,8 +68,7 @@ class ParselyMetadataTest { "tags" to tags, "thumb_url" to thumbUrl, "title" to title, - "pub_date_tmsp" to pubDate.timeInMillis / 1000, - "page_type" to pageType + "pub_date_tmsp" to pubDate.timeInMillis / 1000 ) } } From c2eb7e55a17e1acfe83b44c735a8a3c5606bf0ad Mon Sep 17 00:00:00 2001 From: martinakram Date: Wed, 17 Jan 2024 12:37:32 -0500 Subject: [PATCH 261/261] Fix test --- .../java/com/parsely/parselyandroid/ParselyMetadataTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt index 181f419c..db5fa0e4 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt @@ -26,8 +26,7 @@ class ParselyMetadataTest { val map = sut.toMap() // then - val expectedMapWithPageType = expectedBaseParselyMetadataMap + ("page_type" to pageType) - assertThat(map).isEqualTo(expectedMapWithPageType) + assertThat(map).isEqualTo(expectedParselyMetadataMap + ("page_type" to pageType)) } @Test