Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
104424d
Add ExPlat
renanferrari Feb 4, 2021
7c4aaa1
Add explat Experiment
renanferrari Feb 4, 2021
1e708af
Refresh ExPlat when app is opened
renanferrari Feb 4, 2021
04917fd
Clear ExPlat on logout
renanferrari Feb 4, 2021
85c88d5
Add A/A experiment
renanferrari Feb 4, 2021
4bae22d
Add A/A experiment properties to its exposure event
renanferrari Feb 4, 2021
56cc6fc
Add ExPlat tests
renanferrari Feb 4, 2021
c0e4a75
Merge branch 'develop' into feature/explat-integration
renanferrari Feb 11, 2021
a1401b0
Remove unnecessary getEventProperties method
renanferrari Feb 11, 2021
e28220d
Remove unnecessary id attribute
renanferrari Feb 11, 2021
778aa22
Rename refresh parameter
renanferrari Feb 11, 2021
b65458c
Add RefreshStrategy
renanferrari Feb 11, 2021
22267b1
Rename refresh to refreshIfNeeded
renanferrari Feb 11, 2021
43df8c0
Add forceRefresh method
renanferrari Feb 11, 2021
f5f0f82
Call forceRefresh on app open
renanferrari Feb 11, 2021
e18fd0c
Add logging message
renanferrari Feb 11, 2021
338d305
Call forceRefresh on login and clear on logout
renanferrari Feb 11, 2021
a499652
Fix lint
renanferrari Feb 11, 2021
9b6c61c
Update fetchAssignments method so it returns its result
renanferrari Feb 16, 2021
b48f796
Add active variations cache
renanferrari Feb 16, 2021
7612950
Add test
renanferrari Feb 16, 2021
763c5a2
Merge branch 'develop' into feature/explat-integration
renanferrari Feb 16, 2021
145fe0d
Make Experiment class open instead of abstract
renanferrari Feb 16, 2021
d69506f
Move refresh call to prevent assignments from being fetched twice
renanferrari Feb 18, 2021
7f44273
Move clear call to prevent assignments from being unnecessarily cleared
renanferrari Feb 18, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions WordPress/src/main/java/org/wordpress/android/WordPress.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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<String, Variation>()

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 }
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Variation> = 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
}
}