From 23abd654ef3128bbf469371e4b7358de6eefb01a Mon Sep 17 00:00:00 2001 From: Guilherme Pimenta Date: Fri, 9 Aug 2024 08:25:08 -0300 Subject: [PATCH] chore(sdk): swap out sharedpreferences (#11) Introduces the EventPipeline, in which events are collected and persisted asynchronously (with relation the main thread), and batched for upload. Closes: https://topsort.atlassian.net/browse/API-797 https://topsort.atlassian.net/browse/API-800 PR: https://github.com/Topsort/topsort.kt/pull/11 --- TopsortAnalytics/build.gradle | 5 +- .../topsort/analytics/EventPipelineTest.kt | 229 ++++++++++++++++++ .../com/topsort/analytics/JsonTestAndroid.kt | 56 +++++ .../topsort/analytics/TestObjectsAndroid.kt | 90 +++++++ .../com/topsort/analytics/EventPipeline.kt | 213 ++++++++++++++++ .../topsort/analytics/core/JsonExtensions.kt | 7 + .../java/com/topsort/analytics/core/Logger.kt | 5 + .../com/topsort/analytics/model/ClickEvent.kt | 11 +- .../java/com/topsort/analytics/model/Event.kt | 75 ++++++ .../analytics/model/ImpressionEvent.kt | 23 +- .../analytics/model/JsonSerializable.kt | 7 + .../com/topsort/analytics/model/Placement.kt | 3 +- .../topsort/analytics/model/PurchaseEvent.kt | 16 +- .../service/TopsortAnalyticsHttpService.kt | 19 +- 14 files changed, 737 insertions(+), 22 deletions(-) create mode 100644 TopsortAnalytics/src/androidTest/java/com/topsort/analytics/EventPipelineTest.kt create mode 100644 TopsortAnalytics/src/androidTest/java/com/topsort/analytics/JsonTestAndroid.kt create mode 100644 TopsortAnalytics/src/androidTest/java/com/topsort/analytics/TestObjectsAndroid.kt create mode 100644 TopsortAnalytics/src/main/java/com/topsort/analytics/EventPipeline.kt create mode 100644 TopsortAnalytics/src/main/java/com/topsort/analytics/core/Logger.kt create mode 100644 TopsortAnalytics/src/main/java/com/topsort/analytics/model/Event.kt create mode 100644 TopsortAnalytics/src/main/java/com/topsort/analytics/model/JsonSerializable.kt diff --git a/TopsortAnalytics/build.gradle b/TopsortAnalytics/build.gradle index 2ed0774..c87c03c 100644 --- a/TopsortAnalytics/build.gradle +++ b/TopsortAnalytics/build.gradle @@ -46,7 +46,8 @@ dependencies { implementation 'androidx.core:core-ktx:1.13.1' //Work Manager - implementation "androidx.work:work-runtime-ktx:2.9.0" + implementation 'androidx.work:work-runtime-ktx:2.9.0' + implementation 'androidx.datastore:datastore-preferences:1.1.1' //JodaTime implementation group: 'joda-time', name: 'joda-time', version: '2.12.5' @@ -54,6 +55,8 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.json:json:20200518' testImplementation 'org.assertj:assertj-core:3.26.0' + androidTestImplementation 'org.assertj:assertj-core:3.26.0' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + androidTestImplementation 'androidx.work:work-testing:2.9.0' } diff --git a/TopsortAnalytics/src/androidTest/java/com/topsort/analytics/EventPipelineTest.kt b/TopsortAnalytics/src/androidTest/java/com/topsort/analytics/EventPipelineTest.kt new file mode 100644 index 0000000..61b76a4 --- /dev/null +++ b/TopsortAnalytics/src/androidTest/java/com/topsort/analytics/EventPipelineTest.kt @@ -0,0 +1,229 @@ +package com.topsort.analytics + +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Configuration +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkQuery +import androidx.work.impl.utils.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import com.topsort.analytics.core.Logger +import com.topsort.analytics.model.Click +import com.topsort.analytics.model.ClickEvent +import com.topsort.analytics.model.Event +import com.topsort.analytics.model.Impression +import com.topsort.analytics.model.ImpressionEvent +import com.topsort.analytics.model.Purchase +import com.topsort.analytics.model.PurchaseEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONArray +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) + class EventPipelineTest { + + private lateinit var workManager: WorkManager + + @Before + fun setup() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setExecutor(SynchronousExecutor()) + .build() + + // Initialize WorkManager for instrumentation tests. + WorkManagerTestInitHelper.initializeTestWorkManager(appContext, config) + + workManager = WorkManager.getInstance(appContext) + + EventPipeline.setup(appContext) + Logger.log.clear() + } + + @Test + fun impressions_are_batched() { + val impressions1 = listOf( + getImpressionPromoted(), + getImpressionOrganic() + ) + val impressions2 = listOf( + getImpressionPromoted(), + getImpressionOrganic() + ) + + runBlocking { + EventPipeline.clear() + val job1 = EventPipeline.storeImpression( + ImpressionEvent(impressions1), + shouldFlush = false + ) + val job2 = EventPipeline.storeImpression( + ImpressionEvent(impressions2), + shouldFlush = false + ) + + // Make sure they're persisted + job1.join() + job2.join() + + val storedStr = EventPipeline.readImpressions() + val storedDeserialized = Impression.Factory.fromJsonArray(JSONArray("[$storedStr]")) + + assertThat(storedDeserialized).containsExactlyInAnyOrderElementsOf(impressions1 + impressions2) + } + } + + @Test + fun clicks_are_batched() { + val clicks1 = listOf( + getClickPromoted(), + getClickOrganic(), + ) + val clicks2 = listOf( + getClickPromoted(), + getClickOrganic(), + ) + + runBlocking { + EventPipeline.clear() + val job1 = EventPipeline.storeClick( + ClickEvent(clicks1), + shouldFlush = false + ) + val job2 = EventPipeline.storeClick( + ClickEvent(clicks2), + shouldFlush = false + ) + + // Make sure they're persisted + job1.join() + job2.join() + + val storedStr = EventPipeline.readClicks() + val storedDeserialized = Click.Factory.fromJsonArray(JSONArray("[$storedStr]")) + + assertThat(storedDeserialized).containsExactlyInAnyOrderElementsOf(clicks1 + clicks2) + } + } + + @Test + fun purchases_are_batched() { + val purchases1 = listOf( + getRandomPurchase(), + getRandomPurchase(), + ) + val purchases2 = listOf( + getRandomPurchase(), + getRandomPurchase(), + ) + + runBlocking { + EventPipeline.clear() + val job1 = EventPipeline.storePurchase( + PurchaseEvent(purchases1), + shouldFlush = false + ) + val job2 = EventPipeline.storePurchase( + PurchaseEvent(purchases2), + shouldFlush = false + ) + + // Make sure they're persisted + job1.join() + job2.join() + + val storedStr = EventPipeline.readPurchases() + val storedDeserialized = Purchase.fromJsonArray(JSONArray("[$storedStr]")) + + assertThat(storedDeserialized).containsExactlyInAnyOrderElementsOf(purchases1 + purchases2) + } + } + + @Test + fun aggregate_joins_Events() { + val impressions = listOf( + getImpressionPromoted(), + getImpressionOrganic() + ) + + val clicks = listOf( + getClickPromoted(), + getClickOrganic(), + ) + + val purchases = listOf( + getRandomPurchase(), + getRandomPurchase(), + ) + + runBlocking { + EventPipeline.clear() + val aggregated = Event( + impressions = impressions, + clicks = clicks, + purchases = purchases + ) + val job1 = EventPipeline.storeImpression( + ImpressionEvent(impressions), + shouldFlush = false + ) + val job2 = EventPipeline.storeClick( + ClickEvent(clicks), + shouldFlush = false + ) + val job3 = EventPipeline.storePurchase( + PurchaseEvent(purchases), + shouldFlush = false + ) + + // Make sure they're persisted + job1.join() + job2.join() + job3.join() + + val storedEvent = EventPipeline.aggregateEvents() + + assertThat(aggregated).isEqualTo(storedEvent) + } + } + + @Test + fun events_are_uploaded() { + val impressions = listOf( + getImpressionPromoted(), + getImpressionOrganic() + ) + + val aggregated = Event( + impressions = impressions, + ) + + runBlocking { + EventPipeline.clear() + + EventPipeline.storeImpression(ImpressionEvent(impressions)) + .join() + + // Wait for the upload work to be done + while ( + workManager.getWorkInfos(WorkQuery.fromUniqueWorkNames(UPLOAD_SIGNAL)) + .get().first().state != WorkInfo.State.SUCCEEDED + ) { + yield() + } + + } + + assertThat(Logger.log).contains("uploading: ${aggregated.toJsonObject()}") + } +} diff --git a/TopsortAnalytics/src/androidTest/java/com/topsort/analytics/JsonTestAndroid.kt b/TopsortAnalytics/src/androidTest/java/com/topsort/analytics/JsonTestAndroid.kt new file mode 100644 index 0000000..2cb4649 --- /dev/null +++ b/TopsortAnalytics/src/androidTest/java/com/topsort/analytics/JsonTestAndroid.kt @@ -0,0 +1,56 @@ +package com.topsort.analytics + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.topsort.analytics.model.Click +import com.topsort.analytics.model.Impression +import com.topsort.analytics.model.Purchase +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class JsonTestAndroid { + + @Test + fun json_click_serialization() { + val clicks = listOf( + getClickPromoted(), + getClickOrganic() + ) + + for(click in clicks) { + val serialized = click.toJsonObject().toString() + val deserialized = Click.Factory.fromJsonObject(JSONObject(serialized)) + + assertThat(click).isNotSameAs(deserialized) + assertThat(click).isEqualTo(deserialized) + } + } + + @Test + fun json_impression_serialization() { + val impressions = listOf( + getImpressionPromoted(), + getImpressionOrganic() + ) + + for(impression in impressions) { + val serialized = impression.toJsonObject().toString() + val deserialized = Impression.Factory.fromJsonObject(JSONObject(serialized)) + + assertThat(impression).isNotSameAs(deserialized) + assertThat(impression).isEqualTo(deserialized) + } + } + + @Test + fun json_purchase_serialization() { + val purchase = getRandomPurchase() + val serialized = purchase.toJsonObject().toString() + val deserialized = Purchase.fromJsonObject(JSONObject(serialized)) + + assertThat(purchase).isNotSameAs(deserialized) + assertThat(purchase).isEqualTo(deserialized) + } +} diff --git a/TopsortAnalytics/src/androidTest/java/com/topsort/analytics/TestObjectsAndroid.kt b/TopsortAnalytics/src/androidTest/java/com/topsort/analytics/TestObjectsAndroid.kt new file mode 100644 index 0000000..60a11f8 --- /dev/null +++ b/TopsortAnalytics/src/androidTest/java/com/topsort/analytics/TestObjectsAndroid.kt @@ -0,0 +1,90 @@ +package com.topsort.analytics + +import com.topsort.analytics.core.eventNow +import com.topsort.analytics.core.randomId +import com.topsort.analytics.model.Click +import com.topsort.analytics.model.Entity +import com.topsort.analytics.model.EntityType +import com.topsort.analytics.model.Impression +import com.topsort.analytics.model.Placement +import com.topsort.analytics.model.Purchase +import com.topsort.analytics.model.PurchasedItem + +fun getClickPromoted() : Click { + return Click.Factory.buildPromoted( + placement = getTestPlacement(), + occurredAt = eventNow(), + opaqueUserId = randomId("oId_"), + id = randomId("mktId_"), + resolvedBidId = randomId("resolvedBid_"), + additionalAttribution = "{\"additional\":\"attribution click\"}", + ) +} + +fun getClickOrganic() : Click { + return Click.Factory.buildOrganic( + placement = getTestPlacement(), + entity = Entity( + type = EntityType.Product, + id = randomId("product_"), + ), + occurredAt = eventNow(), + opaqueUserId = randomId("oId_"), + id = randomId("mktId_"), + additionalAttribution = "{\"additional\":\"attribution click\"}", + ) +} + +fun getImpressionPromoted() : Impression { + return Impression.Factory.buildPromoted ( + placement = getTestPlacement(), + occurredAt = eventNow(), + opaqueUserId = randomId("oId_"), + id = randomId("mktId_"), + resolvedBidId = randomId("resolvedBid_"), + additionalAttribution = "{\"additional\":\"attribution impression\"}", + ) +} + +fun getImpressionOrganic() : Impression { + return Impression.Factory.buildOrganic ( + placement = getTestPlacement(), + entity = Entity( + type = EntityType.Product, + id = randomId("product_"), + ), + occurredAt = eventNow(), + opaqueUserId = randomId("oId_"), + id = randomId("mktId_"), + additionalAttribution = "{\"additional\":\"attribution impression\"}", + ) +} + +fun getRandomPurchase() : Purchase { + return Purchase( + opaqueUserId = randomId("oId_"), + occurredAt = eventNow(), + items = listOf( + PurchasedItem( + productId = randomId("p_"), + quantity = 1, + unitPrice = 100, + resolvedBidId = randomId("resolvedBid_"), + ) + ), + id = randomId("orderId_"), + ) +} + +private fun getTestPlacement() : Placement { + return Placement( + path = "test", + position = 2, + page = 1, + pageSize = 20, + productId = randomId(), + categoryIds = listOf("cat1", "cat2"), + searchQuery = "search query", + location = "gibraltar", + ) +} diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/EventPipeline.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/EventPipeline.kt new file mode 100644 index 0000000..e6acac3 --- /dev/null +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/EventPipeline.kt @@ -0,0 +1,213 @@ +package com.topsort.analytics + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.topsort.analytics.core.Logger +import com.topsort.analytics.model.Click +import com.topsort.analytics.model.ClickEvent +import com.topsort.analytics.model.Event +import com.topsort.analytics.model.Impression +import com.topsort.analytics.model.ImpressionEvent +import com.topsort.analytics.model.JsonSerializable +import com.topsort.analytics.model.Purchase +import com.topsort.analytics.model.PurchaseEvent +import com.topsort.analytics.service.TopsortAnalyticsHttpService +import com.topsort.analytics.worker.EventEmitterWorker +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import java.util.concurrent.atomic.AtomicBoolean + +private const val PREFERENCES_NAME = "topsort_event_cache_async" + +private val KEY_IMPRESSION_EVENTS= stringPreferencesKey("KEY_IMPRESSION_EVENTS") +private val KEY_CLICK_EVENTS = stringPreferencesKey("KEY_CLICK_EVENTS") +private val KEY_PURCHASE_EVENTS = stringPreferencesKey("KEY_PURCHASE_EVENTS") + +@VisibleForTesting +const val UPLOAD_SIGNAL = "UPLOAD" + +val Context.eventDatastore: DataStore by preferencesDataStore(name = PREFERENCES_NAME) + +internal object EventPipeline { + + private lateinit var applicationContext: Context + + private val scope = CoroutineScope(SupervisorJob()) + private val dispatcher = Dispatchers.IO + + private var workManager: WorkManager? = null + + private var uploadQueued = AtomicBoolean(false) + + fun setup( + context: Context, + ) { + initialize(context) + } + + fun storeImpression( + impressionEvent: ImpressionEvent, shouldFlush: Boolean = true + ) = asyncWrite(impressionEvent.impressions, KEY_IMPRESSION_EVENTS, shouldFlush) + + fun storeClick( + clickEvent: ClickEvent, shouldFlush: Boolean = true + ) = asyncWrite(clickEvent.clicks, KEY_CLICK_EVENTS, shouldFlush) + + fun storePurchase( + purchaseEvent: PurchaseEvent, shouldFlush: Boolean = true + ) = asyncWrite(purchaseEvent.purchases, KEY_PURCHASE_EVENTS, shouldFlush) + + @VisibleForTesting + fun upload() { + val constraints = Constraints.Builder() + //.setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiresBatteryNotLow(false) + .setRequiresDeviceIdle(false) + .build() + + val requestBuilder = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + + workManager!! + .enqueueUniqueWork( + UPLOAD_SIGNAL, + ExistingWorkPolicy.REPLACE, + requestBuilder.build() + ) + } + + @VisibleForTesting + fun readImpressions(): String? { + return read(KEY_IMPRESSION_EVENTS) + } + + @VisibleForTesting + fun readClicks(): String? { + return read(KEY_CLICK_EVENTS) + } + + @VisibleForTesting + fun readPurchases(): String? { + return read(KEY_PURCHASE_EVENTS) + } + + private fun initialize(context: Context) { + applicationContext = context.applicationContext + workManager = WorkManager.getInstance(applicationContext) + } + + private fun asyncWrite( + events: List, + key: Preferences.Key, + shouldFlush: Boolean = true + ) = + scope.launch(dispatcher) { + val json = StringBuilder() + for (event in events) { + json.append(event.toJsonObject().toString()) + json.append(",") + } + + applicationContext.eventDatastore.edit { store -> + if (store.contains(key)) { + store[key] = store[key] + json.toString() + } else { + store[key] = json.toString() + } + } + + if(shouldFlush && !uploadQueued.getAndSet(true)){ + upload() + } + } + + @VisibleForTesting + suspend fun aggregateEvents(): Event { + val data = applicationContext.eventDatastore.data.first() + val impressions = data[KEY_IMPRESSION_EVENTS]?.trim(',') + val clicks = data[KEY_CLICK_EVENTS]?.trim(',') + val purchases = data[KEY_PURCHASE_EVENTS]?.trim(',') + + val impressionEvent = + impressions?.let { Impression.Factory.fromJsonArray(JSONArray("[$it]")) } + val clickEvent = + clicks?.let { Click.Factory.fromJsonArray(JSONArray("[$it]")) } + val purchaseEvent = + purchases?.let { Purchase.fromJsonArray(JSONArray("[$it]")) } + + val aggregated = Event( + impressions = impressionEvent, + clicks = clickEvent, + purchases = purchaseEvent, + ) + + return aggregated + } + + private fun read(key: Preferences.Key): String? { + return runBlocking { + val ret = scope.async { + applicationContext.eventDatastore.data.first()[key] + }.await() + + ret?.trim(',') + } + } + + @VisibleForTesting + suspend fun clear() { + applicationContext.eventDatastore.edit { store -> + store.remove(KEY_IMPRESSION_EVENTS) + store.remove(KEY_CLICK_EVENTS) + store.remove(KEY_PURCHASE_EVENTS) + } + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + internal class EventEmitterWorker( + context: Context, + params: WorkerParameters + ) : CoroutineWorker( + context, + params + ) { + override suspend fun doWork(): Result { + val aggregated = aggregateEvents() + if (!aggregated.clicks.isNullOrEmpty() || + !aggregated.impressions.isNullOrEmpty() || + !aggregated.purchases.isNullOrEmpty() + ) { + try { + TopsortAnalyticsHttpService.service.reportEvent(aggregated) + } catch(_: ExceptionInInitializerError) { + // ignored, occurs in testing when no http service is available + } catch(ex: Exception){ + return Result.retry() + } + + Logger.log.add("uploading: ${aggregated.toJsonObject()}") + + clear() + uploadQueued.set(false) + } + return Result.success() + } + } +} diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/core/JsonExtensions.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/core/JsonExtensions.kt index c87c0c7..4f27132 100644 --- a/TopsortAnalytics/src/main/java/com/topsort/analytics/core/JsonExtensions.kt +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/core/JsonExtensions.kt @@ -1,5 +1,6 @@ package com.topsort.analytics.core +import org.json.JSONArray import org.json.JSONObject fun JSONObject.getStringOrNull(name: String): String? { @@ -20,3 +21,9 @@ fun JSONObject.getStringListOrNull(name: String): List? { return (0 until array.length()).map { array[it].toString() } } else null } + +fun getListFromJsonArray(array: JSONArray, jsonDeserializer: (JSONObject) -> T): List { + return (0 until array.length()).map { + jsonDeserializer(array.getJSONObject(it)) + } +} diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/core/Logger.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/core/Logger.kt new file mode 100644 index 0000000..54d46cf --- /dev/null +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/core/Logger.kt @@ -0,0 +1,5 @@ +package com.topsort.analytics.core + +object Logger { + val log = mutableListOf() +} diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/ClickEvent.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/ClickEvent.kt index 1f3c598..e996421 100644 --- a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/ClickEvent.kt +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/ClickEvent.kt @@ -1,5 +1,6 @@ package com.topsort.analytics.model +import com.topsort.analytics.core.getListFromJsonArray import com.topsort.analytics.core.getStringOrNull import org.json.JSONArray import org.json.JSONObject @@ -63,8 +64,8 @@ data class Click private constructor ( * The marketplace's ID for the click */ val id: String, -) { - fun toJsonObject(): JSONObject { +) : JsonSerializable { + override fun toJsonObject(): JSONObject { return JSONObject() .let { if (resolvedBidId == null) { @@ -133,6 +134,12 @@ data class Click private constructor ( id = json.getString("id"), ) } + + fun fromJsonArray(array: JSONArray): List = getListFromJsonArray( + array + ) { + fromJsonObject(it) + } } } diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/Event.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/Event.kt new file mode 100644 index 0000000..59609d3 --- /dev/null +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/Event.kt @@ -0,0 +1,75 @@ +package com.topsort.analytics.model + +import org.json.JSONArray +import org.json.JSONObject + +data class Event( + val clicks: List? = null, + val impressions: List? = null, + val purchases: List? = null, +) { + fun toJsonObject(): JSONObject { + val json = JSONObject() + + if (clicks != null) { + val array = JSONArray() + clicks.indices.map { + array.put(it, clicks[it].toJsonObject()) + } + json.put("clicks", array) + } + + if (impressions != null) { + val array = JSONArray() + impressions.indices.map { + array.put(it, impressions[it].toJsonObject()) + } + json.put("impressions", array) + } + + if (purchases != null) { + val array = JSONArray() + purchases.indices.map { + array.put(it, purchases[it].toJsonObject()) + } + json.put("purchases", array) + } + return json + } + + companion object { + fun fromJson(json: String?): Event? { + if (json == null) return null + val jsonObject = JSONObject(json) + + val clicks = if (jsonObject.has("clicks")) { + val array = JSONObject(json).getJSONArray("clicks") + val clicks = (0 until array.length()).map { + Click.Factory.fromJsonObject(array.getJSONObject(it)) + } + + clicks + } else null + + val impressions = if (jsonObject.has("impressions")) { + val array = JSONObject(json).getJSONArray("impressions") + val impressions = (0 until array.length()).map { + Impression.Factory.fromJsonObject(array.getJSONObject(it)) + } + + impressions + } else null + + val purchases = if (jsonObject.has("purchases")) { + val array = JSONObject(json).getJSONArray("purchases") + val purchases = (0 until array.length()).map { + Purchase.fromJsonObject(array.getJSONObject(it)) + } + + purchases + } else null + + return Event(clicks = clicks, impressions = impressions, purchases = purchases) + } + } +} diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/ImpressionEvent.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/ImpressionEvent.kt index b345298..5da8c66 100644 --- a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/ImpressionEvent.kt +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/ImpressionEvent.kt @@ -1,6 +1,8 @@ package com.topsort.analytics.model +import com.topsort.analytics.core.getListFromJsonArray import com.topsort.analytics.core.getStringOrNull +import org.json.JSONArray import org.json.JSONObject data class ImpressionEvent ( @@ -10,13 +12,11 @@ data class ImpressionEvent ( return JSONObject().put("impressions", impressions) } - companion object{ - fun fromJson(json : String?) : ImpressionEvent? { - if(json == null) return null + companion object { + fun fromJson(json: String?): ImpressionEvent? { + if (json == null) return null val array = JSONObject(json).getJSONArray("impressions") - val impressions = (0 until array.length()).map { - Impression.Factory.fromJsonObject(array.getJSONObject(it)) - } + val impressions = Impression.Factory.fromJsonArray(array) return ImpressionEvent(impressions = impressions) } @@ -58,8 +58,8 @@ data class Impression private constructor( * The marketplace assigned ID for the order */ val id: String, -) { - fun toJsonObject(): JSONObject { +) : JsonSerializable { + override fun toJsonObject(): JSONObject { return JSONObject() .let { if (resolvedBidId == null) { @@ -129,6 +129,13 @@ data class Impression private constructor( id = json.getString("id"), ) } + + fun fromJsonArray(array: JSONArray): List = + getListFromJsonArray( + array + ) { + fromJsonObject(it) + } } } diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/JsonSerializable.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/JsonSerializable.kt new file mode 100644 index 0000000..2d96798 --- /dev/null +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/JsonSerializable.kt @@ -0,0 +1,7 @@ +package com.topsort.analytics.model + +import org.json.JSONObject + +interface JsonSerializable { + fun toJsonObject(): JSONObject +} diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/Placement.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/Placement.kt index 87515f1..033ae37 100644 --- a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/Placement.kt +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/Placement.kt @@ -3,6 +3,7 @@ package com.topsort.analytics.model import com.topsort.analytics.core.getIntOrNull import com.topsort.analytics.core.getStringListOrNull import com.topsort.analytics.core.getStringOrNull +import org.json.JSONArray import org.json.JSONObject data class Placement( @@ -63,7 +64,7 @@ data class Placement( .put("page", page) .put("pageSize", pageSize) .put("productId", productId) - .put("categoryIds", categoryIds) + .put("categoryIds", JSONArray(categoryIds)) .put("searchQuery", searchQuery) .put("location", location) } diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/PurchaseEvent.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/PurchaseEvent.kt index 4dc5355..fb852c1 100644 --- a/TopsortAnalytics/src/main/java/com/topsort/analytics/model/PurchaseEvent.kt +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/model/PurchaseEvent.kt @@ -2,7 +2,9 @@ package com.topsort.analytics.model import androidx.annotation.IntRange import com.topsort.analytics.core.getIntOrNull +import com.topsort.analytics.core.getListFromJsonArray import com.topsort.analytics.core.getStringOrNull +import org.json.JSONArray import org.json.JSONObject @@ -47,13 +49,13 @@ data class Purchase( * Items purchased */ val items: List, -) { - fun toJsonObject(): JSONObject { +) : JsonSerializable { + override fun toJsonObject(): JSONObject { return JSONObject() .put("occurredAt", occurredAt) .put("opaqueUserId", opaqueUserId) .put("id", id) - .put("items", items) + .put("items", JSONArray(items.map { it.toJsonObject() })) } companion object { @@ -68,6 +70,13 @@ data class Purchase( }, ) } + + fun fromJsonArray(array: JSONArray): List = + getListFromJsonArray( + array + ) { + fromJsonObject(it) + } } } @@ -92,6 +101,7 @@ data class PurchasedItem( .put("productId", productId) .put("quantity", quantity) .put("unitPrice", unitPrice) + .put("resolvedBidId", resolvedBidId) } companion object { diff --git a/TopsortAnalytics/src/main/java/com/topsort/analytics/service/TopsortAnalyticsHttpService.kt b/TopsortAnalytics/src/main/java/com/topsort/analytics/service/TopsortAnalyticsHttpService.kt index 740212b..540f031 100644 --- a/TopsortAnalytics/src/main/java/com/topsort/analytics/service/TopsortAnalyticsHttpService.kt +++ b/TopsortAnalytics/src/main/java/com/topsort/analytics/service/TopsortAnalyticsHttpService.kt @@ -3,11 +3,11 @@ package com.topsort.analytics.service import com.topsort.analytics.Cache import com.topsort.analytics.core.HttpClient import com.topsort.analytics.core.HttpResponse +import com.topsort.analytics.core.ServiceSettings.baseApiUrl import com.topsort.analytics.model.ClickEvent +import com.topsort.analytics.model.Event import com.topsort.analytics.model.ImpressionEvent import com.topsort.analytics.model.PurchaseEvent -import org.json.JSONObject -import com.topsort.analytics.core.ServiceSettings.baseApiUrl private const val EVENTS_ENDPOINT = "/v2/events" @@ -20,21 +20,24 @@ internal object TopsortAnalyticsHttpService { private fun buildService(): Service { return object : Service { - private fun reportEvent(event: Any): HttpResponse { - val json = JSONObject.wrap(event)!!.toString() + private fun reportSerializedEvent(json: String): HttpResponse { return httpClient.post(json, Cache.token.ifEmpty { null }) } override fun reportImpression(impressionEvent: ImpressionEvent): HttpResponse { - return reportEvent(impressionEvent) + return reportSerializedEvent(impressionEvent.toJsonObject().toString()) } override fun reportClick(clickEvent: ClickEvent): HttpResponse { - return reportEvent(clickEvent) + return reportSerializedEvent(clickEvent.toJsonObject().toString()) } override fun reportPurchase(purchaseEvent: PurchaseEvent): HttpResponse { - return reportEvent(purchaseEvent) + return reportSerializedEvent(purchaseEvent.toJsonObject().toString()) + } + + override fun reportEvent(event: Event): HttpResponse { + return reportSerializedEvent(event.toJsonObject().toString()) } } } @@ -45,5 +48,7 @@ internal object TopsortAnalyticsHttpService { fun reportClick(clickEvent: ClickEvent): HttpResponse fun reportPurchase(purchaseEvent: PurchaseEvent): HttpResponse + + fun reportEvent(event: Event): HttpResponse } }