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 {