For #18993 - Nimbus: Allow internal tooling to opt into specific branches of an experiment (#19333)

upstream-sync
Gabriel Luong 3 years ago committed by GitHub
parent 8db42d6d25
commit d83c9d1e72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,95 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.nimbus
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.R
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.ext.withExperiment
import org.mozilla.fenix.nimbus.controller.NimbusBranchesController
import org.mozilla.fenix.nimbus.view.NimbusBranchesView
/**
* A fragment to show the branches of a Nimbus experiment.
*/
@Suppress("TooGenericExceptionCaught")
class NimbusBranchesFragment : Fragment() {
private lateinit var nimbusBranchesStore: NimbusBranchesStore
private lateinit var nimbusBranchesView: NimbusBranchesView
private lateinit var controller: NimbusBranchesController
private val args by navArgs<NimbusBranchesFragmentArgs>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view =
inflater.inflate(R.layout.mozac_service_nimbus_experiment_details, container, false)
nimbusBranchesStore = StoreProvider.get(this) {
NimbusBranchesStore(NimbusBranchesState(branches = emptyList()))
}
controller = NimbusBranchesController(
nimbusBranchesStore = nimbusBranchesStore,
experiments = requireContext().components.analytics.experiments,
experimentId = args.experimentId
)
nimbusBranchesView =
NimbusBranchesView(view.findViewById(R.id.nimbus_experiment_branches_list), controller)
loadExperimentBranches()
return view
}
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
consumeFrom(nimbusBranchesStore) { state ->
nimbusBranchesView.update(state)
}
}
override fun onResume() {
super.onResume()
showToolbar(args.experimentName)
}
private fun loadExperimentBranches() {
lifecycleScope.launch(Dispatchers.IO) {
try {
val experiments = requireContext().components.analytics.experiments
val branches = experiments.getExperimentBranches(args.experimentId) ?: emptyList()
val selectedBranch = experiments.withExperiment(args.experimentId) ?: ""
nimbusBranchesStore.dispatch(
NimbusBranchesAction.UpdateBranches(
branches,
selectedBranch
)
)
} catch (e: Throwable) {
Logger.error("Failed to getActiveExperiments()", e)
}
}
}
}

@ -0,0 +1,79 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.nimbus
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
import org.mozilla.experiments.nimbus.Branch
/**
* The [Store] for holding the [NimbusBranchesState] and applying [NimbusBranchesAction]s.
*/
class NimbusBranchesStore(initialState: NimbusBranchesState) :
Store<NimbusBranchesState, NimbusBranchesAction>(
initialState, ::nimbusBranchesFragmentStateReducer
)
/**
* The state for [NimbusBranchesFragment].
*
* @property branches The list of [Branch]s to display in the branches list.
* @property selectedBranch The selected [Branch] slug for a Nimbus experiment.
* @property isLoading True if the branches are still being loaded from storage, otherwise false.
*/
data class NimbusBranchesState(
val branches: List<Branch>,
val selectedBranch: String = "",
val isLoading: Boolean = true
) : State
/**
* Actions to dispatch through the [NimbusBranchesStore] to modify the [NimbusBranchesState]
* through the [nimbusBranchesFragmentStateReducer].
*/
sealed class NimbusBranchesAction : Action {
/**
* Updates the list of Nimbus branches and selected branch.
*
* @param branches The list of [Branch]s to display in the branches list.
* @param selectedBranch The selected [Branch] slug for a Nimbus experiment.
*/
data class UpdateBranches(val branches: List<Branch>, val selectedBranch: String) :
NimbusBranchesAction()
/**
* Updates the selected branch.
*
* @param selectedBranch The selected [Branch] slug for a Nimbus experiment.
*/
data class UpdateSelectedBranch(val selectedBranch: String) : NimbusBranchesAction()
}
/**
* Reduces the Nimbus branches state from the current state with the provided [action] to
* be performed.
*
* @param state The current Nimbus branches state.
* @param action The action to be performed on the state.
* @return the new [NimbusBranchesState] with the [action] executed.
*/
private fun nimbusBranchesFragmentStateReducer(
state: NimbusBranchesState,
action: NimbusBranchesAction
): NimbusBranchesState {
return when (action) {
is NimbusBranchesAction.UpdateBranches -> {
state.copy(
branches = action.branches,
selectedBranch = action.selectedBranch,
isLoading = false
)
}
is NimbusBranchesAction.UpdateSelectedBranch -> {
state.copy(selectedBranch = action.selectedBranch)
}
}
}

