diff --git a/experimentation/build.gradle b/experimentation/build.gradle
new file mode 100644
index 00000000..54f52c24
--- /dev/null
+++ b/experimentation/build.gradle
@@ -0,0 +1,52 @@
+plugins {
+ id 'com.android.library'
+ id 'org.jetbrains.kotlin.android'
+}
+
+repositories {
+ google()
+ maven {
+ url 'https://a8c-libs.s3.amazonaws.com/android'
+ content {
+ includeGroup "org.wordpress"
+ includeGroup "org.wordpress.fluxc"
+ includeGroup "org.wordpress.wellsql"
+ }
+ }
+}
+
+android {
+ compileSdkVersion 30
+
+ defaultConfig {
+ minSdkVersion 21
+ targetSdkVersion 30
+ versionCode 1
+ versionName "1.0"
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ lintOptions {
+ warningsAsErrors true
+ warning 'GradleDependency'
+ }
+}
+
+dependencies {
+ // Coroutines
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2"
+
+ //FluxC
+ implementation "org.wordpress:fluxc:1.36.0"
+
+ //WordPress utils
+ implementation "org.wordpress:utils:2.3.0"
+
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2"
+ testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.assertj:assertj-core:3.19.0'
+ testImplementation 'org.mockito.kotlin:mockito-kotlin:2.2.11'
+ testImplementation 'org.mockito:mockito-inline:3.8.0'
+}
\ No newline at end of file
diff --git a/experimentation/src/main/AndroidManifest.xml b/experimentation/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..c84ce677
--- /dev/null
+++ b/experimentation/src/main/AndroidManifest.xml
@@ -0,0 +1,5 @@
+
+
+
+
\ No newline at end of file
diff --git a/experimentation/src/main/java/com/automattic/android/experimentation/ExPlat.kt b/experimentation/src/main/java/com/automattic/android/experimentation/ExPlat.kt
new file mode 100644
index 00000000..bf5ccaff
--- /dev/null
+++ b/experimentation/src/main/java/com/automattic/android/experimentation/ExPlat.kt
@@ -0,0 +1,89 @@
+package com.automattic.android.experimentation
+
+import com.automattic.android.experimentation.ExPlat.RefreshStrategy.ALWAYS
+import com.automattic.android.experimentation.ExPlat.RefreshStrategy.IF_STALE
+import com.automattic.android.experimentation.ExPlat.RefreshStrategy.NEVER
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import org.wordpress.android.fluxc.model.experiments.Assignments
+import org.wordpress.android.fluxc.model.experiments.Variation
+import org.wordpress.android.fluxc.model.experiments.Variation.Control
+import org.wordpress.android.fluxc.store.ExperimentStore
+import org.wordpress.android.fluxc.store.ExperimentStore.Platform
+import org.wordpress.android.fluxc.utils.AppLogWrapper
+import org.wordpress.android.util.AppLog.T
+
+class ExPlat(
+ private val platform: Platform,
+ private val experiments: Set,
+ private val experimentStore: ExperimentStore,
+ private val appLogWrapper: AppLogWrapper,
+ private val coroutineScope: CoroutineScope,
+ private val isDebug: Boolean
+) {
+ private val activeVariations = mutableMapOf()
+ private val experimentNames: List by lazy { experiments.map { it.name } }
+
+ fun refreshIfNeeded() {
+ refresh(refreshStrategy = IF_STALE)
+ }
+
+ fun forceRefresh() {
+ refresh(refreshStrategy = ALWAYS)
+ }
+
+ fun clear() {
+ appLogWrapper.d(T.API, "ExPlat: clearing cached assignments and active variations")
+ activeVariations.clear()
+ experimentStore.clearCachedAssignments()
+ }
+
+ /**
+ * This returns the current active [Variation] for the provided [Experiment].
+ *
+ * If no active [Variation] is found, we can assume this is the first time this method is being
+ * called for the provided [Experiment] during the current session. In this case, the [Variation]
+ * is returned from the cached [Assignments] and then set as active. If the cached [Assignments]
+ * is stale and [shouldRefreshIfStale] is `true`, then new [Assignments] are fetched and their
+ * variations are going to be returned by this method on the next session.
+ *
+ * If the provided [Experiment] was not included in [experiments], then [Control] is returned.
+ * If [isDebug] is `true`, an [IllegalArgumentException] is thrown instead.
+ */
+ internal fun getVariation(experimentName: String, shouldRefreshIfStale: Boolean): Variation {
+ if (!experimentNames.contains(experimentName)) {
+ val message = "ExPlat: experiment not found: \"${experimentName}\"! " +
+ "Make sure to include it in the set provided via constructor."
+ appLogWrapper.e(T.API, message)
+ if (isDebug) throw IllegalArgumentException(message) else return Control
+ }
+ return activeVariations.getOrPut(experimentName) {
+ getAssignments(if (shouldRefreshIfStale) IF_STALE else NEVER)
+ .getVariationForExperiment(experimentName)
+ }
+ }
+
+ private fun refresh(refreshStrategy: RefreshStrategy) {
+ if (experimentNames.isNotEmpty()) {
+ getAssignments(refreshStrategy)
+ }
+ }
+
+ private fun getAssignments(refreshStrategy: RefreshStrategy): Assignments {
+ val cachedAssignments = experimentStore.getCachedAssignments() ?: Assignments()
+ if (refreshStrategy == ALWAYS || (refreshStrategy == IF_STALE && cachedAssignments.isStale())) {
+ coroutineScope.launch { fetchAssignments() }
+ }
+ return cachedAssignments
+ }
+
+ private suspend fun fetchAssignments() = experimentStore.fetchAssignments(platform, experimentNames).also {
+ if (it.isError) {
+ appLogWrapper.d(T.API, "ExPlat: fetching assignments failed with result: ${it.error}")
+ } else {
+ appLogWrapper.d(T.API, "ExPlat: fetching assignments successful with result: ${it.assignments}")
+ }
+ }
+
+ private enum class RefreshStrategy { ALWAYS, IF_STALE, NEVER }
+}
diff --git a/experimentation/src/main/java/com/automattic/android/experimentation/Experiment.kt b/experimentation/src/main/java/com/automattic/android/experimentation/Experiment.kt
new file mode 100644
index 00000000..a82ff728
--- /dev/null
+++ b/experimentation/src/main/java/com/automattic/android/experimentation/Experiment.kt
@@ -0,0 +1,17 @@
+package com.automattic.android.experimentation
+
+import org.wordpress.android.fluxc.model.experiments.Variation
+
+open class Experiment(
+ val name: String,
+ private val exPlat: ExPlat
+) {
+ @Suppress("unused")
+ @JvmOverloads
+ fun getVariation(shouldRefreshIfStale: Boolean = false): Variation {
+ return exPlat.getVariation(
+ experimentName = name,
+ shouldRefreshIfStale = shouldRefreshIfStale
+ )
+ }
+}
diff --git a/experimentation/src/test/java/com/automattic/android/experimentation/ExPlatTest.kt b/experimentation/src/test/java/com/automattic/android/experimentation/ExPlatTest.kt
new file mode 100644
index 00000000..a3bbb2f7
--- /dev/null
+++ b/experimentation/src/test/java/com/automattic/android/experimentation/ExPlatTest.kt
@@ -0,0 +1,247 @@
+package com.automattic.android.experimentation
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runBlockingTest
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.verifyZeroInteractions
+import org.mockito.kotlin.whenever
+import org.wordpress.android.fluxc.model.experiments.Assignments
+import org.wordpress.android.fluxc.model.experiments.Variation
+import org.wordpress.android.fluxc.model.experiments.Variation.Control
+import org.wordpress.android.fluxc.model.experiments.Variation.Treatment
+import org.wordpress.android.fluxc.store.ExperimentStore
+import org.wordpress.android.fluxc.store.ExperimentStore.OnAssignmentsFetched
+import org.wordpress.android.fluxc.store.ExperimentStore.Platform
+import org.wordpress.android.fluxc.utils.AppLogWrapper
+import java.util.Date
+
+@ExperimentalCoroutinesApi
+class ExPlatTest {
+ private val platform = Platform.WORDPRESS_ANDROID
+ private val experimentStore: ExperimentStore = mock()
+ private val appLogWrapper: AppLogWrapper = mock()
+ private var exPlat: ExPlat = createExPlat(
+ isDebug = false,
+ experiments = emptySet()
+ )
+ private val dummyExperiment = object : Experiment("dummy", exPlat) {}
+
+ @Test
+ fun `refreshIfNeeded fetches assignments if cache is null`() = runBlockingTest {
+ exPlat = createExPlat(
+ isDebug = true,
+ experiments = setOf(dummyExperiment)
+ )
+ setupAssignments(cachedAssignments = null, fetchedAssignments = buildAssignments())
+
+ exPlat.refreshIfNeeded()
+
+ verify(experimentStore, times(1)).fetchAssignments(eq(platform), any(), anyOrNull())
+ }
+
+ @Test
+ fun `refreshIfNeeded fetches assignments if cache is stale`() = runBlockingTest {
+ exPlat = createExPlat(
+ isDebug = true,
+ experiments = setOf(dummyExperiment)
+ )
+ setupAssignments(cachedAssignments = buildAssignments(isStale = true), fetchedAssignments = buildAssignments())
+
+ exPlat.refreshIfNeeded()
+
+ verify(experimentStore, times(1)).fetchAssignments(eq(platform), any(), anyOrNull())
+ }
+
+ @Test
+ fun `refreshIfNeeded does not fetch assignments if cache is fresh`() = runBlockingTest {
+ setupAssignments(cachedAssignments = buildAssignments(isStale = false), fetchedAssignments = buildAssignments())
+
+ exPlat.refreshIfNeeded()
+
+ verify(experimentStore, never()).fetchAssignments(eq(platform), any(), anyOrNull())
+ }
+
+ @Test
+ fun `forceRefresh fetches assignments if cache is fresh`() = runBlockingTest {
+ exPlat = createExPlat(
+ isDebug = true,
+ experiments = setOf(dummyExperiment)
+ )
+ setupAssignments(cachedAssignments = buildAssignments(isStale = true), fetchedAssignments = buildAssignments())
+
+ exPlat.forceRefresh()
+
+ verify(experimentStore, times(1)).fetchAssignments(eq(platform), any(), anyOrNull())
+ }
+
+ @Test
+ fun `clear calls experiment store`() = runBlockingTest {
+ exPlat.clear()
+
+ verify(experimentStore, times(1)).clearCachedAssignments()
+ }
+
+ @Test
+ fun `getVariation fetches assignments if cache is null`() = runBlockingTest {
+ exPlat = createExPlat(
+ isDebug = true,
+ experiments = setOf(dummyExperiment)
+ )
+ setupAssignments(cachedAssignments = null, fetchedAssignments = buildAssignments())
+
+ exPlat.getVariation(dummyExperiment.name, shouldRefreshIfStale = true)
+
+ verify(experimentStore, times(1)).fetchAssignments(eq(platform), any(), anyOrNull())
+ }
+
+ @Test
+ fun `getVariation fetches assignments if cache is stale`() = runBlockingTest {
+ exPlat = createExPlat(
+ isDebug = true,
+ experiments = setOf(dummyExperiment)
+ )
+ setupAssignments(cachedAssignments = buildAssignments(isStale = true), fetchedAssignments = buildAssignments())
+
+ exPlat.getVariation(dummyExperiment.name, shouldRefreshIfStale = true)
+
+ verify(experimentStore, times(1)).fetchAssignments(eq(platform), any(), anyOrNull())
+ }
+
+ @Test
+ fun `getVariation does not fetch assignments if cache is fresh`() = runBlockingTest {
+ setupAssignments(cachedAssignments = buildAssignments(isStale = false), fetchedAssignments = buildAssignments())
+
+ exPlat.getVariation(dummyExperiment.name, shouldRefreshIfStale = true)
+
+ verify(experimentStore, never()).fetchAssignments(eq(platform), any(), anyOrNull())
+ }
+
+ @Test
+ fun `getVariation does not fetch assignments if cache is null but shouldRefreshIfStale is false`() = runBlockingTest {
+ setupAssignments(cachedAssignments = null, fetchedAssignments = buildAssignments())
+
+ exPlat.getVariation(dummyExperiment.name, shouldRefreshIfStale = false)
+
+ verify(experimentStore, never()).fetchAssignments(eq(platform), any(), anyOrNull())
+ }
+
+ @Test
+ fun `getVariation does not fetch assignments if cache is stale but shouldRefreshIfStale is false`() = runBlockingTest {
+ setupAssignments(cachedAssignments = null, fetchedAssignments = buildAssignments())
+
+ exPlat.getVariation(dummyExperiment.name, shouldRefreshIfStale = false)
+
+ verify(experimentStore, never()).fetchAssignments(eq(platform), any(), anyOrNull())
+ }
+
+ @Test
+ fun `getVariation does not return different cached assignments if active variation exists`() = runBlockingTest {
+ val controlVariation = Control
+ val treatmentVariation = Treatment("treatment")
+
+ val treatmentAssignments = buildAssignments(variations = mapOf(dummyExperiment.name to treatmentVariation))
+
+ setupAssignments(cachedAssignments = null, fetchedAssignments = treatmentAssignments)
+
+ val firstVariation = exPlat.getVariation(dummyExperiment.name, shouldRefreshIfStale = false)
+ assertThat(firstVariation).isEqualTo(controlVariation)
+
+ exPlat.forceRefresh()
+
+ setupAssignments(cachedAssignments = treatmentAssignments, fetchedAssignments = treatmentAssignments)
+
+ val secondVariation = exPlat.getVariation(dummyExperiment.name, shouldRefreshIfStale = false)
+ assertThat(secondVariation).isEqualTo(controlVariation)
+ }
+
+ @Test
+ fun `forceRefresh fetches assignments if experiments is not empty`() = runBlockingTest {
+ exPlat = createExPlat(
+ isDebug = true,
+ experiments = setOf(dummyExperiment)
+ )
+ exPlat.forceRefresh()
+
+ verify(experimentStore, times(1)).fetchAssignments(eq(platform), any(), anyOrNull())
+ }
+
+ @Test
+ fun `forceRefresh does not interact with store if experiments is empty`() = runBlockingTest {
+ exPlat.forceRefresh()
+
+ verifyZeroInteractions(experimentStore)
+ }
+
+ @Test
+ fun `refreshIfNeeded does not interact with store if experiments is empty`() = runBlockingTest {
+ exPlat.refreshIfNeeded()
+
+ verifyZeroInteractions(experimentStore)
+ }
+
+ @Test
+ fun `getVariation does not interact with store if experiments is empty`() = runBlockingTest {
+ try {
+ exPlat.getVariation(dummyExperiment.name, false)
+ } catch (e: IllegalArgumentException) {
+ // Do nothing.
+ } finally {
+ verifyZeroInteractions(experimentStore)
+ }
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `getVariation throws IllegalArgumentException if experiment was not found and is debug`() {
+ runBlockingTest {
+ exPlat = createExPlat(
+ isDebug = true,
+ experiments = emptySet()
+ )
+ exPlat.getVariation(dummyExperiment.name, false)
+ }
+ }
+
+ private fun createExPlat(isDebug: Boolean, experiments: Set): ExPlat =
+ ExPlat(
+ platform = platform,
+ experiments = experiments,
+ experimentStore = experimentStore,
+ appLogWrapper = appLogWrapper,
+ coroutineScope = CoroutineScope(Dispatchers.Unconfined),
+ isDebug = isDebug
+ )
+
+ private suspend fun setupAssignments(cachedAssignments: Assignments?, fetchedAssignments: Assignments) {
+ whenever(experimentStore.getCachedAssignments()).thenReturn(cachedAssignments)
+ whenever(experimentStore.fetchAssignments(eq(platform), any(), anyOrNull()))
+ .thenReturn(OnAssignmentsFetched(fetchedAssignments))
+ }
+
+ private fun buildAssignments(
+ isStale: Boolean = false,
+ variations: Map = emptyMap()
+ ): Assignments {
+ val now = System.currentTimeMillis()
+ val oneHourAgo = now - ONE_HOUR_IN_SECONDS * 1000
+ val oneHourFromNow = now + ONE_HOUR_IN_SECONDS * 1000
+ return if (isStale) {
+ Assignments(variations, ONE_HOUR_IN_SECONDS, Date(oneHourAgo))
+ } else {
+ Assignments(variations, ONE_HOUR_IN_SECONDS, Date(oneHourFromNow))
+ }
+ }
+
+ companion object {
+ private const val ONE_HOUR_IN_SECONDS = 3600
+ }
+}
diff --git a/settings.gradle b/settings.gradle
index dd3fe524..d3d1f644 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -17,3 +17,4 @@ pluginManagement {
include ':AutomatticTracks'
include ':sampletracksapp'
+include ':experimentation'