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'