@ -1,59 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.nimbus
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.service.nimbus.ui.NimbusDetailAdapter
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.showToolbar
/**
* A fragment to show the details of a Nimbus experiment.
*/
class NimbusDetailsFragment : Fragment(R.layout.mozac_service_nimbus_experiment_details) {
private val args by navArgs<NimbusDetailsFragmentArgs>()
private var adapter: NimbusDetailAdapter? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindRecyclerView(view)
}
override fun onResume() {
super.onResume()
showToolbar(args.experiment)
}
override fun onDestroyView() {
super.onDestroyView()
// Letting go of the resources to avoid memory leak.
adapter = null
}
private fun bindRecyclerView(view: View) {
val recyclerView = view.findViewById<RecyclerView>(R.id.nimbus_experiment_branches_list)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
val shouldRefresh = adapter != null
// Dummy data until we have the appropriate Nimbus API.
val branches = listOf(
"Control",
"Treatment"
)
if (!shouldRefresh) {
adapter = NimbusDetailAdapter(branches)
}
recyclerView.adapter = adapter
}
}

@ -22,6 +22,7 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.nimbus.view.NimbusExperimentsView
/**
* Fragment use for managing Nimbus experiments.
@ -60,7 +61,7 @@ class NimbusExperimentsFragment : Fragment(R.layout.mozac_service_nimbus_experim
lifecycleScope.launch(IO) {
try {
val experiments =
requireContext().components.analytics.experiments.getActiveExperiments()
requireContext().components.analytics.experiments.getAvailableExperiments()
lifecycleScope.launch(Main) {
runIfFragmentIsAttached {
@ -72,7 +73,7 @@ class NimbusExperimentsFragment : Fragment(R.layout.mozac_service_nimbus_experim
}
view.findViewById<TextView>(R.id.nimbus_experiments_empty_message).isVisible =
false
experiments.isEmpty()
recyclerView.adapter = adapter
}
}

@ -0,0 +1,32 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.nimbus.controller
import mozilla.components.service.nimbus.NimbusApi
import mozilla.components.service.nimbus.ui.NimbusBranchesAdapterDelegate
import org.mozilla.experiments.nimbus.Branch
import org.mozilla.fenix.nimbus.NimbusBranchesAction
import org.mozilla.fenix.nimbus.NimbusBranchesStore
/**
* [NimbusBranchesFragment] controller. This implements [NimbusBranchesAdapterDelegate] to handle
* interactions with a Nimbus branch item.
*
* @param nimbusBranchesStore An instance of [NimbusBranchesStore] for dispatching
* [NimbusBranchesAction]s.
* @param experiments An instance of [NimbusApi] for interacting with the Nimbus experiments.
* @param experimentId The string experiment-id or "slug" for a Nimbus experiment.
*/
class NimbusBranchesController(
private val nimbusBranchesStore: NimbusBranchesStore,
private val experiments: NimbusApi,
private val experimentId: String
) : NimbusBranchesAdapterDelegate {
override fun onBranchItemClicked(branch: Branch) {
experiments.optInWithBranch(experimentId, branch.slug)
nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateSelectedBranch(branch.slug))
}
}

@ -0,0 +1,36 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.nimbus.view
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.extensions.LayoutContainer
import mozilla.components.service.nimbus.ui.NimbusBranchAdapter
import org.mozilla.fenix.nimbus.NimbusBranchesState
import org.mozilla.fenix.nimbus.controller.NimbusBranchesController
/**
* View used for managing a Nimbus experiment's branches.
*/
class NimbusBranchesView(
override val containerView: ViewGroup,
val controller: NimbusBranchesController
) : LayoutContainer {
private val nimbusAdapter = NimbusBranchAdapter(controller)
init {
val recyclerView: RecyclerView = containerView as RecyclerView
recyclerView.apply {
adapter = nimbusAdapter
layoutManager = LinearLayoutManager(containerView.context)
}
}
fun update(state: NimbusBranchesState) {
nimbusAdapter.updateData(state.branches, state.selectedBranch)
}
}

