From 61f4e57c7066a573813b25f2a47ba56ccc2942a1 Mon Sep 17 00:00:00 2001 From: Mouaad Aallam Date: Mon, 15 Mar 2021 16:01:19 +0100 Subject: [PATCH 1/4] feat(insights): InstantSearch Insights Integration (#202) --- .gitignore | 2 + Gemfile.lock | 2 +- build.gradle.kts | 4 + .../src/main/kotlin/dependency/lib/Work.kt | 10 + .../dependency/network/AlgoliaClient.kt | 1 - .../kotlin/dependency/plugin/AndroidTools.kt | 2 +- .../dependency/plugin/GradleMavenPublish.kt | 2 +- .../kotlin/dependency/test/AndroidTestExt.kt | 1 - .../dependency/test/AndroidTestRunner.kt | 2 +- .../src/main/kotlin/dependency/test/Mockk.kt | 10 + .../kotlin/dependency/test/Robolectric.kt | 5 +- .../main/kotlin/dependency/util/AtomicFu.kt | 1 - gradle.properties | 1 - gradle/gradle-maven-publish.gradle | 25 -- instantsearch-android-core/build.gradle.kts | 6 +- instantsearch-android/build.gradle.kts | 29 +- .../helper/tracker/FilterTracker.kt | 119 ++++++ .../helper/tracker/HitsTracker.kt | 89 +++++ .../tracker/internal/FilterDataTracker.kt | 68 ++++ .../tracker/internal/HitsDataTracker.kt | 76 ++++ .../helper/tracker/internal/InsightsScope.kt | 16 + .../tracker/internal/InsightsTracker.kt | 14 + .../tracker/internal/QueryIDContainer.kt | 14 + .../tracker/internal/SubscriptionJob.kt | 40 ++ .../tracker/internal/TrackableSearcher.kt | 69 ++++ .../test/java/extension/MainCoroutineRule.kt | 32 ++ .../test/java/tracker/TestFiltersTracker.kt | 134 +++++++ .../src/test/java/tracker/TestHitsTracker.kt | 90 +++++ instantsearch-insights/.gitignore | 1 + instantsearch-insights/build.gradle.kts | 87 +++++ instantsearch-insights/gradle.properties | 2 + .../src/main/AndroidManifest.xml | 6 + .../instantsearch/insights/FilterTrackable.kt | 47 +++ .../insights/HitsAfterSearchTrackable.kt | 80 ++++ .../instantsearch/insights/Insights.kt | 180 +++++++++ .../insights/exception/InsightsException.kt | 19 + .../insights/internal/InsightsController.kt | 221 +++++++++++ .../insights/internal/InsightsMap.kt | 8 + .../insights/internal/cache/InsightsCache.kt | 10 + .../internal/cache/InsightsEventCache.kt | 17 + .../data/distant/InsightsDistantRepository.kt | 9 + .../data/distant/InsightsHttpRepository.kt | 30 ++ .../data/local/InsightsLocalRepository.kt | 16 + .../data/local/InsightsPrefsRepository.kt | 44 +++ .../data/local/mapper/FilterFacetMapper.kt | 65 ++++ .../local/mapper/InsightsEventDOMapper.kt | 52 +++ .../data/local/mapper/InsightsEventsMapper.kt | 105 ++++++ .../internal/data/local/mapper/Mapper.kt | 8 + .../data/local/mapper/ResourcesMapper.kt | 29 ++ .../data/local/model/FilterFacetDO.kt | 23 ++ .../data/local/model/InsightsEventDO.kt | 25 ++ .../data/settings/InsightsEventSettings.kt | 25 ++ .../data/settings/InsightsSettings.kt | 10 + .../insights/internal/event/EventResponse.kt | 8 + .../insights/internal/extension/Insights.kt | 46 +++ .../insights/internal/extension/Map.kt | 5 + .../insights/internal/extension/Platform.kt | 20 + .../extension/SharedPreferencesDelegate.kt | 46 +++ .../insights/internal/extension/Time.kt | 4 + .../internal/logging/InsightsLogger.kt | 20 + .../uploader/InsightsEventUploader.kt | 32 ++ .../internal/uploader/InsightsUploader.kt | 8 + .../internal/worker/InsightsManager.kt | 10 + .../internal/worker/InsightsWorkManager.kt | 57 +++ .../internal/worker/InsightsWorker.kt | 20 + .../instantsearch/insights/BuildConfig.kt | 7 + .../AndroidTestDatabaseSharedPreferences.kt | 82 ++++ .../insights/InsightsAndroidTest.kt | 70 ++++ .../insights/InsightsAndroidTestJava.java | 62 +++ .../instantsearch/insights/InsightsTest.kt | 357 ++++++++++++++++++ .../insights/MockDistantRepository.kt | 14 + .../insights/MockLocalRepository.kt | 30 ++ .../insights/util/WorkerManager.kt | 16 + .../src/test/resources/robolectric.properties | 1 + settings.gradle.kts | 1 + 75 files changed, 2841 insertions(+), 58 deletions(-) create mode 100644 buildSrc/src/main/kotlin/dependency/lib/Work.kt create mode 100644 buildSrc/src/main/kotlin/dependency/test/Mockk.kt delete mode 100644 gradle/gradle-maven-publish.gradle create mode 100644 instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/FilterTracker.kt create mode 100644 instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/HitsTracker.kt create mode 100644 instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/FilterDataTracker.kt create mode 100644 instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/HitsDataTracker.kt create mode 100644 instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/InsightsScope.kt create mode 100644 instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/InsightsTracker.kt create mode 100644 instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/QueryIDContainer.kt create mode 100644 instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/SubscriptionJob.kt create mode 100644 instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/TrackableSearcher.kt create mode 100644 instantsearch-android/src/test/java/extension/MainCoroutineRule.kt create mode 100644 instantsearch-android/src/test/java/tracker/TestFiltersTracker.kt create mode 100644 instantsearch-android/src/test/java/tracker/TestHitsTracker.kt create mode 100644 instantsearch-insights/.gitignore create mode 100644 instantsearch-insights/build.gradle.kts create mode 100644 instantsearch-insights/gradle.properties create mode 100644 instantsearch-insights/src/main/AndroidManifest.xml create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/FilterTrackable.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/HitsAfterSearchTrackable.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/Insights.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/exception/InsightsException.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/InsightsController.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/InsightsMap.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/cache/InsightsCache.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/cache/InsightsEventCache.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/distant/InsightsDistantRepository.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/distant/InsightsHttpRepository.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/InsightsLocalRepository.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/InsightsPrefsRepository.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/FilterFacetMapper.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/InsightsEventDOMapper.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/InsightsEventsMapper.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/Mapper.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/ResourcesMapper.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/model/FilterFacetDO.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/model/InsightsEventDO.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/settings/InsightsEventSettings.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/settings/InsightsSettings.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/event/EventResponse.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Insights.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Map.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Platform.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/SharedPreferencesDelegate.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Time.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/logging/InsightsLogger.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/uploader/InsightsEventUploader.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/uploader/InsightsUploader.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/worker/InsightsManager.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/worker/InsightsWorkManager.kt create mode 100644 instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/worker/InsightsWorker.kt create mode 100644 instantsearch-insights/src/main/templates/com/algolia/instantsearch/insights/BuildConfig.kt create mode 100644 instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/AndroidTestDatabaseSharedPreferences.kt create mode 100644 instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/InsightsAndroidTest.kt create mode 100644 instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/InsightsAndroidTestJava.java create mode 100644 instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/InsightsTest.kt create mode 100644 instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/MockDistantRepository.kt create mode 100644 instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/MockLocalRepository.kt create mode 100644 instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/util/WorkerManager.kt create mode 100644 instantsearch-insights/src/test/resources/robolectric.properties diff --git a/.gitignore b/.gitignore index 88ed1c4d2..d06f76435 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.iml local.properties *.hprof +.bundle/ # fastlane **/fastlane/report.xml @@ -14,3 +15,4 @@ local.properties # bitrise .bitrise.yml +bitrise.yml diff --git a/Gemfile.lock b/Gemfile.lock index 42d362668..4b76d6146 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -175,4 +175,4 @@ DEPENDENCIES fastlane BUNDLED WITH - 1.17.2 + 1.17.3 diff --git a/build.gradle.kts b/build.gradle.kts index dd66aefb5..2cdb3f132 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -54,3 +54,7 @@ subprojects { tasks.withType { maxParallelForks = Runtime.getRuntime().availableProcessors().minus(1).coerceAtLeast(1) } + +tasks.register("clean") { + delete(rootProject.buildDir) +} diff --git a/buildSrc/src/main/kotlin/dependency/lib/Work.kt b/buildSrc/src/main/kotlin/dependency/lib/Work.kt new file mode 100644 index 000000000..6f35ba115 --- /dev/null +++ b/buildSrc/src/main/kotlin/dependency/lib/Work.kt @@ -0,0 +1,10 @@ +package dependency.lib + +import dependency.Dependency + +object Work : Dependency { + + override val group = "androidx.work" + override val artifact = "work" + override val version = "2.5.0" +} diff --git a/buildSrc/src/main/kotlin/dependency/network/AlgoliaClient.kt b/buildSrc/src/main/kotlin/dependency/network/AlgoliaClient.kt index 41c1538e1..a6e26119f 100644 --- a/buildSrc/src/main/kotlin/dependency/network/AlgoliaClient.kt +++ b/buildSrc/src/main/kotlin/dependency/network/AlgoliaClient.kt @@ -2,7 +2,6 @@ package dependency.network import dependency.Dependency - object AlgoliaClient : Dependency { override val group = "com.algolia" diff --git a/buildSrc/src/main/kotlin/dependency/plugin/AndroidTools.kt b/buildSrc/src/main/kotlin/dependency/plugin/AndroidTools.kt index a97d6392b..2aa50020e 100644 --- a/buildSrc/src/main/kotlin/dependency/plugin/AndroidTools.kt +++ b/buildSrc/src/main/kotlin/dependency/plugin/AndroidTools.kt @@ -6,5 +6,5 @@ object AndroidTools : Dependency { override val group = "com.android.tools.build" override val artifact = "gradle" - override val version = "4.0.1" + override val version = "4.0.2" } diff --git a/buildSrc/src/main/kotlin/dependency/plugin/GradleMavenPublish.kt b/buildSrc/src/main/kotlin/dependency/plugin/GradleMavenPublish.kt index 0ee39f4b9..255068042 100644 --- a/buildSrc/src/main/kotlin/dependency/plugin/GradleMavenPublish.kt +++ b/buildSrc/src/main/kotlin/dependency/plugin/GradleMavenPublish.kt @@ -6,5 +6,5 @@ object GradleMavenPublish : Dependency { override val group = "com.vanniktech" override val artifact = "gradle-maven-publish-plugin" - override val version = "0.13.0" + override val version = "0.14.2" } diff --git a/buildSrc/src/main/kotlin/dependency/test/AndroidTestExt.kt b/buildSrc/src/main/kotlin/dependency/test/AndroidTestExt.kt index 24c39bdd2..18fa9ca04 100644 --- a/buildSrc/src/main/kotlin/dependency/test/AndroidTestExt.kt +++ b/buildSrc/src/main/kotlin/dependency/test/AndroidTestExt.kt @@ -2,7 +2,6 @@ package dependency.test import dependency.Dependency - object AndroidTestExt : Dependency { override val group = "androidx.test.ext" diff --git a/buildSrc/src/main/kotlin/dependency/test/AndroidTestRunner.kt b/buildSrc/src/main/kotlin/dependency/test/AndroidTestRunner.kt index 3e548ccdb..1ade9b349 100644 --- a/buildSrc/src/main/kotlin/dependency/test/AndroidTestRunner.kt +++ b/buildSrc/src/main/kotlin/dependency/test/AndroidTestRunner.kt @@ -7,4 +7,4 @@ object AndroidTestRunner: Dependency { override val group = "androidx.test" override val artifact = "runner" override val version = "1.2.0" -} \ No newline at end of file +} diff --git a/buildSrc/src/main/kotlin/dependency/test/Mockk.kt b/buildSrc/src/main/kotlin/dependency/test/Mockk.kt new file mode 100644 index 000000000..329ce1895 --- /dev/null +++ b/buildSrc/src/main/kotlin/dependency/test/Mockk.kt @@ -0,0 +1,10 @@ +package dependency.test + +import dependency.Dependency + +object Mockk : Dependency { + + override val group = "io.mockk" + override val artifact = "mockk" + override val version = "1.10.6" +} diff --git a/buildSrc/src/main/kotlin/dependency/test/Robolectric.kt b/buildSrc/src/main/kotlin/dependency/test/Robolectric.kt index 87e8b6ea6..401c371e5 100644 --- a/buildSrc/src/main/kotlin/dependency/test/Robolectric.kt +++ b/buildSrc/src/main/kotlin/dependency/test/Robolectric.kt @@ -2,10 +2,9 @@ package dependency.test import dependency.Dependency - object Robolectric : Dependency { override val group = "org.robolectric" override val artifact = "robolectric" - override val version = "4.3.1" -} \ No newline at end of file + override val version = "4.5.1" +} diff --git a/buildSrc/src/main/kotlin/dependency/util/AtomicFu.kt b/buildSrc/src/main/kotlin/dependency/util/AtomicFu.kt index 5947d1a9e..43321c06c 100644 --- a/buildSrc/src/main/kotlin/dependency/util/AtomicFu.kt +++ b/buildSrc/src/main/kotlin/dependency/util/AtomicFu.kt @@ -2,7 +2,6 @@ package dependency.util import dependency.Dependency - object AtomicFu: Dependency { override val group = "org.jetbrains.kotlinx" diff --git a/gradle.properties b/gradle.properties index dfdfef5ef..1b87d1bca 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,5 @@ kotlin.code.style=official -android.enableJetifier=true android.useAndroidX=true org.gradle.parallel=true diff --git a/gradle/gradle-maven-publish.gradle b/gradle/gradle-maven-publish.gradle deleted file mode 100644 index 564f12362..000000000 --- a/gradle/gradle-maven-publish.gradle +++ /dev/null @@ -1,25 +0,0 @@ -apply plugin: "com.vanniktech.maven.publish" -apply plugin: "org.jetbrains.dokka" - -mavenPublish { - def username = System.getenv("SONATYPE_USER") - def password = System.getenv("SONATYPE_KEY") - targets { - uploadArchives { - repositoryUsername = username - repositoryPassword = password - } - } - nexus { - repositoryUsername = username - repositoryPassword = password - } -} - -signing { - def signingKeyId = System.getenv("SIGNING_KEY_ID") - def signingKey = System.getenv("SIGNING_KEY") - def signingPassword = System.getenv("SIGNING_PASSWORD") - useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) - sign(publishing.publications) -} diff --git a/instantsearch-android-core/build.gradle.kts b/instantsearch-android-core/build.gradle.kts index 908796efe..cc348fba0 100644 --- a/instantsearch-android-core/build.gradle.kts +++ b/instantsearch-android-core/build.gradle.kts @@ -5,13 +5,9 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { kotlin("jvm") id("java-library") + id ("com.vanniktech.maven.publish") } -apply(from = "../gradle/gradle-maven-publish.gradle") - -group = Library.group -version = Library.version - sourceSets { main { java.srcDirs("$buildDir/generated/sources/templates/kotlin/main") diff --git a/instantsearch-android/build.gradle.kts b/instantsearch-android/build.gradle.kts index 242c6e0ce..9deebe213 100644 --- a/instantsearch-android/build.gradle.kts +++ b/instantsearch-android/build.gradle.kts @@ -1,24 +1,14 @@ -import dependency.network.AlgoliaClient -import dependency.network.Coroutines -import dependency.network.Ktor -import dependency.test.AndroidTestExt -import dependency.test.AndroidTestRunner -import dependency.test.Robolectric -import dependency.ui.AndroidCore -import dependency.ui.AppCompat -import dependency.ui.MaterialDesign -import dependency.ui.Paging -import dependency.ui.RecyclerView -import dependency.ui.SwipeRefreshLayout +import dependency.network.* +import dependency.test.* +import dependency.ui.* plugins { id("com.android.library") id("kotlin-android") id("kotlinx-serialization") + id("com.vanniktech.maven.publish") } -apply(from = "../gradle/gradle-maven-publish.gradle") - android { compileSdkVersion(30) @@ -42,7 +32,6 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() freeCompilerArgs += listOf( - "-Xexplicit-api=warning", "-Xopt-in=kotlin.RequiresOptIn", "-Xopt-in=com.algolia.search.ExperimentalAlgoliaClientAPI" ) @@ -56,11 +45,15 @@ android { } } -group = Library.group -version = Library.version +tasks.withType { + if ("UnitTest" !in name) { + kotlinOptions.freeCompilerArgs += "-Xexplicit-api=strict" + } +} dependencies { api(project(":instantsearch-android-core")) + api(project(":instantsearch-insights")) api(AlgoliaClient()) api(Ktor("client-okhttp")) api(AndroidCore("ktx")) @@ -77,4 +70,6 @@ dependencies { testImplementation(AndroidTestRunner()) testImplementation(AndroidTestExt()) testImplementation(Robolectric()) + testImplementation(Coroutines("test")) + testImplementation(Mockk()) } diff --git a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/FilterTracker.kt b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/FilterTracker.kt new file mode 100644 index 000000000..3cdf6ef70 --- /dev/null +++ b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/FilterTracker.kt @@ -0,0 +1,119 @@ +@file:Suppress("FunctionName") + +package com.algolia.instantsearch.helper.tracker + +import com.algolia.instantsearch.helper.searcher.SearcherMultipleIndex +import com.algolia.instantsearch.helper.searcher.SearcherSingleIndex +import com.algolia.instantsearch.helper.tracker.internal.FilterDataTracker +import com.algolia.instantsearch.helper.tracker.internal.InsightsScope +import com.algolia.instantsearch.helper.tracker.internal.TrackableSearcher +import com.algolia.instantsearch.insights.Insights +import com.algolia.search.model.Attribute +import com.algolia.search.model.filter.Filter +import com.algolia.search.model.insights.EventName +import com.algolia.search.model.search.Facet +import kotlinx.coroutines.CoroutineScope + +/** + * Tracker of filter events insights. + */ +public interface FilterTracker { + + /** + * Coroutine scope in which all tracking operations are executed. + */ + public val trackerScope: CoroutineScope + + /** + * Track a filter click event. + * + * @param filter filter to track + * @param customEventName custom event name, overrides the default event name + */ + public fun trackClick(filter: Filter.Facet, customEventName: EventName? = null) + + /** + * Track a filter view event. + * + * @param filter filter to track + * @param customEventName custom event name, overrides the default event name + */ + public fun trackView(filter: Filter.Facet, customEventName: EventName? = null) + + /** + * Track a filter conversion event. + * + * @param filter filter to track + * @param customEventName custom event name, overrides the default event name + */ + public fun trackConversion(filter: Filter.Facet, customEventName: EventName? = null) + + /** + * Track a facet click event. + * + * @param facet facet to track + * @param attribute facet attribute + * @param customEventName custom event name, overrides the default event name + */ + public fun trackClick(facet: Facet, attribute: Attribute, customEventName: EventName? = null) + + /** + * Track a facet view event. + * + * @param facet facet to track + * @param attribute facet attribute + * @param customEventName custom event name, overrides the default event name + */ + public fun trackView(facet: Facet, attribute: Attribute, customEventName: EventName? = null) + + /** + * Track a facet conversion event. + * + * @param facet facet to track + * @param attribute facet attribute + * @param customEventName custom event name, overrides the default event name + */ + public fun trackConversion(facet: Facet, attribute: Attribute, customEventName: EventName? = null) +} + +/** + * Creates a [FilterTracker] object. + * + * @param eventName default event name + * @param searcher single index searcher + * @param insights actual events handler + * @param coroutineScope coroutine scope to execute tracking operations + */ +public fun FilterTracker( + eventName: EventName, + searcher: SearcherSingleIndex, + insights: Insights, + coroutineScope: CoroutineScope = InsightsScope(), +): FilterTracker = FilterDataTracker( + eventName = eventName, + trackableSearcher = TrackableSearcher.SingleIndex(searcher), + tracker = insights, + trackerScope = coroutineScope +) + +/** + * Creates a [FilterTracker] object. + * + * @param eventName default event name + * @param searcher multiple index searcher + * @param pointer pointer to a specific index position + * @param insights actual events handler + * @param coroutineScope coroutine scope to execute tracking operations + */ +public fun FilterTracker( + eventName: EventName, + searcher: SearcherMultipleIndex, + pointer: Int, + insights: Insights, + coroutineScope: CoroutineScope = InsightsScope(), +): FilterTracker = FilterDataTracker( + eventName = eventName, + trackableSearcher = TrackableSearcher.MultiIndex(searcher, pointer), + tracker = insights, + trackerScope = coroutineScope +) diff --git a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/HitsTracker.kt b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/HitsTracker.kt new file mode 100644 index 000000000..43d42434c --- /dev/null +++ b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/HitsTracker.kt @@ -0,0 +1,89 @@ +@file:Suppress("FunctionName") + +package com.algolia.instantsearch.helper.tracker + +import com.algolia.instantsearch.core.connection.Connection +import com.algolia.instantsearch.helper.searcher.SearcherMultipleIndex +import com.algolia.instantsearch.helper.searcher.SearcherSingleIndex +import com.algolia.instantsearch.helper.tracker.internal.HitsDataTracker +import com.algolia.instantsearch.helper.tracker.internal.InsightsScope +import com.algolia.instantsearch.helper.tracker.internal.TrackableSearcher +import com.algolia.instantsearch.insights.Insights +import com.algolia.search.model.indexing.Indexable +import com.algolia.search.model.insights.EventName +import kotlinx.coroutines.CoroutineScope + +/** + * Tracker of hits events insights. + */ +public interface HitsTracker : Connection { + + /** + * Coroutine scope in which all tracking operations are executed. + */ + public val trackerScope: CoroutineScope + + /** + * Track a hit click event. + * + * @param hit hit to track + * @param customEventName custom event name, overrides the default value. + */ + public fun trackClick(hit: T, position: Int, customEventName: EventName? = null) + + /** + * Track a hit convert event. + * + * @param hit hit to track + * @param customEventName custom event name, overrides the default event name + */ + public fun trackConvert(hit: T, customEventName: EventName? = null) + + /** + * Track a hit view event. + * + * @param hit hit to track + * @param customEventName custom event name, overrides the default event name + */ + public fun trackView(hit: T, customEventName: EventName? = null) +} + +/** + * Creates a [HitsTracker] object. + * + * @param eventName default event name + * @param searcher single index searcher + * @param insights actual events handler + */ +public fun HitsTracker( + eventName: EventName, + searcher: SearcherSingleIndex, + insights: Insights, + coroutineScope: CoroutineScope = InsightsScope(), +): HitsTracker = HitsDataTracker( + eventName = eventName, + trackableSearcher = TrackableSearcher.SingleIndex(searcher), + tracker = insights, + trackerScope = coroutineScope +) + +/** + * Creates a [HitsTracker] object. + * + * @param eventName default event name + * @param searcher multiple index searcher + * @param pointer pointer to a specific index position + * @param insights actual events handler + */ +public fun HitsTracker( + eventName: EventName, + searcher: SearcherMultipleIndex, + pointer: Int, + insights: Insights, + coroutineScope: CoroutineScope = InsightsScope(), +): HitsTracker = HitsDataTracker( + eventName = eventName, + trackableSearcher = TrackableSearcher.MultiIndex(searcher, pointer), + tracker = insights, + trackerScope = coroutineScope +) diff --git a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/FilterDataTracker.kt b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/FilterDataTracker.kt new file mode 100644 index 000000000..c03233f6c --- /dev/null +++ b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/FilterDataTracker.kt @@ -0,0 +1,68 @@ +package com.algolia.instantsearch.helper.tracker.internal + +import com.algolia.instantsearch.helper.filter.state.toFilter +import com.algolia.instantsearch.helper.tracker.FilterTracker +import com.algolia.instantsearch.insights.FilterTrackable +import com.algolia.search.model.Attribute +import com.algolia.search.model.filter.Filter +import com.algolia.search.model.insights.EventName +import com.algolia.search.model.search.Facet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Tracker of filter events insights. + */ +internal class FilterDataTracker( + override val eventName: EventName, + override val trackableSearcher: TrackableSearcher<*>, + override val tracker: FilterTrackable, + override val trackerScope: CoroutineScope = InsightsScope(), +) : FilterTracker, InsightsTracker { + + // region Filter tracking methods + public override fun trackClick(filter: Filter.Facet, customEventName: EventName?) { + trackerScope.launch { + tracker.clickedFilters( + eventName = customEventName ?: eventName, + filters = listOf(filter) + ) + } + } + + public override fun trackView(filter: Filter.Facet, customEventName: EventName?) { + trackerScope.launch { + tracker.viewedFilters( + eventName = customEventName ?: eventName, + filters = listOf(filter) + ) + } + } + + public override fun trackConversion(filter: Filter.Facet, customEventName: EventName?) { + trackerScope.launch { + tracker.convertedFilters( + eventName = customEventName ?: eventName, + filters = listOf(filter) + ) + } + } + // endregion + + // region Facet tracking methods + public override fun trackClick(facet: Facet, attribute: Attribute, customEventName: EventName?) { + val filterFacet = facet.toFilter(attribute) + trackClick(filter = filterFacet, customEventName = customEventName) + } + + public override fun trackView(facet: Facet, attribute: Attribute, customEventName: EventName?) { + val filterFacet = facet.toFilter(attribute) + trackView(filter = filterFacet, customEventName = customEventName) + } + + public override fun trackConversion(facet: Facet, attribute: Attribute, customEventName: EventName?) { + val filterFacet = facet.toFilter(attribute) + trackConversion(filter = filterFacet, customEventName = customEventName) + } + // endregion +} diff --git a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/HitsDataTracker.kt b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/HitsDataTracker.kt new file mode 100644 index 000000000..1251ceaf0 --- /dev/null +++ b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/HitsDataTracker.kt @@ -0,0 +1,76 @@ +package com.algolia.instantsearch.helper.tracker.internal + +import com.algolia.instantsearch.helper.tracker.HitsTracker +import com.algolia.instantsearch.insights.HitsAfterSearchTrackable +import com.algolia.search.model.QueryID +import com.algolia.search.model.indexing.Indexable +import com.algolia.search.model.insights.EventName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Tracker of hits events insights. + */ +internal class HitsDataTracker( + override val eventName: EventName, + override val trackableSearcher: TrackableSearcher<*>, + override val tracker: HitsAfterSearchTrackable, + override val trackerScope: CoroutineScope, +) : HitsTracker, InsightsTracker, QueryIDContainer { + + public override var queryID: QueryID? = null + + init { + trackableSearcher.setClickAnalyticsOn(true) + } + + // region Hits tracking methods + public override fun trackClick(hit: T, position: Int, customEventName: EventName?) { + trackerScope.launch { + val id = queryID ?: return@launch + tracker.clickedObjectIDsAfterSearch( + eventName = customEventName ?: eventName, + queryID = id, + objectIDs = listOf(hit.objectID), + positions = listOf(position) + ) + } + } + + public override fun trackConvert(hit: T, customEventName: EventName?) { + trackerScope.launch { + val id = queryID ?: return@launch + tracker.convertedObjectIDsAfterSearch( + eventName = customEventName ?: eventName, + queryID = id, + objectIDs = listOf(hit.objectID) + ) + } + } + + public override fun trackView(hit: T, customEventName: EventName?) { + trackerScope.launch { + tracker.viewedObjectIDs( + eventName = customEventName ?: eventName, + objectIDs = listOf(hit.objectID) + ) + } + } + // endregion + + override var isConnected: Boolean = false + private set + + private var subscription: SubscriptionJob<*>? = null + + override fun connect() { + subscription?.cancel() + subscription = trackableSearcher.subscribeForQueryIDChange(this) + isConnected = true + } + + override fun disconnect() { + subscription?.cancel() + isConnected = false + } +} diff --git a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/InsightsScope.kt b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/InsightsScope.kt new file mode 100644 index 000000000..d0cf5341a --- /dev/null +++ b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/InsightsScope.kt @@ -0,0 +1,16 @@ +package com.algolia.instantsearch.helper.tracker.internal + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +/** + * Default Insights [CoroutineScope]. + * + * @param dispatcher determines what thread(s) the corresponding coroutine uses for its execution. + */ +internal class InsightsScope(dispatcher: CoroutineDispatcher = Dispatchers.Default) : CoroutineScope { + + override val coroutineContext = SupervisorJob() + dispatcher +} diff --git a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/InsightsTracker.kt b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/InsightsTracker.kt new file mode 100644 index 000000000..274a28e23 --- /dev/null +++ b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/InsightsTracker.kt @@ -0,0 +1,14 @@ +package com.algolia.instantsearch.helper.tracker.internal + +import com.algolia.search.model.insights.EventName +import kotlinx.coroutines.CoroutineScope + +/** + * Insights class wrapper with tracking capabilities. + */ +internal interface InsightsTracker { + public val eventName: EventName + public val trackableSearcher: TrackableSearcher<*> + public val tracker: T + public val trackerScope: CoroutineScope +} diff --git a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/QueryIDContainer.kt b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/QueryIDContainer.kt new file mode 100644 index 000000000..59006e3fd --- /dev/null +++ b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/QueryIDContainer.kt @@ -0,0 +1,14 @@ +package com.algolia.instantsearch.helper.tracker.internal + +import com.algolia.search.model.QueryID + +/** + * A container for query ID. + */ +internal interface QueryIDContainer { + + /** + * Current query ID. + */ + public var queryID: QueryID? +} diff --git a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/SubscriptionJob.kt b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/SubscriptionJob.kt new file mode 100644 index 000000000..f0ed23ef6 --- /dev/null +++ b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/SubscriptionJob.kt @@ -0,0 +1,40 @@ +package com.algolia.instantsearch.helper.tracker.internal + +import com.algolia.instantsearch.core.subscription.Subscription +import com.algolia.instantsearch.core.subscription.SubscriptionValue + +/** + * Represents a link between a [Subscription] and a subscriber. + */ +internal class SubscriptionJob( + private val subscription: Subscription, + private val subscriber: (T) -> Unit +) { + + /** + * Current state, true if active, otherwise false. + */ + internal var isActive: Boolean = false + internal set + + /** + * Subscribe the subscriber to the subscription. + */ + internal fun start() { + if (isActive) return + when (subscription) { + is SubscriptionValue -> subscription.subscribePast(subscriber) + else -> subscription.subscribe(subscriber) + } + isActive = true + } + + /** + * Unsubscribe the subscriber from the subscription. + */ + internal fun cancel() { + if (!isActive) return + subscription.unsubscribe(subscriber) + isActive = false + } +} diff --git a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/TrackableSearcher.kt b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/TrackableSearcher.kt new file mode 100644 index 000000000..1247d128c --- /dev/null +++ b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/tracker/internal/TrackableSearcher.kt @@ -0,0 +1,69 @@ +package com.algolia.instantsearch.helper.tracker.internal + +import com.algolia.instantsearch.core.searcher.Searcher +import com.algolia.instantsearch.helper.searcher.SearcherMultipleIndex +import com.algolia.instantsearch.helper.searcher.SearcherSingleIndex +import com.algolia.search.model.response.ResponseSearch +import com.algolia.search.model.response.ResponseSearches + +/** + * A searcher wrapper to enable tracking capabilities. + */ +internal sealed class TrackableSearcher where T : Searcher<*> { + + /** + * Wrapped searcher. + */ + internal abstract val searcher: T + + /** + * Enable the Click Analytics feature. + * + * @param on true to enable click analytics feature, false to disable it. + */ + internal abstract fun setClickAnalyticsOn(on: Boolean) + + /** + * Subscribe for the Query ID changes. + * + * @param subscriber subscriber for query ID change tracking. + */ + internal abstract fun subscribeForQueryIDChange(subscriber: T): SubscriptionJob<*> + + /** + * A searcher wrapper around [SearcherSingleIndex] to enable tracking capabilities. + */ + internal class SingleIndex(override val searcher: SearcherSingleIndex) : TrackableSearcher() { + + internal override fun setClickAnalyticsOn(on: Boolean) { + searcher.query.clickAnalytics = on + } + + internal override fun subscribeForQueryIDChange(subscriber: T): SubscriptionJob { + val onChange: (ResponseSearch?) -> Unit = { response -> + subscriber.queryID = response?.queryID + } + return SubscriptionJob(searcher.response, onChange).also { it.start() } + } + } + + /** + * A searcher wrapper around [SearcherMultipleIndex] to enable tracking capabilities. + */ + internal class MultiIndex( + override val searcher: SearcherMultipleIndex, + private val pointer: Int + ) : TrackableSearcher() { + + internal override fun setClickAnalyticsOn(on: Boolean) { + searcher.queries[pointer].query.clickAnalytics = on + } + + internal override fun subscribeForQueryIDChange(subscriber: T): SubscriptionJob { + val onChange: (ResponseSearches?) -> Unit = { response -> + subscriber.queryID = response?.results?.get(pointer)?.queryID + } + return SubscriptionJob(searcher.response, onChange).also { it.start() } + } + } +} diff --git a/instantsearch-android/src/test/java/extension/MainCoroutineRule.kt b/instantsearch-android/src/test/java/extension/MainCoroutineRule.kt new file mode 100644 index 000000000..c325652e0 --- /dev/null +++ b/instantsearch-android/src/test/java/extension/MainCoroutineRule.kt @@ -0,0 +1,32 @@ +package extension + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainCoroutineRule( + val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher() +) : TestWatcher() { + + override fun starting(description: Description?) { + super.starting(description) + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description?) { + super.finished(description) + Dispatchers.resetMain() + testDispatcher.cleanupTestCoroutines() + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +internal fun MainCoroutineRule.runBlocking(block: suspend () -> Unit) = this.testDispatcher.runBlockingTest { + block() +} diff --git a/instantsearch-android/src/test/java/tracker/TestFiltersTracker.kt b/instantsearch-android/src/test/java/tracker/TestFiltersTracker.kt new file mode 100644 index 000000000..01a15da29 --- /dev/null +++ b/instantsearch-android/src/test/java/tracker/TestFiltersTracker.kt @@ -0,0 +1,134 @@ +package tracker + +import com.algolia.instantsearch.helper.tracker.internal.FilterDataTracker +import com.algolia.instantsearch.helper.tracker.internal.TrackableSearcher +import com.algolia.instantsearch.insights.FilterTrackable +import com.algolia.search.model.Attribute +import com.algolia.search.model.filter.Filter +import com.algolia.search.model.insights.EventName +import com.algolia.search.model.search.Facet +import extension.MainCoroutineRule +import extension.runBlocking +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import org.junit.Rule +import kotlin.test.Test + +class TestFiltersTracker { + + @get:Rule + val coroutineRule = MainCoroutineRule() + private val testCoroutineScope = CoroutineScope(coroutineRule.testDispatcher) + + private val eventName = EventName("eventName") + private val trackableSearcher = mockk>() + private val filterTrackable = mockk(relaxed = true) + private val filtersTracker = FilterDataTracker(eventName, trackableSearcher, filterTrackable, testCoroutineScope) + + @Test + fun testTrackClick() = coroutineRule.runBlocking { + val eventName = EventName("customEventName") + val filter = Filter.Facet(attribute = Attribute("attribute"), value = "value") + + filtersTracker.trackClick(filter = filter, customEventName = eventName) + + verify { + filterTrackable.clickedFilters( + eventName = eventName, + filters = listOf(filter), + timestamp = any() + ) + } + } + + @Test + fun testTrackClickFacet() = coroutineRule.runBlocking { + val eventName = EventName("customEventName") + val attribute = Attribute("attribute") + val value = "value" + val facet = Facet(value = value, count = 0) + val filter = Filter.Facet(attribute = attribute, value = value) + + filtersTracker.trackClick(facet = facet, attribute = attribute, customEventName = eventName) + + verify { + filterTrackable.clickedFilters( + eventName = eventName, + filters = listOf(filter), + timestamp = any() + ) + } + } + + @Test + fun testTrackConversion() = coroutineRule.runBlocking { + val eventName = EventName("customEventName") + val value = "value" + val filter = Filter.Facet(attribute = Attribute("attribute"), value = value) + + filtersTracker.trackConversion(filter = filter, customEventName = eventName) + + verify { + filterTrackable.convertedFilters( + eventName = eventName, + filters = listOf(filter), + timestamp = any() + ) + } + } + + @Test + fun testTrackConversionFacet() { + val eventName = EventName("customEventName") + val value = "value" + val facet = Facet(value = value, count = 0) + val filter = Filter.Facet(attribute = Attribute("attribute"), value = value) + + filtersTracker.trackConversion(facet = facet, attribute = Attribute("attribute"), customEventName = eventName) + + verify { + filterTrackable.convertedFilters( + eventName = eventName, + filters = listOf(filter), + timestamp = any() + ) + } + } + + @Test + fun testTrackView() { + val eventName = EventName("customEventName") + val value = "value" + val filter = Filter.Facet(attribute = Attribute("attribute"), value = value) + + filtersTracker.trackView(filter = filter, customEventName = eventName) + + verify { + filterTrackable.viewedFilters( + eventName = eventName, + filters = listOf(filter), + timestamp = any() + ) + } + } + + @Test + fun testTrackViewFacet() { + val eventName = EventName("customEventName") + val attribute = "attribute" + val value = "value" + val facet = Facet(value = value, count = 0) + val filter = Filter.Facet(attribute = Attribute("attribute"), value = value) + + filtersTracker.trackView(facet = facet, attribute = Attribute(attribute), customEventName = eventName) + + verify { + filterTrackable.viewedFilters( + eventName = eventName, + filters = listOf(filter), + timestamp = any() + ) + } + } +} diff --git a/instantsearch-android/src/test/java/tracker/TestHitsTracker.kt b/instantsearch-android/src/test/java/tracker/TestHitsTracker.kt new file mode 100644 index 000000000..24cb78ba7 --- /dev/null +++ b/instantsearch-android/src/test/java/tracker/TestHitsTracker.kt @@ -0,0 +1,90 @@ +package tracker + +import com.algolia.instantsearch.helper.tracker.internal.HitsDataTracker +import com.algolia.instantsearch.helper.tracker.internal.TrackableSearcher +import com.algolia.instantsearch.insights.HitsAfterSearchTrackable +import com.algolia.search.model.QueryID +import com.algolia.search.model.indexing.Indexable +import com.algolia.search.model.insights.EventName +import extension.MainCoroutineRule +import extension.runBlocking +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import org.junit.Rule +import kotlin.test.BeforeTest +import kotlin.test.Test + +class TestHitsTracker { + + @get:Rule + val coroutineRule = MainCoroutineRule() + private val testCoroutineScope = CoroutineScope(coroutineRule.testDispatcher) + + private val eventName = EventName("eventName") + private val queryID = QueryID("queryID") + private val trackableSearcher = mockk>(relaxed = true) + private val searchTrackable = mockk(relaxed = true) + private val hitsTracker = HitsDataTracker(eventName, trackableSearcher, searchTrackable, testCoroutineScope) + + @BeforeTest + fun setup() { + hitsTracker.queryID = queryID + } + + @Test + fun testTrackClick() = coroutineRule.runBlocking { + val eventName = EventName("customEventName") + val hit = mockk(relaxed = true) + val position = 10 + val objectIDs = listOf(hit.objectID) + val positions = listOf(position) + + hitsTracker.trackClick(hit = hit, position = position, customEventName = eventName) + + verify { + searchTrackable.clickedObjectIDsAfterSearch( + eventName = eventName, + queryID = queryID, + objectIDs = objectIDs, + positions = positions, + timestamp = any() + ) + } + } + + @Test + fun testTrackConvert() = coroutineRule.runBlocking { + val eventName = EventName("customEventName") + val hit = mockk(relaxed = true) + val objectIDs = listOf(hit.objectID) + + hitsTracker.trackConvert(hit = hit, customEventName = eventName) + + verify { + searchTrackable.convertedObjectIDsAfterSearch( + eventName = eventName, + queryID = queryID, + objectIDs = objectIDs, + timestamp = any() + ) + } + } + + @Test + fun testTrackView() = coroutineRule.runBlocking { + val eventName = EventName("customEventName") + val hit = mockk(relaxed = true) + val objectIDs = listOf(hit.objectID) + + hitsTracker.trackView(hit = hit, customEventName = eventName) + + verify { + searchTrackable.viewedObjectIDs( + eventName = eventName, + objectIDs = objectIDs, + timestamp = any() + ) + } + } +} diff --git a/instantsearch-insights/.gitignore b/instantsearch-insights/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/instantsearch-insights/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/instantsearch-insights/build.gradle.kts b/instantsearch-insights/build.gradle.kts new file mode 100644 index 000000000..b798dc948 --- /dev/null +++ b/instantsearch-insights/build.gradle.kts @@ -0,0 +1,87 @@ +import dependency.lib.Work +import dependency.network.AlgoliaClient +import dependency.network.Ktor +import dependency.test.AndroidTestExt +import dependency.test.AndroidTestRunner +import dependency.test.Robolectric +import dependency.ui.AndroidCore +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("com.android.library") + id("kotlin-android") + id("com.vanniktech.maven.publish") +} + +android { + compileSdkVersion(30) + + defaultConfig { + minSdkVersion(21) + targetSdkVersion(30) + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + testOptions.unitTests.isIncludeAndroidResources = true + + libraryVariants.all { + generateBuildConfigProvider.configure { enabled = false } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + //replace after https://youtrack.jetbrains.com/issue/KT-37652 + freeCompilerArgs = freeCompilerArgs + listOf("-Xopt-in=kotlin.RequiresOptIn") + } + + sourceSets.getByName("main") { + java.srcDirs("$buildDir/generated/sources/templates/kotlin/main") + } + + testOptions { + unitTests { + it.isIncludeAndroidResources = true + it.isReturnDefaultValues = true + } + } +} + +tasks { + + withType { + dependsOn("copyTemplates") + } + + register(name = "copyTemplates", type = Copy::class) { + from("src/main/templates") + into("$buildDir/generated/sources/templates/kotlin/main") + expand("version" to Library.version) + filteringCharset = "UTF-8" + } + + withType { + if ("UnitTest" !in name) { + kotlinOptions.freeCompilerArgs += "-Xexplicit-api=strict" + } + } +} + +dependencies { + api(AlgoliaClient()) + implementation(AndroidCore("ktx")) + implementation(Ktor("client-android")) + implementation(Work("runtime-ktx")) + + testImplementation(kotlin("test-junit")) + testImplementation(kotlin("test-annotations-common")) + testImplementation(AndroidTestRunner()) + testImplementation(AndroidTestExt()) + testImplementation(Robolectric()) + testImplementation(Ktor("client-mock-jvm")) + testImplementation(Work("testing")) +} diff --git a/instantsearch-insights/gradle.properties b/instantsearch-insights/gradle.properties new file mode 100644 index 000000000..654b01a42 --- /dev/null +++ b/instantsearch-insights/gradle.properties @@ -0,0 +1,2 @@ +POM_NAME=InstantSearch Insights for Android +POM_ARTIFACT_ID=instantsearch-insights diff --git a/instantsearch-insights/src/main/AndroidManifest.xml b/instantsearch-insights/src/main/AndroidManifest.xml new file mode 100644 index 000000000..58a9d3496 --- /dev/null +++ b/instantsearch-insights/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/FilterTrackable.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/FilterTrackable.kt new file mode 100644 index 000000000..f6dc76aa6 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/FilterTrackable.kt @@ -0,0 +1,47 @@ +package com.algolia.instantsearch.insights + +import com.algolia.instantsearch.insights.internal.extension.currentTimeMillis +import com.algolia.search.model.filter.Filter +import com.algolia.search.model.insights.EventName + +public interface FilterTrackable { + + /** + * Tracks a View event, unrelated to a specific search query. + * + * @param eventName the event's name, **must not be empty**. + * @param filters the clicked filter(s). + * @param timestamp the time at which the view happened. Defaults to current time. + */ + public fun viewedFilters( + eventName: EventName, + filters: List, + timestamp: Long = currentTimeMillis, + ) + + /** + * Tracks a click event, unrelated to a specific search query. + * + * @param eventName the event's name, **must not be empty**. + * @param filters the clicked filter(s). + * @param timestamp the time at which the click happened. Defaults to current time. + */ + public fun clickedFilters( + eventName: EventName, + filters: List, + timestamp: Long = currentTimeMillis, + ) + + /** + * Tracks a Conversion event, unrelated to a specific search query. + * + * @param eventName the event's name, **must not be empty**. + * @param timestamp the time at which the conversion happened. Defaults to current time. + * @param filters the converted filter(s). + */ + public fun convertedFilters( + eventName: EventName, + filters: List, + timestamp: Long = currentTimeMillis, + ) +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/HitsAfterSearchTrackable.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/HitsAfterSearchTrackable.kt new file mode 100644 index 000000000..71c117008 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/HitsAfterSearchTrackable.kt @@ -0,0 +1,80 @@ +package com.algolia.instantsearch.insights + +import com.algolia.instantsearch.insights.internal.extension.currentTimeMillis +import com.algolia.search.model.ObjectID +import com.algolia.search.model.QueryID +import com.algolia.search.model.insights.EventName + +public interface HitsAfterSearchTrackable { + + /** + * Tracks a View event, unrelated to a specific search query. + * + * @param eventName the event's name, **must not be empty**. + * @param objectIDs the viewed object(s)' `objectID`. + * @param timestamp the time at which the view happened. Defaults to current time. + */ + public fun viewedObjectIDs( + eventName: EventName, + objectIDs: List, + timestamp: Long = currentTimeMillis, + ) + + /** + * Tracks a click event, unrelated to a specific search query. + * + * @param eventName the event's name, **must not be empty**. + * @param objectIDs the clicked object(s)' `objectID`. + * @param timestamp the time at which the click happened. Defaults to current time. + */ + public fun clickedObjectIDs( + eventName: EventName, + objectIDs: List, + timestamp: Long = currentTimeMillis, + ) + + /** + * Tracks a Conversion event, unrelated to a specific search query. + * + * @param eventName the event's name, **must not be empty**. + * @param objectIDs the object(s)' `objectID`. + * @param timestamp the time at which the conversion happened. Defaults to current time. + */ + public fun convertedObjectIDs( + eventName: EventName, + objectIDs: List, + timestamp: Long = currentTimeMillis, + ) + + /** + * Tracks a Click event after a search has been done. + * + * @param eventName the event's name, **must not be empty**. + * @param queryID the related [query's identifier][https://www.algolia.com/doc/guides/insights-and-analytics/click-analytics/?language=php#identifying-the-query-result-position]. + * @param objectIDs the object(s)' `objectID`. + * @param positions the clicked object(s)' position(s). + * @param timestamp the time at which the click happened. Defaults to current time. + */ + public fun clickedObjectIDsAfterSearch( + eventName: EventName, + queryID: QueryID, + objectIDs: List, + positions: List, + timestamp: Long = currentTimeMillis, + ) + + /** + * Tracks a Conversion event after a search has been done. + * + * @param eventName the event's name, **must not be empty**. + * @param queryID the related [query's identifier][https://www.algolia.com/doc/guides/insights-and-analytics/click-analytics/?language=php#identifying-the-query-result-position]. + * @param objectIDs the object(s)' `objectID`. + * @param timestamp the time at which the conversion happened. Defaults to current time. + */ + public fun convertedObjectIDsAfterSearch( + eventName: EventName, + queryID: QueryID, + objectIDs: List, + timestamp: Long = currentTimeMillis, + ) +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/Insights.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/Insights.kt new file mode 100644 index 000000000..f09dac5c5 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/Insights.kt @@ -0,0 +1,180 @@ +package com.algolia.instantsearch.insights + +import android.content.Context +import androidx.work.WorkManager +import com.algolia.instantsearch.insights.exception.InsightsException +import com.algolia.instantsearch.insights.internal.InsightsController +import com.algolia.instantsearch.insights.internal.InsightsMap +import com.algolia.instantsearch.insights.internal.data.distant.InsightsHttpRepository +import com.algolia.instantsearch.insights.internal.data.local.InsightsPrefsRepository +import com.algolia.instantsearch.insights.internal.data.settings.InsightsEventSettings +import com.algolia.instantsearch.insights.internal.extension.clientInsights +import com.algolia.instantsearch.insights.internal.extension.defaultConfiguration +import com.algolia.instantsearch.insights.internal.extension.insightsSettingsPreferences +import com.algolia.instantsearch.insights.internal.extension.insightsSharedPreferences +import com.algolia.search.helper.toAPIKey +import com.algolia.search.helper.toApplicationID +import com.algolia.search.helper.toIndexName +import com.algolia.search.model.APIKey +import com.algolia.search.model.ApplicationID +import com.algolia.search.model.IndexName +import com.algolia.search.model.insights.InsightsEvent +import com.algolia.search.model.insights.UserToken + +public interface Insights : HitsAfterSearchTrackable, FilterTrackable { + + /** + * Change this variable to `true` or `false` to disable Insights, opting-out the current session from tracking. + */ + public var enabled: Boolean + + /** + * Change this variable to change the default debouncing interval. Values lower than 15 minutes will be ignored. + */ + public var debouncingIntervalInMinutes: Long? + + /** + * Set a user identifier that will override any event's. + * + * Depending if the user is logged-in or not, several strategies can be used from a sessionId to a technical identifier. + * You should always send pseudonymous or anonymous userTokens. + */ + public var userToken: UserToken? + + /** + * Change this variable to change the default amount of event sent at once. + */ + public var minBatchSize: Int + + /** + * Change this variable to `true` or `false` to enable or disable logging. + * Use a filter on tag `Algolia Insights` to see all logs generated by the Insights library. + */ + public var loggingEnabled: Boolean + + /** + * Tracks a View event constructed manually. + * + * @param event insights view event to be tracked + */ + public fun viewed(event: InsightsEvent.View) + + /** + * Tracks a Click event constructed manually. + * + * @param event insights click event to be tracked + */ + public fun clicked(event: InsightsEvent.Click) + + /** + * Tracks a Conversion event, constructed manually. + * + * @param event insights conversion event to be tracked + */ + public fun converted(event: InsightsEvent.Conversion) + + /** + * Method for tracking an event. + * [documentation][https://www.algolia.com/doc/rest-api/insights/?language=android#push-events]. + * + * @param event insights event to be tracked. + */ + public fun track(event: InsightsEvent) + + /** + * Insights configuration. + */ + public class Configuration( + + /** + * Maximum amount of time in milliseconds before a connect timeout + */ + public val connectTimeoutInMilliseconds: Long, + + /** + * Maximum amount of time in milliseconds before a read timeout. + */ + public val readTimeoutInMilliseconds: Long, + + /** + * Default User Token. + */ + public val defaultUserToken: UserToken? = null, + ) + + public companion object { + + /** + * Access an already registered `Insights` without having to pass the `apiKey` and `appId`. + * + * If the index was not register before, it will throw an [InsightsException.IndexNotRegistered] exception. + * @param indexName The index that is being tracked. + * @return An Insights instance. + * @throws InsightsException.IndexNotRegistered if no index was registered as indexName before. + */ + @JvmStatic + public fun shared(indexName: IndexName): Insights { + return InsightsMap[indexName] ?: throw InsightsException.IndexNotRegistered() + } + + /** + * Access the latest registered `Insights` instance, if any. + */ + @JvmStatic + public var shared: Insights? = null + @JvmName("shared") + get() = if (field != null) field else throw InsightsException.IndexNotRegistered() + + /** + * Register your index with a given appId and apiKey. + * + * @param context An Android Context. + * @param appId The given app id for which you want to track the events. + * @param apiKey The API Key for your `appId`. + * @param indexName The index that is being tracked. + * @param configuration A Configuration class. + */ + @JvmStatic + public fun register( + context: Context, + appId: String, + apiKey: String, + indexName: String, + configuration: Configuration? = null, + ): Insights { + return register(context, appId.toApplicationID(), apiKey.toAPIKey(), indexName.toIndexName(), configuration) + } + + /** + * Register your index with a given appId and apiKey. + * + * @param context An Android Context. + * @param appId The given app id for which you want to track the events. + * @param apiKey The API Key for your `appId`. + * @param indexName The index that is being tracked. + * @param configuration insights configuration + */ + @JvmStatic + public fun register( + context: Context, + appId: ApplicationID, + apiKey: APIKey, + indexName: IndexName, + configuration: Configuration? = null, + ): Insights { + val localRepository = InsightsPrefsRepository(context.insightsSharedPreferences(indexName)) + val settings = InsightsEventSettings(context.insightsSettingsPreferences()) + val config = configuration ?: defaultConfiguration(settings) + val distantRepository = InsightsHttpRepository(clientInsights(appId, apiKey, config)) + val workManager = WorkManager.getInstance(context) + return InsightsController.register( + indexName = indexName, + localRepository = localRepository, + distantRepository = distantRepository, + workManager = workManager, + settings = settings, + configuration = config + ) + } + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/exception/InsightsException.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/exception/InsightsException.kt new file mode 100644 index 000000000..6649d9eb9 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/exception/InsightsException.kt @@ -0,0 +1,19 @@ +package com.algolia.instantsearch.insights.exception + +/** + * InstantSearch Insights exceptions. + */ +public sealed class InsightsException(override val message: String? = null) : Exception(message) { + + /** + * Will be thrown when you try to access an index through the Insights.shared + * method without having registered the index through the Insights.register method first. + */ + public class IndexNotRegistered : InsightsException("You need to call Insights.register before Insights.shared") + + /** + * Will be thrown when you call Insights.viewed without Insights.userToken first. + */ + public class NoUserToken : InsightsException("You need to set Insights.userToken first.") + // TODO: Remove exception once default userToken +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/InsightsController.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/InsightsController.kt new file mode 100644 index 000000000..6473ed7a4 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/InsightsController.kt @@ -0,0 +1,221 @@ +package com.algolia.instantsearch.insights.internal + +import androidx.work.WorkManager +import com.algolia.instantsearch.insights.Insights +import com.algolia.instantsearch.insights.exception.InsightsException +import com.algolia.instantsearch.insights.internal.cache.InsightsCache +import com.algolia.instantsearch.insights.internal.cache.InsightsEventCache +import com.algolia.instantsearch.insights.internal.data.distant.InsightsDistantRepository +import com.algolia.instantsearch.insights.internal.data.local.InsightsLocalRepository +import com.algolia.instantsearch.insights.internal.data.settings.InsightsSettings +import com.algolia.instantsearch.insights.internal.logging.InsightsLogger +import com.algolia.instantsearch.insights.internal.uploader.InsightsEventUploader +import com.algolia.instantsearch.insights.internal.uploader.InsightsUploader +import com.algolia.instantsearch.insights.internal.worker.InsightsManager +import com.algolia.instantsearch.insights.internal.worker.InsightsWorkManager +import com.algolia.search.model.IndexName +import com.algolia.search.model.ObjectID +import com.algolia.search.model.QueryID +import com.algolia.search.model.filter.Filter +import com.algolia.search.model.insights.EventName +import com.algolia.search.model.insights.InsightsEvent +import com.algolia.search.model.insights.UserToken + +/** + * Main class used for interacting with the InstantSearch Insights library. + * In order to send insights, you first need to register an APP ID and API key for a given Index. + * Once registered, you can simply call `Insights.shared(index: String)` to send your events. + */ +internal class InsightsController( + private val indexName: IndexName, + private val worker: InsightsManager, + private val cache: InsightsCache, + internal val uploader: InsightsUploader, +) : Insights { + + override var enabled: Boolean = true + override var userToken: UserToken? = null + override var minBatchSize: Int = 10 + override var debouncingIntervalInMinutes: Long? = null + set(value) { + value?.let { worker.setInterval(value) } + } + override var loggingEnabled: Boolean = false + set(value) { + field = value + InsightsLogger.enabled[indexName] = value + } + + private fun userTokenOrThrow(): UserToken = userToken ?: throw InsightsException.NoUserToken() + + init { + worker.startPeriodicUpload() + } + + // region Event tracking methods + override fun viewedObjectIDs( + eventName: EventName, + objectIDs: List, + timestamp: Long, + ): Unit = viewed( + InsightsEvent.View( + indexName = indexName, + eventName = eventName, + userToken = userTokenOrThrow(), + timestamp = timestamp, + resources = InsightsEvent.Resources.ObjectIDs(objectIDs) + ) + ) + + override fun viewedFilters( + eventName: EventName, + filters: List, + timestamp: Long, + ): Unit = viewed( + InsightsEvent.View( + indexName = indexName, + eventName = eventName, + userToken = userTokenOrThrow(), + timestamp = timestamp, + resources = InsightsEvent.Resources.Filters(filters) + ) + ) + + override fun clickedObjectIDs( + eventName: EventName, + objectIDs: List, + timestamp: Long, + ): Unit = clicked( + InsightsEvent.Click( + indexName = indexName, + eventName = eventName, + userToken = userTokenOrThrow(), + timestamp = timestamp, + resources = InsightsEvent.Resources.ObjectIDs(objectIDs) + ) + ) + + override fun clickedFilters( + eventName: EventName, + filters: List, + timestamp: Long, + ): Unit = clicked( + InsightsEvent.Click( + indexName = indexName, + eventName = eventName, + userToken = userTokenOrThrow(), + timestamp = timestamp, + resources = InsightsEvent.Resources.Filters(filters) + ) + ) + + override fun clickedObjectIDsAfterSearch( + eventName: EventName, + queryID: QueryID, + objectIDs: List, + positions: List, + timestamp: Long, + ): Unit = clicked( + InsightsEvent.Click( + indexName = indexName, + eventName = eventName, + userToken = userTokenOrThrow(), + timestamp = timestamp, + resources = InsightsEvent.Resources.ObjectIDs(objectIDs), + queryID = queryID, + positions = positions + ) + ) + + override fun convertedFilters( + eventName: EventName, + filters: List, + timestamp: Long, + ): Unit = converted( + InsightsEvent.Conversion( + indexName = indexName, + eventName = eventName, + userToken = userTokenOrThrow(), + timestamp = timestamp, + resources = InsightsEvent.Resources.Filters(filters) + ) + ) + + override fun convertedObjectIDs( + eventName: EventName, + objectIDs: List, + timestamp: Long, + ): Unit = converted( + InsightsEvent.Conversion( + indexName = indexName, + eventName = eventName, + userToken = userTokenOrThrow(), + timestamp = timestamp, + resources = InsightsEvent.Resources.ObjectIDs(objectIDs) + ) + ) + + override fun convertedObjectIDsAfterSearch( + eventName: EventName, + queryID: QueryID, + objectIDs: List, + timestamp: Long, + ): Unit = converted( + InsightsEvent.Conversion( + indexName = indexName, + eventName = eventName, + userToken = userTokenOrThrow(), + timestamp = timestamp, + resources = InsightsEvent.Resources.ObjectIDs(objectIDs), + queryID = queryID + ) + ) + + override fun viewed(event: InsightsEvent.View): Unit = track(event) + + override fun clicked(event: InsightsEvent.Click): Unit = track(event) + + override fun converted(event: InsightsEvent.Conversion): Unit = track(event) + + override fun track(event: InsightsEvent) { + if (enabled) { + cache.save(event) + if (cache.size() >= minBatchSize) { + worker.startOneTimeUpload() + } + } + } + + // endregion + + companion object { + + /** + * Register your index with a given appId and apiKey. + * @param indexName The index that is being tracked. + * @param localRepository local storage + * @param distantRepository server storage + * @param workManager jobs scheduler + * @param settings settings storage + * @param configuration A Configuration class. + */ + internal fun register( + indexName: IndexName, + localRepository: InsightsLocalRepository, + distantRepository: InsightsDistantRepository, + workManager: WorkManager, + settings: InsightsSettings, + configuration: Insights.Configuration = Insights.Configuration(5000, 5000), + ): Insights { + val saver = InsightsEventCache(localRepository) + val uploader = InsightsEventUploader(localRepository, distantRepository) + val worker = InsightsWorkManager(workManager, settings) + return InsightsController(indexName, worker, saver, uploader).also { + it.userToken = configuration.defaultUserToken + Insights.shared = it + InsightsMap[indexName] = it + InsightsLogger.log("Registering new Insights for indexName $indexName. Previous instance: $it") + } + } + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/InsightsMap.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/InsightsMap.kt new file mode 100644 index 000000000..5d9158cef --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/InsightsMap.kt @@ -0,0 +1,8 @@ +package com.algolia.instantsearch.insights.internal + +import com.algolia.search.model.IndexName + +/** + * Map storing all registered Insights instances. + */ +internal object InsightsMap : MutableMap by mutableMapOf() diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/cache/InsightsCache.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/cache/InsightsCache.kt new file mode 100644 index 000000000..c691c810d --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/cache/InsightsCache.kt @@ -0,0 +1,10 @@ +package com.algolia.instantsearch.insights.internal.cache + +import com.algolia.search.model.insights.InsightsEvent + +internal interface InsightsCache { + + fun save(event: InsightsEvent) + + fun size(): Int +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/cache/InsightsEventCache.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/cache/InsightsEventCache.kt new file mode 100644 index 000000000..38354cf1b --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/cache/InsightsEventCache.kt @@ -0,0 +1,17 @@ +package com.algolia.instantsearch.insights.internal.cache + +import com.algolia.instantsearch.insights.internal.data.local.InsightsLocalRepository +import com.algolia.search.model.insights.InsightsEvent + +internal class InsightsEventCache( + private val localRepository: InsightsLocalRepository, +) : InsightsCache { + + override fun save(event: InsightsEvent) { + localRepository.append(event) + } + + override fun size(): Int { + return localRepository.count() + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/distant/InsightsDistantRepository.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/distant/InsightsDistantRepository.kt new file mode 100644 index 000000000..6565ddc7e --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/distant/InsightsDistantRepository.kt @@ -0,0 +1,9 @@ +package com.algolia.instantsearch.insights.internal.data.distant + +import com.algolia.instantsearch.insights.internal.event.EventResponse +import com.algolia.search.model.insights.InsightsEvent + +internal interface InsightsDistantRepository { + + suspend fun send(event: InsightsEvent): EventResponse +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/distant/InsightsHttpRepository.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/distant/InsightsHttpRepository.kt new file mode 100644 index 000000000..a5c7a497a --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/distant/InsightsHttpRepository.kt @@ -0,0 +1,30 @@ +package com.algolia.instantsearch.insights.internal.data.distant + +import com.algolia.instantsearch.insights.internal.event.EventResponse +import com.algolia.instantsearch.insights.internal.logging.InsightsLogger +import com.algolia.search.client.ClientInsights +import com.algolia.search.model.insights.InsightsEvent +import io.ktor.client.features.ClientRequestException +import io.ktor.http.isSuccess +import io.ktor.utils.io.readUTF8Line + +internal class InsightsHttpRepository( + private val clientInsights: ClientInsights, +) : InsightsDistantRepository { + + override suspend fun send(event: InsightsEvent): EventResponse { + val (code: Int, message: String) = try { + val response = clientInsights.sendEvent(event) + val message = when { + response.status.isSuccess() -> "Sync succeeded for $event.\"" + else -> response.content.readUTF8Line().orEmpty() + } + response.status.value to message + } catch (exception: Exception) { + val status = (exception as? ClientRequestException)?.response?.status?.value ?: -1 + status to exception.message.orEmpty() + } + InsightsLogger.log(event.indexName, message) + return EventResponse(code = code, event = event) + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/InsightsLocalRepository.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/InsightsLocalRepository.kt new file mode 100644 index 000000000..f70be1cb8 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/InsightsLocalRepository.kt @@ -0,0 +1,16 @@ +package com.algolia.instantsearch.insights.internal.data.local + +import com.algolia.search.model.insights.InsightsEvent + +internal interface InsightsLocalRepository { + + fun append(event: InsightsEvent) + + fun overwrite(events: List) + + fun read(): List + + fun count(): Int + + fun clear() +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/InsightsPrefsRepository.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/InsightsPrefsRepository.kt new file mode 100644 index 000000000..e8695b38f --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/InsightsPrefsRepository.kt @@ -0,0 +1,44 @@ +package com.algolia.instantsearch.insights.internal.data.local + +import android.content.SharedPreferences +import com.algolia.instantsearch.insights.internal.data.local.mapper.InsightsEventDOMapper +import com.algolia.instantsearch.insights.internal.data.local.mapper.InsightsEventsMapper +import com.algolia.instantsearch.insights.internal.extension.events +import com.algolia.search.model.insights.InsightsEvent + +internal class InsightsPrefsRepository( + private val preferences: SharedPreferences, +) : InsightsLocalRepository { + + override fun append(event: InsightsEvent) { + preferences.events = preferences.events + .toMutableSet() + .apply { add(event.asJsonString()) } + } + + private fun InsightsEvent.asJsonString(): String { + val eventDO = InsightsEventsMapper.map(this) + return InsightsEventDOMapper.map(eventDO) + } + + override fun overwrite(events: List) { + preferences.events = events + .map(InsightsEventsMapper::map) + .map(InsightsEventDOMapper::map) + .toSet() + } + + override fun read(): List { + return preferences.events + .map(InsightsEventDOMapper::unmap) + .map(InsightsEventsMapper::unmap) + } + + override fun count(): Int { + return preferences.events.size + } + + override fun clear() { + preferences.events = setOf() + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/FilterFacetMapper.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/FilterFacetMapper.kt new file mode 100644 index 000000000..3afd9a8e4 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/FilterFacetMapper.kt @@ -0,0 +1,65 @@ +package com.algolia.instantsearch.insights.internal.data.local.mapper + +import com.algolia.instantsearch.insights.internal.data.local.model.FacetKey +import com.algolia.instantsearch.insights.internal.data.local.model.FilterFacetDO +import com.algolia.instantsearch.insights.internal.data.local.model.ValueType +import com.algolia.search.helper.toAttribute +import com.algolia.search.model.filter.Filter + +internal object FilterFacetMapper : Mapper { + + @OptIn(ExperimentalStdlibApi::class) + override fun map(input: Filter.Facet): FilterFacetDO { + return buildMap { + put(FacetKey.Attribute.raw, input.attribute.raw) + put(FacetKey.IsNegated.raw, input.isNegated) + put(FacetKey.Value.raw, input.value.getRaw()) + put(FacetKey.ValueType.raw, input.value.getType().raw) + input.score?.let { put(FacetKey.Score.raw, it) } + } + } + + private fun Filter.Facet.Value.getRaw(): Any { + return when (this) { + is Filter.Facet.Value.String -> raw + is Filter.Facet.Value.Boolean -> raw + is Filter.Facet.Value.Number -> raw + } + } + + private fun Filter.Facet.Value.getType(): ValueType { + return when (this) { + is Filter.Facet.Value.String -> ValueType.String + is Filter.Facet.Value.Boolean -> ValueType.Boolean + is Filter.Facet.Value.Number -> ValueType.Number + } + } + + override fun unmap(input: FilterFacetDO): Filter.Facet { + val attribute = input.getValue(FacetKey.Attribute.raw).toString().toAttribute() + val isNegated = input.getValue(FacetKey.IsNegated.raw) as Boolean + val valueType = ValueType.of((input.getValue(FacetKey.ValueType.raw) as String)) + val value = input.getValue(FacetKey.Value.raw) + val score = input[FacetKey.Score.raw] as Int? + return when (valueType) { + ValueType.String -> Filter.Facet( + attribute = attribute, + value = value as String, + isNegated = isNegated, + score = score + ) + ValueType.Boolean -> Filter.Facet( + attribute = attribute, + value = value as Boolean, + isNegated = isNegated, + score = score + ) + ValueType.Number -> Filter.Facet( + attribute = attribute, + value = value as Number, + isNegated = isNegated, + score = score + ) + } + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/InsightsEventDOMapper.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/InsightsEventDOMapper.kt new file mode 100644 index 000000000..2dc534ae8 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/InsightsEventDOMapper.kt @@ -0,0 +1,52 @@ +package com.algolia.instantsearch.insights.internal.data.local.mapper + +import com.algolia.instantsearch.insights.internal.data.local.model.InsightsEventDO +import org.json.JSONArray +import org.json.JSONObject + +internal object InsightsEventDOMapper : Mapper { + + override fun map(input: InsightsEventDO): String { + return JSONObject().apply { + input.entries.forEach { entry -> + entry.value?.let { + when (it) { + is Collection<*> -> put(entry.key, JSONArray(it)) + is Map<*, *> -> put(entry.key, JSONObject(it)) + else -> put(entry.key, it) + } + } + } + }.toString() + } + + override fun unmap(input: String): InsightsEventDO { + return JSONObject(input).toMap() + } + + private fun JSONObject.toMap(): Map { + return keys() + .asSequence() + .map { jsonMap(it, this) } + .toMap() + } + + private fun jsonMap(key: String, jsonObject: JSONObject): Pair { + return when (jsonObject.get(key)) { + is JSONArray -> key to jsonObject.getJSONArray(key).toList() + else -> key to jsonObject.get(key) + } + } + + private fun JSONArray.toList(): List { + return mutableListOf().also { + for (i in 0 until this.length()) { + when (val element = this[i]) { + is JSONObject -> it.add(element.toMap()) + is JSONArray -> it.add(it.toList()) + else -> it.add(this[i]) + } + } + } + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/InsightsEventsMapper.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/InsightsEventsMapper.kt new file mode 100644 index 000000000..b9a54f3c1 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/InsightsEventsMapper.kt @@ -0,0 +1,105 @@ +package com.algolia.instantsearch.insights.internal.data.local.mapper + +import com.algolia.instantsearch.insights.internal.data.local.model.EventKey +import com.algolia.instantsearch.insights.internal.data.local.model.EventType +import com.algolia.instantsearch.insights.internal.data.local.model.InsightsEventDO +import com.algolia.instantsearch.insights.internal.extension.put +import com.algolia.search.helper.toEventName +import com.algolia.search.helper.toIndexName +import com.algolia.search.helper.toQueryID +import com.algolia.search.helper.toUserToken +import com.algolia.search.model.insights.InsightsEvent + +internal object InsightsEventsMapper : Mapper { + + override fun map(input: InsightsEvent): InsightsEventDO { + return when (input) { + is InsightsEvent.View -> input.toEventDO() + is InsightsEvent.Conversion -> input.toEventDO() + is InsightsEvent.Click -> input.toEventDO() + } + } + + private fun InsightsEvent.Click.toEventDO(): InsightsEventDO { + return commonEventDO().also { map -> + map[EventKey.EventType.raw] = EventType.Click.raw + positions?.let { map[EventKey.Positions.raw] = it } + } + } + + private fun InsightsEvent.Conversion.toEventDO(): InsightsEventDO { + return commonEventDO().also { + it[EventKey.EventType.raw] = EventType.Conversion.raw + } + } + + private fun InsightsEvent.View.toEventDO(): InsightsEventDO { + return commonEventDO().also { + it[EventKey.EventType.raw] = EventType.View.raw + } + } + + private fun InsightsEvent.commonEventDO(): MutableMap { + return mutableMapOf().also { map -> + map[EventKey.EventName.raw] = eventName.raw + map[EventKey.IndexName.raw] = indexName.raw + timestamp?.let { map[EventKey.Timestamp.raw] = it } + queryID?.let { map[EventKey.QueryID.raw] = it.raw } + userToken?.let { map[EventKey.UserToken.raw] = it.raw } + resources?.let { ResourcesMapper.map(it) }?.also { map.put(it) } + } + } + + override fun unmap(input: InsightsEventDO): InsightsEvent { + return when (input[EventKey.EventType.raw]) { + EventType.View.raw -> input.toViewEvent() + EventType.Conversion.raw -> input.toConversionEvent() + EventType.Click.raw -> input.toClickEvent() + else -> throw UnsupportedOperationException() + } + } + + private fun InsightsEventDO.toClickEvent(): InsightsEvent.Click { + @Suppress("UNCHECKED_CAST") + return InsightsEvent.Click( + eventName = getValue(EventKey.EventName.raw).toString().toEventName(), + indexName = getValue(EventKey.IndexName.raw).toString().toIndexName(), + userToken = get(EventKey.UserToken.raw)?.toString()?.toUserToken(), + timestamp = (get(EventKey.Timestamp.raw) as? Long), + queryID = get(EventKey.QueryID.raw)?.toString()?.toQueryID(), + positions = get(EventKey.Positions.raw) as? List, + resources = resourcesOrNull(this), + ) + } + + private fun InsightsEventDO.toConversionEvent(): InsightsEvent.Conversion { + return InsightsEvent.Conversion( + eventName = getValue(EventKey.EventName.raw).toString().toEventName(), + indexName = getValue(EventKey.IndexName.raw).toString().toIndexName(), + userToken = get(EventKey.UserToken.raw)?.toString()?.toUserToken(), + timestamp = get(EventKey.Timestamp.raw) as? Long, + queryID = get(EventKey.QueryID.raw)?.toString()?.toQueryID(), + resources = resourcesOrNull(this), + ) + } + + private fun InsightsEventDO.toViewEvent(): InsightsEvent.View { + return InsightsEvent.View( + eventName = getValue(EventKey.EventName.raw).toString().toEventName(), + indexName = getValue(EventKey.IndexName.raw).toString().toIndexName(), + userToken = get(EventKey.UserToken.raw)?.toString()?.toUserToken(), + timestamp = get(EventKey.Timestamp.raw) as? Long, + queryID = get(EventKey.QueryID.raw)?.toString()?.toQueryID(), + resources = resourcesOrNull(this), + ) + } + + @Suppress("UNCHECKED_CAST") + private fun resourcesOrNull(insightsEventDO: InsightsEventDO): InsightsEvent.Resources? { + val objectsIDs = insightsEventDO[EventKey.ObjectIds.raw] + if (objectsIDs != null) return ResourcesMapper.unmap(EventKey.ObjectIds.raw to objectsIDs) + val filters = insightsEventDO[EventKey.Filters.raw] + if (filters != null) return ResourcesMapper.unmap(EventKey.Filters.raw to filters) + return null + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/Mapper.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/Mapper.kt new file mode 100644 index 000000000..712417552 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/Mapper.kt @@ -0,0 +1,8 @@ +package com.algolia.instantsearch.insights.internal.data.local.mapper + +internal interface Mapper { + + fun map(input: T): R + + fun unmap(input: R): T +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/ResourcesMapper.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/ResourcesMapper.kt new file mode 100644 index 000000000..1cf713912 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/mapper/ResourcesMapper.kt @@ -0,0 +1,29 @@ +package com.algolia.instantsearch.insights.internal.data.local.mapper + +import com.algolia.instantsearch.insights.internal.data.local.model.EventKey +import com.algolia.search.helper.toObjectID +import com.algolia.search.model.insights.InsightsEvent + +internal object ResourcesMapper : Mapper> { + + override fun map(input: InsightsEvent.Resources): Pair { + return when (input) { + is InsightsEvent.Resources.ObjectIDs -> EventKey.ObjectIds.raw to input.objectIDs.map { it.raw } + is InsightsEvent.Resources.Filters -> EventKey.Filters.raw to input.filters.map { FilterFacetMapper.map(it) } + } + } + + @Suppress("UNCHECKED_CAST") + override fun unmap(input: Pair): InsightsEvent.Resources { + val (resourceType, value) = input + return when (resourceType) { + EventKey.ObjectIds.raw -> InsightsEvent.Resources.ObjectIDs( + (value as List).map { it.toObjectID() } + ) + EventKey.Filters.raw -> InsightsEvent.Resources.Filters( + (value as List>).map { FilterFacetMapper.unmap(it) } + ) + else -> throw UnsupportedOperationException("Unknown Resources type: $resourceType") + } + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/model/FilterFacetDO.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/model/FilterFacetDO.kt new file mode 100644 index 000000000..3e30a0bd7 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/model/FilterFacetDO.kt @@ -0,0 +1,23 @@ +package com.algolia.instantsearch.insights.internal.data.local.model + +internal typealias FilterFacetDO = Map + +internal enum class FacetKey(val raw: String) { + Attribute("attribute"), + IsNegated("isNegated"), + Value("value"), + ValueType("valueType"), + Score("score") +} + +internal enum class ValueType(val raw: kotlin.String) { + String("string"), + Boolean("boolean"), + Number("number"); + + companion object { + fun of(value: kotlin.String): ValueType { + return values().first { it.raw == value } + } + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/model/InsightsEventDO.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/model/InsightsEventDO.kt new file mode 100644 index 000000000..c0ebedf6a --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/local/model/InsightsEventDO.kt @@ -0,0 +1,25 @@ +package com.algolia.instantsearch.insights.internal.data.local.model + +internal typealias InsightsEventDO = Map + +internal enum class EventType(val raw: String) { + Click("click"), + View("view"), + Conversion("conversion"); +} + +internal enum class EventKey(val raw: String) { + EventType("eventType"), + EventName("eventName"), + IndexName("index"), + UserToken("userToken"), + Timestamp("timestamp"), + QueryID("queryID"), + ObjectIds("objectIDs"), + Positions("positions"), + Filters("filters"); + + override fun toString(): String { + return raw + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/settings/InsightsEventSettings.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/settings/InsightsEventSettings.kt new file mode 100644 index 000000000..9329c49c5 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/settings/InsightsEventSettings.kt @@ -0,0 +1,25 @@ +package com.algolia.instantsearch.insights.internal.data.settings + +import android.content.SharedPreferences +import com.algolia.instantsearch.insights.internal.extension.SharedPreferencesDelegate +import java.util.UUID + +internal class InsightsEventSettings( + private val preferences: SharedPreferences, +) : InsightsSettings { + + private var SharedPreferences.jobId by SharedPreferencesDelegate.String() + private var SharedPreferences.userToken by SharedPreferencesDelegate.String() + + override var workId: UUID? + get() = preferences.jobId?.let { UUID.fromString(it) } + set(value) { + preferences.jobId = value.toString() + } + + override var userToken: String? + get() = preferences.userToken + set(value) { + value?.let { preferences.userToken = it } + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/settings/InsightsSettings.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/settings/InsightsSettings.kt new file mode 100644 index 000000000..878654088 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/data/settings/InsightsSettings.kt @@ -0,0 +1,10 @@ +package com.algolia.instantsearch.insights.internal.data.settings + +import java.util.UUID + +internal interface InsightsSettings { + + var workId: UUID? + + var userToken: String? +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/event/EventResponse.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/event/EventResponse.kt new file mode 100644 index 000000000..796d3f1aa --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/event/EventResponse.kt @@ -0,0 +1,8 @@ +package com.algolia.instantsearch.insights.internal.event + +import com.algolia.search.model.insights.InsightsEvent + +internal data class EventResponse( + val event: InsightsEvent, + val code: Int, +) diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Insights.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Insights.kt new file mode 100644 index 000000000..7d99949da --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Insights.kt @@ -0,0 +1,46 @@ +package com.algolia.instantsearch.insights.internal.extension + +import android.util.Log +import com.algolia.instantsearch.insights.Insights +import com.algolia.instantsearch.insights.internal.data.settings.InsightsEventSettings +import com.algolia.instantsearch.insights.internal.data.settings.InsightsSettings +import com.algolia.search.client.ClientInsights +import com.algolia.search.configuration.ConfigurationInsights +import com.algolia.search.model.APIKey +import com.algolia.search.model.ApplicationID +import com.algolia.search.model.insights.UserToken +import java.util.UUID + +/** + * Create new Insights API Client. + */ +internal fun clientInsights( + appId: ApplicationID, + apiKey: APIKey, + configuration: Insights.Configuration, +): ClientInsights { + return ClientInsights( + ConfigurationInsights( + applicationID = appId, + apiKey = apiKey, + writeTimeout = configuration.connectTimeoutInMilliseconds, + readTimeout = configuration.readTimeoutInMilliseconds + ) + ) +} + +/** + * Generate a default insights configuration. + */ +internal fun defaultConfiguration(settings: InsightsEventSettings): Insights.Configuration { + val userToken = UserToken(settings.storedUserToken()) + Log.d("Insights", "Insights user token: $userToken") + return Insights.Configuration(5000, 5000, userToken) +} + +/** + * Get user token if not null, otherwise generate and store a new one. + */ +private fun InsightsSettings.storedUserToken(): String { + return userToken ?: UUID.randomUUID().toString().also { userToken = it } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Map.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Map.kt new file mode 100644 index 000000000..16957990c --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Map.kt @@ -0,0 +1,5 @@ +package com.algolia.instantsearch.insights.internal.extension + +internal fun MutableMap.put(entry: Pair) { + put(entry.first, entry.second) +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Platform.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Platform.kt new file mode 100644 index 000000000..b5277ca94 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Platform.kt @@ -0,0 +1,20 @@ +package com.algolia.instantsearch.insights.internal.extension + +import android.content.Context +import android.content.SharedPreferences +import com.algolia.search.model.IndexName + +internal var SharedPreferences.events: Set by SharedPreferencesDelegate.StringSet(setOf()) + +internal fun Context.sharedPreferences(name: String, mode: Int = Context.MODE_PRIVATE): SharedPreferences { + return getSharedPreferences(name, mode) +} + +internal fun Context.insightsSharedPreferences(indexName: IndexName) = sharedPreferences("Algolia Insights-$indexName") + +/** + * Get Insights Settings Shared Preferences. + */ +internal fun Context.insightsSettingsPreferences(): SharedPreferences { + return getSharedPreferences("InsightsEvents", Context.MODE_PRIVATE) +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/SharedPreferencesDelegate.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/SharedPreferencesDelegate.kt new file mode 100644 index 000000000..5214c6969 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/SharedPreferencesDelegate.kt @@ -0,0 +1,46 @@ +package com.algolia.instantsearch.insights.internal.extension + +import android.content.SharedPreferences +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +internal sealed class SharedPreferencesDelegate( + protected val default: T, + protected val key: kotlin.String? = null, +) : ReadWriteProperty { + + class Int(default: kotlin.Int, key: kotlin.String? = null) : SharedPreferencesDelegate(default, key) { + + override fun getValue(thisRef: SharedPreferences, property: KProperty<*>): kotlin.Int { + return thisRef.getInt(key ?: property.name, default) + } + + override fun setValue(thisRef: SharedPreferences, property: KProperty<*>, value: kotlin.Int) { + thisRef.edit().putInt(key ?: property.name, value).apply() + } + } + + class String(default: kotlin.String? = null, key: kotlin.String? = null) : + SharedPreferencesDelegate(default, key) { + + override fun getValue(thisRef: SharedPreferences, property: KProperty<*>): kotlin.String? { + return thisRef.getString(key ?: property.name, default) + } + + override fun setValue(thisRef: SharedPreferences, property: KProperty<*>, value: kotlin.String?) { + thisRef.edit().putString(key ?: property.name, value).apply() + } + } + + class StringSet(default: Set, key: kotlin.String? = null) : + SharedPreferencesDelegate>(default, key) { + + override fun getValue(thisRef: SharedPreferences, property: KProperty<*>): Set { + return thisRef.getStringSet(key ?: property.name, default) ?: setOf() + } + + override fun setValue(thisRef: SharedPreferences, property: KProperty<*>, value: Set) { + thisRef.edit().putStringSet(key ?: property.name, value).apply() + } + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Time.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Time.kt new file mode 100644 index 000000000..216e90321 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/extension/Time.kt @@ -0,0 +1,4 @@ +package com.algolia.instantsearch.insights.internal.extension + +internal val currentTimeMillis: Long + get() = System.currentTimeMillis() diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/logging/InsightsLogger.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/logging/InsightsLogger.kt new file mode 100644 index 000000000..a3a204d6e --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/logging/InsightsLogger.kt @@ -0,0 +1,20 @@ +package com.algolia.instantsearch.insights.internal.logging + +import android.util.Log +import com.algolia.search.model.IndexName + +internal object InsightsLogger { + + private const val TAG = "Algolia Insights" + var enabled: MutableMap = mutableMapOf() + + fun log(indexName: IndexName, message: String) { + if (enabled[indexName] == true) { + Log.d(TAG, "Index=$indexName: $message") + } + } + + fun log(message: String) { + Log.d(TAG, message) + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/uploader/InsightsEventUploader.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/uploader/InsightsEventUploader.kt new file mode 100644 index 000000000..40f869b2f --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/uploader/InsightsEventUploader.kt @@ -0,0 +1,32 @@ +package com.algolia.instantsearch.insights.internal.uploader + +import com.algolia.instantsearch.insights.internal.data.distant.InsightsDistantRepository +import com.algolia.instantsearch.insights.internal.data.local.InsightsLocalRepository +import com.algolia.instantsearch.insights.internal.event.EventResponse +import com.algolia.instantsearch.insights.internal.logging.InsightsLogger +import com.algolia.search.model.insights.InsightsEvent +import kotlinx.coroutines.runBlocking + +internal class InsightsEventUploader( + private val localRepository: InsightsLocalRepository, + private val distantRepository: InsightsDistantRepository, +) : InsightsUploader { + + override fun uploadAll(): List { + val events = localRepository.read() + InsightsLogger.log("Flushing remaining ${events.size} events.") + val failedEvents = sendEvents(events).filterEventsWhenException() + localRepository.overwrite(failedEvents.map { it.event }) + return failedEvents + } + + private fun sendEvents(events: List): List { + return runBlocking { + events.map { event -> distantRepository.send(event) } + } + } + + private fun List.filterEventsWhenException(): List { + return this.filter { it.code == -1 } + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/uploader/InsightsUploader.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/uploader/InsightsUploader.kt new file mode 100644 index 000000000..bac3d58b4 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/uploader/InsightsUploader.kt @@ -0,0 +1,8 @@ +package com.algolia.instantsearch.insights.internal.uploader + +import com.algolia.instantsearch.insights.internal.event.EventResponse + +internal interface InsightsUploader { + + fun uploadAll(): List +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/worker/InsightsManager.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/worker/InsightsManager.kt new file mode 100644 index 000000000..887c5ade1 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/worker/InsightsManager.kt @@ -0,0 +1,10 @@ +package com.algolia.instantsearch.insights.internal.worker + +internal interface InsightsManager { + + fun setInterval(intervalInMinutes: Long) = Unit + + fun startPeriodicUpload() + + fun startOneTimeUpload() +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/worker/InsightsWorkManager.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/worker/InsightsWorkManager.kt new file mode 100644 index 000000000..b7cf22311 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/worker/InsightsWorkManager.kt @@ -0,0 +1,57 @@ +package com.algolia.instantsearch.insights.internal.worker + +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.algolia.instantsearch.insights.internal.data.settings.InsightsSettings +import com.algolia.instantsearch.insights.internal.logging.InsightsLogger +import java.util.concurrent.TimeUnit +import kotlin.math.max + +internal class InsightsWorkManager( + private val workManager: WorkManager, + private val settings: InsightsSettings, +) : InsightsManager { + + private var repeatIntervalInMinutes: Long = DEFAULT_REPEAT_INTERVAL_IN_MINUTES + private val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + override fun setInterval(intervalInMinutes: Long) { + repeatIntervalInMinutes = max(DEFAULT_REPEAT_INTERVAL_IN_MINUTES, intervalInMinutes) + } + + override fun startPeriodicUpload() { + if (settings.workId != null) return + val workRequest = PeriodicWorkRequestBuilder( + repeatInterval = repeatIntervalInMinutes, repeatIntervalTimeUnit = TimeUnit.MINUTES, + flexTimeInterval = FLEX_TIME_INTERVAL_IN_MINUTES, flexTimeIntervalUnit = TimeUnit.MINUTES + ) + .setConstraints(constraints) + .build() + workManager.enqueueUniquePeriodicWork(WORK_NAME_PERIODIC, ExistingPeriodicWorkPolicy.KEEP, workRequest) + val wordId = workRequest.id + settings.workId = wordId + InsightsLogger.log("Unique periodic upload enqueued with id: $wordId") + } + + override fun startOneTimeUpload() { + val workRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() + workManager.enqueueUniqueWork(WORK_NAME_ONETIME, ExistingWorkPolicy.REPLACE, workRequest) + InsightsLogger.log("One time unique upload enqueued with id: ${workRequest.id}") + } + + companion object { + private const val DEFAULT_REPEAT_INTERVAL_IN_MINUTES = 15L + private const val FLEX_TIME_INTERVAL_IN_MINUTES = 5L + private const val WORK_NAME_PERIODIC = "PERIODIC_UPLOAD" + private const val WORK_NAME_ONETIME = "ONETIME_UPLOAD" + } +} diff --git a/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/worker/InsightsWorker.kt b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/worker/InsightsWorker.kt new file mode 100644 index 000000000..cdd11f8b3 --- /dev/null +++ b/instantsearch-insights/src/main/java/com/algolia/instantsearch/insights/internal/worker/InsightsWorker.kt @@ -0,0 +1,20 @@ +package com.algolia.instantsearch.insights.internal.worker + +import android.content.Context +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.algolia.instantsearch.insights.internal.InsightsMap +import com.algolia.instantsearch.insights.internal.logging.InsightsLogger + +internal class InsightsWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) { + + override fun doWork(): Result { + InsightsLogger.log("Worker started with indices ${InsightsMap.keys} from work request $id.") + val hasAnyEventFailed = InsightsMap + .map { it.value.uploader.uploadAll().isEmpty() } + .any { !it } + val result = if (hasAnyEventFailed) Result.retry() else Result.success() + InsightsLogger.log("Worker ended with result: $result.") + return result + } +} diff --git a/instantsearch-insights/src/main/templates/com/algolia/instantsearch/insights/BuildConfig.kt b/instantsearch-insights/src/main/templates/com/algolia/instantsearch/insights/BuildConfig.kt new file mode 100644 index 000000000..61fd49b9e --- /dev/null +++ b/instantsearch-insights/src/main/templates/com/algolia/instantsearch/insights/BuildConfig.kt @@ -0,0 +1,7 @@ +package com.algolia.instantsearch.insights + +import kotlin.String + +internal object BuildConfig { + internal const val INSIGHTS_VERSION: String = "$version" +} diff --git a/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/AndroidTestDatabaseSharedPreferences.kt b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/AndroidTestDatabaseSharedPreferences.kt new file mode 100644 index 000000000..915733628 --- /dev/null +++ b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/AndroidTestDatabaseSharedPreferences.kt @@ -0,0 +1,82 @@ +package com.algolia.instantsearch.insights + +import android.app.Application +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.algolia.instantsearch.insights.internal.data.local.InsightsPrefsRepository +import com.algolia.instantsearch.insights.internal.extension.insightsSharedPreferences +import com.algolia.search.model.Attribute +import com.algolia.search.model.IndexName +import com.algolia.search.model.ObjectID +import com.algolia.search.model.QueryID +import com.algolia.search.model.filter.Filter +import com.algolia.search.model.insights.EventName +import com.algolia.search.model.insights.InsightsEvent +import com.algolia.search.model.insights.UserToken +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.P]) +class AndroidTestDatabaseSharedPreferences { + + private val context get() = ApplicationProvider.getApplicationContext() + private val eventA = EventName("EventA") + private val eventB = EventName("EventB") + private val eventC = EventName("EventC") + private val indexName = IndexName("latency") + private val queryId = QueryID("6de2f7eaa537fa93d8f8f05b927953b1") + private val userToken = UserToken("foobarbaz") + private val positions = listOf(1) + private val objectIDs = listOf(ObjectID("54675051")) + private val timestamp = System.currentTimeMillis() + private val facets = listOf( + Filter.Facet(attribute = Attribute("attributeString"), isNegated = true, score = 1, value = "value"), + Filter.Facet(attribute = Attribute("attributeNum"), isNegated = true, value = 1), + Filter.Facet(attribute = Attribute("attributeBoolean"), isNegated = false, value = true) + ) + private val eventClick = InsightsEvent.Click( + indexName = indexName, + eventName = eventA, + timestamp = timestamp, + resources = InsightsEvent.Resources.ObjectIDs(objectIDs), + userToken = userToken, + positions = positions, + queryID = queryId + ) + private val eventConversion = InsightsEvent.Conversion( + indexName = indexName, + eventName = eventB, + userToken = userToken, + timestamp = timestamp, + resources = InsightsEvent.Resources.ObjectIDs(objectIDs), + queryID = queryId + ) + private val eventView = InsightsEvent.View( + indexName = indexName, + eventName = eventC, + timestamp = timestamp, + resources = InsightsEvent.Resources.Filters(facets), + queryID = queryId, + userToken = userToken + ) + + @Test + fun test() { + val events = listOf( + eventClick, + eventConversion + ) + + val database = InsightsPrefsRepository(context.insightsSharedPreferences(indexName)) + + database.overwrite(events) + assertTrue(database.read().containsAll(events)) + + database.append(eventView) + assertTrue(database.read().containsAll(events.plus(eventView))) + } +} diff --git a/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/InsightsAndroidTest.kt b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/InsightsAndroidTest.kt new file mode 100644 index 000000000..026d47dd4 --- /dev/null +++ b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/InsightsAndroidTest.kt @@ -0,0 +1,70 @@ +package com.algolia.instantsearch.insights + +import android.app.Application +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.algolia.instantsearch.insights.exception.InsightsException +import com.algolia.instantsearch.insights.util.setupWorkManager +import com.algolia.search.model.APIKey +import com.algolia.search.model.ApplicationID +import com.algolia.search.model.IndexName +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.P]) +class InsightsAndroidTest { + + private val context get() = ApplicationProvider.getApplicationContext() + private val configuration = Insights.Configuration( + connectTimeoutInMilliseconds = 5000, + readTimeoutInMilliseconds = 5000 + ) + + private val appId = ApplicationID("appId") + private val apiKey = APIKey("apiKey") + + @Before + fun init() { + setupWorkManager() + } + + @Test + fun testSharedWithoutRegister() { + try { + val indexName = IndexName("index") + Insights.shared(indexName) + } catch (exception: Exception) { + assertTrue(exception is InsightsException.IndexNotRegistered) + } + } + + @Test + fun testSharedAfterRegister() { + val indexName = IndexName("indexSharedAfter") + val insights = Insights.register(context, appId, apiKey, indexName, configuration) + val insightsShared = Insights.shared + assertEquals(insights, insightsShared) + } + + @Test + fun testSharedNamedAfterRegister() { + val indexName = IndexName("indexSharedNamedAfter") + val insights = Insights.register(context, appId, apiKey, indexName, configuration) + val insightsShared = Insights.shared(indexName) + assertEquals(insights, insightsShared) + } + + @Test + fun testRegisterGlobalUserToken() { + val indexName = IndexName("indexGlobalUserToken") + Insights.register(context, appId, apiKey, indexName, configuration) + val insightsShared = Insights.shared(indexName) + assertEquals(configuration.defaultUserToken, insightsShared.userToken) + } +} diff --git a/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/InsightsAndroidTestJava.java b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/InsightsAndroidTestJava.java new file mode 100644 index 000000000..5220fe1a8 --- /dev/null +++ b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/InsightsAndroidTestJava.java @@ -0,0 +1,62 @@ +package com.algolia.instantsearch.insights; + +import android.content.Context; +import android.os.Build; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.algolia.instantsearch.insights.exception.InsightsException; +import com.algolia.search.model.APIKey; +import com.algolia.search.model.ApplicationID; +import com.algolia.search.model.IndexName; +import com.algolia.search.model.insights.EventName; +import com.algolia.search.model.insights.UserToken; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +import java.util.Collections; + +import static com.algolia.instantsearch.insights.util.WorkerManagerKt.setupWorkManager; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@RunWith(AndroidJUnit4.class) +@Config(sdk = Build.VERSION_CODES.P) +public class InsightsAndroidTestJava { + + private Context context = ApplicationProvider.getApplicationContext(); + private Insights.Configuration configuration = new Insights.Configuration(5000, 5000, new UserToken("foobarbaz")); + + @Before + public void init() { + setupWorkManager(); + } + + @Test + public void testInitShouldFail() { + try { + IndexName index = new IndexName("index"); + Insights.shared(index); + } catch (Exception exception) { + assertEquals(exception.getClass(), InsightsException.IndexNotRegistered.class); + } + } + + @Test + public void testInitShouldWork() { + IndexName index = new IndexName("index"); + ApplicationID appId = new ApplicationID("appId"); + APIKey apiKey = new APIKey("apiKey"); + Insights insights = Insights.register(context, appId, apiKey, index, configuration); + Insights insightsShared = Insights.shared(); + assertNotNull("shared Insights should have been registered", insightsShared); + assertEquals(insights, insightsShared); + + insightsShared.clickedObjectIDs( + new EventName("eventName"), + Collections.emptyList(), + 0L + ); + } +} diff --git a/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/InsightsTest.kt b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/InsightsTest.kt new file mode 100644 index 000000000..71857eb87 --- /dev/null +++ b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/InsightsTest.kt @@ -0,0 +1,357 @@ +package com.algolia.instantsearch.insights + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.algolia.instantsearch.insights.internal.InsightsController +import com.algolia.instantsearch.insights.internal.cache.InsightsEventCache +import com.algolia.instantsearch.insights.internal.data.distant.InsightsDistantRepository +import com.algolia.instantsearch.insights.internal.data.distant.InsightsHttpRepository +import com.algolia.instantsearch.insights.internal.data.local.InsightsLocalRepository +import com.algolia.instantsearch.insights.internal.uploader.InsightsEventUploader +import com.algolia.instantsearch.insights.internal.worker.InsightsManager +import com.algolia.search.client.ClientInsights +import com.algolia.search.configuration.ConfigurationInsights +import com.algolia.search.model.APIKey +import com.algolia.search.model.ApplicationID +import com.algolia.search.model.Attribute +import com.algolia.search.model.IndexName +import com.algolia.search.model.ObjectID +import com.algolia.search.model.QueryID +import com.algolia.search.model.filter.Filter +import com.algolia.search.model.insights.EventName +import com.algolia.search.model.insights.InsightsEvent +import com.algolia.search.model.insights.UserToken +import kotlinx.coroutines.runBlocking +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [Build.VERSION_CODES.P]) +internal class InsightsTest { + + private val eventA = EventName("EventA") + private val eventB = EventName("EventB") + private val eventC = EventName("EventC") + private val indexName = IndexName("latency") + private val appId = System.getenv("ALGOLIA_APPLICATION_ID") + private val apiKey = System.getenv("ALGOLIA_API_KEY") + private val queryID = QueryID("6de2f7eaa537fa93d8f8f05b927953b1") + private val userToken = UserToken("foobarbaz") + private val positions = listOf(1) + private val objectIDs = listOf(ObjectID("54675051")) + private val resourcesObjectIDs = InsightsEvent.Resources.ObjectIDs(objectIDs) + private val filters = listOf(Filter.Facet(Attribute("foo"), "bar")) + private val resourcesFilters = InsightsEvent.Resources.Filters(filters) + private val timestamp = System.currentTimeMillis() + private val configuration = Insights.Configuration( + connectTimeoutInMilliseconds = 5000, + readTimeoutInMilliseconds = 5000 + ) + private val eventClick = InsightsEvent.Click( + eventName = eventA, + indexName = indexName, + userToken = userToken, + timestamp = timestamp, + queryID = queryID, + resources = resourcesObjectIDs, + positions = positions + ) + private val eventConversion = InsightsEvent.Conversion( + eventName = eventB, + indexName = indexName, + userToken = userToken, + timestamp = timestamp, + resources = resourcesObjectIDs, + queryID = queryID + ) + private val eventView = InsightsEvent.View( + eventName = eventC, + indexName = indexName, + userToken = userToken, + timestamp = timestamp, + resources = resourcesFilters, + queryID = queryID + ) + + private val clientInsights = ClientInsights( + ConfigurationInsights( + applicationID = ApplicationID(appId), + apiKey = APIKey(apiKey), + writeTimeout = configuration.connectTimeoutInMilliseconds, + readTimeout = configuration.readTimeoutInMilliseconds + ) + ) + + private val webService + get() = InsightsHttpRepository(clientInsights) + + init { + System.setProperty("javax.net.ssl.trustStoreType", "JKS") + } + + @Test + fun testClickEvent() { + runBlocking { + val response = webService.send(eventClick) + assertEquals(200, response.code) + } + } + + @Test + fun testViewEvent() { + runBlocking { + val response = webService.send(eventView) + assertEquals(200, response.code) + } + } + + @Test + fun testConversionEvent() { + runBlocking { + val response = webService.send(eventConversion) + assertEquals(200, response.code) + } + } + + @Test + fun testMethods() { + val events = mutableListOf(eventClick, eventConversion, eventView) + val localRepository = MockLocalRepository(events) + val distantRepository = MockDistantRepository() + val eventUploader = object : AssertingWorker(events, distantRepository, localRepository) { + override fun startOneTimeUpload() { + val trackedEvents = localRepository.read() + assertEquals(5, trackedEvents.size, "Five events should have been tracked") + assertTrue( + trackedEvents.contains(eventClick), + "The clicked event should have been tracked through clicked and clickedAfterSearch" + ) + assertTrue( + trackedEvents.contains(eventClick), + "The converted event should have been tracked through converted and convertedAfterSearch" + ) + assertTrue( + trackedEvents.contains(eventClick), + "The viewed event should have been tracked through viewed" + ) + } + } + val cache = InsightsEventCache(localRepository) + val uploader = InsightsEventUploader(localRepository, distantRepository) + val insights = InsightsController(indexName, eventUploader, cache, uploader) + insights.userToken = UserToken("foo") // TODO: git stash apply to use default UUID token + insights.clickedObjectIDs(eventClick.eventName, objectIDs) + insights.clickedObjectIDsAfterSearch( + eventName = eventClick.eventName, + queryID = eventClick.queryID!!, + objectIDs = objectIDs, + positions = eventClick.positions!! + ) + insights.convertedObjectIDs(eventConversion.eventName, objectIDs) + insights.convertedObjectIDsAfterSearch( + eventName = eventConversion.eventName, + queryID = eventConversion.queryID!!, + objectIDs = objectIDs + ) + insights.viewedFilters(eventView.eventName, filters) + } + + @Test + fun testEnabled() { + val events = mutableListOf(eventClick, eventConversion, eventView) + val localRepository = MockLocalRepository(events) + val distantRepository = MockDistantRepository() + val eventUploader = object : AssertingWorker(events, distantRepository, localRepository) { + override fun startOneTimeUpload() { + val trackedEvents = localRepository.read() + assertFalse(trackedEvents.contains(eventClick), "The first event should have been ignored") + assertTrue(trackedEvents.contains(eventConversion), "The second event should be uploaded") + } + } + val cache = InsightsEventCache(localRepository) + val uploader = InsightsEventUploader(localRepository, distantRepository) + val insights = InsightsController(indexName, eventUploader, cache, uploader) + insights.minBatchSize = 1 // Given an Insights that uploads every event + + insights.enabled = false // When a firstEvent is sent with insight disabled + insights.clicked(eventClick) + insights.enabled = true // And a secondEvent sent with insight enabled + insights.converted(eventConversion) + } + + @Test + fun testMinBatchSize() { + val events = mutableListOf(eventClick, eventConversion, eventView) + val localRepository = MockLocalRepository(events) + val distantRepository = MockDistantRepository() + val eventUploader = MinBatchSizeWorker(events, distantRepository, localRepository) + val cache = InsightsEventCache(localRepository) + val uploader = InsightsEventUploader(localRepository, distantRepository) + val insights = InsightsController(indexName, eventUploader, cache, uploader) + + // Given a minBatchSize of one and one event + insights.minBatchSize = 1 + insights.track(eventClick) + // Given a minBatchSize of two and two events + insights.minBatchSize = 2 + insights.track(eventClick) + insights.track(eventClick) + // Given a minBatchSize of four and four events + insights.minBatchSize = 4 + insights.track(eventClick) + insights.track(eventClick) + insights.track(eventClick) + insights.track(eventClick) + } + + inner class MinBatchSizeWorker( + events: MutableList, + webService: MockDistantRepository, + private val database: InsightsLocalRepository, + ) : AssertingWorker(events, webService, database) { + + override fun startOneTimeUpload() { + when (count) { + // Expect a single event on first call + 0 -> assertEquals(1, database.count(), "startOneTimeUpload should be called first with one event") + // Expect two events on second call + 1 -> assertEquals(2, database.count(), "startOneTimeUpload should be called second with two events") + // Expect two events on third call + 2 -> assertEquals(4, database.count(), "startOneTimeUpload should be called third with four events") + } + count++ + database.clear() + } + } + + /** + * Tests the integration of events, WebService and Database. + */ + @Test + fun testIntegration() { + val events = mutableListOf(eventClick, eventConversion, eventView) + val localRepository = MockLocalRepository(events) + val distantRepository = MockDistantRepository() + val eventUploader = IntegrationWorker(events, distantRepository, localRepository) + val cache = InsightsEventCache(localRepository) + val uploader = InsightsEventUploader(localRepository, distantRepository) + val insights = InsightsController(indexName, eventUploader, cache, uploader).apply { + minBatchSize = 1 + } + + distantRepository.code = 200 // Given a working web service + insights.track(eventClick) + distantRepository.code = -1 // Given a web service that errors + insights.track(eventConversion) + distantRepository.code = 400 // Given a working web service returning an HTTP error + insights.track(eventView) // When tracking an event + + distantRepository.code = -1 // Given a web service that errors + insights.userToken = userToken // Given an userToken + + // When adding events without explicitly-provided userToken + insights.clickedObjectIDsAfterSearch( + eventName = eventA, + queryID = queryID, + objectIDs = objectIDs, + positions = positions, + timestamp = timestamp + ) + insights.clickedObjectIDs( + eventName = eventA, + timestamp = timestamp, + objectIDs = objectIDs + ) + insights.convertedObjectIDsAfterSearch( + eventName = eventB, + timestamp = timestamp, + queryID = queryID, + objectIDs = objectIDs + ) + distantRepository.code = 200 // Given a working web service + insights.viewed(eventView) + } + + inner class IntegrationWorker( + events: MutableList, + webService: InsightsDistantRepository, + private val database: InsightsLocalRepository, + ) : AssertingWorker(events, webService, database) { + + override fun startOneTimeUpload() { + val clickEventNotForSearch = InsightsEvent.Click( + eventName = eventA, + indexName = indexName, + userToken = userToken, + timestamp = timestamp, + resources = resourcesObjectIDs, + positions = null // A Click event not for Search has no positions + ) + + when (count) { + 0 -> assertEquals(listOf(eventClick), database.read(), "failed 0") // expect added first + 1 -> + assertEquals( + listOf(eventConversion), + database.read(), + "failed 1" + ) // expect flush then added second + 2 -> assertEquals(listOf(eventConversion, eventView), database.read(), "failed 2") + + 3 -> assertEquals(listOf(eventClick), database.read(), "failed 3") // expect flush then added first + 4 -> + assertEquals( + listOf(eventClick, clickEventNotForSearch), + database.read(), + "failed 4" + ) // expect added first + 5 -> assertEquals( + listOf(eventClick, clickEventNotForSearch, eventConversion), + database.read(), + "failed 5" + ) // expect added second + 6 -> assertEquals( + listOf(eventClick, clickEventNotForSearch, eventConversion, eventView), + database.read(), + "failed 6" + ) // expect added third + } + uploader.uploadAll() + when (count) { + 0 -> assert(database.read().isEmpty()) // expect flushed first + 1 -> assertEquals(listOf(eventConversion), database.read()) // expect kept second + 2 -> assert(database.read().isEmpty()) // expect flushed events + + 3 -> assertEquals(listOf(eventClick), database.read()) // expect kept first + 4 -> assertEquals(listOf(eventClick, clickEventNotForSearch), database.read()) // expect kept first2 + 5 -> + assertEquals( + listOf(eventClick, clickEventNotForSearch, eventConversion), + database.read() + ) // expect kept second + 6 -> assert(database.read().isEmpty()) // expect flushed events + } + count++ + } + } + + abstract inner class AssertingWorker( + private val events: MutableList, + distantRepository: InsightsDistantRepository, + private val localRepository: InsightsLocalRepository, + ) : InsightsManager { + + protected var count: Int = 0 + protected val uploader = InsightsEventUploader(localRepository, distantRepository) + + override fun startPeriodicUpload() { + assertEquals(events, localRepository.read()) + uploader.uploadAll() + assert(localRepository.read().isEmpty()) + } + } +} diff --git a/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/MockDistantRepository.kt b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/MockDistantRepository.kt new file mode 100644 index 000000000..8acaa47b1 --- /dev/null +++ b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/MockDistantRepository.kt @@ -0,0 +1,14 @@ +package com.algolia.instantsearch.insights + +import com.algolia.instantsearch.insights.internal.data.distant.InsightsDistantRepository +import com.algolia.instantsearch.insights.internal.event.EventResponse +import com.algolia.search.model.insights.InsightsEvent + +internal class MockDistantRepository : InsightsDistantRepository { + + var code = 200 + + override suspend fun send(event: InsightsEvent): EventResponse { + return EventResponse(event, code) + } +} diff --git a/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/MockLocalRepository.kt b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/MockLocalRepository.kt new file mode 100644 index 000000000..e406f88c5 --- /dev/null +++ b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/MockLocalRepository.kt @@ -0,0 +1,30 @@ +package com.algolia.instantsearch.insights + +import com.algolia.instantsearch.insights.internal.data.local.InsightsLocalRepository +import com.algolia.search.model.insights.InsightsEvent + +internal class MockLocalRepository( + private val events: MutableList, +) : InsightsLocalRepository { + + override fun append(event: InsightsEvent) { + events.add(event) + } + + override fun overwrite(events: List) { + clear() + this.events += events + } + + override fun read(): List { + return events + } + + override fun count(): Int { + return events.size + } + + override fun clear() { + events.clear() + } +} diff --git a/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/util/WorkerManager.kt b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/util/WorkerManager.kt new file mode 100644 index 000000000..fbb1f9bb9 --- /dev/null +++ b/instantsearch-insights/src/test/java/com/algolia/instantsearch/insights/util/WorkerManager.kt @@ -0,0 +1,16 @@ +package com.algolia.instantsearch.insights.util + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import androidx.work.Configuration +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper + +internal fun setupWorkManager() { + val context = ApplicationProvider.getApplicationContext() + val config = Configuration.Builder() + .setExecutor(SynchronousExecutor()) + .build() + + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) +} diff --git a/instantsearch-insights/src/test/resources/robolectric.properties b/instantsearch-insights/src/test/resources/robolectric.properties new file mode 100644 index 000000000..932b01b9e --- /dev/null +++ b/instantsearch-insights/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/settings.gradle.kts b/settings.gradle.kts index 24850c91c..de8f7f47a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,3 +2,4 @@ rootProject.name = "instantsearch-android" include(":instantsearch-android-core") include(":instantsearch-android") +include(":instantsearch-insights") From 9bb87d74c1d328ad349f9addba683cc8c1a9a7c9 Mon Sep 17 00:00:00 2001 From: Mouaad Aallam Date: Wed, 17 Mar 2021 15:44:34 +0100 Subject: [PATCH 2/4] feat(paging): add retry functions to paging data source (#232) --- .../list/RetryablePageKeyedDataSource.kt | 36 +++++++++++++++++++ .../list/SearcherMultipleIndexDataSource.kt | 19 ++++++++-- .../list/SearcherSingleIndexDataSource.kt | 18 ++++++++-- 3 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 instantsearch-android/src/main/java/com/algolia/instantsearch/helper/android/list/RetryablePageKeyedDataSource.kt diff --git a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/android/list/RetryablePageKeyedDataSource.kt b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/android/list/RetryablePageKeyedDataSource.kt new file mode 100644 index 000000000..549cf2825 --- /dev/null +++ b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/android/list/RetryablePageKeyedDataSource.kt @@ -0,0 +1,36 @@ +package com.algolia.instantsearch.helper.android.list + +import androidx.paging.PageKeyedDataSource +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.withContext + +/** + * A [PageKeyedDataSource] with retry capability. + */ +public abstract class RetryablePageKeyedDataSource(private val retryDispatcher: CoroutineDispatcher) : + PageKeyedDataSource() { + + internal var retry: (() -> Any)? = null + + public suspend fun retry() { + retry?.let { prevRetry -> + retry = null + withContext(retryDispatcher) { + prevRetry() + } + } + } + + /** + * Retries the latest call. + */ + public fun retryAsync() { + retry?.let { prevRetry -> + retry = null + retryDispatcher.asExecutor().execute { + prevRetry() + } + } + } +} diff --git a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/android/list/SearcherMultipleIndexDataSource.kt b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/android/list/SearcherMultipleIndexDataSource.kt index eb3c92419..0616a40f6 100644 --- a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/android/list/SearcherMultipleIndexDataSource.kt +++ b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/android/list/SearcherMultipleIndexDataSource.kt @@ -1,10 +1,11 @@ package com.algolia.instantsearch.helper.android.list import androidx.paging.DataSource -import androidx.paging.PageKeyedDataSource import com.algolia.instantsearch.helper.searcher.SearcherMultipleIndex import com.algolia.search.model.multipleindex.IndexQuery import com.algolia.search.model.response.ResponseSearch +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -12,18 +13,26 @@ public class SearcherMultipleIndexDataSource( private val searcher: SearcherMultipleIndex, private val indexQuery: IndexQuery, private val triggerSearchForQueries: ((List) -> Boolean) = { true }, + retryDispatcher: CoroutineDispatcher = Dispatchers.IO, private val transformer: (ResponseSearch.Hit) -> T, -) : PageKeyedDataSource() { +) : RetryablePageKeyedDataSource(retryDispatcher) { public class Factory( private val searcher: SearcherMultipleIndex, private val indexQuery: IndexQuery, private val triggerSearchForQueries: ((List) -> Boolean) = { true }, + private val retryDispatcher: CoroutineDispatcher = Dispatchers.IO, private val transformer: (ResponseSearch.Hit) -> T, ) : DataSource.Factory() { override fun create(): DataSource { - return SearcherMultipleIndexDataSource(searcher, indexQuery, triggerSearchForQueries, transformer) + return SearcherMultipleIndexDataSource( + searcher = searcher, + indexQuery = indexQuery, + triggerSearchForQueries = triggerSearchForQueries, + transformer = transformer, + retryDispatcher = retryDispatcher + ) } } @@ -52,8 +61,10 @@ public class SearcherMultipleIndexDataSource( searcher.response.value = response searcher.isLoading.value = false } + retry = null callback.onResult(result.hits.map(transformer), 0, result.nbHits, null, nextKey) } catch (throwable: Throwable) { + retry = { loadInitial(params, callback) } resultError(throwable) } } @@ -76,8 +87,10 @@ public class SearcherMultipleIndexDataSource( searcher.response.value = response searcher.isLoading.value = false } + retry = null callback.onResult(result.hits.map(transformer), nextKey) } catch (throwable: Throwable) { + retry = { loadAfter(params, callback) } resultError(throwable) } } diff --git a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/android/list/SearcherSingleIndexDataSource.kt b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/android/list/SearcherSingleIndexDataSource.kt index 97274ca86..9ba1dd568 100644 --- a/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/android/list/SearcherSingleIndexDataSource.kt +++ b/instantsearch-android/src/main/java/com/algolia/instantsearch/helper/android/list/SearcherSingleIndexDataSource.kt @@ -1,27 +1,35 @@ package com.algolia.instantsearch.helper.android.list import androidx.paging.DataSource -import androidx.paging.PageKeyedDataSource import com.algolia.instantsearch.helper.searcher.SearcherSingleIndex import com.algolia.search.model.response.ResponseSearch import com.algolia.search.model.search.Query +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext public class SearcherSingleIndexDataSource( private val searcher: SearcherSingleIndex, private val triggerSearchForQuery: ((Query) -> Boolean) = { true }, + retryDispatcher: CoroutineDispatcher = Dispatchers.IO, private val transformer: (ResponseSearch.Hit) -> T, -) : PageKeyedDataSource() { +) : RetryablePageKeyedDataSource(retryDispatcher) { public class Factory( private val searcher: SearcherSingleIndex, private val triggerSearchForQuery: ((Query) -> Boolean) = { true }, + private val retryDispatcher: CoroutineDispatcher = Dispatchers.IO, private val transformer: (ResponseSearch.Hit) -> T, ) : DataSource.Factory() { override fun create(): DataSource { - return SearcherSingleIndexDataSource(searcher, triggerSearchForQuery, transformer) + return SearcherSingleIndexDataSource( + searcher = searcher, + triggerSearchForQuery = triggerSearchForQuery, + retryDispatcher = retryDispatcher, + transformer = transformer + ) } } @@ -47,8 +55,10 @@ public class SearcherSingleIndexDataSource( searcher.response.value = response searcher.isLoading.value = false } + retry = null callback.onResult(response.hits.map(transformer), 0, response.nbHits, null, nextKey) } catch (throwable: Throwable) { + retry = { loadInitial(params, callback) } resultError(throwable) } } @@ -70,8 +80,10 @@ public class SearcherSingleIndexDataSource( searcher.response.value = response searcher.isLoading.value = false } + retry = null callback.onResult(response.hits.map(transformer), nextKey) } catch (throwable: Throwable) { + retry = { loadAfter(params, callback) } resultError(throwable) } } From 49ce5a67650af1010282ae5361169c001ed40149 Mon Sep 17 00:00:00 2001 From: Mouaad Aallam Date: Thu, 18 Mar 2021 11:34:36 +0100 Subject: [PATCH 3/4] chore: prepare release 2.10.0 --- CHANGELOG.md | 6 ++++++ buildSrc/src/main/kotlin/Library.kt | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 235cbe9b1..6a9ecebf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 2.10.0 + +### Added +- Integration of `InstantSearch Insights` library +- Add `retry` to paging data sources + # 2.9.0 ### Added diff --git a/buildSrc/src/main/kotlin/Library.kt b/buildSrc/src/main/kotlin/Library.kt index 8c517f3bc..045488785 100644 --- a/buildSrc/src/main/kotlin/Library.kt +++ b/buildSrc/src/main/kotlin/Library.kt @@ -4,5 +4,5 @@ object Library: Dependency { override val group = "com.algolia" override val artifact = "instantsearch-android" - override val version = "2.9.0" + override val version = "2.10.0" } From ed77fc2e0358177e1ebeca1e1ffe3963dd488b7e Mon Sep 17 00:00:00 2001 From: Mouaad Aallam Date: Thu, 18 Mar 2021 11:46:07 +0100 Subject: [PATCH 4/4] chore(release): enable artifacts signing --- gradle.properties | 2 -- 1 file changed, 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 1b87d1bca..c1c2ddca0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,5 +18,3 @@ POM_LICENCE_DIST=repo POM_DEVELOPER_ID=algolia POM_DEVELOPER_NAME=The Algolia Team POM_DEVELOPER_EMAIL=hey@algolia.com - -RELEASE_SIGNING_ENABLED=false