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 diff --git a/example/src/main/java/com/example/MainActivity.java b/example/src/main/java/com/example/MainActivity.java index ee286ef8..6fff5e6c 100644 --- a/example/src/main/java/com/example/MainActivity.java +++ b/example/src/main/java/com/example/MainActivity.java @@ -30,33 +30,18 @@ protected void onCreate(Bundle savedInstanceState) { // Set debugging to true so we don't actually send things to Parse.ly ParselyTracker.sharedInstance().setDebug(true); - final TextView queueView = (TextView) findViewById(R.id.queue_size); - queueView.setText(String.format("Queued events: %d", ParselyTracker.sharedInstance().queueSize())); - - final TextView storedView = (TextView) findViewById(R.id.stored_size); - storedView.setText(String.format("Stored events: %d", ParselyTracker.sharedInstance().storedEventsCount())); - final TextView intervalView = (TextView) findViewById(R.id.interval); - storedView.setText(String.format("Flush interval: %d", ParselyTracker.sharedInstance().getFlushInterval())); updateEngagementStrings(); - final TextView views[] = new TextView[3]; - views[0] = queueView; - views[1] = storedView; - views[2] = intervalView; + final TextView views[] = new TextView[1]; + views[0] = intervalView; final Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { TextView[] v = (TextView[]) msg.obj; - TextView qView = v[0]; - qView.setText(String.format("Queued events: %d", ParselyTracker.sharedInstance().queueSize())); - - TextView sView = v[1]; - sView.setText(String.format("Stored events: %d", ParselyTracker.sharedInstance().storedEventsCount())); - - TextView iView = v[2]; + TextView iView = v[0]; if (ParselyTracker.sharedInstance().flushTimerIsActive()) { iView.setText(String.format("Flush Interval: %d", ParselyTracker.sharedInstance().getFlushInterval())); } else { diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml index ce23ecdd..e86a9223 100644 --- a/example/src/main/res/layout/activity_main.xml +++ b/example/src/main/res/layout/activity_main.xml @@ -68,26 +68,11 @@ android:onClick="trackReset" android:text="@string/button_reset_video" /> - - - - - \ No newline at end of file + diff --git a/parsely/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' diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index e2248069..8a4f591c 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -28,6 +28,7 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.RecordedRequest import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.within import org.junit.Test import org.junit.runner.RunWith @@ -49,12 +50,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) @@ -76,6 +77,47 @@ class FunctionalTests { } } + /** + * In this scenario, the consumer app tracks 2 events during the first flush interval. + * Then, we validate, that after flush interval passed the SDK sends the events + * to Parse.ly servers. + * + * Then, the consumer app tracks another event and we validate that the SDK sends the event + * to Parse.ly servers as well. + */ + @Test + fun appFlushesEventsAfterFlushInterval() { + ActivityScenario.launch(SampleActivity::class.java).use { scenario -> + scenario.onActivity { activity: Activity -> + beforeEach(activity) + server.enqueue(MockResponse().setResponseCode(200)) + parselyTracker = initializeTracker(activity) + + parselyTracker.trackPageview("url", null, null, null) + } + + Thread.sleep((defaultFlushInterval / 2).inWholeMilliseconds) + + scenario.onActivity { + parselyTracker.trackPageview("url", null, null, null) + } + + Thread.sleep((defaultFlushInterval / 2).inWholeMilliseconds) + + val firstRequestPayload = server.takeRequest(2000, TimeUnit.MILLISECONDS)?.toMap() + assertThat(firstRequestPayload!!["events"]).hasSize(2) + + scenario.onActivity { + parselyTracker.trackPageview("url", null, null, null) + } + + Thread.sleep(defaultFlushInterval.inWholeMilliseconds) + + val secondRequestPayload = server.takeRequest(2000, TimeUnit.MILLISECONDS)?.toMap() + assertThat(secondRequestPayload!!["events"]).hasSize(1) + } + } + /** * In this scenario, the consumer application: * 1. Goes to the background @@ -113,6 +155,132 @@ class FunctionalTests { } } + /** + * 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 eventsToSend = 500 + + ActivityScenario.launch(SampleActivity::class.java).use { scenario -> + scenario.onActivity { activity: Activity -> + beforeEach(activity) + server.enqueue(MockResponse().setResponseCode(200)) + parselyTracker = initializeTracker(activity) + + repeat(eventsToSend) { + parselyTracker.trackPageview("url", null, null, null) + } + } + + // Wait some time to give events chance to be saved in local data storage + Thread.sleep((defaultFlushInterval * 2).inWholeMilliseconds) + + // 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(eventsToSend) + } + } + + /** + * 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>>() {} @@ -123,6 +291,15 @@ class FunctionalTests { @JsonIgnoreProperties(ignoreUnknown = true) data class Event( @JsonProperty("idsite") var idsite: String, + @JsonProperty("action") var action: String, + @JsonProperty("data") var data: ExtraData, + @JsonProperty("tt") var totalTime: Long, + @JsonProperty("inc") var incremental: Long, + ) + + @JsonIgnoreProperties(ignoreUnknown = true) + data class ExtraData( + @JsonProperty("ts") var timestamp: Long, ) private val locallyStoredEvents @@ -137,19 +314,18 @@ 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 { const val siteId = "123" const val localStorageFileName = "parsely-events.ser" - val defaultFlushInterval = 10.seconds + val defaultFlushInterval = 5.seconds } class SampleActivity : Activity() 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..e9f11ce6 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt @@ -0,0 +1,50 @@ +package com.parsely.parselyandroid + +import android.content.Context +import android.provider.Settings +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 { + adKey = AdvertisingIdClient.getAdvertisingIdInfo(context).id + } catch (e: Exception) { + ParselyTracker.PLog("No Google play services or error!") + } + } + } + + /** + * @return advertisement id if the coroutine in the constructor finished executing AdvertisingIdClient#getAdvertisingIdInfo + * null otherwise + */ + override fun provide(): String? = adKey +} + +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 + } + ParselyTracker.PLog(String.format("Android ID: %s", uuid)) + return uuid + } +} + +internal fun interface IdProvider { + fun provide(): String? +} 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/DeviceInfoRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt new file mode 100644 index 00000000..fc899215 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt @@ -0,0 +1,46 @@ +package com.parsely.parselyandroid + +import android.os.Build + +internal interface DeviceInfoRepository{ + fun collectDeviceInfo(): Map +} + +internal open class AndroidDeviceInfoRepository( + private val advertisementIdProvider: IdProvider, + private val androidIdProvider: IdProvider, +): DeviceInfoRepository { + + /** + * Collect device-specific info. + * + * + * Collects info about the device and user to use in Parsely events. + */ + override fun collectDeviceInfo(): Map { + val dInfo: MutableMap = HashMap() + + // TODO: screen dimensions (maybe?) + dInfo["parsely_site_uuid"] = parselySiteUuid + dInfo["manufacturer"] = Build.MANUFACTURER + dInfo["os"] = "android" + dInfo["os_version"] = String.format("%d", Build.VERSION.SDK_INT) + + return dInfo + } + + private val parselySiteUuid: String + get() { + val adKey = advertisementIdProvider.provide() + val androidId = androidIdProvider.provide() + + ParselyTracker.PLog("adkey is: %s, uuid is %s", adKey, androidId) + + return if (adKey != null) { + adKey + } else { + ParselyTracker.PLog("falling back to device uuid") + androidId .orEmpty() + } + } +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java deleted file mode 100644 index 182dc407..00000000 --- a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java +++ /dev/null @@ -1,120 +0,0 @@ -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 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; - } - - 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()); - latestDelayMillis = intervalCalculator.calculate(startTime); - 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<>(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); - } - - - public double getIntervalMillis() { - return latestDelayMillis; - } -} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt new file mode 100644 index 00000000..2f1dc620 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt @@ -0,0 +1,84 @@ +package com.parsely.parselyandroid + +import kotlin.time.Duration +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. + * + * + * 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. + */ +internal class EngagementManager( + private val parselyTracker: ParselyTracker, + private var latestDelayMillis: Long, + private val baseEvent: Map, + private val intervalCalculator: HeartbeatIntervalCalculator, + private val coroutineScope: CoroutineScope, + private val clock: Clock, +) { + private var job: Job? = null + private var totalTime: Long = 0 + private var nextScheduledExecution: Long = 0 + + val isRunning: Boolean + get() = job?.isActive ?: false + + fun start() { + val startTime = clock.now + job = coroutineScope.launch { + while (isActive) { + latestDelayMillis = intervalCalculator.calculate(startTime) + nextScheduledExecution = clock.now.inWholeMilliseconds + latestDelayMillis + delay(latestDelayMillis) + doEnqueue(clock.now.inWholeMilliseconds) + } + } + } + + fun stop() { + job?.let { + it.cancel() + doEnqueue(nextScheduledExecution) + } + } + + 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 fun doEnqueue(scheduledExecutionTime: Long) { + // Create a copy of the base event to enqueue + 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. + val baseEventData = (event["data"] as Map?)!! + val data: MutableMap = HashMap(baseEventData) + data["ts"] = clock.now.inWholeMilliseconds + event["data"] = data + + // Adjust inc by execution time in case we're late or early. + val executionDiff = clock.now.inWholeMilliseconds - scheduledExecutionTime + val inc = latestDelayMillis + executionDiff + totalTime += inc + event["inc"] = inc / 1000 + event["tt"] = totalTime + parselyTracker.enqueueEvent(event) + } + + val intervalMillis: Double + get() = latestDelayMillis.toDouble() +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java index 2f1c1ae8..b70c1aec 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java +++ b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.java @@ -3,41 +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 Map deviceInfo; + @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); - deviceInfo = collectDeviceInfo(null); - new GetAdKey(context).execute(); + this.deviceInfoRepository = deviceInfoRepository; } /** @@ -74,11 +60,11 @@ Map buildEvent( 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")); + + final Map deviceInfo = deviceInfoRepository.collectDeviceInfo(); 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) { @@ -96,90 +82,4 @@ Map buildEvent( 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/FlushManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt new file mode 100644 index 00000000..5026c8d8 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushManager.kt @@ -0,0 +1,47 @@ +package com.parsely.parselyandroid + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Manager for the event flush timer. + * + * + * Handles stopping and starting the flush timer. The flush timer + * controls how often we send events to Parse.ly servers. + */ +internal interface FlushManager { + fun start() + fun stop() + val isRunning: Boolean + val intervalMillis: Long +} + +internal class ParselyFlushManager( + private val onFlush: () -> Unit, + override val intervalMillis: Long, + private val coroutineScope: CoroutineScope +) : FlushManager { + private var job: Job? = null + + override fun start() { + if (job?.isActive == true) return + + job = coroutineScope.launch { + while (isActive) { + delay(intervalMillis) + onFlush.invoke() + } + } + } + + override fun stop() { + job?.cancel() + } + + override val isRunning: Boolean + get() = job?.isActive ?: false +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt new file mode 100644 index 00000000..4a989b95 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt @@ -0,0 +1,51 @@ +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 FlushQueue( + private val flushManager: FlushManager, + private val repository: QueueRepository, + private val restClient: RestClient, + private val scope: CoroutineScope +) { + + private val mutex = Mutex() + + operator fun invoke(skipSendingEvents: Boolean) { + scope.launch { + mutex.withLock { + val eventsToSend = repository.getStoredQueue() + + if (eventsToSend.isEmpty()) { + flushManager.stop() + return@launch + } + + if (skipSendingEvents) { + ParselyTracker.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) + 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) + }, + onFailure = { + ParselyTracker.PLog("Pixel request exception") + ParselyTracker.PLog(it.toString()) + } + ) + } + } + } +} 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/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt new file mode 100644 index 00000000..619e993d --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt @@ -0,0 +1,44 @@ +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 + +internal class InMemoryBuffer( + private val coroutineScope: CoroutineScope, + private val localStorageRepository: QueueRepository, + private val onEventAddedListener: () -> Unit, +) { + + private val mutex = Mutex() + private val buffer = mutableListOf>() + + init { + coroutineScope.launch { + while (isActive) { + mutex.withLock { + if (buffer.isNotEmpty()) { + ParselyTracker.PLog("Persisting ${buffer.size} events") + localStorageRepository.insertEvents(buffer) + buffer.clear() + } + } + delay(1.seconds) + } + } + } + + fun add(event: Map) { + coroutineScope.launch { + mutex.withLock { + ParselyTracker.PLog("Event added to buffer") + buffer.add(event) + onEventAddedListener() + } + } + } +} 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..dde232ce --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/JsonSerializer.kt @@ -0,0 +1,32 @@ +package com.parsely.parselyandroid + +import com.fasterxml.jackson.databind.ObjectMapper +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`. + */ + private 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/LocalStorageRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt index 1f4f903b..1f1f28fc 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt @@ -5,8 +5,19 @@ import java.io.EOFException import java.io.FileNotFoundException import java.io.ObjectInputStream import java.io.ObjectOutputStream +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal 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() -internal open class LocalStorageRepository(private val context: Context) { /** * Persist an object to storage. * @@ -21,24 +32,13 @@ 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()) } } - /** - * Delete the stored queue from persistent storage. - */ - fun purgeStoredQueue() { - persistObject(ArrayList>()) - } - - /** - * Get the stored event queue from persistent storage. - * - * @return The stored queue of events. - */ - open fun getStoredQueue(): ArrayList?> { + private fun getInternalStoredQueue(): ArrayList?> { var storedQueue: ArrayList?> = ArrayList() try { val fis = context.applicationContext.openFileInput(STORAGE_KEY) @@ -46,6 +46,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) { @@ -59,21 +60,26 @@ internal open class LocalStorageRepository(private val context: Context) { return storedQueue } + override suspend fun remove(toRemove: List?>) = mutex.withLock { + val storedEvents = getInternalStoredQueue() + persistObject(storedEvents - toRemove.toSet()) + } + /** - * Delete an event from the stored queue. + * Get the stored event queue from persistent storage. + * + * @return The stored queue of events. */ - open fun expelStoredEvent() { - val storedQueue = getStoredQueue() - storedQueue.removeAt(0) + override suspend fun getStoredQueue(): ArrayList?> = mutex.withLock { + getInternalStoredQueue() } /** * Save the event queue to persistent storage. */ - @Synchronized - open fun persistQueue(inMemoryQueue: List?>) { - ParselyTracker.PLog("Persisting event queue") - persistObject((inMemoryQueue + getStoredQueue()).distinct()) + override suspend fun insertEvents(toInsert: List?>) = mutex.withLock { + val storedEvents = getInternalStoredQueue() + persistObject(ArrayList((toInsert + storedEvents).distinct())) } companion object { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt index 8d0634bd..c1c1e422 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt @@ -13,50 +13,32 @@ 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 -internal class ParselyAPIConnection(private val tracker: ParselyTracker) : AsyncTask() { - private var exception: Exception? = null +internal interface RestClient { + suspend fun send(payload: String): Result +} - @Deprecated("Deprecated in Java") - override fun doInBackground(vararg data: String?): Void? { +internal class ParselyAPIConnection(private val url: String) : RestClient { + override suspend fun send(payload: String): Result { 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) - connection.setRequestProperty("Content-Type", "application/json") - val output = connection.outputStream - output.write(data[1]?.toByteArray()) - output.close() - connection.inputStream - } + 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) { - exception = ex + return Result.failure(ex) + } finally { + connection?.disconnect() } - 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() - } + return Result.success(Unit) } } 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..1c7be0fe --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyCoroutineScope.kt @@ -0,0 +1,9 @@ +package com.parsely.parselyandroid + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +internal val sdkScope = + CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineName("Parse.ly SDK Scope")) diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java index 609ed087..0afd9471 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,20 +26,13 @@ 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.HashSet; -import java.util.List; import java.util.Map; -import java.util.Timer; -import java.util.TimerTask; import java.util.UUID; +import kotlin.Unit; +import kotlin.jvm.functions.Function0; + /** * Tracks Parse.ly app views in Android apps *

@@ -52,12 +44,10 @@ 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(); - private final ArrayList> eventQueue; +// 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; private final FlushManager flushManager; private EngagementManager engagementManager, videoEngagementManager; @Nullable @@ -65,29 +55,50 @@ 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 + private final InMemoryBuffer inMemoryBuffer; + @NonNull + private final FlushQueue flushQueue; /** * Create a new ParselyTracker instance. */ protected ParselyTracker(String siteId, int flushInterval, Context c) { context = c.getApplicationContext(); - eventsBuilder = new EventsBuilder(context, siteId); + eventsBuilder = new EventsBuilder( + new AndroidDeviceInfoRepository( + new AdvertisementIdProvider(context, ParselyCoroutineScopeKt.getSdkScope()), + new AndroidIdProvider(context) + ), siteId); localStorageRepository = new LocalStorageRepository(context); + 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()) { + startFlushTimer(); + PLog("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000)); + } + return Unit.INSTANCE; + }); + flushQueue = new FlushQueue(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(); isDebug = false; - eventQueue = new ArrayList<>(); - - flushManager = new FlushManager(timer, flushInterval * 1000L); - - if (localStorageRepository.getStoredQueue().size() > 0) { - startFlushTimer(); - } + flushManager.start(); ProcessLifecycleOwner.get().getLifecycle().addObserver( (LifecycleEventObserver) (lifecycleOwner, event) -> { @@ -98,10 +109,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)} * @@ -288,7 +295,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, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event, intervalCalculator, ParselyCoroutineScopeKt.getSdkScope(), clock ); engagementManager.start(); } @@ -364,7 +371,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, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent, intervalCalculator, ParselyCoroutineScopeKt.getSdkScope(), clock); videoEngagementManager.start(); } @@ -410,19 +417,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); - new QueueManager(this, localStorageRepository).execute(); - if (!flushTimerIsActive()) { - startFlushTimer(); - PLog("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000)); - } + inMemoryBuffer.add(event); } /** @@ -434,34 +434,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) { - 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", JsonEncode(batchMap)); - PLog("Requested %s", ROOT_URL); - } - PLog("POST Data %s", JsonEncode(batchMap)); - } - /** * Returns whether the network is accessible and Parsely is reachable. * @@ -474,30 +446,6 @@ private boolean isReachable() { return netInfo != null && netInfo.isConnectedOrConnecting(); } - void purgeEventsQueue() { - eventQueue.clear(); - 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. *

@@ -518,114 +466,16 @@ 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(); } - /** - * 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 eventQueue.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(); - } - - 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()); - // in case both queues have been flushed and app quits, don't crash - if ((eventQueue == null || eventQueue.size() == 0) && storedQueue.size() == 0) { - stopFlushTimer(); - return null; - } - if (!isReachable()) { - 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); - return null; - } - } - - /** - * 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; + void flushEvents() { + if (!isReachable()) { + PLog("Network unreachable. Not flushing."); + return; } + flushQueue.invoke(isDebug); } - - private void flushEvents() { - new FlushQueue().execute(); - } - } 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 59d5401b..00000000 --- a/parsely/src/main/java/com/parsely/parselyandroid/QueueManager.kt +++ /dev/null @@ -1,30 +0,0 @@ -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) { - 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() - } - } - 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/AndroidDeviceInfoRepositoryTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/AndroidDeviceInfoRepositoryTest.kt new file mode 100644 index 00000000..08ef47b8 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/AndroidDeviceInfoRepositoryTest.kt @@ -0,0 +1,76 @@ +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 { + + @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" + val 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" + val 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 + val 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" + ) + } +} diff --git a/parsely/src/test/java/com/parsely/parselyandroid/AndroidIdProviderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/AndroidIdProviderTest.kt new file mode 100644 index 00000000..eaf24427 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/AndroidIdProviderTest.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 AndroidIdProviderTest { + + lateinit var sut: AndroidIdProvider + + @Before + fun setUp() { + sut = AndroidIdProvider(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) + } +} diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt index 6b5448c1..a7e5df9e 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt @@ -1,106 +1,182 @@ 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 +import kotlin.time.Duration.Companion.seconds +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 -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 private typealias Event = MutableMap +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) 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 ) - @Before - fun setUp() { + @Test + fun `when starting manager, then record the correct event after interval millis`() = runTest { + // given sut = EngagementManager( tracker, - parentTimer, - DEFAULT_INTERVAL_MILLIS, + DEFAULT_INTERVAL.inWholeMilliseconds, 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) + runCurrent() // 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)) } + withIncrementalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeSeconds)}, + withTotalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeMilliseconds) }, + withTimestamp = { isEqualTo(currentTime) } ) } @Test - fun `when starting manager, then schedule task each interval period`() { + fun `when starting manager, then schedule task each interval period`() = runTest { + // given + sut = EngagementManager( + tracker, + DEFAULT_INTERVAL.inWholeMilliseconds, + baseEvent, + FakeIntervalCalculator(), + backgroundScope, + FakeClock(testScheduler), + ) sut.start() - sleep(DEFAULT_INTERVAL_MILLIS) - val firstTimestamp = now - THREAD_SLEEPING_THRESHOLD - - sleep(DEFAULT_INTERVAL_MILLIS) - val secondTimestamp = now - 2 * THREAD_SLEEPING_THRESHOLD + // when + advanceTimeBy(DEFAULT_INTERVAL) + val firstTimestamp = currentTime - sleep(DEFAULT_INTERVAL_MILLIS) - val thirdTimestamp = now - 3 * THREAD_SLEEPING_THRESHOLD + advanceTimeBy(DEFAULT_INTERVAL) + val secondTimestamp = currentTime - sleep(THREAD_SLEEPING_THRESHOLD) + advanceTimeBy(DEFAULT_INTERVAL) + 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)) } + withIncrementalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeSeconds) }, + withTotalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeMilliseconds) }, + 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)) } + withIncrementalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeSeconds) }, + withTotalTime = { isEqualTo((DEFAULT_INTERVAL * 2).inWholeMilliseconds) }, + 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)) } + withIncrementalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeSeconds) }, + withTotalTime = { isEqualTo((DEFAULT_INTERVAL * 3).inWholeMilliseconds) }, + withTimestamp = { isEqualTo(thirdTimestamp) } ) } - private fun sleep(millis: Long) = Thread.sleep(millis + THREAD_SLEEPING_THRESHOLD) + @Test + fun `given started manager, when stopping manager before interval ticks, then schedule an event`() = runTest { + // given + sut = EngagementManager( + tracker, + DEFAULT_INTERVAL.inWholeMilliseconds, + baseEvent, + FakeIntervalCalculator(), + this, + FakeClock(testScheduler) + ) + sut.start() + + // when + advanceTimeBy(70.seconds.inWholeMilliseconds) + sut.stop() + + // then + // 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", 30L) + assertThat(it[1]).containsEntry("inc", 30L) + assertThat(it[2]).containsEntry("inc", 10L) + }) + } + + @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<*>, 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() @@ -115,9 +191,6 @@ internal class EngagementManagerTest { } } - private val now: Long - get() = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis - class FakeTracker : ParselyTracker( "", 0, @@ -131,15 +204,18 @@ internal class EngagementManagerTest { } class FakeIntervalCalculator : HeartbeatIntervalCalculator(Clock()) { - override fun calculate(startTime: Calendar): Long { - return DEFAULT_INTERVAL_MILLIS + override fun calculate(startTime: Duration): Long { + return DEFAULT_INTERVAL.inWholeMilliseconds } } + class FakeClock(private val scheduler: TestCoroutineScheduler) : Clock() { + override val now: Duration + get() = scheduler.currentTime.milliseconds + } + 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 DEFAULT_INTERVAL = 30.seconds val testData = mutableMapOf( "os" to "android", "parsely_site_uuid" to "e8857cbe-5ace-44f4-a85e-7e7475f675c5", diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index 5630a8d5..e76210c2 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -1,8 +1,5 @@ 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 import org.junit.Before @@ -10,15 +7,13 @@ import org.junit.Test 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( - applicationContext, + FakeDeviceInfoRepository(), TEST_SITE_ID, ) } @@ -116,7 +111,7 @@ class EventsBuilderTest { // then @Suppress("UNCHECKED_CAST") - assertThat(event["data"] as Map).hasSize(5) + assertThat(event["data"] as Map).hasSize(2) } @Test @@ -139,7 +134,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 +187,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 } } 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..02842a2c --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushManagerTest.kt @@ -0,0 +1,99 @@ +package com.parsely.parselyandroid + +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 + +@OptIn(ExperimentalCoroutinesApi::class) +class FlushManagerTest { + + @Test + fun `when timer starts and interval time passes, then flush queue`() = runTest { + var flushEventsCounter = 0 + val sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) + + sut.start() + advanceTimeBy(DEFAULT_INTERVAL_MILLIS) + runCurrent() + + assertThat(flushEventsCounter).isEqualTo(1) + } + + @Test + fun `when timer starts and three interval time passes, then flush queue 3 times`() = runTest { + var flushEventsCounter = 0 + val sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) + + sut.start() + advanceTimeBy(3 * DEFAULT_INTERVAL_MILLIS) + runCurrent() + + assertThat(flushEventsCounter).isEqualTo(3) + } + + @Test + fun `when timer starts and is stopped after 2 intervals passes, then flush queue 2 times`() = + runTest { + var flushEventsCounter = 0 + val sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) + + sut.start() + advanceTimeBy(2 * DEFAULT_INTERVAL_MILLIS) + runCurrent() + sut.stop() + advanceTimeBy(DEFAULT_INTERVAL_MILLIS) + runCurrent() + + 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 { + var flushEventsCounter = 0 + val sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) + + sut.start() + advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) + runCurrent() + sut.stop() + advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) + runCurrent() + + 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 { + var flushEventsCounter = 0 + val sut = + ParselyFlushManager({ flushEventsCounter++ }, DEFAULT_INTERVAL_MILLIS, backgroundScope) + + sut.start() + advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) + runCurrent() + sut.start() + advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) + runCurrent() + + assertThat(flushEventsCounter).isEqualTo(1) + + advanceTimeBy(DEFAULT_INTERVAL_MILLIS / 2) + runCurrent() + + assertThat(flushEventsCounter).isEqualTo(1) + } + + private companion object { + val DEFAULT_INTERVAL_MILLIS: Long = 30.seconds.inWholeMilliseconds + } +} diff --git a/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt new file mode 100644 index 00000000..e49605b6 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt @@ -0,0 +1,219 @@ +package com.parsely.parselyandroid + +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 FlushQueueTest { + + @Test + fun `given empty local storage, when sending events, then do nothing`() = + runTest { + // given + val sut = FlushQueue( + FakeFlushManager(), + FakeRepository(), + FakeRestClient(), + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(FakeRepository().getStoredQueue()).isEmpty() + } + + @Test + 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 { + insertEvents(listOf(mapOf("test" to 123))) + } + val parselyAPIConnection = FakeRestClient().apply { + nextResult = Result.success(Unit) + } + val sut = FlushQueue( + FakeFlushManager(), + repository, + parselyAPIConnection, + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(repository.getStoredQueue()).isEmpty() + } + + @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 + val repository = FakeRepository().apply { + insertEvents(listOf(mapOf("test" to 123))) + } + val sut = FlushQueue( + FakeFlushManager(), + repository, + FakeRestClient(), + this + ) + + // when + sut.invoke(true) + runCurrent() + + // then + assertThat(repository.getStoredQueue()).isEmpty() + } + + @Test + 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 { + insertEvents(listOf(mapOf("test" to 123))) + } + val parselyAPIConnection = FakeRestClient().apply { + nextResult = Result.failure(Exception()) + } + val sut = FlushQueue( + FakeFlushManager(), + repository, + parselyAPIConnection, + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(repository.getStoredQueue()).isNotEmpty + } + + @Test + 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() + val repository = FakeRepository().apply { + insertEvents(listOf(mapOf("test" to 123))) + } + val parselyAPIConnection = FakeRestClient().apply { + nextResult = Result.failure(Exception()) + } + val sut = FlushQueue( + flushManager, + repository, + parselyAPIConnection, + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(flushManager.stopped).isFalse + } + + @Test + 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() + val repository = object : FakeRepository() { + override suspend fun getStoredQueue(): ArrayList?> { + return ArrayList(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).isFalse + } + + @Test + fun `given empty local storage, when invoked, then flush manager is stopped`() = runTest { + // given + val flushManager = FakeFlushManager() + val sut = FlushQueue( + flushManager, + FakeRepository(), + FakeRestClient(), + this + ) + + // when + sut.invoke(false) + runCurrent() + + // then + assertThat(flushManager.stopped).isTrue() + } + + private class FakeFlushManager : FlushManager { + var stopped = false + override fun start() { + TODO("Not implemented") + } + + override fun stop() { + stopped = true + } + + override val isRunning + get() = TODO("Not implemented") + override val intervalMillis + get() = TODO("Not implemented") + } + + private open class FakeRepository : QueueRepository { + private var storage = emptyList?>() + + override suspend fun insertEvents(toInsert: List?>) { + storage = storage + toInsert + } + + override suspend fun remove(toRemove: List?>) { + storage = storage - toRemove.toSet() + } + + override suspend fun getStoredQueue(): ArrayList?> { + return ArrayList(storage) + } + } + + private class FakeRestClient : RestClient { + + var nextResult: Result? = null + + override suspend fun send(payload: String): Result { + return nextResult!! + } + } +} 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 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..e4f354ff --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/InMemoryBufferTest.kt @@ -0,0 +1,96 @@ +package com.parsely.parselyandroid + +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 `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 + sut = InMemoryBuffer(backgroundScope, repository) { onEventAddedExecuted = true } + + // when + sut.add(event) + advanceTimeBy(1.seconds) + runCurrent() + backgroundScope.cancel() + + // then + assertThat(onEventAddedExecuted).isTrue + } + + @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 : QueueRepository { + + private val events = mutableListOf?>() + + override suspend fun insertEvents(toInsert: List?>) { + 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/LocalStorageRepositoryTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt index 2879fdab..47a60057 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,43 +25,34 @@ 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) }) - - // 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`() { + 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 - sut.persistQueue(eventsList) + sut.insertEvents(eventsList) + runCurrent() // then assertThat(sut.getStoredQueue()).hasSize(10).containsExactlyInAnyOrderElementsOf(eventsList) } @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() } @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`() = runTest { // 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) } + sut.insertEvents(storedQueue) + runCurrent() // when - sut.persistQueue(inMemoryQueue) + sut.insertEvents(newEvents) + runCurrent() // then val expectedQueue = (1..10).map { mapOf("index" to it) } @@ -65,20 +60,26 @@ 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`() = runTest { // given - val eventsList = (1..10).map { mapOf("index" to it) } - sut.persistQueue(eventsList) + val initialList = (1..10).map { mapOf("index" to it) } + sut.insertEvents(initialList) + runCurrent() + 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 - 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/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt index 815abc93..497e5d5c 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt @@ -1,29 +1,29 @@ 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 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 +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) -@LooperMode(LooperMode.Mode.PAUSED) 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(tracker) + sut = ParselyAPIConnection(url) } @After @@ -32,88 +32,43 @@ class ParselyAPIConnectionTest { } @Test - fun `given successful response, when making connection without any events, then make GET request`() { - // given - mockServer.enqueue(MockResponse().setResponseCode(200)) - - // when - sut.execute(url).get() - 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`() { - // given - mockServer.enqueue(MockResponse().setResponseCode(200)) - - // when - sut.execute(url, pixelPayload).get() - shadowMainLooper().idle(); - - // then - assertThat(mockServer.takeRequest()).satisfies({ - assertThat(it.method).isEqualTo("POST") - assertThat(it.headers["Content-Type"]).isEqualTo("application/json") - assertThat(it.body.readUtf8()).isEqualTo(pixelPayload) - }) - } - - @Test - fun `given successful response, when request is made, then purge events queue and stop flush timer`() { - // given - mockServer.enqueue(MockResponse().setResponseCode(200)) - tracker.events.add(mapOf("idsite" to "example.com")) - - // when - sut.execute(url).get() - shadowMainLooper().idle(); - - // then - assertThat(tracker.events).isEmpty() - assertThat(tracker.flushTimerStopped).isTrue - } + 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 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") - tracker.events.add(sampleEvents) - - // when - sut.execute(url).get() - shadowMainLooper().idle(); - - // then - assertThat(tracker.events).containsExactly(sampleEvents) - assertThat(tracker.flushTimerStopped).isFalse - } + fun `given unsuccessful response, when request is made, then return failure with exception`() = + runTest { + // given + mockServer.enqueue(MockResponse().setResponseCode(400)) + + // when + val result = sut.send(pixelPayload) + runCurrent() + + // then + assertThat(result.isFailure).isTrue + assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java) + } companion object { 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 - } + ClassLoader.getSystemResource("pixel_payload.json").readText().apply { + assert(isNotBlank()) + } } } 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 86613295..00000000 --- a/parsely/src/test/java/com/parsely/parselyandroid/QueueManagerTest.kt +++ /dev/null @@ -1,108 +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 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") -@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.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`() { - // 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.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`() { - // 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.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 - } - } -}