@ -2,12 +2,13 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.nimbus
package org.mozilla.fenix.nimbus.view
import androidx.navigation.NavController
import mozilla.components.service.nimbus.ui.NimbusExperimentsAdapterDelegate
import org.mozilla.experiments.nimbus.EnrolledExperiment
import org.mozilla.experiments.nimbus.AvailableExperiment
import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
import org.mozilla.fenix.nimbus.NimbusExperimentsFragmentDirections
/**
* View used for managing Nimbus experiments.
@ -16,10 +17,11 @@ class NimbusExperimentsView(
private val navController: NavController
) : NimbusExperimentsAdapterDelegate {
override fun onExperimentItemClicked(experiment: EnrolledExperiment) {
override fun onExperimentItemClicked(experiment: AvailableExperiment) {
val directions =
NimbusExperimentsFragmentDirections.actionNimbusExperimentsFragmentToNimbusDetailsFragment(
experiment.userFacingName
NimbusExperimentsFragmentDirections.actionNimbusExperimentsFragmentToNimbusBranchesFragment(
experimentId = experiment.slug,
experimentName = experiment.userFacingName
)
navController.navigateBlockingForAsyncNavGraph(directions)

@ -1017,15 +1017,18 @@
android:label="@string/preferences_nimbus_experiments"
tools:layout="@layout/mozac_service_nimbus_experiments">
<action
android:id="@+id/action_nimbusExperimentsFragment_to_nimbusDetailsFragment"
app:destination="@+id/nimbusDetailsFragment" />
android:id="@+id/action_nimbusExperimentsFragment_to_nimbusBranchesFragment"
app:destination="@+id/nimbusBranchesFragment" />
</fragment>
<fragment
android:id="@+id/nimbusDetailsFragment"
android:name="org.mozilla.fenix.nimbus.NimbusDetailsFragment"
android:id="@+id/nimbusBranchesFragment"
android:name="org.mozilla.fenix.nimbus.NimbusBranchesFragment"
tools:layout="@layout/mozac_service_nimbus_experiment_details">
<argument
android:name="experiment"
android:name="experimentId"
app:argType="string" />
<argument
android:name="experimentName"
app:argType="string" />
</fragment>
</navigation>

@ -0,0 +1,49 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.nimbus
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.service.nimbus.NimbusApi
import mozilla.components.support.test.mock
import org.junit.Before
import org.junit.Test
import org.mozilla.experiments.nimbus.Branch
import org.mozilla.experiments.nimbus.FeatureConfig
import org.mozilla.fenix.nimbus.controller.NimbusBranchesController
class NimbusBranchesControllerTest {
private val experiments: NimbusApi = mockk(relaxed = true)
private val experimentId = "id"
private lateinit var controller: NimbusBranchesController
private lateinit var nimbusBranchesStore: NimbusBranchesStore
@Before
fun setup() {
nimbusBranchesStore = mock()
controller = NimbusBranchesController(nimbusBranchesStore, experiments, experimentId)
}
@Test
fun `WHEN branch item is clicked THEN branch is opted into and selectedBranch state is updated`() {
val branch = Branch(
slug = "slug",
ratio = 1,
feature = FeatureConfig(
featureId = "1",
enabled = true
)
)
controller.onBranchItemClicked(branch)
verify {
experiments.optInWithBranch(experimentId, branch.slug)
nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateSelectedBranch(branch.slug))
}
}
}

@ -0,0 +1,52 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.nimbus
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mozilla.experiments.nimbus.Branch
class NimbusBranchesStoreTest {
private lateinit var nimbusBranchesState: NimbusBranchesState
private lateinit var nimbusBranchesStore: NimbusBranchesStore
@Before
fun setup() {
nimbusBranchesState = NimbusBranchesState(branches = emptyList())
nimbusBranchesStore = NimbusBranchesStore(nimbusBranchesState)
}
@Test
fun `GIVEN a new branch and selected branch WHEN UpdateBranches action is dispatched THEN state is updated`() = runBlocking {
assertTrue(nimbusBranchesStore.state.isLoading)
val branches: List<Branch> = listOf(mockk(), mockk())
val selectedBranch = "control"
nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateBranches(branches, selectedBranch))
.join()
assertEquals(branches, nimbusBranchesStore.state.branches)
assertEquals(selectedBranch, nimbusBranchesStore.state.selectedBranch)
assertFalse(nimbusBranchesStore.state.isLoading)
}
@Test
fun `GIVEN a new selected branch WHEN UpdateSelectedBranch action is dispatched THEN selectedBranch state is updated`() = runBlocking {
assertEquals("", nimbusBranchesStore.state.selectedBranch)
val selectedBranch = "control"
nimbusBranchesStore.dispatch(NimbusBranchesAction.UpdateSelectedBranch(selectedBranch)).join()
assertEquals(selectedBranch, nimbusBranchesStore.state.selectedBranch)
}
}

@ -3,5 +3,5 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
object AndroidComponents {
const val VERSION = "90.0.20210504190127"
const val VERSION = "90.0.20210507143122"
}

Loading…
Cancel
Save