From d83c9d1e7225405b906e3215c076aa342a8c100f Mon Sep 17 00:00:00 2001 From: Gabriel Luong Date: Fri, 7 May 2021 14:21:03 -0400 Subject: [PATCH] For #18993 - Nimbus: Allow internal tooling to opt into specific branches of an experiment (#19333) --- .../fenix/nimbus/NimbusBranchesFragment.kt | 95 +++++++++++++++++++ .../fenix/nimbus/NimbusBranchesStore.kt | 79 +++++++++++++++ .../fenix/nimbus/NimbusDetailsFragment.kt | 59 ------------ .../fenix/nimbus/NimbusExperimentsFragment.kt | 5 +- .../controller/NimbusBranchesController.kt | 32 +++++++ .../fenix/nimbus/view/NimbusBranchesView.kt | 36 +++++++ .../{ => view}/NimbusExperimentsView.kt | 12 ++- app/src/main/res/navigation/nav_graph.xml | 13 ++- .../nimbus/NimbusBranchesControllerTest.kt | 49 ++++++++++ .../fenix/nimbus/NimbusBranchesStoreTest.kt | 52 ++++++++++ buildSrc/src/main/java/AndroidComponents.kt | 2 +- 11 files changed, 362 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/nimbus/NimbusBranchesFragment.kt create mode 100644 app/src/main/java/org/mozilla/fenix/nimbus/NimbusBranchesStore.kt delete mode 100644 app/src/main/java/org/mozilla/fenix/nimbus/NimbusDetailsFragment.kt create mode 100644 app/src/main/java/org/mozilla/fenix/nimbus/controller/NimbusBranchesController.kt create mode 100644 app/src/main/java/org/mozilla/fenix/nimbus/view/NimbusBranchesView.kt rename app/src/main/java/org/mozilla/fenix/nimbus/{ => view}/NimbusExperimentsView.kt (65%) create mode 100644 app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesControllerTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesStoreTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/nimbus/NimbusBranchesFragment.kt b/app/src/main/java/org/mozilla/fenix/nimbus/NimbusBranchesFragment.kt new file mode 100644 index 000000000..689afecd5 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/nimbus/NimbusBranchesFragment.kt @@ -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() + + 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) + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/nimbus/NimbusBranchesStore.kt b/app/src/main/java/org/mozilla/fenix/nimbus/NimbusBranchesStore.kt new file mode 100644 index 000000000..c378686ff --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/nimbus/NimbusBranchesStore.kt @@ -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( + 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, + 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, 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) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/nimbus/NimbusDetailsFragment.kt b/app/src/main/java/org/mozilla/fenix/nimbus/NimbusDetailsFragment.kt deleted file mode 100644 index 7caf576bd..000000000 --- a/app/src/main/java/org/mozilla/fenix/nimbus/NimbusDetailsFragment.kt +++ /dev/null @@ -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() - 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(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 - } -} diff --git a/app/src/main/java/org/mozilla/fenix/nimbus/NimbusExperimentsFragment.kt b/app/src/main/java/org/mozilla/fenix/nimbus/NimbusExperimentsFragment.kt index 4a8ec904f..30820dac2 100644 --- a/app/src/main/java/org/mozilla/fenix/nimbus/NimbusExperimentsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/nimbus/NimbusExperimentsFragment.kt @@ -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(R.id.nimbus_experiments_empty_message).isVisible = - false + experiments.isEmpty() recyclerView.adapter = adapter } } diff --git a/app/src/main/java/org/mozilla/fenix/nimbus/controller/NimbusBranchesController.kt b/app/src/main/java/org/mozilla/fenix/nimbus/controller/NimbusBranchesController.kt new file mode 100644 index 000000000..0916e26df --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/nimbus/controller/NimbusBranchesController.kt @@ -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)) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/nimbus/view/NimbusBranchesView.kt b/app/src/main/java/org/mozilla/fenix/nimbus/view/NimbusBranchesView.kt new file mode 100644 index 000000000..5fdb59137 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/nimbus/view/NimbusBranchesView.kt @@ -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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/nimbus/NimbusExperimentsView.kt b/app/src/main/java/org/mozilla/fenix/nimbus/view/NimbusExperimentsView.kt similarity index 65% rename from app/src/main/java/org/mozilla/fenix/nimbus/NimbusExperimentsView.kt rename to app/src/main/java/org/mozilla/fenix/nimbus/view/NimbusExperimentsView.kt index 1bc6e030f..643a0b3a8 100644 --- a/app/src/main/java/org/mozilla/fenix/nimbus/NimbusExperimentsView.kt +++ b/app/src/main/java/org/mozilla/fenix/nimbus/view/NimbusExperimentsView.kt @@ -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) diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index a302b5d2f..04257c136 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -1017,15 +1017,18 @@ android:label="@string/preferences_nimbus_experiments" tools:layout="@layout/mozac_service_nimbus_experiments"> + android:id="@+id/action_nimbusExperimentsFragment_to_nimbusBranchesFragment" + app:destination="@+id/nimbusBranchesFragment" /> + diff --git a/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesControllerTest.kt b/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesControllerTest.kt new file mode 100644 index 000000000..0111aacb4 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesControllerTest.kt @@ -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)) + } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesStoreTest.kt b/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesStoreTest.kt new file mode 100644 index 000000000..f47fe8e2e --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/nimbus/NimbusBranchesStoreTest.kt @@ -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 = 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) + } +} diff --git a/buildSrc/src/main/java/AndroidComponents.kt b/buildSrc/src/main/java/AndroidComponents.kt index 56f07bcf9..d1b15566d 100644 --- a/buildSrc/src/main/java/AndroidComponents.kt +++ b/buildSrc/src/main/java/AndroidComponents.kt @@ -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" }