[fenix] For https://github.com/mozilla-mobile/fenix/issues/20764 add screen for opting out of experiments
parent
be0f7d9dcc
commit
d4a2d1ac83
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue