diff --git a/WordPress/src/main/java/org/wordpress/android/WordPress.java b/WordPress/src/main/java/org/wordpress/android/WordPress.java index 0d32a6732267..ada204e66fec 100644 --- a/WordPress/src/main/java/org/wordpress/android/WordPress.java +++ b/WordPress/src/main/java/org/wordpress/android/WordPress.java @@ -115,6 +115,7 @@ import org.wordpress.android.util.VolleyUtils; import org.wordpress.android.util.WPActivityUtils; import org.wordpress.android.util.analytics.AnalyticsUtils; +import org.wordpress.android.util.experiments.ExPlat; import org.wordpress.android.util.config.AppConfig; import org.wordpress.android.util.image.ImageManager; import org.wordpress.android.widgets.AppRatingDialog; @@ -181,6 +182,7 @@ public class WordPress extends MultiDexApplication implements HasAndroidInjector @Inject EncryptedLogging mEncryptedLogging; @Inject AppConfig mAppConfig; @Inject ImageEditorFileUtils mImageEditorFileUtils; + @Inject ExPlat mExPlat; @Inject @Named(APPLICATION_SCOPE) CoroutineScope mAppScope; // For development and production `AnalyticsTrackerNosara`, for testing a mocked `Tracker` will be injected. @@ -362,6 +364,8 @@ public void onConnectionSuspended(int i) { mStoryNotificationTrackerProvider = new StoryNotificationTrackerProvider(); mStoryMediaSaveUploadBridge.init(this); ProcessLifecycleOwner.get().getLifecycle().addObserver(mStoryMediaSaveUploadBridge); + + mExPlat.forceRefresh(); } protected void initWorkManager() { @@ -617,6 +621,9 @@ public void onAuthenticationChanged(OnAuthenticationChanged event) { // Make sure the Push Notification token is sent to our servers after a successful login GCMRegistrationIntentService.enqueueWork(this, new Intent(this, GCMRegistrationIntentService.class)); + + // Force a refresh if user has logged in. This can be removed once we start using an anonymous ID. + mExPlat.forceRefresh(); } } @@ -670,6 +677,9 @@ public void removeWpComUserRelatedData(Context context) { // Remove private Atomic cookie mPrivateAtomicCookie.clearCookie(); + + // Clear cached assignments if user has logged out. This can be removed once we start using an anonymous ID. + mExPlat.clear(); } private static String mDefaultUserAgent; @@ -989,6 +999,12 @@ public void onAppComesFromBackground() { // Let's migrate the old editor preference if available in AppPrefs to the remote backend SiteUtils.migrateAppWideMobileEditorPreferenceToRemote(mAccountStore, mSiteStore, mDispatcher); + if (!mFirstActivityResumed) { + // Since we're force refreshing on app startup, we don't need to try refreshing again + // when starting our first Activity. + mExPlat.refreshIfNeeded(); + } + if (mFirstActivityResumed) { deferredInit(); } diff --git a/WordPress/src/main/java/org/wordpress/android/util/experiments/BiasAAExperiment.kt b/WordPress/src/main/java/org/wordpress/android/util/experiments/BiasAAExperiment.kt new file mode 100644 index 000000000000..78542549b52b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/experiments/BiasAAExperiment.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.util.experiments + +import javax.inject.Inject + +class BiasAAExperiment +@Inject constructor( + exPlat: ExPlat +) : Experiment( + name = "explat_test_aa_weekly_wpandroid_2021_week_06", + exPlat +) 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 new file mode 100644 index 000000000000..ad25d2ddc033 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/experiments/ExPlat.kt @@ -0,0 +1,75 @@ +package org.wordpress.android.util.experiments + +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.store.ExperimentStore +import org.wordpress.android.fluxc.store.ExperimentStore.FetchAssignmentsPayload +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.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 experimentStore: ExperimentStore, + private val appLog: AppLogWrapper, + @Named(APPLICATION_SCOPE) private val coroutineScope: CoroutineScope +) { + private val platform = Platform.WORDPRESS_COM + private val activeVariations = mutableMapOf() + + fun refreshIfNeeded() { + getAssignments(refreshStrategy = IF_STALE) + } + + fun forceRefresh() { + getAssignments(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. + */ + internal fun getVariation(experiment: Experiment, shouldRefreshIfStale: Boolean) = + activeVariations.getOrPut(experiment.name) { + getAssignments(if (shouldRefreshIfStale) IF_STALE else NEVER).getVariationForExperiment(experiment.name) + } + + 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(FetchAssignmentsPayload(platform)).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/Experiment.kt b/WordPress/src/main/java/org/wordpress/android/util/experiments/Experiment.kt new file mode 100644 index 000000000000..5e77411c7f91 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/experiments/Experiment.kt @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000000..401bf1fb4fc4 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/util/experiments/ExPlatTest.kt @@ -0,0 +1,169 @@ +package org.wordpress.android.util.experiments + +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +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.wordpress.android.BaseUnitTest +import org.wordpress.android.TEST_SCOPE +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.utils.AppLogWrapper +import org.wordpress.android.test +import java.util.Date + +@RunWith(MockitoJUnitRunner::class) +class ExPlatTest : BaseUnitTest() { + @Mock lateinit var experimentStore: ExperimentStore + @Mock lateinit var appLog: AppLogWrapper + private lateinit var exPlat: ExPlat + private lateinit var dummyExperiment: Experiment + + @Before + fun setUp() { + exPlat = ExPlat(experimentStore, appLog, TEST_SCOPE) + dummyExperiment = object : Experiment("dummy", exPlat) {} + } + + @Test + fun `refreshIfNeeded fetches assignments if cache is null`() = test { + setupAssignments(cachedAssignments = null, fetchedAssignments = buildAssignments()) + + exPlat.refreshIfNeeded() + + verify(experimentStore, times(1)).fetchAssignments(any()) + } + + @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()) + } + + @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()) + } + + @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()) + } + + @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()) + } + + @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()) + } + + @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()) + } + + @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()) + } + + @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()) + } + + @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) + } + + private suspend fun setupAssignments(cachedAssignments: Assignments?, fetchedAssignments: Assignments) { + whenever(experimentStore.getCachedAssignments()).thenReturn(cachedAssignments) + whenever(experimentStore.fetchAssignments(any())).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 + } +}