diff --git a/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt b/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt index d9557b4a73fd..283d5f67d1e3 100644 --- a/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt +++ b/WordPress/src/main/java/org/wordpress/android/AppInitializer.kt @@ -110,7 +110,6 @@ import org.wordpress.android.util.analytics.AnalyticsUtils import org.wordpress.android.util.config.AppConfig import org.wordpress.android.util.config.OpenWebLinksWithJetpackFlowFeatureConfig import org.wordpress.android.util.enqueuePeriodicUploadWorkRequestForAllSites -import org.wordpress.android.util.experiments.ExPlat import org.wordpress.android.util.image.ImageManager import org.wordpress.android.widgets.AppReviewManager import org.wordpress.android.workers.WordPressWorkersFactory @@ -184,9 +183,6 @@ class AppInitializer @Inject constructor( @Inject lateinit var imageEditorFileUtils: ImageEditorFileUtils - @Inject - lateinit var exPlat: ExPlat - @Inject lateinit var wordPressWorkerFactory: WordPressWorkersFactory @@ -372,8 +368,6 @@ class AppInitializer @Inject constructor( systemNotificationsTracker.checkSystemNotificationsState() ImageEditorInitializer.init(imageManager, imageEditorTracker, imageEditorFileUtils, appScope) - exPlat.forceRefresh() - initDebugCookieManager() if (!initialized && BuildConfig.DEBUG && Build.VERSION.SDK_INT >= VERSION_CODES.R) { @@ -662,9 +656,6 @@ class AppInitializer @Inject constructor( if (accountStore.hasAccessToken()) { // Make sure the Push Notification token is sent to our servers after a successful login gcmRegistrationScheduler.scheduleRegistration() - - // Force a refresh if user has logged in. This can be removed once we start using an anonymous ID. - exPlat.forceRefresh() } } @@ -727,9 +718,6 @@ class AppInitializer @Inject constructor( // Clear WordPress.com account cookie cache wordPressCookieAuthenticator.clearAllCachedCookies() - - // Clear cached assignments if user has logged out. This can be removed once we start using an anonymous ID. - exPlat.clear() } /* @@ -934,11 +922,6 @@ class AppInitializer @Inject constructor( // Let's migrate the old editor preference if available in AppPrefs to the remote backend SiteUtils.migrateAppWideMobileEditorPreferenceToRemote(accountStore, siteStore, dispatcher) - if (!firstActivityResumed) { - // Since we're force refreshing on app startup, we don't need to try refreshing again when starting - // our first Activity. - exPlat.refreshIfNeeded() - } if (firstActivityResumed) { deferredInit() } diff --git a/WordPress/src/main/java/org/wordpress/android/modules/ExperimentModule.kt b/WordPress/src/main/java/org/wordpress/android/modules/ExperimentModule.kt deleted file mode 100644 index 8556ac4504e2..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/modules/ExperimentModule.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.wordpress.android.modules - -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import dagger.multibindings.Multibinds -import org.wordpress.android.util.experiments.Experiment - -@InstallIn(SingletonComponent::class) -@Module -interface ExperimentModule { - @Multibinds - fun experiments(): Set - - // Copy and paste the line below to add a new experiment. - // @Binds @IntoSet fun exampleExperiment(experiment: ExampleExperiment): Experiment -} diff --git a/WordPress/src/main/java/org/wordpress/android/util/experiments/ExPlat.kt b/WordPress/src/main/java/org/wordpress/android/util/experiments/ExPlat.kt deleted file mode 100644 index 7154f1fa68a1..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/util/experiments/ExPlat.kt +++ /dev/null @@ -1,100 +0,0 @@ -package org.wordpress.android.util.experiments - -import dagger.Lazy -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import org.wordpress.android.BuildConfig -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.AccountStore -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.modules.APPLICATION_SCOPE -import org.wordpress.android.util.AppLog.T -import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper -import org.wordpress.android.util.experiments.ExPlat.RefreshStrategy.ALWAYS -import org.wordpress.android.util.experiments.ExPlat.RefreshStrategy.IF_STALE -import org.wordpress.android.util.experiments.ExPlat.RefreshStrategy.NEVER -import javax.inject.Inject -import javax.inject.Named -import javax.inject.Singleton - -@Singleton -class ExPlat -@Inject constructor( - private val experiments: Lazy>, - private val experimentStore: ExperimentStore, - private val appLog: AppLogWrapper, - private val accountStore: AccountStore, - private val analyticsTracker: AnalyticsTrackerWrapper, - @Named(APPLICATION_SCOPE) private val coroutineScope: CoroutineScope -) { - private val platform = Platform.WORDPRESS_ANDROID - private val activeVariations = mutableMapOf() - private val experimentNames by lazy { experiments.get().map { it.name } } - - fun refreshIfNeeded() { - refresh(refreshStrategy = IF_STALE) - } - - fun forceRefresh() { - refresh(refreshStrategy = ALWAYS) - } - - fun clear() { - appLog.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 [ExPlat.start], then [Control] is returned. - * If [BuildConfig.DEBUG] is `true`, an [IllegalArgumentException] is thrown instead. - */ - internal fun getVariation(experiment: Experiment, shouldRefreshIfStale: Boolean): Variation { - if (!experimentNames.contains(experiment.name)) { - val message = "ExPlat: experiment not found: \"${experiment.name}\"! " + - "Make sure to include it in the set provided via constructor." - appLog.e(T.API, message) - if (BuildConfig.DEBUG) throw IllegalArgumentException(message) else return Control - } - return activeVariations.getOrPut(experiment.name) { - getAssignments(if (shouldRefreshIfStale) IF_STALE else NEVER).getVariationForExperiment(experiment.name) - } - } - - private fun refresh(refreshStrategy: RefreshStrategy) { - if (experimentNames.isNotEmpty() && (accountStore.hasAccessToken() || analyticsTracker.getAnonID() != null)) { - 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, analyticsTracker.getAnonID()).also { - if (it.isError) { - appLog.d(T.API, "ExPlat: fetching assignments failed with result: ${it.error}") - } else { - appLog.d(T.API, "ExPlat: fetching assignments successful with result: ${it.assignments}") - } - } - - private enum class RefreshStrategy { ALWAYS, IF_STALE, NEVER } -} diff --git a/WordPress/src/main/java/org/wordpress/android/util/experiments/ExampleExperiment.kt b/WordPress/src/main/java/org/wordpress/android/util/experiments/ExampleExperiment.kt deleted file mode 100644 index ef6a96c17a0f..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/util/experiments/ExampleExperiment.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.wordpress.android.util.experiments - -import javax.inject.Inject - -class ExampleExperiment -@Inject constructor( - exPlat: ExPlat -) : Experiment( - "example_experiment", - exPlat -) diff --git a/WordPress/src/main/java/org/wordpress/android/util/experiments/Experiment.kt b/WordPress/src/main/java/org/wordpress/android/util/experiments/Experiment.kt deleted file mode 100644 index 607a4c105402..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/util/experiments/Experiment.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.wordpress.android.util.experiments - -import org.wordpress.android.fluxc.model.experiments.Variation - -open class Experiment( - val name: String, - private val exPlat: ExPlat -) { - @JvmOverloads - fun getVariation(shouldRefreshIfStale: Boolean = false): Variation { - return exPlat.getVariation(this, shouldRefreshIfStale) - } -} diff --git a/WordPress/src/test/java/org/wordpress/android/util/experiments/ExPlatTest.kt b/WordPress/src/test/java/org/wordpress/android/util/experiments/ExPlatTest.kt deleted file mode 100644 index b350b660f665..000000000000 --- a/WordPress/src/test/java/org/wordpress/android/util/experiments/ExPlatTest.kt +++ /dev/null @@ -1,314 +0,0 @@ -package org.wordpress.android.util.experiments - -import dagger.Lazy -import kotlinx.coroutines.ExperimentalCoroutinesApi -import org.assertj.core.api.Assertions.assertThat -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.never -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoInteractions -import org.mockito.kotlin.whenever -import org.wordpress.android.BaseUnitTest -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.AccountStore -import org.wordpress.android.fluxc.store.ExperimentStore -import org.wordpress.android.fluxc.store.ExperimentStore.OnAssignmentsFetched -import org.wordpress.android.fluxc.utils.AppLogWrapper -import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper -import java.util.Date - -@ExperimentalCoroutinesApi -@RunWith(MockitoJUnitRunner::class) -class ExPlatTest : BaseUnitTest() { - @Mock - lateinit var experiments: Lazy> - - @Mock - lateinit var experimentStore: ExperimentStore - - @Mock - lateinit var appLog: AppLogWrapper - - @Mock - lateinit var accountStore: AccountStore - - @Mock - lateinit var analyticsTracker: AnalyticsTrackerWrapper - private lateinit var exPlat: ExPlat - private lateinit var dummyExperiment: Experiment - - @Before - fun setUp() { - exPlat = ExPlat( - experiments, - experimentStore, - appLog, - accountStore, - analyticsTracker, - testScope() - ) - dummyExperiment = object : Experiment(DUMMY_EXPERIMENT_NAME, exPlat) {} - whenever(accountStore.hasAccessToken()).thenReturn(true) - whenever(analyticsTracker.getAnonID()).thenReturn(DUMMY_ANON_ID) - setupExperiments(setOf(dummyExperiment)) - } - - @Test - fun `refreshIfNeeded fetches assignments if cache is null`() = test { - setupAssignments(cachedAssignments = null, fetchedAssignments = buildAssignments()) - - exPlat.refreshIfNeeded() - - verify(experimentStore, times(1)).fetchAssignments(any(), any(), anyOrNull()) - } - - @Test - fun `refreshIfNeeded fetches assignments if cache is stale`() = test { - setupAssignments(cachedAssignments = buildAssignments(isStale = true), fetchedAssignments = buildAssignments()) - - exPlat.refreshIfNeeded() - - verify(experimentStore, times(1)).fetchAssignments(any(), any(), anyOrNull()) - } - - @Test - fun `refreshIfNeeded does not fetch assignments if cache is fresh`() = test { - setupAssignments(cachedAssignments = buildAssignments(isStale = false), fetchedAssignments = buildAssignments()) - - exPlat.refreshIfNeeded() - - verify(experimentStore, never()).fetchAssignments(any(), any(), anyOrNull()) - } - - @Test - fun `forceRefresh fetches assignments if cache is fresh`() = test { - setupAssignments(cachedAssignments = buildAssignments(isStale = true), fetchedAssignments = buildAssignments()) - - exPlat.forceRefresh() - - verify(experimentStore, times(1)).fetchAssignments(any(), any(), anyOrNull()) - } - - @Test - fun `clear calls experiment store`() = test { - exPlat.clear() - - verify(experimentStore, times(1)).clearCachedAssignments() - } - - @Test - fun `getVariation fetches assignments if cache is null`() = test { - setupAssignments(cachedAssignments = null, fetchedAssignments = buildAssignments()) - - exPlat.getVariation(dummyExperiment, shouldRefreshIfStale = true) - - verify(experimentStore, times(1)).fetchAssignments(any(), any(), anyOrNull()) - } - - @Test - fun `getVariation fetches assignments if cache is stale`() = test { - setupAssignments(cachedAssignments = buildAssignments(isStale = true), fetchedAssignments = buildAssignments()) - - exPlat.getVariation(dummyExperiment, shouldRefreshIfStale = true) - - verify(experimentStore, times(1)).fetchAssignments(any(), any(), anyOrNull()) - } - - @Test - fun `getVariation does not fetch assignments if cache is fresh`() = test { - setupAssignments(cachedAssignments = buildAssignments(isStale = false), fetchedAssignments = buildAssignments()) - - exPlat.getVariation(dummyExperiment, shouldRefreshIfStale = true) - - verify(experimentStore, never()).fetchAssignments(any(), any(), anyOrNull()) - } - - @Test - fun `getVariation does not fetch assignments if cache is null but shouldRefreshIfStale is false`() = test { - setupAssignments(cachedAssignments = null, fetchedAssignments = buildAssignments()) - - exPlat.getVariation(dummyExperiment, shouldRefreshIfStale = false) - - verify(experimentStore, never()).fetchAssignments(any(), any(), anyOrNull()) - } - - @Test - fun `getVariation does not fetch assignments if cache is stale but shouldRefreshIfStale is false`() = test { - setupAssignments(cachedAssignments = null, fetchedAssignments = buildAssignments()) - - exPlat.getVariation(dummyExperiment, shouldRefreshIfStale = false) - - verify(experimentStore, never()).fetchAssignments(any(), any(), anyOrNull()) - } - - @Test - fun `getVariation does not return different cached assignments if active variation exists`() = test { - 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, shouldRefreshIfStale = false) - assertThat(firstVariation).isEqualTo(controlVariation) - - exPlat.forceRefresh() - - setupAssignments(cachedAssignments = treatmentAssignments, fetchedAssignments = treatmentAssignments) - - val secondVariation = exPlat.getVariation(dummyExperiment, shouldRefreshIfStale = false) - assertThat(secondVariation).isEqualTo(controlVariation) - } - - @Test - fun `forceRefresh fetches assignments if experiments is not empty`() = test { - setupExperiments(setOf(dummyExperiment)) - - exPlat.forceRefresh() - - verify(experimentStore, times(1)).fetchAssignments(any(), any(), anyOrNull()) - } - - @Test - fun `forceRefresh does not interact with store if experiments is empty`() = test { - setupExperiments(emptySet()) - - exPlat.forceRefresh() - - verifyNoInteractions(experimentStore) - } - - @Test - fun `refreshIfNeeded does not interact with store if experiments is empty`() = test { - setupExperiments(emptySet()) - - exPlat.refreshIfNeeded() - - verifyNoInteractions(experimentStore) - } - - @Test - @Suppress("SwallowedException") - fun `getVariation does not interact with store if experiments is empty`() = test { - setupExperiments(emptySet()) - - try { - exPlat.getVariation(dummyExperiment, false) - } catch (e: IllegalArgumentException) { - // Do nothing. - } finally { - verifyNoInteractions(experimentStore) - } - } - - @Test - @Suppress("MaxLineLength") - fun `refreshIfNeeded does not interact with store if the user is not authorised and there is no anonymous id`() = - test { - setupExperiments(setOf(dummyExperiment)) - whenever(accountStore.hasAccessToken()).thenReturn(false) - whenever(analyticsTracker.getAnonID()).thenReturn(null) - - exPlat.refreshIfNeeded() - - verifyNoInteractions(experimentStore) - } - - @Test - @Suppress("MaxLineLength") - fun `forceRefresh does not interact with store if the user is not authorised and there is no anonymous id`() = - test { - setupExperiments(setOf(dummyExperiment)) - whenever(accountStore.hasAccessToken()).thenReturn(false) - whenever(analyticsTracker.getAnonID()).thenReturn(null) - - exPlat.forceRefresh() - - verifyNoInteractions(experimentStore) - } - - @Test - fun `refreshIfNeeded does interact with store if the user is authorised`() = test { - setupExperiments(setOf(dummyExperiment)) - whenever(accountStore.hasAccessToken()).thenReturn(true) - whenever(analyticsTracker.getAnonID()).thenReturn(null) - - exPlat.refreshIfNeeded() - - verify(experimentStore, times(1)).fetchAssignments(any(), any(), anyOrNull()) - } - - @Test - fun `forceRefresh does interact with store if the user is authorised`() = test { - setupExperiments(setOf(dummyExperiment)) - whenever(accountStore.hasAccessToken()).thenReturn(true) - whenever(analyticsTracker.getAnonID()).thenReturn(null) - - exPlat.forceRefresh() - - verify(experimentStore, times(1)).fetchAssignments(any(), any(), anyOrNull()) - } - - @Test - fun `refreshIfNeeded does interact with store if there is an anonymous id`() = test { - setupExperiments(setOf(dummyExperiment)) - whenever(accountStore.hasAccessToken()).thenReturn(false) - whenever(analyticsTracker.getAnonID()).thenReturn(DUMMY_ANON_ID) - - exPlat.refreshIfNeeded() - - verify(experimentStore, times(1)).fetchAssignments(any(), any(), anyOrNull()) - } - - @Test - fun `forceRefresh does interact with store if there is an anonymous id`() = test { - setupExperiments(setOf(dummyExperiment)) - whenever(accountStore.hasAccessToken()).thenReturn(false) - whenever(analyticsTracker.getAnonID()).thenReturn(DUMMY_ANON_ID) - - exPlat.forceRefresh() - - verify(experimentStore, times(1)).fetchAssignments(any(), any(), anyOrNull()) - } - - private fun setupExperiments(experiments: Set) { - whenever(this.experiments.get()).thenReturn(experiments) - } - - private suspend fun setupAssignments(cachedAssignments: Assignments?, fetchedAssignments: Assignments) { - whenever(experimentStore.getCachedAssignments()).thenReturn(cachedAssignments) - whenever(experimentStore.fetchAssignments(any(), 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 - private const val DUMMY_ANON_ID = "dummy_anon_id" - private const val DUMMY_EXPERIMENT_NAME = "dummy" - } -} diff --git a/libs/fluxc-processor/src/main/resources/wp-com-v2-endpoints.txt b/libs/fluxc-processor/src/main/resources/wp-com-v2-endpoints.txt index 4932ecf35291..5d4200ce7de8 100644 --- a/libs/fluxc-processor/src/main/resources/wp-com-v2-endpoints.txt +++ b/libs/fluxc-processor/src/main/resources/wp-com-v2-endpoints.txt @@ -68,8 +68,6 @@ /me/gutenberg/ -/experiments/$version#String/assignments/$platform#String - /auth/qr-code/validate /auth/qr-code/authenticate @@ -78,4 +76,4 @@ /jetpack-ai-transcription /jetpack-ai-query -/sites/$site/jetpack-ai/ai-assistant-feature \ No newline at end of file +/sites/$site/jetpack-ai/ai-assistant-feature diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/model/experiments/AssignmentsModel.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/model/experiments/AssignmentsModel.kt deleted file mode 100644 index 91565a4dd7b8..000000000000 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/model/experiments/AssignmentsModel.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.wordpress.android.fluxc.model.experiments - -import org.wordpress.android.fluxc.model.experiments.Variation.Control -import java.lang.System.currentTimeMillis -import java.util.Date - -data class AssignmentsModel( - val variations: Map = emptyMap(), - val ttl: Int = 0, - val fetchedAt: Long = currentTimeMillis() -) - -data class Assignments( - val variations: Map = emptyMap(), - val ttl: Int = 0, - val fetchedAt: Date = Date() -) { - val expiresAt = Date(fetchedAt.time + (ttl * 1000)) - - fun isStale(now: Date = Date()) = !now.before(expiresAt) - - fun getVariationForExperiment(experiment: String) = variations[experiment] ?: Control - - companion object { - fun fromModel(model: AssignmentsModel) = Assignments( - model.variations.mapValues { Variation.fromName(it.value) }, - model.ttl, - Date(model.fetchedAt) - ) - } -} - -sealed class Variation(open val name: String) { - object Control : Variation(CONTROL) - data class Treatment(override val name: String) : Variation(name) - companion object { - private const val CONTROL = "control" - fun fromName(name: String?) = when (name) { - CONTROL, null -> Control - else -> Treatment(name) - } - } -} diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/experiments/ExperimentRestClient.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/experiments/ExperimentRestClient.kt deleted file mode 100644 index 1ccc41f58275..000000000000 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/experiments/ExperimentRestClient.kt +++ /dev/null @@ -1,64 +0,0 @@ -package org.wordpress.android.fluxc.network.rest.wpcom.experiments - -import android.content.Context -import com.android.volley.RequestQueue -import org.wordpress.android.fluxc.Dispatcher -import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 -import org.wordpress.android.fluxc.model.experiments.AssignmentsModel -import org.wordpress.android.fluxc.network.UserAgent -import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient -import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder -import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error -import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success -import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken -import org.wordpress.android.fluxc.store.ExperimentStore.ExperimentErrorType.GENERIC_ERROR -import org.wordpress.android.fluxc.store.ExperimentStore.FetchAssignmentsError -import org.wordpress.android.fluxc.store.ExperimentStore.FetchedAssignmentsPayload -import org.wordpress.android.fluxc.store.ExperimentStore.Platform -import javax.inject.Inject -import javax.inject.Named -import javax.inject.Singleton - -@Singleton -class ExperimentRestClient @Inject constructor( - private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, - appContext: Context?, - dispatcher: Dispatcher, - @Named("regular") requestQueue: RequestQueue, - accessToken: AccessToken, - userAgent: UserAgent -) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { - suspend fun fetchAssignments( - platform: Platform, - experimentNames: List, - anonymousId: String? = null, - version: String = DEFAULT_VERSION - ): FetchedAssignmentsPayload { - val url = WPCOMV2.experiments.version(version).assignments.platform(platform.value).url - val params = mapOf( - "experiment_names" to experimentNames.joinToString(","), - "anon_id" to anonymousId.orEmpty() - ) - val response = wpComGsonRequestBuilder.syncGetRequest( - this, - url, - params, - FetchAssignmentsResponse::class.java, - enableCaching = false, - forced = true - ) - return when (response) { - is Success -> FetchedAssignmentsPayload(response.data.let { AssignmentsModel(it.variations, it.ttl) }) - is Error -> FetchedAssignmentsPayload(FetchAssignmentsError(GENERIC_ERROR, response.error.message)) - } - } - - data class FetchAssignmentsResponse( - val variations: Map, - val ttl: Int - ) - - companion object { - const val DEFAULT_VERSION = "0.1.0" - } -} diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/store/ExperimentStore.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/store/ExperimentStore.kt deleted file mode 100644 index 7b2794c8f8ff..000000000000 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/store/ExperimentStore.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.wordpress.android.fluxc.store - -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import org.wordpress.android.fluxc.Payload -import org.wordpress.android.fluxc.model.experiments.Assignments -import org.wordpress.android.fluxc.model.experiments.AssignmentsModel -import org.wordpress.android.fluxc.network.rest.wpcom.experiments.ExperimentRestClient -import org.wordpress.android.fluxc.store.Store.OnChanged -import org.wordpress.android.fluxc.store.Store.OnChangedError -import org.wordpress.android.fluxc.tools.CoroutineEngine -import org.wordpress.android.fluxc.utils.PreferenceUtils.PreferenceUtilsWrapper -import org.wordpress.android.util.AppLog.T.API -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class ExperimentStore @Inject constructor( - private val experimentRestClient: ExperimentRestClient, - private val preferenceUtils: PreferenceUtilsWrapper, - private val coroutineEngine: CoroutineEngine -) { - companion object { - const val EXPERIMENT_ASSIGNMENTS_KEY = "EXPERIMENT_ASSIGNMENTS_KEY" - } - - private val gson: Gson by lazy { GsonBuilder().serializeNulls().create() } - - suspend fun fetchAssignments( - platform: Platform, - experimentNames: List, - anonymousId: String? = null - ) = coroutineEngine.withDefaultContext(API, this, "fetchAssignments") { - val fetchedPayload = experimentRestClient.fetchAssignments(platform, experimentNames, anonymousId) - if (!fetchedPayload.isError) { - storeFetchedAssignments(fetchedPayload.assignments) - OnAssignmentsFetched(assignments = Assignments.fromModel(fetchedPayload.assignments)) - } else { - OnAssignmentsFetched(error = fetchedPayload.error) - } - } - - private fun storeFetchedAssignments(model: AssignmentsModel) { - val json = gson.toJson(model, AssignmentsModel::class.java) - preferenceUtils.getFluxCPreferences().edit().putString(EXPERIMENT_ASSIGNMENTS_KEY, json).apply() - } - - fun getCachedAssignments(): Assignments? { - val json = preferenceUtils.getFluxCPreferences().getString(EXPERIMENT_ASSIGNMENTS_KEY, null) - val model = gson.fromJson(json, AssignmentsModel::class.java) - return model?.let { Assignments.fromModel(it) } - } - - fun clearCachedAssignments() { - preferenceUtils.getFluxCPreferences().edit().remove(EXPERIMENT_ASSIGNMENTS_KEY).apply() - } - - data class FetchedAssignmentsPayload( - val assignments: AssignmentsModel - ) : Payload() { - constructor(error: FetchAssignmentsError) : this(AssignmentsModel()) { - this.error = error - } - } - - data class OnAssignmentsFetched( - val assignments: Assignments - ) : OnChanged() { - constructor(error: FetchAssignmentsError) : this(Assignments()) { - this.error = error - } - } - - data class FetchAssignmentsError( - val type: ExperimentErrorType, - val message: String? = null - ) : OnChangedError - - enum class ExperimentErrorType { - GENERIC_ERROR - } - - enum class Platform(val value: String) { - WORDPRESS_COM("wpcom"), - CALYPSO("calypso"), - JETPACK("jetpack"), - WOOCOMMERCE("woocommerce"), - WORDPRESS_IOS("wpios"), - WORDPRESS_ANDROID("wpandroid"), - WOOCOMMERCE_IOS("woocommerceios"), - WOOCOMMERCE_ANDROID("woocommerceandroid"); - - companion object { - fun fromValue(value: String): Platform? { - return values().firstOrNull { it.value == value } - } - } - } -} diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/utils/AppLogWrapper.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/utils/AppLogWrapper.kt index 36eff1a578f8..d938b299a7d8 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/utils/AppLogWrapper.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/utils/AppLogWrapper.kt @@ -3,8 +3,7 @@ package org.wordpress.android.fluxc.utils import org.wordpress.android.util.AppLog import javax.inject.Inject -class AppLogWrapper -@Inject constructor() { +class AppLogWrapper @Inject constructor() { fun d(tag: AppLog.T, message: String) = AppLog.d(tag, message) fun e(tag: AppLog.T, message: String) = AppLog.e(tag, message) fun w(tag: AppLog.T, message: String) = AppLog.w(tag, message) diff --git a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/experiments/AssignmentsTest.kt b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/experiments/AssignmentsTest.kt deleted file mode 100644 index ea764f279598..000000000000 --- a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/experiments/AssignmentsTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package org.wordpress.android.fluxc.experiments - -import org.assertj.core.api.Assertions.assertThat -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.junit.MockitoJUnitRunner -import org.wordpress.android.fluxc.model.experiments.Assignments -import org.wordpress.android.fluxc.model.experiments.AssignmentsModel -import org.wordpress.android.fluxc.model.experiments.Variation.Control -import org.wordpress.android.fluxc.model.experiments.Variation.Treatment -import java.lang.System.currentTimeMillis -import java.util.Date - -@RunWith(MockitoJUnitRunner::class) -class AssignmentsTest { - @Test - fun `is stale if expiry time is before or equal current time`() { - val now = currentTimeMillis() - val oneHourAgo = now - ONE_HOUR_IN_SECONDS * 1000 - val assignments = Assignments(emptyMap(), ONE_HOUR_IN_SECONDS, Date(oneHourAgo)) - assertThat(assignments.isStale(Date(now))).isTrue - } - - @Test - fun `is not stale if expiry time is after current time`() { - val now = currentTimeMillis() - val oneHourFromNow = now + ONE_HOUR_IN_SECONDS * 1000 - val assignments = Assignments(emptyMap(), ONE_HOUR_IN_SECONDS, Date(oneHourFromNow)) - assertThat(assignments.isStale(Date(now))).isFalse - } - - @Test - fun `variation is control if experiment is not found`() { - val model = AssignmentsModel(mapOf("test_experiment" to "treatment")) - val assignments = Assignments.fromModel(model) - val variation = assignments.getVariationForExperiment("other_experiment") - assertThat(variation).isInstanceOf(Control::class.java) - } - - @Test - fun `null variation is mapped to control type`() { - val model = AssignmentsModel(mapOf("test_experiment" to null)) - val assignments = Assignments.fromModel(model) - val variation = assignments.getVariationForExperiment("test_experiment") - assertThat(variation).isInstanceOf(Control::class.java) - } - - @Test - fun `treatment variation is mapped to treatment type with correct name`() { - val model = AssignmentsModel(mapOf("test_experiment" to "treatment_name")) - val assignments = Assignments.fromModel(model) - val variation = assignments.getVariationForExperiment("test_experiment") - assertThat(variation).isEqualTo(Treatment("treatment_name")) - } - - companion object { - private const val ONE_HOUR_IN_SECONDS = 3600 - } -} diff --git a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/experiments/ExperimentStoreTest.kt b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/experiments/ExperimentStoreTest.kt deleted file mode 100644 index eced5820d0ef..000000000000 --- a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/experiments/ExperimentStoreTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.wordpress.android.fluxc.experiments - -import android.content.SharedPreferences -import org.assertj.core.api.Assertions.assertThat -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.Mockito.inOrder -import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.any -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.wordpress.android.fluxc.model.experiments.Assignments -import org.wordpress.android.fluxc.model.experiments.AssignmentsModel -import org.wordpress.android.fluxc.network.rest.wpcom.experiments.ExperimentRestClient -import org.wordpress.android.fluxc.store.ExperimentStore -import org.wordpress.android.fluxc.store.ExperimentStore.Companion.EXPERIMENT_ASSIGNMENTS_KEY -import org.wordpress.android.fluxc.store.ExperimentStore.FetchedAssignmentsPayload -import org.wordpress.android.fluxc.store.ExperimentStore.OnAssignmentsFetched -import org.wordpress.android.fluxc.store.ExperimentStore.Platform.CALYPSO -import org.wordpress.android.fluxc.test -import org.wordpress.android.fluxc.tools.initCoroutineEngine -import org.wordpress.android.fluxc.utils.PreferenceUtils.PreferenceUtilsWrapper - -@RunWith(MockitoJUnitRunner::class) -class ExperimentStoreTest { - @Mock private lateinit var experimentRestClient: ExperimentRestClient - @Mock private lateinit var preferenceUtils: PreferenceUtilsWrapper - @Mock private lateinit var sharedPreferences: SharedPreferences - @Mock private lateinit var sharedPreferencesEditor: SharedPreferences.Editor - - private lateinit var experimentStore: ExperimentStore - - @Before - fun setUp() { - whenever(preferenceUtils.getFluxCPreferences()).thenReturn(sharedPreferences) - whenever(sharedPreferences.edit()).thenReturn(sharedPreferencesEditor) - whenever(sharedPreferencesEditor.putString(any(), any())).thenReturn(sharedPreferencesEditor) - - experimentStore = ExperimentStore(experimentRestClient, preferenceUtils, initCoroutineEngine()) - } - - @Test - fun `fetch assignments emits correct event when successful`() = test { - whenever(experimentRestClient.fetchAssignments(defaultPlatform, emptyList())).thenReturn(successfulPayload) - - val onAssignmentsFetched = experimentStore.fetchAssignments(defaultPlatform, emptyList()) - - assertThat(onAssignmentsFetched).isEqualTo(OnAssignmentsFetched(successfulAssignments)) - } - - @Test - fun `fetch assignments stores result locally when successful`() = test { - whenever(experimentRestClient.fetchAssignments(defaultPlatform, emptyList())).thenReturn(successfulPayload) - - experimentStore.fetchAssignments(defaultPlatform, emptyList()) - - verify(sharedPreferences).edit() - inOrder(sharedPreferencesEditor).apply { - verify(sharedPreferencesEditor).putString(EXPERIMENT_ASSIGNMENTS_KEY, successfulModelJson) - verify(sharedPreferencesEditor).apply() - } - } - - @Test - fun `get cached assignments returns last fetch result when existent`() { - whenever(sharedPreferences.getString(EXPERIMENT_ASSIGNMENTS_KEY, null)).thenReturn(successfulModelJson) - - val cachedAssignments = experimentStore.getCachedAssignments() - - assertThat(cachedAssignments).isNotNull - assertThat(cachedAssignments).isEqualTo(successfulAssignments) - } - - @Test - fun `get cached assignments returns null when no fetch results were stored`() { - whenever(sharedPreferences.getString(EXPERIMENT_ASSIGNMENTS_KEY, null)).thenReturn(null) - - val cachedAssignments = experimentStore.getCachedAssignments() - - assertThat(cachedAssignments).isNull() - } - - companion object { - val defaultPlatform = CALYPSO - - private val successfulVariations = mapOf( - "experiment_one" to null, - "experiment_two" to "treatment", - "experiment_three" to "other" - ) - - private val successfulModel = AssignmentsModel(successfulVariations, 3600, 1604964458273) - - const val successfulModelJson = "{\"variations\":{" + - "\"experiment_one\":null," + - "\"experiment_two\":\"treatment\"," + - "\"experiment_three\":\"other\"}," + - "\"ttl\":3600," + - "\"fetchedAt\":1604964458273}" - - val successfulPayload = FetchedAssignmentsPayload(successfulModel) - - val successfulAssignments = Assignments.fromModel(successfulModel) - } -} diff --git a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/experiments/ExperimentRestClientTest.kt b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/experiments/ExperimentRestClientTest.kt deleted file mode 100644 index a7659af27f9c..000000000000 --- a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/experiments/ExperimentRestClientTest.kt +++ /dev/null @@ -1,201 +0,0 @@ -package org.wordpress.android.fluxc.network.rest.wpcom.experiments - -import com.android.volley.RequestQueue -import org.assertj.core.api.Assertions.assertThat -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.KArgumentCaptor -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.eq -import org.mockito.kotlin.whenever -import org.wordpress.android.fluxc.Dispatcher -import org.wordpress.android.fluxc.model.experiments.AssignmentsModel -import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError -import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR -import org.wordpress.android.fluxc.network.UserAgent -import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError -import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder -import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response -import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error -import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success -import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken -import org.wordpress.android.fluxc.network.rest.wpcom.experiments.ExperimentRestClient.Companion.DEFAULT_VERSION -import org.wordpress.android.fluxc.network.rest.wpcom.experiments.ExperimentRestClient.FetchAssignmentsResponse -import org.wordpress.android.fluxc.store.ExperimentStore.FetchedAssignmentsPayload -import org.wordpress.android.fluxc.store.ExperimentStore.Platform.CALYPSO -import org.wordpress.android.fluxc.test - -@RunWith(MockitoJUnitRunner::class) -class ExperimentRestClientTest { - @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder - @Mock private lateinit var dispatcher: Dispatcher - @Mock private lateinit var requestQueue: RequestQueue - @Mock private lateinit var accessToken: AccessToken - @Mock private lateinit var userAgent: UserAgent - - private lateinit var urlCaptor: KArgumentCaptor - private lateinit var paramsCaptor: KArgumentCaptor> - - private lateinit var experimentRestClient: ExperimentRestClient - - @Before - fun setUp() { - urlCaptor = argumentCaptor() - paramsCaptor = argumentCaptor() - experimentRestClient = ExperimentRestClient( - wpComGsonRequestBuilder, - null, - dispatcher, - requestQueue, - accessToken, - userAgent - ) - } - - @Test - fun `calls correct url with default values`() = test { - initRequest(Success(successfulResponse)) - - experimentRestClient.fetchAssignments(defaultPlatform, emptyList()) - - val expectedUrl = "$EXPERIMENTS_ENDPOINT/$DEFAULT_VERSION/assignments/${defaultPlatform.value}/" - val expectedParams = mapOf( - "experiment_names" to "", - "anon_id" to "" - ) - - assertThat(urlCaptor.lastValue).isEqualTo(expectedUrl) - assertThat(paramsCaptor.lastValue).isEqualTo(expectedParams) - } - - @Test - fun `calls correct url with single experiment name`() = test { - initRequest(Success(successfulResponse)) - - val experimentsNames = listOf("experiment_one") - - experimentRestClient.fetchAssignments(defaultPlatform, experimentsNames) - - val expectedUrl = "$EXPERIMENTS_ENDPOINT/$DEFAULT_VERSION/assignments/${defaultPlatform.value}/" - val expectedParams = mapOf( - "experiment_names" to "experiment_one", - "anon_id" to "" - ) - - assertThat(urlCaptor.lastValue).isEqualTo(expectedUrl) - assertThat(paramsCaptor.lastValue).isEqualTo(expectedParams) - } - - @Test - fun `calls correct url with multiple experiment names`() = test { - initRequest(Success(successfulResponse)) - - val experimentNames = listOf("experiment_one", "experiment_two", "experiment_three") - - experimentRestClient.fetchAssignments(defaultPlatform, experimentNames) - - val expectedUrl = "$EXPERIMENTS_ENDPOINT/$DEFAULT_VERSION/assignments/${defaultPlatform.value}/" - val expectedParams = mapOf( - "experiment_names" to "experiment_one,experiment_two,experiment_three", - "anon_id" to "" - ) - - assertThat(urlCaptor.lastValue).isEqualTo(expectedUrl) - assertThat(paramsCaptor.lastValue).isEqualTo(expectedParams) - } - - @Test - fun `calls correct url with anonymous id`() = test { - initRequest(Success(successfulResponse)) - - val anonymousId = "myAnonymousId" - - experimentRestClient.fetchAssignments(defaultPlatform, emptyList(), anonymousId) - - val expectedUrl = "$EXPERIMENTS_ENDPOINT/$DEFAULT_VERSION/assignments/${defaultPlatform.value}/" - val expectedParams = mapOf( - "experiment_names" to "", - "anon_id" to anonymousId - ) - - assertThat(urlCaptor.lastValue).isEqualTo(expectedUrl) - assertThat(paramsCaptor.lastValue).isEqualTo(expectedParams) - } - - @Test - fun `calls correct url with version`() = test { - initRequest(Success(successfulResponse)) - - val version = "1.0.0" - - experimentRestClient.fetchAssignments(defaultPlatform, emptyList(), null, version) - - val expectedUrl = "$EXPERIMENTS_ENDPOINT/$version/assignments/${defaultPlatform.value}/" - val expectedParams = mapOf( - "experiment_names" to "", - "anon_id" to "" - ) - - assertThat(urlCaptor.lastValue).isEqualTo(expectedUrl) - assertThat(paramsCaptor.lastValue).isEqualTo(expectedParams) - } - - @Test - fun `returns assignments when API call is successful`() = test { - initRequest(Success(successfulResponse)) - - val payload = experimentRestClient.fetchAssignments(defaultPlatform, emptyList()) - - assertThat(payload).isNotNull - assertThat(payload.assignments.variations).isEqualTo(successfulPayload.assignments.variations) - assertThat(payload.assignments.ttl).isEqualTo(successfulPayload.assignments.ttl) - } - - @Test - fun `returns error when API call fails`() = test { - initRequest(Error(errorResponse)) - - val payload = experimentRestClient.fetchAssignments(defaultPlatform, emptyList()) - - assertThat(payload).isNotNull - assertThat(payload.isError).isTrue - } - - private suspend fun initRequest(response: Response) { - whenever( - wpComGsonRequestBuilder.syncGetRequest( - eq(experimentRestClient), - urlCaptor.capture(), - paramsCaptor.capture(), - eq(FetchAssignmentsResponse::class.java), - eq(false), - any(), - eq(true), - customGsonBuilder = anyOrNull() - ) - ).thenReturn(response) - } - - companion object { - const val EXPERIMENTS_ENDPOINT = "https://public-api.wordpress.com/wpcom/v2/experiments" - - val defaultPlatform = CALYPSO - - private val successfulVariations = mapOf( - "experiment_one" to null, - "experiment_two" to "treatment", - "experiment_three" to "other" - ) - - val successfulResponse = FetchAssignmentsResponse(successfulVariations, 3600) - - val errorResponse = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR)) - - val successfulPayload = FetchedAssignmentsPayload(AssignmentsModel(successfulVariations, 3600)) - } -} diff --git a/libs/mocks/src/main/assets/mocks/mappings/wpcom/experiments/assignments.json b/libs/mocks/src/main/assets/mocks/mappings/wpcom/experiments/assignments.json deleted file mode 100644 index 04e173f204f8..000000000000 --- a/libs/mocks/src/main/assets/mocks/mappings/wpcom/experiments/assignments.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "request": { - "method": "GET", - "urlPattern": "/wpcom/v2/experiments/.*/assignments/.*" - }, - "response": { - "status": 200, - "jsonBody": { - "variations": {}, - "ttl": 3600 - }, - "headers": { - "Content-Type": "application/json" - } - } -} \ No newline at end of file