From 861dc6589e60a08cbbfe97ce4d9bb9168cbd1b7b Mon Sep 17 00:00:00 2001 From: Arturo Mejia Date: Thu, 19 Aug 2021 17:18:38 -0400 Subject: [PATCH] For #20919 quit the after removing a study. (cherry picked from commit d3019986a4dcfe98bc0179690a4acf1d62ae4c9e) # Conflicts: # app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapter.kt # app/src/main/java/org/mozilla/fenix/settings/studies/StudiesInteractor.kt # app/src/main/res/values/strings.xml # app/src/test/java/org/mozilla/fenix/settings/studies/DefaultStudiesInteractorTest.kt # app/src/test/java/org/mozilla/fenix/settings/studies/StudiesAdapterTest.kt --- .../fenix/settings/studies/StudiesAdapter.kt | 189 ++++++++++++++++++ .../settings/studies/StudiesInteractor.kt | 47 +++++ app/src/main/res/values/strings.xml | 19 ++ .../studies/DefaultStudiesInteractorTest.kt | 62 ++++++ .../settings/studies/StudiesAdapterTest.kt | 136 +++++++++++++ 5 files changed, 453 insertions(+) create mode 100644 app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapter.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/studies/StudiesInteractor.kt create mode 100644 app/src/test/java/org/mozilla/fenix/settings/studies/DefaultStudiesInteractorTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/settings/studies/StudiesAdapterTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapter.kt b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapter.kt new file mode 100644 index 000000000000..d08a1762d7fa --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapter.kt @@ -0,0 +1,189 @@ +/* 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.settings.studies + +import android.annotation.SuppressLint +import android.content.Context +import android.content.DialogInterface +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.google.android.material.button.MaterialButton +import org.mozilla.experiments.nimbus.internal.EnrolledExperiment +import org.mozilla.fenix.R +import org.mozilla.fenix.settings.studies.CustomViewHolder.SectionViewHolder +import org.mozilla.fenix.settings.studies.CustomViewHolder.StudyViewHolder + +private const val VIEW_HOLDER_TYPE_SECTION = 0 +private const val VIEW_HOLDER_TYPE_STUDY = 1 + +/** + * An adapter for displaying studies items. This will display information related to the state of + * a study such as active. In addition, it will perform actions such as removing a study. + * + * @property studiesDelegate Delegate that will provides method for handling + * the studies actions items. + * @param studies The list of studies. + * * @property studiesDelegate Delegate that will provides method for handling + * the studies actions items. + * @param shouldSubmitOnInit The sole purpose of this property is to prevent the submitList function + * to run on init, it should only be used from tests. + */ +@Suppress("LargeClass") +class StudiesAdapter( + private val studiesDelegate: StudiesAdapterDelegate, + studies: List, + @VisibleForTesting + internal val shouldSubmitOnInit: Boolean = true +) : ListAdapter(DifferCallback) { + /** + * Represents all the studies that will be distributed in multiple headers like + * active, and completed, this helps to have the data source of the items, + * displayed in the UI. + */ + @VisibleForTesting + internal var studiesMap: MutableMap = + studies.associateBy({ it.slug }, { it }).toMutableMap() + + init { + if (shouldSubmitOnInit) { + submitList(createListWithSections(studies)) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder { + return when (viewType) { + VIEW_HOLDER_TYPE_STUDY -> createStudiesViewHolder(parent) + VIEW_HOLDER_TYPE_SECTION -> createSectionViewHolder(parent) + else -> throw IllegalArgumentException("Unrecognized viewType") + } + } + + private fun createSectionViewHolder(parent: ViewGroup): CustomViewHolder { + val context = parent.context + val inflater = LayoutInflater.from(context) + val view = inflater.inflate(R.layout.studies_section_item, parent, false) + val titleView = view.findViewById(R.id.title) + val divider = view.findViewById(R.id.divider) + return SectionViewHolder(view, titleView, divider) + } + + private fun createStudiesViewHolder(parent: ViewGroup): StudyViewHolder { + val context = parent.context + val view = LayoutInflater.from(context).inflate(R.layout.study_item, parent, false) + val titleView = view.findViewById(R.id.studyTitle) + val summaryView = view.findViewById(R.id.study_description) + val removeButton = view.findViewById(R.id.remove_button) + return StudyViewHolder( + view, + titleView, + summaryView, + removeButton + ) + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is EnrolledExperiment -> VIEW_HOLDER_TYPE_STUDY + is Section -> VIEW_HOLDER_TYPE_SECTION + else -> throw IllegalArgumentException("items[position] has unrecognized type") + } + } + + override fun onBindViewHolder(holder: CustomViewHolder, position: Int) { + val item = getItem(position) + + when (holder) { + is SectionViewHolder -> bindSection(holder, item as Section) + is StudyViewHolder -> bindStudy(holder, item as EnrolledExperiment) + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun bindSection(holder: SectionViewHolder, section: Section) { + holder.titleView.setText(section.title) + holder.divider.isVisible = section.visibleDivider + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun bindStudy(holder: StudyViewHolder, study: EnrolledExperiment) { + holder.titleView.text = study.userFacingName + holder.summaryView.text = study.userFacingDescription + + holder.deleteButton.setOnClickListener { + showDeleteDialog(holder.titleView.context, study) + } + } + + @VisibleForTesting + internal fun showDeleteDialog(context: Context, study: EnrolledExperiment): AlertDialog { + val builder = AlertDialog.Builder(context) + .setPositiveButton( + R.string.studies_restart_dialog_ok + ) { dialog, _ -> + studiesDelegate.onRemoveButtonClicked(study) + dialog.dismiss() + } + .setNegativeButton( + R.string.studies_restart_dialog_cancel + ) { dialog: DialogInterface, _ -> + dialog.dismiss() + } + .setTitle(R.string.preference_experiments_2) + .setMessage(R.string.studies_restart_app) + .setCancelable(false) + val alertDialog: AlertDialog = builder.create() + alertDialog.show() + return alertDialog + } + + internal fun createListWithSections(studies: List): List { + val itemsWithSections = ArrayList() + val activeStudies = ArrayList() + + activeStudies.addAll(studies) + + if (activeStudies.isNotEmpty()) { + itemsWithSections.add(Section(R.string.studies_active, true)) + itemsWithSections.addAll(activeStudies) + } + + return itemsWithSections + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal data class Section(@StringRes val title: Int, val visibleDivider: Boolean = true) + + /** + * Removes the portion of the list that contains the provided [study]. + * @property study The study to be removed. + */ + fun removeStudy(study: EnrolledExperiment) { + studiesMap.remove(study.slug) + submitList(createListWithSections(studiesMap.values.toList())) + } + + internal object DifferCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean { + return when { + oldItem is EnrolledExperiment && newItem is EnrolledExperiment -> oldItem.slug == newItem.slug + oldItem is Section && newItem is Section -> oldItem.title == newItem.title + else -> false + } + } + + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean { + return oldItem == newItem + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesInteractor.kt new file mode 100644 index 000000000000..46d4d879431e --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesInteractor.kt @@ -0,0 +1,47 @@ +/* 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.settings.studies + +import androidx.annotation.VisibleForTesting +import mozilla.components.service.nimbus.NimbusApi +import org.mozilla.experiments.nimbus.internal.EnrolledExperiment +import org.mozilla.fenix.BrowserDirection +import org.mozilla.fenix.HomeActivity +import kotlin.system.exitProcess + +interface StudiesInteractor { + /** + * Open the given [url] in the browser. + */ + fun openWebsite(url: String) + + /** + * Remove a study by the given [experiment]. + */ + fun removeStudy(experiment: EnrolledExperiment) +} + +class DefaultStudiesInteractor( + private val homeActivity: HomeActivity, + private val experiments: NimbusApi, +) : StudiesInteractor { + override fun openWebsite(url: String) { + homeActivity.openToBrowserAndLoad( + searchTermOrURL = url, + newTab = true, + from = BrowserDirection.FromStudiesFragment + ) + } + + override fun removeStudy(experiment: EnrolledExperiment) { + experiments.optOut(experiment.slug) + killApplication() + } + + @VisibleForTesting + internal fun killApplication() { + exitProcess(0) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ac4ef65cc8bc..b8f02465db67 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -614,6 +614,25 @@ Close after one month +<<<<<<< HEAD +======= + + + Remove + + Active + + Firefox may install and run studies from time to time. + + Learn more + + The application will quit to apply changes + + OK + + Cancel + +>>>>>>> d3019986a (For #20919 quit the after removing a study.) Open tabs diff --git a/app/src/test/java/org/mozilla/fenix/settings/studies/DefaultStudiesInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/settings/studies/DefaultStudiesInteractorTest.kt new file mode 100644 index 000000000000..3b90fc4c0a6e --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/studies/DefaultStudiesInteractorTest.kt @@ -0,0 +1,62 @@ +/* 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.settings.studies + +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.service.nimbus.NimbusApi +import org.junit.Before +import org.junit.Test +import org.mozilla.experiments.nimbus.internal.EnrolledExperiment +import org.mozilla.fenix.BrowserDirection +import org.mozilla.fenix.HomeActivity + +@ExperimentalCoroutinesApi +class DefaultStudiesInteractorTest { + @RelaxedMockK + private lateinit var activity: HomeActivity + + @RelaxedMockK + private lateinit var experiments: NimbusApi + + private lateinit var interactor: DefaultStudiesInteractor + + @Before + fun setup() { + MockKAnnotations.init(this) + interactor = spyk(DefaultStudiesInteractor(activity, experiments)) + } + + @Test + fun `WHEN calling openWebsite THEN delegate to the homeActivity`() { + val url = "" + interactor.openWebsite(url) + + verify { + activity.openToBrowserAndLoad(url, true, BrowserDirection.FromStudiesFragment) + } + } + + @Test + fun `WHEN calling removeStudy THEN delegate to the NimbusApi`() { + val experiment = mockk(relaxed = true) + + every { experiment.slug } returns "slug" + every { interactor.killApplication() } just runs + + interactor.removeStudy(experiment) + + verify { + experiments.optOut("slug") + } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesAdapterTest.kt b/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesAdapterTest.kt new file mode 100644 index 000000000000..c6b70d98a9dd --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesAdapterTest.kt @@ -0,0 +1,136 @@ +/* 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.settings.studies + +import android.view.View +import android.widget.TextView +import androidx.core.view.isVisible +import com.google.android.material.button.MaterialButton +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.spyk +import io.mockk.verify +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.support.test.robolectric.testContext +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.experiments.nimbus.internal.EnrolledExperiment +import org.mozilla.fenix.R +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.mozilla.fenix.settings.studies.CustomViewHolder.SectionViewHolder +import org.mozilla.fenix.settings.studies.CustomViewHolder.StudyViewHolder +import org.mozilla.fenix.settings.studies.StudiesAdapter.Section + +@ExperimentalCoroutinesApi +@RunWith(FenixRobolectricTestRunner::class) +class StudiesAdapterTest { + @RelaxedMockK + private lateinit var delegate: StudiesAdapterDelegate + + private lateinit var adapter: StudiesAdapter + private lateinit var studies: List + + @Before + fun setup() { + MockKAnnotations.init(this) + studies = emptyList() + adapter = spyk(StudiesAdapter(delegate, studies, false)) + } + + @Test + fun `WHEN bindSection THEN bind the section information`() { + val holder = mockk() + val section = Section(R.string.studies_active, true) + val titleView = mockk(relaxed = true) + val divider = mockk(relaxed = true) + + every { holder.titleView } returns titleView + every { holder.divider } returns divider + + adapter.bindSection(holder, section) + + verify { + titleView.setText(section.title) + divider.isVisible = section.visibleDivider + } + } + + @Test + fun `WHEN bindStudy THEN bind the study information`() { + val holder = mockk() + val study = mockk() + val titleView = spyk(TextView(testContext)) + val summaryView = mockk(relaxed = true) + val deleteButton = spyk(MaterialButton(testContext)) + + every { study.slug } returns "slug" + every { study.userFacingName } returns "userFacingName" + every { study.userFacingDescription } returns "userFacingDescription" + every { holder.titleView } returns titleView + every { holder.summaryView } returns summaryView + every { holder.deleteButton } returns deleteButton + + adapter = spyk(StudiesAdapter(delegate, listOf(study), false)) + + every { adapter.showDeleteDialog(any(), any()) } returns mockk() + + adapter.bindStudy(holder, study) + + verify { + titleView.text = any() + summaryView.text = any() + } + + deleteButton.performClick() + + verify { + adapter.showDeleteDialog(any(), any()) + } + } + + @Test + fun `WHEN removeStudy THEN the study should be removed`() { + val study = mockk() + + every { study.slug } returns "slug" + + adapter = spyk(StudiesAdapter(delegate, listOf(study), false)) + + every { adapter.submitList(any()) } just runs + + assertFalse(adapter.studiesMap.isEmpty()) + + adapter.removeStudy(study) + + assertTrue(adapter.studiesMap.isEmpty()) + + verify { + adapter.submitList(any()) + } + } + + @Test + fun `WHEN calling createListWithSections THEN returns the section + experiments`() { + val study = mockk() + + every { study.slug } returns "slug" + + adapter = spyk(StudiesAdapter(delegate, listOf(study), false)) + + val list = adapter.createListWithSections(listOf(study)) + + assertEquals(2, list.size) + assertTrue(list[0] is Section) + assertTrue(list[1] is EnrolledExperiment) + } +}