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..fc470bd5
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.java
@@ -0,0 +1,120 @@
+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 UpdateEngagementIntervalCalculator intervalCalculator;
+
+ 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"));
+ }
+
+ 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.updateLatestInterval(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/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java
index 47e848c2..49feabaa 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;
@@ -74,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.
@@ -176,7 +175,7 @@ public double getVideoEngagementInterval() {
* @return Whether the engagement tracker is running.
*/
public boolean engagementIsActive() {
- return engagementManager != null && engagementManager.started;
+ return engagementManager != null && engagementManager.isRunning();
}
/**
@@ -185,7 +184,7 @@ public boolean engagementIsActive() {
* @return Whether video tracking is active.
*/
public boolean videoIsActive() {
- return videoEngagementManager != null && videoEngagementManager.started;
+ return videoEngagementManager != null && videoEngagementManager.isRunning();
}
/**
@@ -289,7 +288,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, intervalCalculator);
engagementManager.start();
}
@@ -365,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(timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent);
+ videoEngagementManager = new EngagementManager(this, timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent, intervalCalculator);
videoEngagementManager.start();
}
@@ -416,7 +415,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();
@@ -722,118 +721,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;
- }
- }
}
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..d85afb1a
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/UpdateEngagementIntervalCalculator.java
@@ -0,0 +1,22 @@
+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);
+ return clampedNewInterval * 1000;
+ }
+}
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..19732f3b
--- /dev/null
+++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt
@@ -0,0 +1,151 @@
+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
+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
+
+@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() {
+ sut = EngagementManager(
+ tracker,
+ parentTimer,
+ DEFAULT_INTERVAL_MILLIS,
+ baseEvent,
+ FakeIntervalCalculator()
+ )
+ }
+
+ @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
+
+ // 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)) }
+ )
+ }
+
+ @Test
+ fun `when starting manager, then schedule task each interval period`() {
+ sut.start()
+
+ 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
+
+ sleep(THREAD_SLEEPING_THRESHOLD)
+
+ 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)) }
+ )
+ }
+
+ private fun sleep(millis: Long) = Thread.sleep(millis + THREAD_SLEEPING_THRESHOLD)
+
+ 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 ->
+ @Suppress("UNCHECKED_CAST")
+ data as Map
+ assertThat(data).hasEntrySatisfying("ts") { timestamp ->
+ timestamp as Long
+ assertThat(timestamp).withTimestamp()
+ }.containsAllEntriesOf(testData.minus("ts"))
+ }
+ }
+
+ private val now: Long
+ get() = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis
+
+ class FakeTracker : ParselyTracker(
+ "",
+ 0,
+ ApplicationProvider.getApplicationContext()
+ ) {
+ val events = mutableListOf()
+
+ override fun enqueueEvent(event: Event) {
+ events += event
+ }
+ }
+
+ class FakeIntervalCalculator : UpdateEngagementIntervalCalculator() {
+ override fun updateLatestInterval(startTime: Calendar): Long {
+ return DEFAULT_INTERVAL_MILLIS
+ }
+ }
+
+ 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",
+ "os_version" to "34",
+ "manufacturer" to "Google",
+ "ts" to 123L
+ )
+ }
+}