= 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