For #20764 add screen for opting out of experiments

This commit is contained in:
Arturo Mejia 2021-08-09 14:43:44 -04:00
parent 71f1f6b88b
commit 463728e007
20 changed files with 956 additions and 10 deletions

View File

@ -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),

View File

@ -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
)
}
/**

View File

@ -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<SwitchPreference>(R.string.pref_key_experimentation).apply {
isChecked = context.settings().isExperimentationEnabled
onPreferenceChangeListener = SharedPreferenceUpdater()
private fun updateStudiesSection() {
val studiesPreference = requirePreference<Preference>(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
}
}
}

View File

@ -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"),

View File

@ -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)
}

View File

@ -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<EnrolledExperiment>,
@VisibleForTesting
internal val shouldSubmitOnInit: Boolean = true
) : ListAdapter<Any, CustomViewHolder>(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<String, EnrolledExperiment> =
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<TextView>(R.id.title)
val divider = view.findViewById<View>(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<TextView>(R.id.studyTitle)
val summaryView = view.findViewById<TextView>(R.id.study_description)
val removeButton = view.findViewById<MaterialButton>(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<EnrolledExperiment>): List<Any> {
val itemsWithSections = ArrayList<Any>()
val activeStudies = ArrayList<EnrolledExperiment>()
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<Any>() {
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
}
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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<URLSpan>()
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
}

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?><!-- 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/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="16dp">
<TextView
android:id="@+id/studiesTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/top_bar_alignment_margin_start"
android:clickable="false"
android:focusable="false"
android:importantForAccessibility="no"
android:textAppearance="@style/ListItemTextStyle"
android:textSize="16sp"
app:layout_constraintEnd_toStartOf="@id/studies_switch"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="On" />
<TextView
android:id="@+id/studiesDescription"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:importantForAccessibility="no"
android:text="@string/preference_experiments_summary_2"
android:textColor="?attr/secondaryText"
android:textColorLink="?aboutLink"
app:layout_constraintEnd_toEndOf="@id/studiesTitle"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="@id/studiesTitle"
app:layout_constraintTop_toBottomOf="@id/studiesTitle" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/studies_switch"
style="@style/QuickSettingsText.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:minHeight="48dp"
android:textOff="@string/studies_off"
android:textOn="@string/studies_on"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/studies_list"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/studiesDescription" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<View
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:visibility="gone"
android:layout_marginTop="7dp"
android:background="?android:attr/listDivider" />
<TextView
android:layout_marginStart="@dimen/top_bar_alignment_margin_start"
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="@dimen/section_header_height"
android:gravity="start|center_vertical"
app:fontFamily="@font/metropolis_semibold"
android:textColor="?preferenceSectionHeader"
android:background="?android:attr/selectableItemBackground"
android:orientation="horizontal"
android:paddingVertical="16dp"
android:textStyle="bold"
tools:text="Active" />
</LinearLayout>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?><!-- 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/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginStart="@dimen/top_bar_alignment_margin_start"
android:background="?android:attr/selectableItemBackground">
<TextView
android:id="@+id/studyTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?primaryText"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="HTTP3 on Firefox" />
<TextView
android:id="@+id/study_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textColor="?secondaryText"
android:textSize="12sp"
app:layout_constraintEnd_toStartOf="@id/remove_button"
app:layout_constraintStart_toStartOf="@id/studyTitle"
app:layout_constraintTop_toBottomOf="@id/studyTitle"
tools:text="HTTP3 is a new protocol that will improve web page load performance. This experiment should measure the performance of our implementation of the HTTP3 protocol." />
<com.google.android.material.button.MaterialButton
android:id="@+id/remove_button"
style="@style/DestructiveButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:text="@string/studies_remove"
android:layout_marginStart="0dp"
android:layout_marginEnd="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/studyTitle"
app:tint="?android:attr/textColorPrimary" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -553,7 +553,20 @@
<fragment
android:id="@+id/dataChoicesFragment"
android:name="org.mozilla.fenix.settings.DataChoicesFragment"
android:label="@string/preferences_data_choices" />
android:label="@string/preferences_data_choices">
<action
android:id="@+id/action_dataChoicesFragment_to_studiesFragment"
app:destination="@id/studiesFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right"
app:popUpTo="@id/dataChoicesFragment" />
</fragment>
<fragment
android:id="@+id/studiesFragment"
android:name="org.mozilla.fenix.settings.studies.StudiesFragment"
android:label="@string/preference_experiments_2" />
<fragment
android:id="@+id/sitePermissionsFragment"
android:name="org.mozilla.fenix.settings.sitepermissions.SitePermissionsFragment"

View File

@ -57,6 +57,7 @@
<string name="pref_key_private_browsing" translatable="false">pref_key_private_browsing</string>
<string name="pref_key_leakcanary" translatable="false">pref_key_leakcanary</string>
<string name="pref_key_remote_debugging" translatable="false">pref_key_remote_debugging</string>
<string name="pref_key_studies_section" translatable="false">pref_key_studies_section</string>
<string name="pref_key_experimentation" translatable="false">pref_key_experimentation</string>
<string name="pref_key_showed_private_mode_cfr" translatable="false">pref_key_showed_private_mode_cfr</string>
<string name="pref_key_private_mode_opened" translatable="false">pref_key_private_mode_opened</string>

View File

@ -614,6 +614,14 @@
<!-- Summary for tabs preference when auto closing tabs setting is set to auto close tabs after one month-->
<string name="close_tabs_after_one_month_summary">Close after one month</string>
<!-- Studies -->
<!-- Title of the remove studies button -->
<string name="studies_remove">Remove</string>
<!-- Title of the active section on the studies list -->
<string name="studies_active">Active</string>
<!-- Description for studies, it indicates why Firefox use studies and links to an article for more information. The %1 will be replaced with the corresponding article URL -->
<string name="studies_description"><![CDATA[Firefox may install and run studies from time to time. <a href="%1$s">Learn more</a>]]></string>
<!-- Sessions -->
<!-- Title for the list of tabs -->
<string name="tab_header_label">Open tabs</string>
@ -929,6 +937,10 @@
<string name="delete_browsing_data_quit_on">On</string>
<!-- Summary of delete browsing data on quit preference if it is set to off -->
<string name="delete_browsing_data_quit_off">Off</string>
<!-- Summary of studies preference if it is set to on -->
<string name="studies_on">On</string>
<!-- Summary of studies data on quit preference if it is set to off -->
<string name="studies_off">Off</string>
<!-- Collections -->
<!-- Collections header on home fragment -->

View File

@ -11,8 +11,8 @@
android:key="@string/pref_key_marketing_telemetry"
android:summary="@string/preferences_marketing_data_description2"
android:title="@string/preferences_marketing_data" />
<SwitchPreference
android:key="@string/pref_key_experimentation"
android:summary="@string/preference_experiments_summary_2"
<androidx.preference.Preference
android:key="@string/pref_key_studies_section"
android:title="@string/preference_experiments_2" />
</PreferenceScreen>

View File

@ -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<EnrolledExperiment>(relaxed = true)
every { experiment.slug } returns "slug"
interactor.removeStudy(experiment)
verify {
experiments.optOut("slug")
}
}
}

View File

@ -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<EnrolledExperiment>
@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<SectionViewHolder>()
val section = Section(R.string.studies_active, true)
val titleView = mockk<TextView>(relaxed = true)
val divider = mockk<View>(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<StudyViewHolder>()
val study = mockk<EnrolledExperiment>()
val titleView = mockk<TextView>(relaxed = true)
val summaryView = mockk<TextView>(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<EnrolledExperiment>()
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<EnrolledExperiment>()
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)
}
}

View File

@ -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<TextView>(relaxed = true)
val studiesSwitch = mockk<SwitchCompat>(relaxed = true)
val studiesList = mockk<RecyclerView>(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<EnrolledExperiment>()
val adapter = mockk<StudiesAdapter>(relaxed = true)
every { view.adapter } returns adapter
view.onRemoveButtonClicked(experiment)
verify {
interactor.removeStudy(experiment)
adapter.removeStudy(experiment)
}
}
}