diff --git a/.github/workflows/readme.yml b/.github/workflows/readme.yml
index e369c1a6..01172d57 100644
--- a/.github/workflows/readme.yml
+++ b/.github/workflows/readme.yml
@@ -22,6 +22,14 @@ jobs:
         run: ./gradlew :example:assembleDebug
       - name: Android Lint
         run: ./gradlew lintDebug
+      - name: Android Unit Tests
+        run: ./gradlew :parsely:testDebugUnitTest
+      - name: Generate XML coverage report
+        run: ./gradlew :parsely:koverXmlReportDebug
+      - uses: codecov/codecov-action@v3
+        with:
+          files: parsely/build/reports/kover/reportDebug.xml
+          token: ${{ secrets.CODECOV_TOKEN }}
       - name: Validate Maven publication
         run: ./gradlew :parsely:publishReleasePublicationToMavenLocal
         env:
@@ -35,3 +43,35 @@ jobs:
         with:
           name: artifact
           path: ~/.m2/repository/com/parsely/parsely/*
+  functional-tests:
+    runs-on: macos-latest
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+      - name: set up JDK
+        uses: actions/setup-java@v3
+        with:
+          java-version: '17'
+          distribution: 'temurin'
+          cache: gradle
+      - name: Functional Tests
+        uses: reactivecircus/android-emulator-runner@v2.28.0
+        with:
+          working-directory: .
+          api-level: 31
+          profile: Nexus 6
+          arch: x86_64
+          force-avd-creation: false
+          avd-name: macOS-avd-x86_64-31
+          emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+          disable-animations: true
+          script: ./gradlew :parsely:connectedDebugAndroidTest
+      - name: Publish build artifacts
+        uses: actions/upload-artifact@v3
+        if: always()
+        with:
+          name: artifact
+          path: |
+            ./parsely/build/reports/*
+            ./parsely/build/outputs/androidTest-results
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 00000000..7e29772e
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,7 @@
+github_checks:
+    annotations: false
+
+coverage:
+  status:
+    project: off
+    patch: off
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" />
 
-    <TextView
-        android:id="@+id/queue_size"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_below="@+id/reset_video_button"
-        android:layout_centerHorizontal="true"
-        android:text="Queued events: 0" />
-
-    <TextView android:id="@+id/stored_size"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_centerHorizontal="true"
-        android:layout_below="@id/queue_size" 
-        android:text="Stored events: 0"/>
-        
     <TextView android:id="@+id/interval"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_centerHorizontal="true"
-        android:layout_below="@id/stored_size" 
+        android:layout_below="@id/reset_video_button"
         android:text="Flush timer inactive"/>
 
     <TextView
@@ -107,4 +92,4 @@
         android:text="Video is inactive." />
 
 
-</RelativeLayout>
\ No newline at end of file
+</RelativeLayout>
diff --git a/parsely/build.gradle b/parsely/build.gradle
index 764898d1..811a02af 100644
--- a/parsely/build.gradle
+++ b/parsely/build.gradle
@@ -1,5 +1,13 @@
 plugins {
     id 'com.android.library'
+    id 'org.jetbrains.kotlin.android'
+    id 'org.jetbrains.kotlinx.kover'
+}
+
+ext {
+    assertJVersion = '3.24.2'
+    coroutinesVersion = '1.7.3'
+    mockWebServerVersion = '4.12.0'
 }
 
 android {
@@ -8,6 +16,12 @@ android {
     defaultConfig {
         minSdkVersion 21
         targetSdkVersion 33
+
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+        testInstrumentationRunnerArguments clearPackageData: 'true'
+    }
+    testOptions {
+        execution 'ANDROIDX_TEST_ORCHESTRATOR'
     }
     buildTypes {
         release {
@@ -27,27 +41,45 @@ android {
             withJavadocJar()
         }
     }
+
+    testOptions {
+        unitTests {
+            includeAndroidResources = true
+        }
+    }
+
+    koverReport {
+        filters {
+            excludes {
+                classes(
+                        "*.BuildConfig"
+                )
+            }
+        }
+    }
 }
 
 dependencies {
-    def kotlinVersion = "1.8.10"
-
     implementation 'androidx.appcompat:appcompat:1.4.2'
     implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.3'
     implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1'
     implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
-    constraints {
-        add("implementation", "org.jetbrains.kotlin:kotlin-stdlib-jdk7") {
-            version {
-                require(kotlinVersion)
-            }
-        }
-        add("implementation", "org.jetbrains.kotlin:kotlin-stdlib-jdk8") {
-            version {
-                require(kotlinVersion)
-            }
-        }
-    }
+    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'
+    androidTestImplementation "org.assertj:assertj-core:$assertJVersion"
+    androidTestImplementation "com.squareup.okhttp3:mockwebserver:$mockWebServerVersion"
+    androidTestImplementation 'androidx.test:runner:1.5.2'
+    androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
+    androidTestUtil 'androidx.test:orchestrator:1.4.2'
 }
 
 apply from: "${rootProject.projectDir}/publication.gradle"
diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt
new file mode 100644
index 00000000..eaeed64e
--- /dev/null
+++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt
@@ -0,0 +1,306 @@
+package com.parsely.parselyandroid
+
+import android.app.Activity
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.UiSelector
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.fasterxml.jackson.core.type.TypeReference
+import com.fasterxml.jackson.databind.ObjectMapper
+import java.io.File
+import java.io.FileInputStream
+import java.io.ObjectInputStream
+import java.lang.reflect.Field
+import java.nio.file.Path
+import java.util.concurrent.TimeUnit
+import kotlin.io.path.Path
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.coroutines.yield
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import 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
+
+
+@RunWith(AndroidJUnit4::class)
+class FunctionalTests {
+
+    private lateinit var parselyTracker: ParselyTracker
+    private val server = MockWebServer()
+    private val url = server.url("/").toString()
+    private lateinit var appsFiles: Path
+
+    private fun beforeEach(activity: Activity) {
+        appsFiles = Path(activity.filesDir.path)
+
+        if (File("$appsFiles/$localStorageFileName").exists()) {
+            throw RuntimeException("Local storage file exists. Something went wrong with orchestrating the tests.")
+        }
+    }
+
+    /**
+     * 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 appTracksEventsDuringTheFlushInterval() {
+        ActivityScenario.launch(SampleActivity::class.java).use { scenario ->
+            scenario.onActivity { activity: Activity ->
+                beforeEach(activity)
+                server.enqueue(MockResponse().setResponseCode(200))
+                parselyTracker = initializeTracker(activity)
+
+                repeat(51) {
+                    parselyTracker.trackPageview("url", null, null, null)
+                }
+            }
+
+            // Waits for the SDK to send events (flush interval passes)
+            val requestPayload = server.takeRequest().toMap()
+            assertThat(requestPayload["events"]).hasSize(51)
+
+            // Wait a moment to give SDK time to delete the content of local storage file
+            waitFor { locallyStoredEvents.isEmpty() }
+            assertThat(locallyStoredEvents).isEmpty()
+        }
+    }
+
+    /**
+     * 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 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.trackPageview("url", null, null, null)
+                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<String, List<Event>> {
+        val listType: TypeReference<Map<String, List<Event>>> =
+            object : TypeReference<Map<String, List<Event>>>() {}
+
+        return ObjectMapper().readValue(body.readUtf8(), listType)
+    }
+
+    @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
+        get() = FileInputStream(File("$appsFiles/$localStorageFileName")).use {
+            ObjectInputStream(it).use { objectInputStream ->
+                @Suppress("UNCHECKED_CAST")
+                objectInputStream.readObject() as ArrayList<Map<String, Any>>
+            }
+        }
+
+    private fun initializeTracker(
+        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
+        )
+    }
+
+    private companion object {
+        const val siteId = "123"
+        const val localStorageFileName = "parsely-events.ser"
+        val defaultFlushInterval = 5.seconds
+    }
+
+    class SampleActivity : Activity()
+
+    private fun waitFor(condition: () -> Boolean) = runBlocking {
+        withTimeoutOrNull(500.milliseconds) {
+            while (true) {
+                yield()
+                if (condition()) {
+                    break
+                }
+            }
+        }
+    }
+}
diff --git a/parsely/src/debug/AndroidManifest.xml b/parsely/src/debug/AndroidManifest.xml
new file mode 100644
index 00000000..e1811077
--- /dev/null
+++ b/parsely/src/debug/AndroidManifest.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.parsely.parselyandroid">
+
+    <uses-permission android:name="android.permission.INTERNET"/>
+    <application
+        android:usesCleartextTraffic="true">
+        <activity android:name=".FunctionalTests$SampleActivity"/>
+    </application>
+
+</manifest>
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..87e93a84
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/AdvertisementIdProvider.kt
@@ -0,0 +1,51 @@
+package com.parsely.parselyandroid
+
+import android.content.Context
+import android.provider.Settings
+import com.google.android.gms.ads.identifier.AdvertisingIdClient
+import com.parsely.parselyandroid.Logging.log
+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) {
+                log("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
+        }
+        log(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
new file mode 100644
index 00000000..42d937b3
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/Clock.kt
@@ -0,0 +1,11 @@
+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: Duration
+        get() = Calendar.getInstance(TimeZone.getTimeZone("UTC")).timeInMillis.milliseconds
+}
diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ConnectivityStatusProvider.kt b/parsely/src/main/java/com/parsely/parselyandroid/ConnectivityStatusProvider.kt
new file mode 100644
index 00000000..f7d90842
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/ConnectivityStatusProvider.kt
@@ -0,0 +1,22 @@
+package com.parsely.parselyandroid
+
+import android.content.Context
+import android.net.ConnectivityManager
+
+internal interface ConnectivityStatusProvider {
+    /**
+     * @return Whether the network is accessible.
+     */
+    fun isReachable(): Boolean
+}
+
+internal class AndroidConnectivityStatusProvider(private val context: Context): ConnectivityStatusProvider {
+
+    override fun isReachable(): Boolean {
+        val cm = context.getSystemService(
+            Context.CONNECTIVITY_SERVICE
+        ) as ConnectivityManager
+        val netInfo = cm.activeNetworkInfo
+        return netInfo != null && netInfo.isConnectedOrConnecting
+    }
+}
diff --git a/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt
new file mode 100644
index 00000000..61abdc4f
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/DeviceInfoRepository.kt
@@ -0,0 +1,47 @@
+package com.parsely.parselyandroid
+
+import android.os.Build
+import com.parsely.parselyandroid.Logging.log
+
+internal interface DeviceInfoRepository{
+    fun collectDeviceInfo(): Map<String, String>
+}
+
+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<String, String> {
+        val dInfo: MutableMap<String, String> = 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()
+
+            log("adkey is: %s, uuid is %s", adKey, androidId)
+
+            return if (adKey != null) {
+                adKey
+            } else {
+                log("falling back to device uuid")
+                androidId .orEmpty()
+            }
+        }
+}
diff --git a/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt
new file mode 100644
index 00000000..750b2a65
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/EngagementManager.kt
@@ -0,0 +1,84 @@
+package com.parsely.parselyandroid
+
+import com.parsely.parselyandroid.Logging.log
+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<String, Any>,
+    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<String, Any>?
+        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<String, Any> = HashMap(
+            baseEvent
+        )
+        log(String.format("Enqueuing %s event.", event["action"]))
+
+        // Update `ts` for the event since it's happening right now.
+        val baseEventData = (event["data"] as Map<String, Any>?)!!
+        val data: MutableMap<String, Any> = 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.kt b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt
new file mode 100644
index 00000000..945c8b33
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/EventsBuilder.kt
@@ -0,0 +1,63 @@
+package com.parsely.parselyandroid
+
+import com.parsely.parselyandroid.Logging.log
+import java.util.Calendar
+import java.util.TimeZone
+
+internal class EventsBuilder(
+    private val deviceInfoRepository: DeviceInfoRepository,
+    private val siteId: String,
+    private val clock: Clock,
+) {
+    /**
+     * Create an event Map
+     *
+     * @param url       The URL identifying the pageview/heartbeat
+     * @param action    Action to use (e.g. pageview, heartbeat, videostart, vheartbeat)
+     * @param metadata  Metadata to attach to the event.
+     * @param extraData A Map of additional information to send with the event.
+     * @return A Map object representing the event to be sent to Parse.ly.
+     */
+    fun buildEvent(
+        url: String,
+        urlRef: String,
+        action: String,
+        metadata: ParselyMetadata?,
+        extraData: Map<String, Any>?,
+        uuid: String
+    ): Map<String, Any> {
+        log("buildEvent called for %s/%s", action, url)
+
+        // Main event info
+        val event: MutableMap<String, Any> = HashMap()
+        event["url"] = url
+        event["urlref"] = urlRef
+        event["idsite"] = siteId
+        event["action"] = action
+
+        // Make a copy of extraData and add some things.
+        val data: MutableMap<String, Any> = HashMap()
+        if (extraData != null) {
+            data.putAll(extraData)
+        }
+        val deviceInfo = deviceInfoRepository.collectDeviceInfo()
+        data["ts"] = clock.now.inWholeMilliseconds
+        data.putAll(deviceInfo)
+        event["data"] = data
+        metadata?.let {
+            event["metadata"] = it.toMap()
+        }
+        if (action == "videostart" || action == "vheartbeat") {
+            event[VIDEO_START_ID_KEY] = uuid
+        }
+        if (action == "pageview" || action == "heartbeat") {
+            event[PAGE_VIEW_ID_KEY] = uuid
+        }
+        return event
+    }
+
+    companion object {
+        private const val VIDEO_START_ID_KEY = "vsid"
+        private const val PAGE_VIEW_ID_KEY = "pvid"
+    }
+}
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..01de870c
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/FlushQueue.kt
@@ -0,0 +1,57 @@
+package com.parsely.parselyandroid
+
+import com.parsely.parselyandroid.JsonSerializer.toParselyEventsPayload
+import com.parsely.parselyandroid.Logging.log
+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 connectivityStatusProvider: ConnectivityStatusProvider
+) {
+
+    private val mutex = Mutex()
+
+    operator fun invoke(skipSendingEvents: Boolean) {
+        if (!connectivityStatusProvider.isReachable()) {
+            log("Network unreachable. Not flushing.")
+            return
+        }
+        scope.launch {
+            mutex.withLock {
+                val eventsToSend = repository.getStoredQueue()
+
+                if (eventsToSend.isEmpty()) {
+                    flushManager.stop()
+                    return@launch
+                }
+
+                if (skipSendingEvents) {
+                    log("Debug mode on. Not sending to Parse.ly. Otherwise, would sent ${eventsToSend.size} events")
+                    repository.remove(eventsToSend)
+                    return@launch
+                }
+                log("Sending request with %d events", eventsToSend.size)
+                val jsonPayload = toParselyEventsPayload(eventsToSend)
+                log("POST Data %s", jsonPayload)
+                log("Requested %s", ParselyTracker.ROOT_URL)
+                restClient.send(jsonPayload)
+                    .fold(
+                        onSuccess = {
+                            log("Pixel request success")
+                            repository.remove(eventsToSend)
+                        },
+                        onFailure = {
+                            log("Pixel request exception")
+                            log(it.toString())
+                        }
+                    )
+            }
+        }
+    }
+}
diff --git a/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt b/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt
new file mode 100644
index 00000000..d50223ff
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/HeartbeatIntervalCalculator.kt
@@ -0,0 +1,24 @@
+package com.parsely.parselyandroid
+
+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: Duration): Long {
+        val nowDuration = clock.now
+
+        val totalTrackedTime = nowDuration - startTime
+        val totalWithOffset = totalTrackedTime + OFFSET_MATCHING_BASE_INTERVAL
+        val newInterval = totalWithOffset * BACKOFF_PROPORTION
+        val clampedNewInterval = minOf(MAX_TIME_BETWEEN_HEARTBEATS, newInterval)
+        return clampedNewInterval.inWholeMilliseconds
+    }
+
+    companion object {
+        const val BACKOFF_PROPORTION = 0.3
+        val OFFSET_MATCHING_BASE_INTERVAL = 35.seconds
+        val MAX_TIME_BETWEEN_HEARTBEATS = 15.minutes
+    }
+}
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..63f6e70a
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/InMemoryBuffer.kt
@@ -0,0 +1,45 @@
+package com.parsely.parselyandroid
+
+import com.parsely.parselyandroid.Logging.log
+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<Map<String, Any?>>()
+
+    init {
+        coroutineScope.launch {
+            while (isActive) {
+                mutex.withLock {
+                    if (buffer.isNotEmpty()) {
+                        log("Persisting ${buffer.size} events")
+                        localStorageRepository.insertEvents(buffer)
+                        buffer.clear()
+                    }
+                }
+                delay(1.seconds)
+            }
+        }
+    }
+
+    fun add(event: Map<String, Any>) {
+        coroutineScope.launch {
+            mutex.withLock {
+                log("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<Map<String, Any?>?>): String {
+        val batchMap: MutableMap<String, Any> = 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, Any>): 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
new file mode 100644
index 00000000..f8dc30fe
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/LocalStorageRepository.kt
@@ -0,0 +1,89 @@
+package com.parsely.parselyandroid
+
+import android.content.Context
+import com.parsely.parselyandroid.Logging.log
+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<Map<String, Any?>?>)
+    suspend fun getStoredQueue(): ArrayList<Map<String, Any?>?>
+    suspend fun insertEvents(toInsert: List<Map<String, Any?>?>)
+}
+
+internal class LocalStorageRepository(private val context: Context) : QueueRepository {
+
+    private val mutex = Mutex()
+
+    /**
+     * Persist an object to storage.
+     *
+     * @param o Object to store.
+     */
+    private fun persistObject(o: Any) {
+        try {
+            val fos = context.applicationContext.openFileOutput(
+                STORAGE_KEY,
+                Context.MODE_PRIVATE
+            )
+            val oos = ObjectOutputStream(fos)
+            oos.writeObject(o)
+            oos.close()
+            fos.close()
+        } catch (ex: Exception) {
+            log("Exception thrown during queue serialization: %s", ex.toString())
+        }
+    }
+
+    private fun getInternalStoredQueue(): ArrayList<Map<String, Any?>?> {
+        var storedQueue: ArrayList<Map<String, Any?>?> = ArrayList()
+        try {
+            val fis = context.applicationContext.openFileInput(STORAGE_KEY)
+            val ois = ObjectInputStream(fis)
+            @Suppress("UNCHECKED_CAST")
+            storedQueue = ois.readObject() as ArrayList<Map<String, Any?>?>
+            ois.close()
+            fis.close()
+        } catch (ex: EOFException) {
+            // Nothing to do here.
+        } catch (ex: FileNotFoundException) {
+            // Nothing to do here. Means there was no saved queue.
+        } catch (ex: Exception) {
+            log(
+                "Exception thrown during queue deserialization: %s",
+                ex.toString()
+            )
+        }
+        return storedQueue
+    }
+
+    override suspend fun remove(toRemove: List<Map<String, Any?>?>) = mutex.withLock {
+        val storedEvents = getInternalStoredQueue()
+        persistObject(storedEvents - toRemove.toSet())
+    }
+
+    /**
+     * Get the stored event queue from persistent storage.
+     *
+     * @return The stored queue of events.
+     */
+    override suspend fun getStoredQueue(): ArrayList<Map<String, Any?>?> = mutex.withLock {
+        getInternalStoredQueue()
+    }
+
+    /**
+     * Save the event queue to persistent storage.
+     */
+    override suspend fun insertEvents(toInsert: List<Map<String, Any?>?>) = mutex.withLock {
+        val storedEvents = getInternalStoredQueue()
+        persistObject(ArrayList((toInsert + storedEvents).distinct()))
+    }
+
+    companion object {
+        private const val STORAGE_KEY = "parsely-events.ser"
+    }
+}
diff --git a/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt b/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt
new file mode 100644
index 00000000..cdee406a
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/Logging.kt
@@ -0,0 +1,17 @@
+package com.parsely.parselyandroid
+
+import java.util.Formatter
+
+internal object Logging {
+
+    /**
+     * Log a message to the console.
+     */
+    @JvmStatic
+    fun log(logString: String, vararg objects: Any?) {
+        if (logString == "") {
+            return
+        }
+        println(Formatter().format("[Parsely] $logString", *objects).toString())
+    }
+}
diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java
deleted file mode 100644
index beb225f3..00000000
--- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.java
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
-    Copyright 2016 Parse.ly, Inc.
-
-    Licensed under the Apache License, Version 2.0 (the "License");
-    you may not use this file except in compliance with the License.
-    You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-    Unless required by applicable law or agreed to in writing, software
-    distributed under the License is distributed on an "AS IS" BASIS,
-    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-    See the License for the specific language governing permissions and
-    limitations under the License.
-*/
-
-package com.parsely.parselyandroid;
-
-import android.os.AsyncTask;
-
-import java.io.OutputStream;
-import java.net.URL;
-import java.net.HttpURLConnection;
-
-public class ParselyAPIConnection extends AsyncTask<String, Exception, HttpURLConnection> {
-
-    public Exception exception;
-
-    @Override
-    protected HttpURLConnection doInBackground(String... data) {
-        HttpURLConnection connection = null;
-        try {
-            if (data.length == 1) {  // non-batched (since no post data is included)
-                connection = (HttpURLConnection) new URL(data[0]).openConnection();
-                connection.getInputStream();
-            } else if (data.length == 2) {  // batched (post data included)
-                connection = (HttpURLConnection) new URL(data[0]).openConnection();
-                connection.setDoOutput(true);  // Triggers POST (aka silliest interface ever)
-                connection.setRequestProperty("Content-Type", "application/json");
-
-                OutputStream output = connection.getOutputStream();
-                output.write(data[1].getBytes());
-                output.close();
-                connection.getInputStream();
-            }
-
-        } catch (Exception ex) {
-            this.exception = ex;
-            return null;
-        }
-        return connection;
-    }
-
-    protected void onPostExecute(HttpURLConnection conn) {
-        if (this.exception != null) {
-            ParselyTracker.PLog("Pixel request exception");
-            ParselyTracker.PLog(this.exception.toString());
-        } else {
-            ParselyTracker.PLog("Pixel request success");
-
-            ParselyTracker instance = null;
-            try {
-                instance = ParselyTracker.sharedInstance();
-            } catch (NullPointerException ex) {
-                ParselyTracker.PLog("ParselyTracker is null");
-            }
-
-            if (instance != null) {
-                // only purge the queue if the request was successful
-                instance.eventQueue.clear();
-                instance.purgeStoredQueue();
-
-                if (instance.queueSize() == 0 && instance.storedEventsCount() == 0) {
-                    ParselyTracker.PLog("Event queue empty, flush timer cleared.");
-                    instance.stopFlushTimer();
-                }
-            }
-        }
-    }
-}
diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt
new file mode 100644
index 00000000..c1c1e422
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAPIConnection.kt
@@ -0,0 +1,44 @@
+/*
+    Copyright 2016 Parse.ly, Inc.
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+*/
+package com.parsely.parselyandroid
+
+import java.net.HttpURLConnection
+import java.net.URL
+
+internal interface RestClient {
+    suspend fun send(payload: String): Result<Unit>
+}
+
+internal class ParselyAPIConnection(private val url: String) : RestClient {
+    override suspend fun send(payload: String): Result<Unit> {
+        var connection: HttpURLConnection? = null
+        try {
+            connection = URL(url).openConnection() as HttpURLConnection
+            connection.doOutput = true
+            connection.setRequestProperty("Content-Type", "application/json")
+            val output = connection.outputStream
+            output.write(payload.toByteArray())
+            output.close()
+            connection.inputStream
+        } catch (ex: Exception) {
+            return Result.failure(ex)
+        } finally {
+            connection?.disconnect()
+        }
+
+        return Result.success(Unit)
+    }
+}
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/ParselyMetadata.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.java
deleted file mode 100644
index 805902ab..00000000
--- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.java
+++ /dev/null
@@ -1,83 +0,0 @@
-package com.parsely.parselyandroid;
-
-import androidx.annotation.Nullable;
-
-import java.util.ArrayList;
-import java.util.Calendar;
-import java.util.HashMap;
-import java.util.Map;
-
-/**
- * Represents post metadata to be passed to Parsely tracking.
- * <p>
- * This class is used to attach a metadata block to a Parse.ly pageview
- * request. Pageview metadata is only required for URLs not accessible over the
- * internet (i.e. app-only content) or if the customer is using an "in-pixel" integration.
- * Otherwise, metadata will be gathered by Parse.ly's crawling infrastructure.
- */
-public class ParselyMetadata {
-    public ArrayList<String> authors, tags;
-    public String link, section, thumbUrl, title;
-    public Calendar pubDate;
-
-    /**
-     * Create a new ParselyMetadata object.
-     *
-     * @param authors  The names of the authors of the content. Up to 10 authors are accepted.
-     * @param link     A post's canonical url.
-     * @param section  The category or vertical to which this content belongs.
-     * @param tags     User-defined tags for the content. Up to 20 are allowed.
-     * @param thumbUrl URL at which the main image for this content is located.
-     * @param title    The title of the content.
-     * @param pubDate  The date this piece of content was published.
-     */
-    public ParselyMetadata(
-            @Nullable ArrayList<String> authors,
-            @Nullable String link,
-            @Nullable String section,
-            @Nullable ArrayList<String> tags,
-            @Nullable String thumbUrl,
-            @Nullable String title,
-            @Nullable Calendar pubDate
-    ) {
-        this.authors = authors;
-        this.link = link;
-        this.section = section;
-        this.tags = tags;
-        this.thumbUrl = thumbUrl;
-        this.title = title;
-        this.pubDate = pubDate;
-    }
-
-    /**
-     * Turn this object into a Map
-     *
-     * @return a Map object representing the metadata.
-     */
-    public Map<String, Object> toMap() {
-        Map<String, Object> output = new HashMap<>();
-        if (this.authors != null) {
-            output.put("authors", this.authors);
-        }
-        if (this.link != null) {
-            output.put("link", this.link);
-        }
-        if (this.section != null) {
-            output.put("section", this.section);
-        }
-        if (this.tags != null) {
-            output.put("tags", this.tags);
-        }
-        if (this.thumbUrl != null) {
-            output.put("thumb_url", this.thumbUrl);
-        }
-        if (this.title != null) {
-            output.put("title", this.title);
-        }
-        if (this.pubDate != null) {
-            output.put("pub_date_tmsp", this.pubDate.getTimeInMillis() / 1000);
-        }
-        return output;
-    }
-}
-
diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt
new file mode 100644
index 00000000..f7ad0d40
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyMetadata.kt
@@ -0,0 +1,69 @@
+package com.parsely.parselyandroid
+
+import java.util.Calendar
+
+/**
+ * Represents post metadata to be passed to Parsely tracking.
+ *
+ *
+ * This class is used to attach a metadata block to a Parse.ly pageview
+ * request. Pageview metadata is only required for URLs not accessible over the
+ * internet (i.e. app-only content) or if the customer is using an "in-pixel" integration.
+ * Otherwise, metadata will be gathered by Parse.ly's crawling infrastructure.
+ */
+open class ParselyMetadata
+/**
+ * Create a new ParselyMetadata object.
+ *
+ * @param authors   The names of the authors of the content. Up to 10 authors are accepted.
+ * @param link      A post's canonical url.
+ * @param section   The category or vertical to which this content belongs.
+ * @param tags      User-defined tags for the content. Up to 20 are allowed.
+ * @param thumbUrl  URL at which the main image for this content is located.
+ * @param title     The title of the content.
+ * @param pubDate   The date this piece of content was published.
+ * @param pageType The type of page being tracked
+ */(
+    private val authors: List<String>? = null,
+    @JvmField internal val link: String? = null,
+    private val section: String? = null,
+    private val tags: List<String>? = null,
+    private val thumbUrl: String? = null,
+    private val title: String? = null,
+    private val pubDate: Calendar? = null,
+    private val pageType: String? = null
+) {
+    /**
+     * Turn this object into a Map
+     *
+     * @return a Map object representing the metadata.
+     */
+    open fun toMap(): Map<String, Any?> {
+        val output: MutableMap<String, Any?> = HashMap()
+        if (authors != null) {
+            output["authors"] = authors
+        }
+        if (link != null) {
+            output["link"] = link
+        }
+        if (section != null) {
+            output["section"] = section
+        }
+        if (tags != null) {
+            output["tags"] = tags
+        }
+        if (thumbUrl != null) {
+            output["thumb_url"] = thumbUrl
+        }
+        if (title != null) {
+            output["title"] = title
+        }
+        if (pubDate != null) {
+            output["pub_date_tmsp"] = pubDate.timeInMillis / 1000
+        }
+        if (pageType != null) {
+            output["page_type"] = pageType
+        }
+        return output
+    }
+}
diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java
index 8db1cdf0..17682e90 100644
--- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java
+++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.java
@@ -16,42 +16,22 @@
 
 package com.parsely.parselyandroid;
 
+import static com.parsely.parselyandroid.Logging.log;
+
 import android.content.Context;
-import android.content.SharedPreferences;
-import android.net.ConnectivityManager;
-import android.net.NetworkInfo;
-import android.os.AsyncTask;
-import android.provider.Settings.Secure;
+
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.lifecycle.Lifecycle;
 import androidx.lifecycle.LifecycleEventObserver;
 import androidx.lifecycle.ProcessLifecycleOwner;
 
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.google.android.gms.ads.identifier.AdvertisingIdClient;
-import com.google.android.gms.common.GooglePlayServicesNotAvailableException;
-import com.google.android.gms.common.GooglePlayServicesRepairableException;
-
-import java.io.EOFException;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
-import java.io.StringWriter;
-import java.util.ArrayList;
-import java.util.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;
 
+import kotlin.Unit;
+import kotlin.jvm.functions.Function0;
+
 /**
  * Tracks Parse.ly app views in Android apps
  * <p>
@@ -62,49 +42,59 @@ public class ParselyTracker {
     private static ParselyTracker instance = null;
     private static final int DEFAULT_FLUSH_INTERVAL_SECS = 60;
     private static final int DEFAULT_ENGAGEMENT_INTERVAL_MILLIS = 10500;
-    private static final int QUEUE_SIZE_LIMIT = 50;
-    private static final int STORAGE_SIZE_LIMIT = 100;
-    private static final String STORAGE_KEY = "parsely-events.ser";
-// emulator localhost
-//    private static final String ROOT_URL = "http://10.0.2.2:5001/";
-    private static final String ROOT_URL = "https://p1.parsely.com/";
-    private static final String UUID_KEY = "parsely-uuid";
-    private static final String VIDEO_START_ID_KEY = "vsid";
-    private static final String PAGE_VIEW_ID_KEY = "pvid";
-
-    protected ArrayList<Map<String, Object>> eventQueue;
-    private final String siteId;
+    @SuppressWarnings("StringOperationCanBeSimplified")
+//    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 SharedPreferences settings;
-    private Map<String, String> deviceInfo;
-    private final Context context;
-    private final Timer timer;
     private final FlushManager flushManager;
     private EngagementManager engagementManager, videoEngagementManager;
     @Nullable
     private String lastPageviewUuid = null;
+    @NonNull
+    private final EventsBuilder eventsBuilder;
+    @NonNull
+    private final Clock clock;
+    @NonNull
+    private final HeartbeatIntervalCalculator intervalCalculator;
+    @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();
-        settings = context.getSharedPreferences("parsely-prefs", 0);
+        Context context = c.getApplicationContext();
+        clock = new Clock();
+        eventsBuilder = new EventsBuilder(
+                new AndroidDeviceInfoRepository(
+                        new AdvertisementIdProvider(context, ParselyCoroutineScopeKt.getSdkScope()),
+                        new AndroidIdProvider(context)
+                ), siteId, clock);
+        LocalStorageRepository localStorageRepository = new LocalStorageRepository(context);
+        flushManager = new ParselyFlushManager(new Function0<Unit>() {
+            @Override
+            public Unit invoke() {
+                flushEvents();
+                return Unit.INSTANCE;
+            }
+        },  flushInterval * 1000L,
+                ParselyCoroutineScopeKt.getSdkScope());
+        inMemoryBuffer = new InMemoryBuffer(ParselyCoroutineScopeKt.getSdkScope(), localStorageRepository, () -> {
+            if (!flushTimerIsActive()) {
+                startFlushTimer();
+                log("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000));
+            }
+            return Unit.INSTANCE;
+        });
+        flushQueue = new FlushQueue(flushManager, localStorageRepository, new ParselyAPIConnection(ROOT_URL + "mobileproxy"), ParselyCoroutineScopeKt.getSdkScope(), new AndroidConnectivityStatusProvider(context));
+        intervalCalculator = new HeartbeatIntervalCalculator(clock);
 
-        this.siteId = siteId;
         // get the adkey straight away on instantiation
-        deviceInfo = collectDeviceInfo(null);
-        new GetAdKey(c).execute();
-        timer = new Timer();
         isDebug = false;
 
-        eventQueue = new ArrayList<>();
-
-        flushManager = new FlushManager(timer, flushInterval * 1000L);
-
-        if (getStoredQueue().size() > 0) {
-            startFlushTimer();
-        }
+        flushManager.start();
 
         ProcessLifecycleOwner.get().getLifecycle().addObserver(
                 (LifecycleEventObserver) (lifecycleOwner, event) -> {
@@ -153,31 +143,23 @@ public static ParselyTracker sharedInstance(String siteId, int flushInterval, Co
         return instance;
     }
 
-    /**
-     * Log a message to the console.
-     */
-    protected static void PLog(String logString, Object... objects) {
-        if (logString.equals("")) {
-            return;
-        }
-        System.out.println(new Formatter().format("[Parsely] " + logString, objects).toString());
-    }
-
     /**
      * Get the heartbeat interval
      *
      * @return The base engagement tracking interval.
      */
-    public double getEngagementInterval() {
+    @Nullable
+    public Double getEngagementInterval() {
         if (engagementManager == null) {
-            return -1;
+            return null;
         }
         return engagementManager.getIntervalMillis();
     }
 
-    public double getVideoEngagementInterval() {
+    @Nullable
+    public Double getVideoEngagementInterval() {
         if (videoEngagementManager == null) {
-            return -1;
+            return null;
         }
         return videoEngagementManager.getIntervalMillis();
     }
@@ -188,7 +170,7 @@ public double getVideoEngagementInterval() {
      * @return Whether the engagement tracker is running.
      */
     public boolean engagementIsActive() {
-        return engagementManager != null && engagementManager.started;
+        return engagementManager != null && engagementManager.isRunning();
     }
 
     /**
@@ -197,7 +179,7 @@ public boolean engagementIsActive() {
      * @return Whether video tracking is active.
      */
     public boolean videoIsActive() {
-        return videoEngagementManager != null && videoEngagementManager.started;
+        return videoEngagementManager != null && videoEngagementManager.isRunning();
     }
 
     /**
@@ -209,15 +191,6 @@ public long getFlushInterval() {
         return flushManager.getIntervalMillis() / 1000;
     }
 
-    /**
-     * Getter for isDebug
-     *
-     * @return Whether debug mode is active.
-     */
-    public boolean getDebug() {
-        return isDebug;
-    }
-
     /**
      * Set a debug flag which will prevent data from being sent to Parse.ly
      * <p>
@@ -228,7 +201,7 @@ public boolean getDebug() {
      */
     public void setDebug(boolean debug) {
         isDebug = debug;
-        PLog("Debugging is now set to " + isDebug);
+        log("Debugging is now set to " + isDebug);
     }
 
     /**
@@ -249,7 +222,8 @@ public void trackPageview(
             @Nullable ParselyMetadata urlMetadata,
             @Nullable Map<String, Object> extraData) {
         if (url.equals("")) {
-            throw new IllegalArgumentException("url cannot be null or empty.");
+            log("url cannot be empty");
+            return;
         }
 
         // Blank urlref is better than null
@@ -259,7 +233,7 @@ public void trackPageview(
 
         lastPageviewUuid = generatePixelId();
 
-        enqueueEvent(buildEvent(url, urlRef, "pageview", urlMetadata, extraData, lastPageviewUuid));
+        enqueueEvent(eventsBuilder.buildEvent(url, urlRef, "pageview", urlMetadata, extraData, lastPageviewUuid));
     }
 
     /**
@@ -289,7 +263,12 @@ public void startEngagement(
             final @Nullable Map<String, Object> extraData
     ) {
         if (url.equals("")) {
-            throw new IllegalArgumentException("url cannot be null or empty.");
+            log("url cannot be empty");
+            return;
+        }
+        if (lastPageviewUuid == null) {
+            log("engagement session cannot start without calling trackPageview first");
+            return;
         }
 
         // Blank urlref is better than null
@@ -300,8 +279,8 @@ public void startEngagement(
         stopEngagement();
 
         // Start a new EngagementTask
-        Map<String, Object> event = buildEvent(url, urlRef, "heartbeat", null, extraData, lastPageviewUuid);
-        engagementManager = new EngagementManager(timer, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event);
+        Map<String, Object> event = eventsBuilder.buildEvent(url, urlRef, "heartbeat", null, extraData, lastPageviewUuid);
+        engagementManager = new EngagementManager(this, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, event, intervalCalculator, ParselyCoroutineScopeKt.getSdkScope(), clock );
         engagementManager.start();
     }
 
@@ -347,7 +326,8 @@ public void trackPlay(
             @NonNull ParselyVideoMetadata videoMetadata,
             @Nullable Map<String, Object> extraData) {
         if (url.equals("")) {
-            throw new IllegalArgumentException("url cannot be null or empty.");
+            log("url cannot be empty");
+            return;
         }
 
         // Blank urlref is better than null
@@ -371,13 +351,13 @@ public void trackPlay(
         @NonNull final String uuid = generatePixelId();
 
         // Enqueue the videostart
-        @NonNull final Map<String, Object> videostartEvent = buildEvent(url, urlRef, "videostart", videoMetadata, extraData, uuid);
+        @NonNull final Map<String, Object> videostartEvent = eventsBuilder.buildEvent(url, urlRef, "videostart", videoMetadata, extraData, uuid);
         enqueueEvent(videostartEvent);
 
         // Start a new engagement manager for the video.
-        @NonNull final Map<String, Object> hbEvent = buildEvent(url, urlRef, "vheartbeat", videoMetadata, extraData, uuid);
+        @NonNull final Map<String, Object> 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, DEFAULT_ENGAGEMENT_INTERVAL_MILLIS, hbEvent, intervalCalculator, ParselyCoroutineScopeKt.getSdkScope(), clock);
         videoEngagementManager.start();
     }
 
@@ -418,80 +398,17 @@ public void resetVideo() {
         videoEngagementManager = null;
     }
 
-    /**
-     * Create an event Map
-     *
-     * @param url       The URL identifying the pageview/heartbeat
-     * @param action    Action to use (e.g. pageview, heartbeat, videostart, vheartbeat)
-     * @param metadata  Metadata to attach to the event.
-     * @param extraData A Map of additional information to send with the event.
-     * @return A Map object representing the event to be sent to Parse.ly.
-     */
-    @NonNull
-    private Map<String, Object> buildEvent(
-            String url,
-            String urlRef,
-            String action,
-            ParselyMetadata metadata,
-            Map<String, Object> extraData,
-            @Nullable String uuid
-    ) {
-        PLog("buildEvent called for %s/%s", action, url);
-
-        Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
-
-        // Main event info
-        Map<String, Object> event = new HashMap<>();
-        event.put("url", url);
-        event.put("urlref", urlRef);
-        event.put("idsite", siteId);
-        event.put("action", action);
-
-        // Make a copy of extraData and add some things.
-        Map<String, Object> data = new HashMap<>();
-        if (extraData != null) {
-            data.putAll(extraData);
-        }
-        data.put("manufacturer", deviceInfo.get("manufacturer"));
-        data.put("os", deviceInfo.get("os"));
-        data.put("os_version", deviceInfo.get("os_version"));
-        data.put("ts", now.getTimeInMillis());
-        data.put("parsely_site_uuid", deviceInfo.get("parsely_site_uuid"));
-        event.put("data", data);
-
-        if (metadata != null) {
-            event.put("metadata", metadata.toMap());
-        }
-
-        if (action.equals("videostart") || action.equals("vheartbeat")) {
-            event.put(VIDEO_START_ID_KEY, uuid);
-        }
-
-        if (action.equals("pageview") || action.equals("heartbeat")) {
-            event.put(PAGE_VIEW_ID_KEY, uuid);
-        }
-
-        return event;
-    }
-
     /**
      * Add an event Map to the queue.
      * <p>
      * Place a data structure representing the event into the in-memory queue for later use.
      * <p>
-     * **Note**: Events placed into this queue will be discarded if the size of the persistent queue
-     * store exceeds {@link #STORAGE_SIZE_LIMIT}.
      *
      * @param event The event Map to enqueue.
      */
-    private void enqueueEvent(Map<String, Object> event) {
+    void enqueueEvent(Map<String, Object> event) {
         // Push it onto the queue
-        eventQueue.add(event);
-        new QueueManager().execute();
-        if (!flushTimerIsActive()) {
-            startFlushTimer();
-            PLog("Flush flushTimer set to %ds", (flushManager.getIntervalMillis() / 1000));
-        }
+        inMemoryBuffer.add(event);
     }
 
     /**
@@ -503,142 +420,6 @@ public void flushEventQueue() {
         // no-op
     }
 
-    /**
-     * Send the batched event request to Parsely.
-     * <p>
-     * 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<Map<String, Object>> 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<String, Object> batchMap = new HashMap<>();
-        batchMap.put("events", events);
-
-        if (isDebug) {
-            PLog("Debug mode on. Not sending to Parse.ly");
-            eventQueue.clear();
-            purgeStoredQueue();
-        } else {
-            new ParselyAPIConnection().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.
-     *
-     * @return Whether the network is accessible and Parsely is reachable.
-     */
-    private boolean isReachable() {
-        ConnectivityManager cm = (ConnectivityManager) context.getSystemService(
-                Context.CONNECTIVITY_SERVICE);
-        NetworkInfo netInfo = cm.getActiveNetworkInfo();
-        return netInfo != null && netInfo.isConnectedOrConnecting();
-    }
-
-    /**
-     * Save the event queue to persistent storage.
-     */
-    private synchronized void persistQueue() {
-        PLog("Persisting event queue");
-        ArrayList<Map<String, Object>> storedQueue = getStoredQueue();
-        HashSet<Map<String, Object>> hs = new HashSet<>();
-        hs.addAll(storedQueue);
-        hs.addAll(eventQueue);
-        storedQueue.clear();
-        storedQueue.addAll(hs);
-        persistObject(storedQueue);
-    }
-
-    /**
-     * Get the stored event queue from persistent storage.
-     *
-     * @return The stored queue of events.
-     */
-    @NonNull
-    private ArrayList<Map<String, Object>> getStoredQueue() {
-        ArrayList<Map<String, Object>> storedQueue = null;
-        try {
-            FileInputStream fis = context.getApplicationContext().openFileInput(STORAGE_KEY);
-            ObjectInputStream ois = new ObjectInputStream(fis);
-            //noinspection unchecked
-            storedQueue = (ArrayList<Map<String, Object>>) ois.readObject();
-            ois.close();
-        } catch (EOFException ex) {
-            // Nothing to do here.
-        } catch (FileNotFoundException ex) {
-            // Nothing to do here. Means there was no saved queue.
-        } catch (Exception ex) {
-            PLog("Exception thrown during queue deserialization: %s", ex.toString());
-        }
-
-        if (storedQueue == null) {
-            storedQueue = new ArrayList<>();
-        }
-        return storedQueue;
-    }
-
-    /**
-     * Delete the stored queue from persistent storage.
-     */
-    protected void purgeStoredQueue() {
-        persistObject(new ArrayList<Map<String, Object>>());
-    }
-
-    /**
-     * Delete an event from the stored queue.
-     */
-    private void expelStoredEvent() {
-        ArrayList<Map<String, Object>> storedQueue = getStoredQueue();
-        storedQueue.remove(0);
-    }
-
-    /**
-     * Persist an object to storage.
-     *
-     * @param o Object to store.
-     */
-    private void persistObject(Object o) {
-        try {
-            FileOutputStream fos = context.getApplicationContext().openFileOutput(
-                    STORAGE_KEY,
-                    android.content.Context.MODE_PRIVATE
-            );
-            ObjectOutputStream oos = new ObjectOutputStream(fos);
-            oos.writeObject(o);
-            oos.close();
-        } catch (Exception ex) {
-            PLog("Exception thrown during queue serialization: %s", ex.toString());
-        }
-    }
-
-    /**
-     * 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<String, Object> 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.
      * <p>
@@ -659,334 +440,12 @@ 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();
     }
 
-    /**
-     * Read the Parsely UUID from application context or make a new one.
-     *
-     * @return The UUID to use for this user.
-     */
-    private String generateSiteUuid() {
-        String uuid = Secure.getString(context.getApplicationContext().getContentResolver(),
-                Secure.ANDROID_ID);
-        PLog(String.format("Generated UUID: %s", uuid));
-        return uuid;
-    }
-
-    /**
-     * Get the UUID for this user.
-     */
-    //TODO: docs about where we get this UUID from and how.
-    private String getSiteUuid() {
-        String uuid = "";
-        try {
-            uuid = settings.getString(UUID_KEY, "");
-            if (uuid.equals("")) {
-                uuid = generateSiteUuid();
-            }
-        } catch (Exception ex) {
-            PLog("Exception caught during site uuid generation: %s", ex.toString());
-        }
-        return uuid;
-    }
-
-    /**
-     * Collect device-specific info.
-     * <p>
-     * Collects info about the device and user to use in Parsely events.
-     */
-    private Map<String, String> collectDeviceInfo(@Nullable final String adKey) {
-        Map<String, String> dInfo = new HashMap<>();
-
-        // TODO: screen dimensions (maybe?)
-        PLog("adkey is: %s, uuid is %s", adKey, getSiteUuid());
-        final String uuid = (adKey != null) ? adKey : getSiteUuid();
-        dInfo.put("parsely_site_uuid", uuid);
-        dInfo.put("manufacturer", android.os.Build.MANUFACTURER);
-        dInfo.put("os", "android");
-        dInfo.put("os_version", String.format("%d", android.os.Build.VERSION.SDK_INT));
-
-        // FIXME: Not passed in event or used anywhere else.
-        CharSequence txt = context.getPackageManager().getApplicationLabel(context.getApplicationInfo());
-        dInfo.put("appname", txt.toString());
-
-        return dInfo;
-    }
-
-    /**
-     * Get the number of events waiting to be flushed to Parsely.
-     *
-     * @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<Map<String, Object>> ar = getStoredQueue();
-        return ar.size();
-    }
-
-    private class QueueManager extends AsyncTask<Void, Void, Void> {
-        @Override
-        protected Void doInBackground(Void... params) {
-            // if event queue is too big, push to persisted storage
-            if (eventQueue.size() > QUEUE_SIZE_LIMIT) {
-                PLog("Queue size exceeded, expelling oldest event to persistent memory");
-                persistQueue();
-                eventQueue.remove(0);
-                // if persisted storage is too big, expel one
-                if (storedEventsCount() > STORAGE_SIZE_LIMIT) {
-                    expelStoredEvent();
-                }
-            }
-            return null;
-        }
-    }
-
-    private class FlushQueue extends AsyncTask<Void, Void, Void> {
-        @Override
-        protected synchronized Void doInBackground(Void... params) {
-            ArrayList<Map<String, Object>> storedQueue = 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<Map<String, Object>> hs = new HashSet<>();
-            ArrayList<Map<String, Object>> newQueue = new ArrayList<>();
-
-            hs.addAll(eventQueue);
-            hs.addAll(storedQueue);
-            newQueue.addAll(hs);
-            PLog("Flushing queue");
-            sendBatchRequest(newQueue);
-            return null;
-        }
-    }
-
-    /**
-     * Async task to get adKey for this device.
-     */
-    private class GetAdKey extends AsyncTask<Void, Void, String> {
-        private final Context mContext;
-
-        public GetAdKey(Context context) {
-            mContext = context;
-        }
-
-        @Override
-        protected String doInBackground(Void... params) {
-            AdvertisingIdClient.Info idInfo = null;
-            String advertId = null;
-            try {
-                idInfo = AdvertisingIdClient.getAdvertisingIdInfo(mContext);
-            } catch (GooglePlayServicesRepairableException | IOException | GooglePlayServicesNotAvailableException | IllegalArgumentException e) {
-                PLog("No Google play services or error! falling back to device uuid");
-                // fall back to device uuid on google play errors
-                advertId = getSiteUuid();
-            }
-            try {
-                advertId = idInfo.getId();
-            } catch (NullPointerException e) {
-                advertId = getSiteUuid();
-            }
-            return advertId;
-        }
-
-        @Override
-        protected void onPostExecute(String advertId) {
-            deviceInfo = collectDeviceInfo(advertId);
-        }
-
-    }
-
-
-    /**
-     * Manager for the event flush timer.
-     * <p>
-     * Handles stopping and starting the flush timer. The flush timer
-     * controls how often we send events to Parse.ly servers.
-     */
-    private class FlushManager {
-
-        private final Timer parentTimer;
-        private final long intervalMillis;
-        private TimerTask runningTask;
-
-        public FlushManager(Timer parentTimer, long intervalMillis) {
-            this.parentTimer = parentTimer;
-            this.intervalMillis = intervalMillis;
-        }
-
-        public void start() {
-            if (runningTask != null) {
-                return;
-            }
-
-            runningTask = new TimerTask() {
-                public void run() {
-                    flushEvents();
-                }
-            };
-            parentTimer.scheduleAtFixedRate(runningTask, intervalMillis, intervalMillis);
-        }
-
-        public boolean stop() {
-            if (runningTask == null) {
-                return false;
-            } else {
-                boolean output = runningTask.cancel();
-                runningTask = null;
-                return output;
-            }
-        }
-
-        public boolean isRunning() {
-            return runningTask != null;
-        }
-
-        public long getIntervalMillis() {
-            return intervalMillis;
-        }
-    }
-
-    private void flushEvents() {
-        new FlushQueue().execute();
-    }
-
-    /**
-     * Engagement manager for article and video engagement.
-     * <p>
-     * Implemented to handle its own queuing of future executions to accomplish
-     * two things:
-     * <p>
-     * 1. Flushing any engaged time before canceling.
-     * 2. Progressive backoff for long engagements to save data.
-     */
-    private class EngagementManager {
-
-        public Map<String, Object> 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<String, Object> 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<String, Object> baseMetadata = (Map<String, Object>) 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<String, Object> 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<String, Object> baseEventData = (Map<String, Object>) event.get("data");
-            assert baseEventData != null;
-            Map<String, Object> data = new HashMap<>((Map<String, Object>) 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;
-        }
+    void flushEvents() {
+        flushQueue.invoke(isDebug);
     }
 }
diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java
deleted file mode 100644
index 874ead34..00000000
--- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package com.parsely.parselyandroid;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import java.util.ArrayList;
-import java.util.Calendar;
-import java.util.Map;
-
-/**
- * ParselyMetadata for video content.
- */
-public class ParselyVideoMetadata extends ParselyMetadata {
-
-    public int durationSeconds;
-
-    /**
-     * Create a new ParselyVideoMetadata object.
-     *
-     * @param authors         The names of the authors of the video. Up to 10 authors are accepted.
-     * @param videoId         Unique identifier for the video. Required.
-     * @param section         The category or vertical to which this video belongs.
-     * @param tags            User-defined tags for the video. Up to 20 are allowed.
-     * @param thumbUrl        URL at which the main image for this video is located.
-     * @param title           The title of the video.
-     * @param pubDate         The date this video was published.
-     * @param durationSeconds Duration of the video in seconds. Required.
-     */
-    public ParselyVideoMetadata(
-            @Nullable ArrayList<String> authors,
-            @NonNull String videoId,
-            @Nullable String section,
-            @Nullable ArrayList<String> tags,
-            @Nullable String thumbUrl,
-            @Nullable String title,
-            @Nullable Calendar pubDate,
-            @NonNull int durationSeconds
-    ) {
-        super(authors, videoId, section, tags, thumbUrl, title, pubDate);
-        if (videoId == null) {
-            throw new NullPointerException("videoId cannot be null");
-        }
-        this.durationSeconds = durationSeconds;
-    }
-
-    /**
-     * Turn this object into a Map
-     *
-     * @return a Map object representing the metadata.
-     */
-    public Map<String, Object> toMap() {
-        Map<String, Object> output = super.toMap();
-        output.put("duration", this.durationSeconds);
-        return output;
-    }
-}
diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt
new file mode 100644
index 00000000..36c6381a
--- /dev/null
+++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyVideoMetadata.kt
@@ -0,0 +1,40 @@
+package com.parsely.parselyandroid
+
+import java.util.Calendar
+
+/**
+ * ParselyMetadata for video content.
+ */
+class ParselyVideoMetadata
+/**
+ * Create a new ParselyVideoMetadata object.
+ *
+ * @param authors         The names of the authors of the video. Up to 10 authors are accepted.
+ * @param videoId         Unique identifier for the video. Required.
+ * @param section         The category or vertical to which this video belongs.
+ * @param tags            User-defined tags for the video. Up to 20 are allowed.
+ * @param thumbUrl        URL at which the main image for this video is located.
+ * @param title           The title of the video.
+ * @param pubDate         The timestamp in milliseconds this video was published.
+ * @param durationSeconds Duration of the video in seconds. Required.
+ */(
+    authors: List<String>? = null,
+    videoId: String,
+    section: String? = null,
+    tags: List<String>? = null,
+    thumbUrl: String? = null,
+    title: String? = null,
+    pubDate: Calendar? = null,
+    @JvmField internal val durationSeconds: Int
+) : ParselyMetadata(authors, videoId, section, tags, thumbUrl, title, pubDate) {
+    /**
+     * Turn this object into a Map
+     *
+     * @return a Map object representing the metadata.
+     */
+    override fun toMap(): Map<String, Any?> {
+        val output = super.toMap().toMutableMap()
+        output["duration"] = durationSeconds
+        return output
+    }
+}
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<Application>().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<Application>().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
new file mode 100644
index 00000000..a7e5df9e
--- /dev/null
+++ b/parsely/src/test/java/com/parsely/parselyandroid/EngagementManagerTest.kt
@@ -0,0 +1,227 @@
+package com.parsely.parselyandroid
+
+import androidx.test.core.app.ApplicationProvider
+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.MapAssert
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+private typealias Event = MutableMap<String, Any>
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+internal class EngagementManagerTest {
+
+    private lateinit var sut: EngagementManager
+    private val tracker = FakeTracker()
+    private val baseEvent: Event = mutableMapOf(
+        "action" to "heartbeat",
+        "data" to testData
+    )
+
+    @Test
+    fun `when starting manager, then record the correct event after interval millis`() = runTest {
+        // given
+        sut = EngagementManager(
+            tracker,
+            DEFAULT_INTERVAL.inWholeMilliseconds,
+            baseEvent,
+            FakeIntervalCalculator(),
+            backgroundScope,
+            FakeClock(testScheduler),
+        )
+
+        // when
+        sut.start()
+        advanceTimeBy(DEFAULT_INTERVAL)
+        runCurrent()
+
+        // then
+        assertThat(tracker.events[0]).isCorrectEvent(
+            withIncrementalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeSeconds)},
+            withTotalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeMilliseconds) },
+            withTimestamp = { isEqualTo(currentTime) }
+        )
+    }
+
+    @Test
+    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()
+
+        // when
+        advanceTimeBy(DEFAULT_INTERVAL)
+        val firstTimestamp = currentTime
+
+        advanceTimeBy(DEFAULT_INTERVAL)
+        val secondTimestamp = currentTime
+
+        advanceTimeBy(DEFAULT_INTERVAL)
+        runCurrent()
+        val thirdTimestamp = currentTime
+
+        // then
+        val firstEvent = tracker.events[0]
+        assertThat(firstEvent).isCorrectEvent(
+            withIncrementalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeSeconds) },
+            withTotalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeMilliseconds) },
+            withTimestamp = { isEqualTo(firstTimestamp) }
+        )
+        val secondEvent = tracker.events[1]
+        assertThat(secondEvent).isCorrectEvent(
+            withIncrementalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeSeconds) },
+            withTotalTime = { isEqualTo((DEFAULT_INTERVAL * 2).inWholeMilliseconds) },
+            withTimestamp = { isEqualTo(secondTimestamp) }
+        )
+        val thirdEvent = tracker.events[2]
+        assertThat(thirdEvent).isCorrectEvent(
+            withIncrementalTime = { isEqualTo(DEFAULT_INTERVAL.inWholeSeconds) },
+            withTotalTime = { isEqualTo((DEFAULT_INTERVAL * 3).inWholeMilliseconds) },
+            withTimestamp = { isEqualTo(thirdTimestamp) }
+        )
+    }
+
+    @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<String, Any>.isCorrectEvent(
+        withIncrementalTime: AbstractLongAssert<*>.() -> AbstractLongAssert<*>,
+        withTotalTime: AbstractLongAssert<*>.() -> AbstractLongAssert<*>,
+        withTimestamp: AbstractLongAssert<*>.() -> AbstractLongAssert<*>,
+    ): MapAssert<String, Any> {
+        return containsEntry("action", "heartbeat")
+            .hasEntrySatisfying("inc") { incrementalTime ->
+                incrementalTime as Long
+                assertThat(incrementalTime).withIncrementalTime()
+            }
+            .hasEntrySatisfying("tt") { totalTime ->
+                totalTime as Long
+                assertThat(totalTime).withTotalTime()
+            }
+            .hasEntrySatisfying("data") { data ->
+                @Suppress("UNCHECKED_CAST")
+                data as Map<String, Any>
+                assertThat(data).hasEntrySatisfying("ts") { timestamp ->
+                    timestamp as Long
+                    assertThat(timestamp).withTimestamp()
+                }.containsAllEntriesOf(testData.minus("ts"))
+            }
+    }
+
+    class FakeTracker : ParselyTracker(
+        "",
+        0,
+        ApplicationProvider.getApplicationContext()
+    ) {
+        val events = mutableListOf<Event>()
+
+        override fun enqueueEvent(event: Event) {
+            events += event
+        }
+    }
+
+    class FakeIntervalCalculator : HeartbeatIntervalCalculator(Clock()) {
+        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 {
+        val DEFAULT_INTERVAL = 30.seconds
+        val testData = mutableMapOf<String, Any>(
+            "os" to "android",
+            "parsely_site_uuid" to "e8857cbe-5ace-44f4-a85e-7e7475f675c5",
+            "os_version" to "34",
+            "manufacturer" to "Google",
+            "ts" to 123L
+        )
+    }
+}
diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt
new file mode 100644
index 00000000..05af5efb
--- /dev/null
+++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt
@@ -0,0 +1,212 @@
+package com.parsely.parselyandroid
+
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.hours
+import org.assertj.core.api.Assertions.assertThat
+import org.assertj.core.api.MapAssert
+import org.junit.Before
+import org.junit.Test
+
+internal class EventsBuilderTest {
+    private lateinit var sut: EventsBuilder
+    private val clock = FakeClock()
+
+    @Before
+    fun setUp() {
+        sut = EventsBuilder(
+            FakeDeviceInfoRepository(),
+            TEST_SITE_ID,
+            clock
+        )
+    }
+
+    @Test
+    fun `when building pageview event, then build the correct one`() {
+        // when
+        val event: Map<String, Any> = sut.buildEvent(
+            TEST_URL,
+            "",
+            "pageview",
+            null,
+            null,
+            TEST_UUID,
+        )
+
+        // then
+        assertThat(event)
+            .containsEntry("action", "pageview")
+            .containsEntry("pvid", TEST_UUID)
+            .sharedPixelAssertions()
+    }
+
+    @Test
+    fun `when building heartbeat event, then build the correct one`() {
+        // when
+        val event: Map<String, Any> = sut.buildEvent(
+            TEST_URL,
+            "",
+            "heartbeat",
+            null,
+            null,
+            TEST_UUID,
+        )
+
+        // then
+        assertThat(event)
+            .containsEntry("action", "heartbeat")
+            .containsEntry("pvid", TEST_UUID)
+            .sharedPixelAssertions()
+    }
+
+    @Test
+    fun `when building videostart event, then build the correct one`() {
+        // when
+        val event: Map<String, Any> = sut.buildEvent(
+            TEST_URL,
+            "",
+            "videostart",
+            null,
+            null,
+            TEST_UUID,
+        )
+
+        // then
+        assertThat(event)
+            .containsEntry("action", "videostart")
+            .containsEntry("vsid", TEST_UUID)
+            .sharedPixelAssertions()
+    }
+
+    @Test
+    fun `when building vheartbeat event, then build the correct one`() {
+        // when
+        val event: Map<String, Any> = sut.buildEvent(
+            TEST_URL,
+            "",
+            "vheartbeat",
+            null,
+            null,
+            TEST_UUID,
+        )
+
+        // then
+        assertThat(event)
+            .containsEntry("action", "vheartbeat")
+            .containsEntry("vsid", TEST_UUID)
+            .sharedPixelAssertions()
+    }
+
+    @Test
+    fun `given extraData is null, when creating a pixel, don't include extraData`() {
+        // given
+        val extraData: Map<String, Any>? = null
+
+        // when
+        val event: Map<String, Any> = sut.buildEvent(
+            TEST_URL,
+            "",
+            "pageview",
+            null,
+            extraData,
+            TEST_UUID,
+        )
+
+        // then
+        @Suppress("UNCHECKED_CAST")
+        assertThat(event["data"] as Map<String, Any>).hasSize(2)
+    }
+
+    @Test
+    fun `given extraData is not null, when creating a pixel, include extraData`() {
+        // given
+        val extraData: Map<String, Any> = mapOf(
+            "extra 1" to "data 1",
+            "extra 2" to "data 2"
+        )
+
+        // when
+        val event: Map<String, Any> = sut.buildEvent(
+            TEST_URL,
+            "",
+            "pageview",
+            null,
+            extraData,
+            TEST_UUID,
+        )
+
+        // then
+        @Suppress("UNCHECKED_CAST")
+        assertThat(event["data"] as Map<String, Any>).hasSize(4)
+            .containsAllEntriesOf(extraData)
+    }
+
+    @Test
+    fun `given metadata is null, when creating a pixel, don't include metadata`() {
+        // given
+        val metadata: ParselyMetadata? = null
+
+        // when
+        val event: Map<String, Any> = sut.buildEvent(
+            TEST_URL,
+            "",
+            "pageview",
+            metadata,
+            null,
+            TEST_UUID,
+        )
+
+        // then
+        assertThat(event).doesNotContainKey("metadata")
+    }
+
+    @Test
+    fun `given metadata is not null, when creating a pixel, include metadata`() {
+        // given
+        val metadata = ParselyMetadata(
+            ArrayList<String>(), "link", "section", null, null, null, null
+        )
+
+        // when
+        val event: Map<String, Any> = sut.buildEvent(
+            TEST_URL,
+            "",
+            "pageview",
+            metadata,
+            null,
+            TEST_UUID,
+        )
+
+        // then
+        assertThat(event).containsKey("metadata")
+    }
+
+    private fun MapAssert<String, Any>.sharedPixelAssertions() =
+        hasSize(6)
+            .containsEntry("url", TEST_URL)
+            .containsEntry("urlref", "")
+            .containsEntry("idsite", TEST_SITE_ID)
+            .hasEntrySatisfying("data") {
+                @Suppress("UNCHECKED_CAST")
+                it as Map<String, Any>
+                assertThat(it)
+                    .hasSize(2)
+                    .containsAllEntriesOf(FAKE_DEVICE_INFO)
+            }
+
+    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_NOW = 15.hours
+        val FAKE_DEVICE_INFO = mapOf("device" to "info", "ts" to FAKE_NOW.inWholeMilliseconds.toString())
+    }
+
+    class FakeDeviceInfoRepository: DeviceInfoRepository {
+        override fun collectDeviceInfo(): Map<String, String> = FAKE_DEVICE_INFO
+    }
+
+    class FakeClock() : Clock() {
+        override val now: Duration = FAKE_NOW
+    }
+}
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..0dea4b1a
--- /dev/null
+++ b/parsely/src/test/java/com/parsely/parselyandroid/FlushQueueTest.kt
@@ -0,0 +1,255 @@
+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,
+                FakeConnectivityStatusProvider()
+            )
+
+            // 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,
+                FakeConnectivityStatusProvider()
+            )
+
+            // 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,
+                FakeConnectivityStatusProvider()
+            )
+
+            // 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,
+                FakeConnectivityStatusProvider()
+            )
+
+            // 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,
+                FakeConnectivityStatusProvider()
+            )
+
+            // 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<Map<String, Any?>?> {
+                    return ArrayList(listOf(mapOf("test" to 123)))
+                }
+            }
+            val parselyAPIConnection = FakeRestClient().apply {
+                nextResult = Result.success(Unit)
+            }
+            val sut = FlushQueue(
+                flushManager,
+                repository,
+                parselyAPIConnection,
+                this,
+                FakeConnectivityStatusProvider()
+            )
+
+            // 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,
+            FakeConnectivityStatusProvider()
+        )
+
+        // when
+        sut.invoke(false)
+        runCurrent()
+
+        // then
+        assertThat(flushManager.stopped).isTrue()
+    }
+
+    @Test
+    fun `given non-empty local storage, when flushing queue with no internet connection, then events are not sent and not removed from local storage`() =
+        runTest {
+            // given
+            val repository = FakeRepository().apply {
+                insertEvents(listOf(mapOf("test" to 123)))
+            }
+            val sut = FlushQueue(
+                FakeFlushManager(),
+                repository,
+                FakeRestClient(),
+                this,
+                FakeConnectivityStatusProvider().apply { reachable = false }
+            )
+
+            // when
+            sut.invoke(false)
+            runCurrent()
+
+            // then
+            assertThat(repository.getStoredQueue()).isNotEmpty
+        }
+
+
+    private class FakeFlushManager : FlushManager {
+        var stopped = false
+        override fun start() {
+            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<Map<String, Any?>?>()
+
+        override suspend fun insertEvents(toInsert: List<Map<String, Any?>?>) {
+            storage = storage + toInsert
+        }
+
+        override suspend fun remove(toRemove: List<Map<String, Any?>?>) {
+            storage = storage - toRemove.toSet()
+        }
+
+        override suspend fun getStoredQueue(): ArrayList<Map<String, Any?>?> {
+            return ArrayList(storage)
+        }
+    }
+
+    private class FakeRestClient : RestClient {
+
+        var nextResult: Result<Unit>? = null
+
+        override suspend fun send(payload: String): Result<Unit> {
+            return nextResult!!
+        }
+    }
+
+    private class FakeConnectivityStatusProvider : ConnectivityStatusProvider {
+        var reachable = true
+        override fun isReachable() = reachable
+    }
+}
diff --git a/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt
new file mode 100644
index 00000000..eb5c420e
--- /dev/null
+++ b/parsely/src/test/java/com/parsely/parselyandroid/HeartbeatIntervalCalculatorTest.kt
@@ -0,0 +1,76 @@
+package com.parsely.parselyandroid
+
+import com.parsely.parselyandroid.HeartbeatIntervalCalculator.Companion.MAX_TIME_BETWEEN_HEARTBEATS
+import java.util.Calendar
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Before
+import org.junit.Test
+
+internal class HeartbeatIntervalCalculatorTest {
+
+    private lateinit var sut: HeartbeatIntervalCalculator
+    private val fakeClock = FakeClock()
+
+    @Before
+    fun setUp() {
+        sut = HeartbeatIntervalCalculator(fakeClock)
+    }
+
+    @Test
+    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 = Duration.ZERO
+
+        // when
+        val result = sut.calculate(startTime)
+
+        // then
+        // ((currentTime + offset) * backoff) and then in milliseconds
+        // (0 + 35) * 0.3 * 1000 = 10500
+        assertThat(result).isEqualTo(10500)
+    }
+
+    @Test
+    fun `given a time that will cause the interval to surpass the MAX_TIME_BETWEEN_HEARTBEATS, when calculating interval, then return the MAX_TIME_BETWEEN_HEARTBEATS`() {
+        // given
+        // "excessiveTime" is a calculated point in time where the resulting interval would
+        // surpass MAX_TIME_BETWEEN_HEARTBEATS
+        // (currentTime + offset) * backoff = max
+        // currentTime = (max / backoff) - offset, so
+        // (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 = Duration.ZERO
+
+        // when
+        val result = sut.calculate(startTime)
+
+        // then
+        assertThat(result).isEqualTo(MAX_TIME_BETWEEN_HEARTBEATS.inWholeMilliseconds)
+    }
+
+    @Test
+    fun `given a specific time point, when updating latest interval, it correctly calculates the interval`() {
+        // given
+        val startTime = Duration.ZERO
+        fakeClock.fakeNow = 2.seconds
+
+        // when
+        val result = sut.calculate(startTime)
+
+        // then
+        // ((currentTime + offset) * backoff) and then in milliseconds
+        // (2 + 35) * 0.3 * 1000 = 11100
+        assertThat(result).isEqualTo(11100)
+    }
+
+    class FakeClock : Clock() {
+        var fakeNow = Duration.ZERO
+
+        override val now: Duration
+            get() = fakeNow
+    }
+}
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<Map<String, Any?>?>()
+
+        override suspend fun insertEvents(toInsert: List<Map<String, Any?>?>) {
+            events.addAll(toInsert)
+        }
+
+        override suspend fun remove(toRemove: List<Map<String, Any?>?>) {
+            TODO("Not implemented")
+        }
+
+        override suspend fun getStoredQueue(): ArrayList<Map<String, Any?>?> {
+            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
new file mode 100644
index 00000000..47a60057
--- /dev/null
+++ b/parsely/src/test/java/com/parsely/parselyandroid/LocalStorageRepositoryTest.kt
@@ -0,0 +1,124 @@
+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 {
+
+    private lateinit var sut: LocalStorageRepository
+    private val context = ApplicationProvider.getApplicationContext<Context>()
+
+    @Before
+    fun setUp() {
+        sut = LocalStorageRepository(context)
+    }
+
+    @Test
+    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.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`() = runTest {
+        assertThat(sut.getStoredQueue()).isEmpty()
+    }
+
+    @Test
+    fun `given stored queue with some elements, when persisting an event, then assert there'll be no duplicates`() = runTest {
+        // given
+        val storedQueue = (1..5).map { mapOf("index" to it) }
+        val newEvents = (3..10).map { mapOf("index" to it) }
+        sut.insertEvents(storedQueue)
+        runCurrent()
+
+        // when
+        sut.insertEvents(newEvents)
+        runCurrent()
+
+        // then
+        val expectedQueue = (1..10).map { mapOf("index" to it) }
+        assertThat(sut.getStoredQueue()).hasSize(10).containsExactlyInAnyOrderElementsOf(expectedQueue)
+    }
+
+    @Test
+    fun `given stored queue, when removing some events, then assert queue is doesn't contain removed events and contains not removed events`() = runTest {
+        // given
+        val initialList = (1..10).map { mapOf("index" to it) }
+        sut.insertEvents(initialList)
+        runCurrent()
+        val eventsToRemove = initialList.slice(0..5)
+        val eventsToKeep = initialList.slice(6..9)
+
+        // when
+        sut.remove(eventsToRemove)
+
+        // then
+        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`() = runTest {
+        // given
+        val file = File(context.filesDir.path + "/parsely-events.ser")
+        File(ClassLoader.getSystemResource("valid-java-parsely-events.ser")?.path!!).copyTo(file)
+
+        // when
+        val queue = sut.getStoredQueue()
+
+        // then
+        assertThat(queue).isEqualTo(
+            listOf(
+                mapOf(
+                    "idsite" to "example.com",
+                    "urlref" to "http://example.com/",
+                    "data" to mapOf<String, Any>(
+                        "manufacturer" to "Google",
+                        "os" to "android",
+                        "os_version" to "33",
+                        "parsely_site_uuid" to "b325e2c9-498c-4331-a967-2d6049317c77",
+                        "ts" to 1698918720863L
+                    ),
+                    "pvid" to "272cc2b8-5acc-4a70-80c7-20bb6eb843e4",
+                    "action" to "pageview",
+                    "url" to "http://example.com/article1.html"
+                ),
+                mapOf(
+                    "idsite" to "example.com",
+                    "urlref" to "http://example.com/",
+                    "data" to mapOf<String, Any>(
+                        "manufacturer" to "Google",
+                        "os" to "android",
+                        "os_version" to "33",
+                        "parsely_site_uuid" to "b325e2c9-498c-4331-a967-2d6049317c77",
+                        "ts" to 1698918742375L
+                    ),
+                    "pvid" to "e94567ec-3459-498c-bf2e-6a1b85ed5a82",
+                    "action" to "pageview",
+                    "url" to "http://example.com/article1.html"
+                )
+            )
+        )
+    }
+}
diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt
new file mode 100644
index 00000000..497e5d5c
--- /dev/null
+++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyAPIConnectionTest.kt
@@ -0,0 +1,74 @@
+package com.parsely.parselyandroid
+
+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
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class ParselyAPIConnectionTest {
+
+    private lateinit var sut: ParselyAPIConnection
+    private val mockServer = MockWebServer()
+    private val url = mockServer.url("").toString()
+
+    @Before
+    fun setUp() {
+        sut = ParselyAPIConnection(url)
+    }
+
+    @After
+    fun tearDown() {
+        mockServer.shutdown()
+    }
+
+    @Test
+    fun `given successful response, when making connection with events, then make POST request with JSON Content-Type header`() =
+        runTest {
+            // given
+            mockServer.enqueue(MockResponse().setResponseCode(200))
+
+            // when
+            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 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 =
+            ClassLoader.getSystemResource("pixel_payload.json").readText().apply {
+                assert(isNotBlank())
+            }
+    }
+}
diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt
new file mode 100644
index 00000000..db5fa0e4
--- /dev/null
+++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyMetadataTest.kt
@@ -0,0 +1,73 @@
+package com.parsely.parselyandroid
+
+import java.util.Calendar
+import kotlin.time.Duration.Companion.seconds
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Test
+
+class ParselyMetadataTest {
+
+    @Test
+    fun `given metadata with complete set of data, when converting to map, then the map is as expected`() {
+        // given
+        val pageType = "post"
+        val sut = ParselyMetadata(
+            authors,
+            link,
+            section,
+            tags,
+            thumbUrl,
+            title,
+            pubDate,
+            pageType
+        )
+
+        // when
+        val map = sut.toMap()
+
+        // then
+        assertThat(map).isEqualTo(expectedParselyMetadataMap + ("page_type" to pageType))
+    }
+
+    @Test
+    fun `given video metadata with complete set of data, when converting to map, then the map is as expected`() {
+        // given
+        val duration = 12
+        val sut = ParselyVideoMetadata(
+            authors,
+            link,
+            section,
+            tags,
+            thumbUrl,
+            title,
+            pubDate,
+            duration
+        )
+
+        // when
+        val map = sut.toMap()
+
+        // then
+        assertThat(map).isEqualTo(expectedParselyMetadataMap + ("duration" to duration))
+    }
+
+    companion object {
+        val authors = arrayListOf("first author", "second author")
+        val link = "sample link"
+        val section = "sample section"
+        val tags = arrayListOf("first tag", "second tag")
+        val thumbUrl = "sample thumb url"
+        val title = "sample title"
+        val pubDate = Calendar.getInstance().apply { set(2023, 0, 1) }
+
+        val expectedParselyMetadataMap = mapOf(
+            "authors" to authors,
+            "link" to link,
+            "section" to section,
+            "tags" to tags,
+            "thumb_url" to thumbUrl,
+            "title" to title,
+            "pub_date_tmsp" to pubDate.timeInMillis / 1000
+        )
+    }
+}
diff --git a/parsely/src/test/resources/pixel_payload.json b/parsely/src/test/resources/pixel_payload.json
new file mode 100644
index 00000000..803b4930
--- /dev/null
+++ b/parsely/src/test/resources/pixel_payload.json
@@ -0,0 +1,10 @@
+{
+  "events": [
+    {
+      "idsite": "example.com"
+    },
+    {
+      "idsite": "example2.com"
+    }
+  ]
+}
diff --git a/parsely/src/test/resources/valid-java-parsely-events.ser b/parsely/src/test/resources/valid-java-parsely-events.ser
new file mode 100644
index 00000000..2d514a8c
Binary files /dev/null and b/parsely/src/test/resources/valid-java-parsely-events.ser differ
diff --git a/settings.gradle b/settings.gradle
index e86c25fe..e42700e3 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -4,6 +4,8 @@ pluginManagement {
     plugins {
         id 'com.android.application' version gradle.ext.agpVersion
         id 'com.android.library' version gradle.ext.agpVersion
+        id 'org.jetbrains.kotlin.android' version '1.9.10'
+        id 'org.jetbrains.kotlinx.kover' version '0.7.4'
     }
 
     repositories {