1
0
Fork 0

For #4127 - Converts Exceptions to LibState and adds tests

master
Emily Kager 2019-07-18 16:07:48 -07:00 committed by Emily Kager
parent 9b5baa2358
commit be10d427e8
11 changed files with 262 additions and 135 deletions

View File

@ -258,6 +258,12 @@ open class HomeActivity : AppCompatActivity(), ShareFragment.TabsSharedCallback
customTabSessionId customTabSessionId
) )
} }
BrowserDirection.FromExceptions -> {
fragmentId = R.id.exceptionsFragment
ExceptionsFragmentDirections.actionExceptionsFragmentToBrowserFragment(
customTabSessionId
)
}
} }
} else { } else {
null null

View File

@ -7,7 +7,6 @@ package org.mozilla.fenix.exceptions
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import org.mozilla.fenix.exceptions.viewholders.ExceptionsDeleteButtonViewHolder import org.mozilla.fenix.exceptions.viewholders.ExceptionsDeleteButtonViewHolder
import org.mozilla.fenix.exceptions.viewholders.ExceptionsHeaderViewHolder import org.mozilla.fenix.exceptions.viewholders.ExceptionsHeaderViewHolder
import org.mozilla.fenix.exceptions.viewholders.ExceptionsListItemViewHolder import org.mozilla.fenix.exceptions.viewholders.ExceptionsListItemViewHolder
@ -21,6 +20,7 @@ private sealed class AdapterItem {
private class ExceptionsList(val exceptions: List<ExceptionsItem>) { private class ExceptionsList(val exceptions: List<ExceptionsItem>) {
val items: List<AdapterItem> val items: List<AdapterItem>
init { init {
val items = mutableListOf<AdapterItem>() val items = mutableListOf<AdapterItem>()
items.add(AdapterItem.Header) items.add(AdapterItem.Header)
@ -33,7 +33,7 @@ private class ExceptionsList(val exceptions: List<ExceptionsItem>) {
} }
class ExceptionsAdapter( class ExceptionsAdapter(
private val actionEmitter: Observer<ExceptionsAction> private val interactor: ExceptionsInteractor
) : AdapterWithJob<RecyclerView.ViewHolder>() { ) : AdapterWithJob<RecyclerView.ViewHolder>() {
private var exceptionsList: ExceptionsList = ExceptionsList(emptyList()) private var exceptionsList: ExceptionsList = ExceptionsList(emptyList())
@ -56,9 +56,16 @@ class ExceptionsAdapter(
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return when (viewType) { return when (viewType) {
ExceptionsDeleteButtonViewHolder.LAYOUT_ID -> ExceptionsDeleteButtonViewHolder(view, actionEmitter) ExceptionsDeleteButtonViewHolder.LAYOUT_ID -> ExceptionsDeleteButtonViewHolder(
view,
interactor
)
ExceptionsHeaderViewHolder.LAYOUT_ID -> ExceptionsHeaderViewHolder(view) ExceptionsHeaderViewHolder.LAYOUT_ID -> ExceptionsHeaderViewHolder(view)
ExceptionsListItemViewHolder.LAYOUT_ID -> ExceptionsListItemViewHolder(view, actionEmitter, adapterJob) ExceptionsListItemViewHolder.LAYOUT_ID -> ExceptionsListItemViewHolder(
view,
interactor,
adapterJob
)
else -> throw IllegalStateException() else -> throw IllegalStateException()
} }
} }

View File

@ -1,62 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.exceptions
import android.view.ViewGroup
import org.mozilla.fenix.mvi.Action
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.Change
import org.mozilla.fenix.mvi.UIComponent
import org.mozilla.fenix.mvi.UIComponentViewModelBase
import org.mozilla.fenix.mvi.UIComponentViewModelProvider
import org.mozilla.fenix.mvi.ViewState
import org.mozilla.fenix.test.Mockable
data class ExceptionsItem(val url: String)
@Mockable
class ExceptionsComponent(
private val container: ViewGroup,
bus: ActionBusFactory,
viewModelProvider: UIComponentViewModelProvider<ExceptionsState, ExceptionsChange>
) :
UIComponent<ExceptionsState, ExceptionsAction, ExceptionsChange>(
bus.getManagedEmitter(ExceptionsAction::class.java),
bus.getSafeManagedObservable(ExceptionsChange::class.java),
viewModelProvider
) {
override fun initView() = ExceptionsUIView(container, actionEmitter, changesObservable)
init {
bind()
}
}
data class ExceptionsState(val items: List<ExceptionsItem>) : ViewState
sealed class ExceptionsAction : Action {
object LearnMore : ExceptionsAction()
sealed class Delete : ExceptionsAction() {
object All : Delete()
data class One(val item: ExceptionsItem) : Delete()
}
}
sealed class ExceptionsChange : Change {
data class Change(val list: List<ExceptionsItem>) : ExceptionsChange()
}
class ExceptionsViewModel(
initialState: ExceptionsState
) : UIComponentViewModelBase<ExceptionsState, ExceptionsChange>(initialState, reducer) {
companion object {
val reducer: (ExceptionsState, ExceptionsChange) -> ExceptionsState = { state, change ->
when (change) {
is ExceptionsChange.Change -> state.copy(items = change.list)
}
}
}
}

View File

@ -11,23 +11,24 @@ import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenStarted
import androidx.navigation.Navigation import androidx.navigation.Navigation
import kotlinx.android.synthetic.main.fragment_exceptions.view.* import kotlinx.android.synthetic.main.fragment_exceptions.view.*
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.components.lib.state.ext.observe
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.FenixViewModelProvider
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.mvi.ActionBusFactory import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.mvi.getAutoDisposeObservable
import org.mozilla.fenix.mvi.getManagedEmitter
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
class ExceptionsFragment : Fragment() { class ExceptionsFragment : Fragment() {
private lateinit var exceptionsComponent: ExceptionsComponent private lateinit var exceptionsStore: ExceptionsStore
private lateinit var exceptionsView: ExceptionsView
private lateinit var exceptionsInteractor: ExceptionsInteractor
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
@ -41,45 +42,54 @@ class ExceptionsFragment : Fragment() {
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View? {
val view = inflater.inflate(R.layout.fragment_exceptions, container, false) val view = inflater.inflate(R.layout.fragment_exceptions, container, false)
exceptionsComponent = ExceptionsComponent( exceptionsStore = StoreProvider.get(this) {
view.exceptions_layout, ExceptionsStore(
ActionBusFactory.get(this), ExceptionsState(
FenixViewModelProvider.create( items = loadAndMapExceptions()
this, )
ExceptionsViewModel::class.java )
) { }
ExceptionsViewModel(ExceptionsState(loadAndMapExceptions())) exceptionsInteractor =
} ExceptionsInteractor(::openLearnMore, ::deleteOneItem, ::deleteAllItems)
) exceptionsView = ExceptionsView(view.exceptions_layout, exceptionsInteractor)
return view return view
} }
override fun onStart() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onStart() super.onViewCreated(view, savedInstanceState)
getAutoDisposeObservable<ExceptionsAction>() exceptionsStore.observe(view) {
.subscribe { viewLifecycleOwner.lifecycleScope.launch {
when (it) { whenStarted {
is ExceptionsAction.LearnMore -> { exceptionsView.update(it)
(activity as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getGenericSumoURLForTopic
(SupportUtils.SumoTopic.TRACKING_PROTECTION),
newTab = true,
from = BrowserDirection.FromExceptions
)
}
is ExceptionsAction.Delete.All -> viewLifecycleOwner.lifecycleScope.launch(IO) {
val domains = ExceptionDomains.load(context!!)
ExceptionDomains.remove(context!!, domains)
launch(Main) {
view?.let { view -> Navigation.findNavController(view).navigateUp() }
}
}
is ExceptionsAction.Delete.One -> viewLifecycleOwner.lifecycleScope.launch(IO) {
ExceptionDomains.remove(context!!, listOf(it.item.url))
reloadData()
}
} }
} }
}
}
private fun deleteAllItems() {
viewLifecycleOwner.lifecycleScope.launch(IO) {
val domains = ExceptionDomains.load(context!!)
ExceptionDomains.remove(context!!, domains)
launch(Main) {
view?.let { view -> Navigation.findNavController(view).navigateUp() }
}
}
}
private fun deleteOneItem(item: ExceptionsItem) {
viewLifecycleOwner.lifecycleScope.launch(IO) {
ExceptionDomains.remove(context!!, listOf(item.url))
reloadData()
}
}
private fun openLearnMore() {
(activity as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = SupportUtils.getGenericSumoURLForTopic
(SupportUtils.SumoTopic.TRACKING_PROTECTION),
newTab = true,
from = BrowserDirection.FromExceptions
)
} }
private fun loadAndMapExceptions(): List<ExceptionsItem> { private fun loadAndMapExceptions(): List<ExceptionsItem> {
@ -100,7 +110,7 @@ class ExceptionsFragment : Fragment() {
view?.let { view: View -> Navigation.findNavController(view).navigateUp() } view?.let { view: View -> Navigation.findNavController(view).navigateUp() }
return@launch return@launch
} }
getManagedEmitter<ExceptionsChange>().onNext(ExceptionsChange.Change(items)) exceptionsStore.dispatch(ExceptionsAction.Change(items))
} }
} }
} }

View File

@ -0,0 +1,27 @@
/* 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.exceptions
/**
* Interactor for the exceptions screen
* Provides implementations for the ExceptionsViewInteractor
*/
class ExceptionsInteractor(
private val learnMore: () -> Unit,
private val deleteOne: (ExceptionsItem) -> Unit,
private val deleteAll: () -> Unit
) : ExceptionsViewInteractor {
override fun onLearnMore() {
learnMore.invoke()
}
override fun onDeleteAll() {
deleteAll.invoke()
}
override fun onDeleteOne(item: ExceptionsItem) {
deleteOne.invoke(item)
}
}

View File

@ -0,0 +1,43 @@
/* 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.exceptions
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
/**
* Class representing an exception item
* @property url Host of the exception
*/
data class ExceptionsItem(val url: String)
/**
* The [Store] for holding the [ExceptionsState] and applying [ExceptionsAction]s.
*/
class ExceptionsStore(initialState: ExceptionsState) :
Store<ExceptionsState, ExceptionsAction>(initialState, ::exceptionsStateReducer)
/**
* Actions to dispatch through the `ExceptionsStore` to modify `ExceptionsState` through the reducer.
*/
sealed class ExceptionsAction : Action {
data class Change(val list: List<ExceptionsItem>) : ExceptionsAction()
}
/**
* The state for the Exceptions Screen
* @property items List of exceptions to display
*/
data class ExceptionsState(val items: List<ExceptionsItem>) : State
/**
* The ExceptionsState Reducer.
*/
fun exceptionsStateReducer(state: ExceptionsState, action: ExceptionsAction): ExceptionsState {
return when (action) {
is ExceptionsAction.Change -> state.copy(items = action.list)
}
}

View File

@ -13,31 +13,49 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import io.reactivex.Observable import kotlinx.android.extensions.LayoutContainer
import io.reactivex.Observer
import io.reactivex.functions.Consumer
import kotlinx.android.synthetic.main.component_exceptions.view.* import kotlinx.android.synthetic.main.component_exceptions.view.*
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.mvi.UIView
class ExceptionsUIView( /**
container: ViewGroup, * Interface for the ExceptionsViewInteractor. This interface is implemented by objects that want
actionEmitter: Observer<ExceptionsAction>, * to respond to user interaction on the ExceptionsView
changesObservable: Observable<ExceptionsChange> */
) : interface ExceptionsViewInteractor {
UIView<ExceptionsState, ExceptionsAction, ExceptionsChange>( /**
container, * Called whenever learn more about tracking protection is tapped
actionEmitter, */
changesObservable fun onLearnMore()
) {
override val view: FrameLayout = LayoutInflater.from(container.context) /**
* Called whenever all exception items are deleted
*/
fun onDeleteAll()
/**
* Called whenever one exception item is deleted
*/
fun onDeleteOne(item: ExceptionsItem)
}
/**
* View that contains and configures the Exceptions List
*/
class ExceptionsView(
private val container: ViewGroup,
val interactor: ExceptionsInteractor
) : LayoutContainer {
val view: FrameLayout = LayoutInflater.from(container.context)
.inflate(R.layout.component_exceptions, container, true) .inflate(R.layout.component_exceptions, container, true)
.findViewById(R.id.exceptions_wrapper) .findViewById(R.id.exceptions_wrapper)
override val containerView: View?
get() = container
init { init {
view.exceptions_list.apply { view.exceptions_list.apply {
adapter = ExceptionsAdapter(actionEmitter) adapter = ExceptionsAdapter(interactor)
layoutManager = LinearLayoutManager(container.context) layoutManager = LinearLayoutManager(container.context)
} }
val descriptionText = String val descriptionText = String
@ -48,7 +66,7 @@ class ExceptionsUIView(
val linkStartIndex = descriptionText.indexOf("\n\n") + 2 val linkStartIndex = descriptionText.indexOf("\n\n") + 2
val linkAction = object : ClickableSpan() { val linkAction = object : ClickableSpan() {
override fun onClick(widget: View?) { override fun onClick(widget: View?) {
actionEmitter.onNext(ExceptionsAction.LearnMore) interactor.onLearnMore()
} }
} }
val textWithLink = SpannableString(descriptionText).apply { val textWithLink = SpannableString(descriptionText).apply {
@ -61,9 +79,10 @@ class ExceptionsUIView(
view.exceptions_empty_view.text = textWithLink view.exceptions_empty_view.text = textWithLink
} }
override fun updateView() = Consumer<ExceptionsState> { fun update(state: ExceptionsState) {
view.exceptions_empty_view.visibility = if (it.items.isEmpty()) View.VISIBLE else View.GONE view.exceptions_empty_view.visibility =
view.exceptions_list.visibility = if (it.items.isEmpty()) View.GONE else View.VISIBLE if (state.items.isEmpty()) View.VISIBLE else View.GONE
(view.exceptions_list.adapter as ExceptionsAdapter).updateData(it.items) view.exceptions_list.visibility = if (state.items.isEmpty()) View.GONE else View.VISIBLE
(view.exceptions_list.adapter as ExceptionsAdapter).updateData(state.items)
} }
} }

View File

@ -6,20 +6,19 @@ package org.mozilla.fenix.exceptions.viewholders
import android.view.View import android.view.View
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.delete_exceptions_button.view.* import kotlinx.android.synthetic.main.delete_exceptions_button.view.*
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.exceptions.ExceptionsAction import org.mozilla.fenix.exceptions.ExceptionsInteractor
class ExceptionsDeleteButtonViewHolder( class ExceptionsDeleteButtonViewHolder(
view: View, view: View,
private val actionEmitter: Observer<ExceptionsAction> private val interactor: ExceptionsInteractor
) : RecyclerView.ViewHolder(view) { ) : RecyclerView.ViewHolder(view) {
private val deleteButton = view.removeAllExceptions private val deleteButton = view.removeAllExceptions
init { init {
deleteButton.setOnClickListener { deleteButton.setOnClickListener {
actionEmitter.onNext(ExceptionsAction.Delete.All) interactor.onDeleteAll()
} }
} }

View File

@ -6,7 +6,6 @@ package org.mozilla.fenix.exceptions.viewholders
import android.view.View import android.view.View
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.exception_item.view.* import kotlinx.android.synthetic.main.exception_item.view.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -14,14 +13,14 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.components.browser.icons.IconRequest import mozilla.components.browser.icons.IconRequest
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.exceptions.ExceptionsAction import org.mozilla.fenix.exceptions.ExceptionsInteractor
import org.mozilla.fenix.exceptions.ExceptionsItem import org.mozilla.fenix.exceptions.ExceptionsItem
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
class ExceptionsListItemViewHolder( class ExceptionsListItemViewHolder(
view: View, view: View,
private val actionEmitter: Observer<ExceptionsAction>, private val interactor: ExceptionsInteractor,
val job: Job val job: Job
) : RecyclerView.ViewHolder(view), CoroutineScope { ) : RecyclerView.ViewHolder(view), CoroutineScope {
@ -37,7 +36,7 @@ class ExceptionsListItemViewHolder(
init { init {
deleteButton.setOnClickListener { deleteButton.setOnClickListener {
item?.let { item?.let {
actionEmitter.onNext(ExceptionsAction.Delete.One(it)) interactor.onDeleteOne(it)
} }
} }
} }

View File

@ -0,0 +1,49 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.exceptions
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Test
class ExceptionsInteractorTest {
@Test
fun onLearnMore() {
var learnMoreClicked = false
val interactor = ExceptionsInteractor(
{ learnMoreClicked = true },
mockk(),
mockk()
)
interactor.onLearnMore()
assertEquals(true, learnMoreClicked)
}
@Test
fun onDeleteAll() {
var onDeleteAll = false
val interactor = ExceptionsInteractor(
mockk(),
mockk(),
{ onDeleteAll = true }
)
interactor.onDeleteAll()
assertEquals(true, onDeleteAll)
}
@Test
fun onDeleteOne() {
var exceptionsItemReceived: ExceptionsItem? = null
val exceptionsItem = ExceptionsItem("url")
val interactor = ExceptionsInteractor(
mockk(),
{ exceptionsItemReceived = exceptionsItem },
mockk()
)
interactor.onDeleteOne(exceptionsItem)
assertEquals(exceptionsItemReceived, exceptionsItem)
}
}

View File

@ -0,0 +1,30 @@
/* 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.exceptions
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Test
class ExceptionsStoreTest {
@Test
fun onChange() = runBlocking {
val initialState = emptyDefaultState()
val store = ExceptionsStore(initialState)
val newExceptionsItem = ExceptionsItem("URL")
store.dispatch(ExceptionsAction.Change(listOf(newExceptionsItem))).join()
assertNotSame(initialState, store.state)
assertEquals(
store.state.items,
listOf(newExceptionsItem)
)
}
private fun emptyDefaultState(): ExceptionsState = ExceptionsState(
items = listOf()
)
}