From 463728e007142a285046e644e35241539ea6d381 Mon Sep 17 00:00:00 2001 From: Arturo Mejia Date: Mon, 9 Aug 2021 14:43:44 -0400 Subject: [PATCH] For #20764 add screen for opting out of experiments --- .../org/mozilla/fenix/BrowserDirection.kt | 1 + .../java/org/mozilla/fenix/HomeActivity.kt | 4 + .../fenix/settings/DataChoicesFragment.kt | 25 ++- .../mozilla/fenix/settings/SupportUtils.kt | 1 + .../settings/studies/CustomViewHolder.kt | 34 ++++ .../fenix/settings/studies/StudiesAdapter.kt | 164 ++++++++++++++++++ .../studies/StudiesAdapterDelegate.kt | 19 ++ .../fenix/settings/studies/StudiesFragment.kt | 54 ++++++ .../settings/studies/StudiesInteractor.kt | 39 +++++ .../fenix/settings/studies/StudiesView.kt | 132 ++++++++++++++ app/src/main/res/layout/settings_studies.xml | 61 +++++++ .../main/res/layout/studies_section_item.xml | 34 ++++ app/src/main/res/layout/study_item.xml | 54 ++++++ app/src/main/res/navigation/nav_graph.xml | 15 +- app/src/main/res/values/preference_keys.xml | 1 + app/src/main/res/values/strings.xml | 12 ++ .../main/res/xml/data_choices_preferences.xml | 6 +- .../studies/DefaultStudiesInteractorTest.kt | 58 +++++++ .../settings/studies/StudiesAdapterTest.kt | 134 ++++++++++++++ .../fenix/settings/studies/StudiesViewTest.kt | 118 +++++++++++++ 20 files changed, 956 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/settings/studies/CustomViewHolder.kt 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/StudiesAdapterDelegate.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/studies/StudiesFragment.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/studies/StudiesInteractor.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/studies/StudiesView.kt create mode 100644 app/src/main/res/layout/settings_studies.xml create mode 100644 app/src/main/res/layout/studies_section_item.xml create mode 100644 app/src/main/res/layout/study_item.xml 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 create mode 100644 app/src/test/java/org/mozilla/fenix/settings/studies/StudiesViewTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt b/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt index 113001daa..bd50ba9fd 100644 --- a/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt +++ b/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt @@ -28,6 +28,7 @@ enum class BrowserDirection(@IdRes val fragmentId: Int) { FromAddSearchEngineFragment(R.id.addSearchEngineFragment), FromEditCustomSearchEngineFragment(R.id.editCustomSearchEngineFragment), FromAddonDetailsFragment(R.id.addonDetailsFragment), + FromStudiesFragment(R.id.studiesFragment), FromAddonPermissionsDetailsFragment(R.id.addonPermissionsDetailFragment), FromLoginDetailFragment(R.id.loginDetailFragment), FromTabsTray(R.id.tabsTrayFragment), diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index af01860f0..019ee6cfc 100644 --- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -111,6 +111,7 @@ import org.mozilla.fenix.settings.logins.fragment.LoginDetailFragmentDirections import org.mozilla.fenix.settings.logins.fragment.SavedLoginsAuthFragmentDirections import org.mozilla.fenix.settings.search.AddSearchEngineFragmentDirections import org.mozilla.fenix.settings.search.EditCustomSearchEngineFragmentDirections +import org.mozilla.fenix.settings.studies.StudiesFragmentDirections import org.mozilla.fenix.share.AddNewDeviceFragmentDirections import org.mozilla.fenix.tabstray.TabsTrayFragment import org.mozilla.fenix.tabstray.TabsTrayFragmentDirections @@ -764,6 +765,9 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { TabsTrayFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromRecentlyClosed -> RecentlyClosedFragmentDirections.actionGlobalBrowser(customTabSessionId) + BrowserDirection.FromStudiesFragment -> StudiesFragmentDirections.actionGlobalBrowser( + customTabSessionId + ) } /** diff --git a/app/src/main/java/org/mozilla/fenix/settings/DataChoicesFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/DataChoicesFragment.kt index ceb6b2ab6..6aa832f47 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/DataChoicesFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/DataChoicesFragment.kt @@ -5,12 +5,15 @@ package org.mozilla.fenix.settings import android.os.Bundle +import androidx.navigation.findNavController +import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreference import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.MetricServiceType import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.getPreferenceKey +import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar @@ -40,9 +43,6 @@ class DataChoicesFragment : PreferenceFragmentCompat() { } else { context.components.analytics.metrics.stop(MetricServiceType.Marketing) } - } else if (key == getPreferenceKey(R.string.pref_key_experimentation)) { - val enabled = context.settings().isExperimentationEnabled - context.components.analytics.experiments.globalUserParticipation = enabled } } } @@ -50,6 +50,7 @@ class DataChoicesFragment : PreferenceFragmentCompat() { override fun onResume() { super.onResume() showToolbar(getString(R.string.preferences_data_collection)) + updateStudiesSection() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -68,10 +69,22 @@ class DataChoicesFragment : PreferenceFragmentCompat() { isChecked = context.settings().isMarketingTelemetryEnabled onPreferenceChangeListener = SharedPreferenceUpdater() } + } - requirePreference(R.string.pref_key_experimentation).apply { - isChecked = context.settings().isExperimentationEnabled - onPreferenceChangeListener = SharedPreferenceUpdater() + private fun updateStudiesSection() { + val studiesPreference = requirePreference(R.string.pref_key_studies_section) + val settings = requireContext().settings() + val stringId = if (settings.isExperimentationEnabled) { + R.string.studies_on + } else { + R.string.studies_off + } + studiesPreference.summary = getString(stringId) + + studiesPreference.setOnPreferenceClickListener { + val action = DataChoicesFragmentDirections.actionDataChoicesFragmentToStudiesFragment() + view?.findNavController()?.nav(R.id.dataChoicesFragment, action) + true } } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt b/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt index 6f510a30a..b0ca33f43 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SupportUtils.kt @@ -44,6 +44,7 @@ object SupportUtils { YOUR_RIGHTS("your-rights"), TRACKING_PROTECTION("tracking-protection-firefox-android"), WHATS_NEW("whats-new-firefox-preview"), + OPT_OUT_STUDIES("how-opt-out-studies-firefox-android"), SEND_TABS("send-tab-preview"), SET_AS_DEFAULT_BROWSER("set-firefox-preview-default"), SEARCH_SUGGESTION("how-search-firefox-preview"), diff --git a/app/src/main/java/org/mozilla/fenix/settings/studies/CustomViewHolder.kt b/app/src/main/java/org/mozilla/fenix/settings/studies/CustomViewHolder.kt new file mode 100644 index 000000000..6c7a2b00a --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/CustomViewHolder.kt @@ -0,0 +1,34 @@ +/* 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.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton + +/** + * A base view holder for Studies. + */ +sealed class CustomViewHolder(view: View) : RecyclerView.ViewHolder(view) { + /** + * A view holder for displaying section items. + */ + class SectionViewHolder( + view: View, + val titleView: TextView, + val divider: View + ) : CustomViewHolder(view) + + /** + * A view holder for displaying study items. + */ + class StudyViewHolder( + view: View, + val titleView: TextView, + val summaryView: TextView, + val deleteButton: MaterialButton, + ) : CustomViewHolder(view) +} 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 000000000..5fe74443d --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapter.kt @@ -0,0 +1,164 @@ +/* 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.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +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 { + studiesDelegate.onRemoveButtonClicked(study) + } + } + + 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/StudiesAdapterDelegate.kt b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapterDelegate.kt new file mode 100644 index 000000000..86ef6a494 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesAdapterDelegate.kt @@ -0,0 +1,19 @@ +/* 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 org.mozilla.experiments.nimbus.internal.EnrolledExperiment + +/** + * Provides methods for handling the studies items. + */ +interface StudiesAdapterDelegate { + /** + * Handler for when the remove button is clicked. + * + * @param experiment The [EnrolledExperiment] to remove. + */ + fun onRemoveButtonClicked(experiment: EnrolledExperiment) = Unit +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesFragment.kt new file mode 100644 index 000000000..f45c0344b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesFragment.kt @@ -0,0 +1,54 @@ +/* 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.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.databinding.SettingsStudiesBinding +import org.mozilla.fenix.ext.requireComponents +import org.mozilla.fenix.ext.settings + +/** + * Lets the users control studies settings. + */ +class StudiesFragment : Fragment() { + private var _binding: SettingsStudiesBinding? = null + // This property is only valid between onCreateView and + // onDestroyView. + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val experiments = requireComponents.analytics.experiments + _binding = SettingsStudiesBinding.inflate(inflater, container, false) + val interactor = DefaultStudiesInteractor((activity as HomeActivity), experiments) + StudiesView( + lifecycleScope, + requireContext(), + binding, + interactor, + requireContext().settings(), + experiments, + ::isAttached + ).bind() + + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun isAttached(): Boolean = context != null +} 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 000000000..c859f4875 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesInteractor.kt @@ -0,0 +1,39 @@ +/* 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 mozilla.components.service.nimbus.NimbusApi +import org.mozilla.experiments.nimbus.internal.EnrolledExperiment +import org.mozilla.fenix.BrowserDirection +import org.mozilla.fenix.HomeActivity + +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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesView.kt b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesView.kt new file mode 100644 index 000000000..92723d734 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/studies/StudiesView.kt @@ -0,0 +1,132 @@ +/* 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.content.Context +import android.text.SpannableStringBuilder +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.URLSpan +import android.view.View +import android.widget.TextView +import androidx.annotation.VisibleForTesting +import androidx.appcompat.widget.SwitchCompat +import androidx.core.text.HtmlCompat +import androidx.core.text.getSpans +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.service.nimbus.NimbusApi +import mozilla.components.support.base.log.logger.Logger +import org.mozilla.experiments.nimbus.internal.EnrolledExperiment +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.SettingsStudiesBinding +import org.mozilla.fenix.settings.SupportUtils +import org.mozilla.fenix.settings.SupportUtils.SumoTopic.OPT_OUT_STUDIES +import org.mozilla.fenix.utils.Settings + +@Suppress("LongParameterList") +class StudiesView( + private val scope: CoroutineScope, + private val context: Context, + private val binding: SettingsStudiesBinding, + private val interactor: StudiesInteractor, + private val settings: Settings, + private val experiments: NimbusApi, + private val isAttached: () -> Boolean +) : StudiesAdapterDelegate { + private val logger = Logger("StudiesView") + + @VisibleForTesting + internal lateinit var adapter: StudiesAdapter + + @Suppress("TooGenericExceptionCaught") + fun bind() { + provideStudiesTitle().text = getSwitchTitle() + provideStudiesSwitch().isChecked = settings.isExperimentationEnabled + provideStudiesSwitch().setOnCheckedChangeListener { _, isChecked -> + settings.isExperimentationEnabled = isChecked + experiments.globalUserParticipation = isChecked + provideStudiesTitle().text = getSwitchTitle() + } + bindDescription() + + scope.launch(Dispatchers.IO) { + try { + val experiments = experiments.getActiveExperiments() + scope.launch(Dispatchers.Main) { + if (isAttached()) { + adapter = StudiesAdapter( + this@StudiesView, + experiments + ) + provideStudiesList().adapter = adapter + } + } + } catch (e: Throwable) { + logger.error("Failed to getActiveExperiments()", e) + } + } + } + + override fun onRemoveButtonClicked(experiment: EnrolledExperiment) { + interactor.removeStudy(experiment) + adapter.removeStudy(experiment) + } + + @VisibleForTesting + internal fun bindDescription() { + val sumoUrl = SupportUtils.getSumoURLForTopic(context, OPT_OUT_STUDIES) + val rawText = + context.getString(R.string.studies_description, sumoUrl) + val text = HtmlCompat.fromHtml(rawText, HtmlCompat.FROM_HTML_MODE_COMPACT) + + val spannableStringBuilder = SpannableStringBuilder(text) + val links = spannableStringBuilder.getSpans() + for (link in links) { + addActionToLinks(spannableStringBuilder, link) + } + binding.studiesDescription.text = spannableStringBuilder + binding.studiesDescription.movementMethod = LinkMovementMethod.getInstance() + } + + private fun addActionToLinks( + spannableStringBuilder: SpannableStringBuilder, + link: URLSpan + ) { + val start = spannableStringBuilder.getSpanStart(link) + val end = spannableStringBuilder.getSpanEnd(link) + val flags = spannableStringBuilder.getSpanFlags(link) + val clickable: ClickableSpan = object : ClickableSpan() { + override fun onClick(view: View) { + view.setOnClickListener { + interactor.openWebsite(link.url) + } + } + } + spannableStringBuilder.setSpan(clickable, start, end, flags) + spannableStringBuilder.removeSpan(link) + } + + @VisibleForTesting + internal fun getSwitchTitle(): String { + val stringId = if (settings.isExperimentationEnabled) { + R.string.studies_on + } else { + R.string.studies_off + } + return context.getString(stringId) + } + + @VisibleForTesting + internal fun provideStudiesTitle(): TextView = binding.studiesTitle + + @VisibleForTesting + internal fun provideStudiesSwitch(): SwitchCompat = binding.studiesSwitch + + @VisibleForTesting + internal fun provideStudiesList(): RecyclerView = binding.studiesList +} diff --git a/app/src/main/res/layout/settings_studies.xml b/app/src/main/res/layout/settings_studies.xml new file mode 100644 index 000000000..b97884922 --- /dev/null +++ b/app/src/main/res/layout/settings_studies.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/studies_section_item.xml b/app/src/main/res/layout/studies_section_item.xml new file mode 100644 index 000000000..ef2b6d2de --- /dev/null +++ b/app/src/main/res/layout/studies_section_item.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/study_item.xml b/app/src/main/res/layout/study_item.xml new file mode 100644 index 000000000..5cff29278 --- /dev/null +++ b/app/src/main/res/layout/study_item.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 71b28b4a1..d780417aa 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -553,7 +553,20 @@ + android:label="@string/preferences_data_choices"> + + + pref_key_private_browsing pref_key_leakcanary pref_key_remote_debugging + pref_key_studies_section pref_key_experimentation pref_key_showed_private_mode_cfr pref_key_private_mode_opened diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ac4ef65cc..29204116f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -614,6 +614,14 @@ Close after one month + + + Remove + + Active + + Learn more]]> + Open tabs @@ -929,6 +937,10 @@ On Off + + On + + Off diff --git a/app/src/main/res/xml/data_choices_preferences.xml b/app/src/main/res/xml/data_choices_preferences.xml index d2dcc9d06..2aa2e3909 100644 --- a/app/src/main/res/xml/data_choices_preferences.xml +++ b/app/src/main/res/xml/data_choices_preferences.xml @@ -11,8 +11,8 @@ android:key="@string/pref_key_marketing_telemetry" android:summary="@string/preferences_marketing_data_description2" android:title="@string/preferences_marketing_data" /> - 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 000000000..5384e8025 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/studies/DefaultStudiesInteractorTest.kt @@ -0,0 +1,58 @@ +/* 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.mockk +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 = 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" + + 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 000000000..33b190cb7 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesAdapterTest.kt @@ -0,0 +1,134 @@ +/* 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.assertTrue +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertEquals +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 = mockk(relaxed = true) + 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)) + + adapter.bindStudy(holder, study) + + verify { + titleView.text = any() + summaryView.text = any() + } + + deleteButton.performClick() + + verify { + delegate.onRemoveButtonClicked(study) + } + } + + @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) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesViewTest.kt b/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesViewTest.kt new file mode 100644 index 000000000..9deca9156 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/studies/StudiesViewTest.kt @@ -0,0 +1,118 @@ +/* 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.widget.TextView +import androidx.appcompat.widget.SwitchCompat +import androidx.recyclerview.widget.RecyclerView +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 kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope +import mozilla.components.service.nimbus.NimbusApi +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.experiments.nimbus.internal.EnrolledExperiment +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.databinding.SettingsStudiesBinding +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.mozilla.fenix.utils.Settings + +@ExperimentalCoroutinesApi +@RunWith(FenixRobolectricTestRunner::class) +class StudiesViewTest { + @RelaxedMockK + private lateinit var activity: HomeActivity + + @RelaxedMockK + private lateinit var experiments: NimbusApi + + @RelaxedMockK + private lateinit var binding: SettingsStudiesBinding + + @RelaxedMockK + private lateinit var interactor: StudiesInteractor + + @RelaxedMockK + private lateinit var settings: Settings + + private val testCoroutineScope = TestCoroutineScope() + private val testDispatcher = TestCoroutineDispatcher() + private lateinit var view: StudiesView + + @get:Rule + val coroutinesTestRule = MainCoroutineRule(testDispatcher) + + @Before + fun setup() { + MockKAnnotations.init(this) + view = spyk( + StudiesView( + testCoroutineScope, + testContext, + binding, + interactor, + settings, + experiments + ) { true } + ) + } + + @After + fun cleanUp() { + testCoroutineScope.cleanupTestCoroutines() + testDispatcher.cleanupTestCoroutines() + } + + @Test + fun `WHEN calling bind THEN bind all the related information`() { + val studiesTitle = mockk(relaxed = true) + val studiesSwitch = mockk(relaxed = true) + val studiesList = mockk(relaxed = true) + + every { settings.isExperimentationEnabled } returns true + every { view.provideStudiesTitle() } returns studiesTitle + every { view.provideStudiesSwitch() } returns studiesSwitch + every { view.provideStudiesList() } returns studiesList + every { view.bindDescription() } just runs + every { view.getSwitchTitle() } returns "Title" + + view.bind() + + verify { + studiesTitle.text = "Title" + studiesSwitch.isChecked = true + view.bindDescription() + studiesList.adapter = any() + } + } + + @Test + fun `WHEN calling onRemoveButtonClicked THEN delegate to the interactor`() { + val experiment = mockk() + val adapter = mockk(relaxed = true) + + every { view.adapter } returns adapter + + view.onRemoveButtonClicked(experiment) + + verify { + interactor.removeStudy(experiment) + adapter.removeStudy(experiment) + } + } +}