1
0
Fork 0

For #3987 - Convert History to Lib-State and add tests

master
Emily Kager 2019-07-11 14:39:06 -07:00 committed by Emily Kager
parent 9ab67557cf
commit ae3d187909
12 changed files with 648 additions and 409 deletions

View File

@ -10,7 +10,6 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import org.mozilla.fenix.R
import org.mozilla.fenix.library.history.viewholders.HistoryDeleteButtonViewHolder
import org.mozilla.fenix.library.history.viewholders.HistoryHeaderViewHolder
@ -94,9 +93,8 @@ private class HistoryList(val history: List<HistoryItem>) {
}
}
class HistoryAdapter(
private val actionEmitter: Observer<HistoryAction>
) : AdapterWithJob<RecyclerView.ViewHolder>() {
class HistoryAdapter(private val historyInteractor: HistoryInteractor) :
AdapterWithJob<RecyclerView.ViewHolder>() {
private var historyList: HistoryList = HistoryList(emptyList())
private var mode: HistoryState.Mode = HistoryState.Mode.Normal
var selected = listOf<HistoryItem>()
@ -160,9 +158,16 @@ class HistoryAdapter(
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return when (viewType) {
HistoryDeleteButtonViewHolder.LAYOUT_ID -> HistoryDeleteButtonViewHolder(view, actionEmitter)
HistoryDeleteButtonViewHolder.LAYOUT_ID -> HistoryDeleteButtonViewHolder(
view,
historyInteractor
)
HistoryHeaderViewHolder.LAYOUT_ID -> HistoryHeaderViewHolder(view)
HistoryListItemViewHolder.LAYOUT_ID -> HistoryListItemViewHolder(view, actionEmitter, adapterJob)
HistoryListItemViewHolder.LAYOUT_ID -> HistoryListItemViewHolder(
view,
historyInteractor,
adapterJob
)
else -> throw IllegalStateException()
}
}

View File

@ -1,107 +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.library.history
import android.view.ViewGroup
import org.mozilla.fenix.mvi.ViewState
import org.mozilla.fenix.mvi.Change
import org.mozilla.fenix.mvi.Action
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.UIComponent
import org.mozilla.fenix.mvi.UIComponentViewModelBase
import org.mozilla.fenix.mvi.UIComponentViewModelProvider
import org.mozilla.fenix.test.Mockable
data class HistoryItem(val id: Int, val title: String, val url: String, val visitedAt: Long)
@Mockable
class HistoryComponent(
private val container: ViewGroup,
bus: ActionBusFactory,
viewModelProvider: UIComponentViewModelProvider<HistoryState, HistoryChange>
) :
UIComponent<HistoryState, HistoryAction, HistoryChange>(
bus.getManagedEmitter(HistoryAction::class.java),
bus.getSafeManagedObservable(HistoryChange::class.java),
viewModelProvider
) {
override fun initView() = HistoryUIView(container, actionEmitter, changesObservable)
init {
bind()
}
}
data class HistoryState(val items: List<HistoryItem>, val mode: Mode) : ViewState {
sealed class Mode {
object Normal : Mode()
data class Editing(val selectedItems: List<HistoryItem>) : Mode()
object Deleting : Mode()
}
}
sealed class HistoryAction : Action {
data class Open(val item: HistoryItem) : HistoryAction()
data class EnterEditMode(val item: HistoryItem) : HistoryAction()
object BackPressed : HistoryAction()
data class AddItemForRemoval(val item: HistoryItem) : HistoryAction()
data class RemoveItemForRemoval(val item: HistoryItem) : HistoryAction()
object SwitchMode : HistoryAction()
sealed class Delete : HistoryAction() {
object All : Delete()
data class One(val item: HistoryItem) : Delete()
data class Some(val items: List<HistoryItem>) : Delete()
}
}
sealed class HistoryChange : Change {
data class Change(val list: List<HistoryItem>) : HistoryChange()
data class EnterEditMode(val item: HistoryItem) : HistoryChange()
object ExitEditMode : HistoryChange()
data class AddItemForRemoval(val item: HistoryItem) : HistoryChange()
data class RemoveItemForRemoval(val item: HistoryItem) : HistoryChange()
object EnterDeletionMode : HistoryChange()
object ExitDeletionMode : HistoryChange()
}
class HistoryViewModel(
initialState: HistoryState
) : UIComponentViewModelBase<HistoryState, HistoryChange>(initialState, reducer) {
companion object {
fun create() = HistoryViewModel(HistoryState(emptyList(), HistoryState.Mode.Normal))
val reducer: (HistoryState, HistoryChange) -> HistoryState = { state, change ->
when (change) {
is HistoryChange.Change -> state.copy(mode = HistoryState.Mode.Normal, items = change.list)
is HistoryChange.EnterEditMode -> state.copy(mode = HistoryState.Mode.Editing(listOf(change.item)))
is HistoryChange.AddItemForRemoval -> {
val mode = state.mode
if (mode is HistoryState.Mode.Editing) {
val items = mode.selectedItems + listOf(change.item)
state.copy(mode = mode.copy(selectedItems = items))
} else {
state
}
}
is HistoryChange.RemoveItemForRemoval -> {
var mode = state.mode
if (mode is HistoryState.Mode.Editing) {
val items = mode.selectedItems.filter { it.id != change.item.id }
mode = if (items.isEmpty()) HistoryState.Mode.Normal else HistoryState.Mode.Editing(items)
state.copy(mode = mode)
} else {
state
}
}
is HistoryChange.ExitEditMode -> state.copy(mode = HistoryState.Mode.Normal)
is HistoryChange.EnterDeletionMode -> state.copy(mode = HistoryState.Mode.Deleting)
is HistoryChange.ExitDeletionMode -> state.copy(mode = HistoryState.Mode.Normal)
}
}
}
}

View File

@ -19,6 +19,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenStarted
import androidx.navigation.Navigation
import kotlinx.android.synthetic.main.fragment_history.view.*
import kotlinx.coroutines.Dispatchers
@ -26,46 +27,56 @@ import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.concept.storage.VisitType
import mozilla.components.lib.state.ext.observe
import mozilla.components.support.base.feature.BackHandler
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.BrowsingModeManager
import org.mozilla.fenix.FenixViewModelProvider
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getHostFromUrl
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.getAutoDisposeObservable
import org.mozilla.fenix.mvi.getManagedEmitter
import org.mozilla.fenix.share.ShareTab
import java.util.concurrent.TimeUnit
@SuppressWarnings("TooManyFunctions")
class HistoryFragment : Fragment(), BackHandler {
private lateinit var historyComponent: HistoryComponent
private val navigation by lazy { Navigation.findNavController(requireView()) }
private lateinit var historyStore: HistoryStore
private lateinit var historyView: HistoryView
private lateinit var historyInteractor: HistoryInteractor
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater
.inflate(R.layout.fragment_history, container, false).also { view ->
historyComponent = HistoryComponent(
view.history_layout,
ActionBusFactory.get(this),
FenixViewModelProvider.create(
this,
HistoryViewModel::class.java,
HistoryViewModel.Companion::create
): View? {
val view = inflater.inflate(R.layout.fragment_history, container, false)
historyStore = StoreProvider.get(
this,
HistoryStore(
HistoryState(
items = listOf(), mode = HistoryState.Mode.Normal
)
)
}
)
historyInteractor = HistoryInteractor(
historyStore,
::openItem,
::displayDeleteAllDialog,
::invalidateOptionsMenu,
::deleteHistoryItems
)
historyView = HistoryView(view.history_layout, historyInteractor)
return view
}
private fun invalidateOptionsMenu() {
activity?.invalidateOptionsMenu()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -74,16 +85,28 @@ class HistoryFragment : Fragment(), BackHandler {
setHasOptionsMenu(true)
}
fun deleteHistoryItems(items: List<HistoryItem>) {
lifecycleScope.launch {
val storage = context?.components?.core?.historyStorage
for (item in items) {
storage?.deleteVisit(item.url, item.visitedAt)
}
reloadData()
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
lifecycleScope.launch { reloadData() }
}
historyStore.observe(view) {
viewLifecycleOwner.lifecycleScope.launch {
whenStarted {
historyView.update(it)
}
}
}
override fun onStart() {
super.onStart()
getAutoDisposeObservable<HistoryAction>()
.subscribe(this::handleNewHistoryAction)
lifecycleScope.launch { reloadData() }
}
override fun onResume() {
@ -95,7 +118,7 @@ class HistoryFragment : Fragment(), BackHandler {
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
val mode = (historyComponent.uiView as HistoryUIView).mode
val mode = historyStore.state.mode
when (mode) {
HistoryState.Mode.Normal ->
R.menu.library_menu
@ -117,7 +140,8 @@ class HistoryFragment : Fragment(), BackHandler {
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.share_history_multi_select -> {
val selectedHistory = (historyComponent.uiView as HistoryUIView).getSelected()
val selectedHistory =
(historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: listOf()
when {
selectedHistory.size == 1 ->
share(selectedHistory.first().url)
@ -135,7 +159,8 @@ class HistoryFragment : Fragment(), BackHandler {
}
R.id.delete_history_multi_select -> {
val components = context?.applicationContext?.components!!
val selectedHistory = (historyComponent.uiView as HistoryUIView).getSelected()
val selectedHistory =
(historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: listOf()
lifecycleScope.launch(Main) {
deleteSelectedHistory(selectedHistory, components)
@ -144,7 +169,8 @@ class HistoryFragment : Fragment(), BackHandler {
true
}
R.id.open_history_in_new_tabs_multi_select -> {
val selectedHistory = (historyComponent.uiView as HistoryUIView).getSelected()
val selectedHistory =
(historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: listOf()
requireComponents.useCases.tabsUseCases.addTab.let { useCase ->
for (selectedItem in selectedHistory) {
useCase.invoke(selectedItem.url)
@ -155,11 +181,15 @@ class HistoryFragment : Fragment(), BackHandler {
browsingModeManager.mode = BrowsingModeManager.Mode.Normal
supportActionBar?.hide()
}
nav(R.id.historyFragment, HistoryFragmentDirections.actionHistoryFragmentToHomeFragment())
nav(
R.id.historyFragment,
HistoryFragmentDirections.actionHistoryFragmentToHomeFragment()
)
true
}
R.id.open_history_in_private_tabs_multi_select -> {
val selectedHistory = (historyComponent.uiView as HistoryUIView).getSelected()
val selectedHistory =
(historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: listOf()
requireComponents.useCases.tabsUseCases.addPrivateTab.let { useCase ->
for (selectedItem in selectedHistory) {
useCase.invoke(selectedItem.url)
@ -170,47 +200,18 @@ class HistoryFragment : Fragment(), BackHandler {
browsingModeManager.mode = BrowsingModeManager.Mode.Private
supportActionBar?.hide()
}
nav(R.id.historyFragment, HistoryFragmentDirections.actionHistoryFragmentToHomeFragment())
nav(
R.id.historyFragment,
HistoryFragmentDirections.actionHistoryFragmentToHomeFragment()
)
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onBackPressed(): Boolean = (historyComponent.uiView as HistoryUIView).onBackPressed()
override fun onBackPressed(): Boolean = historyView.onBackPressed()
private fun handleNewHistoryAction(action: HistoryAction) {
when (action) {
is HistoryAction.Open ->
openItem(action.item)
is HistoryAction.EnterEditMode ->
emitChange { HistoryChange.EnterEditMode(action.item) }
is HistoryAction.AddItemForRemoval ->
emitChange { HistoryChange.AddItemForRemoval(action.item) }
is HistoryAction.RemoveItemForRemoval ->
emitChange { HistoryChange.RemoveItemForRemoval(action.item) }
is HistoryAction.BackPressed ->
emitChange { HistoryChange.ExitEditMode }
is HistoryAction.Delete.All ->
displayDeleteAllDialog()
is HistoryAction.Delete.One -> lifecycleScope.launch {
requireComponents.core
.historyStorage
.deleteVisit(action.item.url, action.item.visitedAt)
reloadData()
}
is HistoryAction.Delete.Some -> lifecycleScope.launch {
val storage = requireComponents.core.historyStorage
for (item in action.items) {
storage.deleteVisit(item.url, item.visitedAt)
}
reloadData()
}
is HistoryAction.SwitchMode ->
activity?.invalidateOptionsMenu()
}
}
private fun openItem(item: HistoryItem) {
fun openItem(item: HistoryItem) {
requireComponents.analytics.metrics.track(Event.HistoryItemOpened)
(activity as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = item.url,
@ -219,7 +220,7 @@ class HistoryFragment : Fragment(), BackHandler {
)
}
private fun displayDeleteAllDialog() {
fun displayDeleteAllDialog() {
activity?.let { activity ->
AlertDialog.Builder(activity).apply {
setMessage(R.string.history_delete_all_dialog)
@ -227,13 +228,13 @@ class HistoryFragment : Fragment(), BackHandler {
dialog.cancel()
}
setPositiveButton(R.string.history_clear_dialog) { dialog: DialogInterface, _ ->
emitChange { HistoryChange.EnterDeletionMode }
historyStore.dispatch(HistoryAction.EnterDeletionMode)
lifecycleScope.launch {
requireComponents.analytics.metrics.track(Event.HistoryAllItemsRemoved)
requireComponents.core.historyStorage.deleteEverything()
reloadData()
launch(Dispatchers.Main) {
emitChange { HistoryChange.ExitDeletionMode }
historyStore.dispatch(HistoryAction.ExitDeletionMode)
}
}
@ -275,7 +276,7 @@ class HistoryFragment : Fragment(), BackHandler {
.toList()
withContext(Main) {
emitChange { HistoryChange.Change(items) }
historyStore.dispatch(HistoryAction.Change(items))
}
}
@ -300,10 +301,6 @@ class HistoryFragment : Fragment(), BackHandler {
nav(R.id.historyFragment, directions)
}
private inline fun emitChange(producer: () -> HistoryChange) {
getManagedEmitter<HistoryChange>().onNext(producer())
}
companion object {
private const val HISTORY_TIME_DAYS = 3L
}

View File

@ -0,0 +1,53 @@
/* 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.library.history
/**
* Interactor for the history screen
* Provides implementations for the HistoryViewInteractor
*/
class HistoryInteractor(
private val store: HistoryStore,
private val openToBrowser: (item: HistoryItem) -> Unit,
private val displayDeleteAll: () -> Unit,
private val invalidateOptionsMenu: () -> Unit,
private val deleteHistoryItems: (List<HistoryItem>) -> Unit
) : HistoryViewInteractor {
override fun onHistoryItemOpened(item: HistoryItem) {
openToBrowser(item)
}
override fun onEnterEditMode(selectedItem: HistoryItem) {
store.dispatch(HistoryAction.EnterEditMode(selectedItem))
}
override fun onBackPressed() {
store.dispatch(HistoryAction.ExitEditMode)
}
override fun onItemAddedForRemoval(item: HistoryItem) {
store.dispatch(HistoryAction.AddItemForRemoval(item))
}
override fun onItemRemovedForRemoval(item: HistoryItem) {
store.dispatch(HistoryAction.RemoveItemForRemoval(item))
}
override fun onModeSwitched() {
invalidateOptionsMenu.invoke()
}
override fun onDeleteAll() {
displayDeleteAll.invoke()
}
override fun onDeleteOne(item: HistoryItem) {
deleteHistoryItems.invoke(listOf(item))
}
override fun onDeleteSome(items: List<HistoryItem>) {
deleteHistoryItems.invoke(items)
}
}

View File

@ -0,0 +1,88 @@
/* 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.library.history
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
/**
* Class representing a history entry
* @property id Unique id of the history item
* @property title Title of the history item
* @property url URL of the history item
* @property visitedAt Timestamp of when this history item was visited
*/
data class HistoryItem(val id: Int, val title: String, val url: String, val visitedAt: Long)
/**
* The [Store] for holding the [HistoryState] and applying [HistoryAction]s.
*/
class HistoryStore(initialState: HistoryState) :
Store<HistoryState, HistoryAction>(initialState, ::historyStateReducer)
/**
* Actions to dispatch through the `HistoryStore` to modify `HistoryState` through the reducer.
*/
sealed class HistoryAction : Action {
data class Change(val list: List<HistoryItem>) : HistoryAction()
data class EnterEditMode(val item: HistoryItem) : HistoryAction()
object ExitEditMode : HistoryAction()
data class AddItemForRemoval(val item: HistoryItem) : HistoryAction()
data class RemoveItemForRemoval(val item: HistoryItem) : HistoryAction()
object EnterDeletionMode : HistoryAction()
object ExitDeletionMode : HistoryAction()
}
/**
* The state for the History Screen
* @property items List of HistoryItem to display
* @property mode Current Mode of History
*/
data class HistoryState(val items: List<HistoryItem>, val mode: Mode) : State {
sealed class Mode {
object Normal : Mode()
data class Editing(val selectedItems: List<HistoryItem>) : Mode()
object Deleting : Mode()
}
}
/**
* The HistoryState Reducer.
*/
fun historyStateReducer(state: HistoryState, action: HistoryAction): HistoryState {
return when (action) {
is HistoryAction.Change -> state.copy(mode = HistoryState.Mode.Normal, items = action.list)
is HistoryAction.EnterEditMode -> state.copy(
mode = HistoryState.Mode.Editing(listOf(action.item))
)
is HistoryAction.AddItemForRemoval -> {
val mode = state.mode
if (mode is HistoryState.Mode.Editing) {
val items = mode.selectedItems + listOf(action.item)
state.copy(mode = HistoryState.Mode.Editing(items))
} else {
state
}
}
is HistoryAction.RemoveItemForRemoval -> {
var mode = state.mode
if (mode is HistoryState.Mode.Editing) {
val items = mode.selectedItems.filter { it.id != action.item.id }
mode = if (items.isEmpty()) HistoryState.Mode.Normal else HistoryState.Mode.Editing(
items
)
state.copy(mode = mode)
} else {
state
}
}
is HistoryAction.ExitEditMode -> state.copy(mode = HistoryState.Mode.Normal)
is HistoryAction.EnterDeletionMode -> state.copy(mode = HistoryState.Mode.Deleting)
is HistoryAction.ExitDeletionMode -> state.copy(mode = HistoryState.Mode.Normal)
}
}

View File

@ -1,102 +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.library.history
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import io.reactivex.Observable
import io.reactivex.Observer
import io.reactivex.functions.Consumer
import kotlinx.android.synthetic.main.component_history.*
import kotlinx.android.synthetic.main.component_history.view.*
import kotlinx.android.synthetic.main.delete_history_button.*
import mozilla.components.support.base.feature.BackHandler
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.getColorIntFromAttr
import org.mozilla.fenix.library.LibraryPageUIView
class HistoryUIView(
container: ViewGroup,
actionEmitter: Observer<HistoryAction>,
changesObservable: Observable<HistoryChange>
) :
LibraryPageUIView<HistoryState, HistoryAction, HistoryChange>(container, actionEmitter, changesObservable),
BackHandler {
var mode: HistoryState.Mode = HistoryState.Mode.Normal
private set
private val historyAdapter: HistoryAdapter
private var items: List<HistoryItem> = listOf()
fun getSelected(): List<HistoryItem> = historyAdapter.selected
override val view: ConstraintLayout = LayoutInflater.from(container.context)
.inflate(R.layout.component_history, container, true)
.findViewById(R.id.history_wrapper)
init {
view.history_list.apply {
historyAdapter = HistoryAdapter(actionEmitter)
adapter = historyAdapter
layoutManager = LinearLayoutManager(container.context)
}
}
override fun updateView() = Consumer<HistoryState> {
view.progress_bar.visibility = if (it.mode is HistoryState.Mode.Deleting) View.VISIBLE else View.GONE
if (it.mode != mode) {
mode = it.mode
actionEmitter.onNext(HistoryAction.SwitchMode)
}
(view.history_list.adapter as HistoryAdapter).updateData(it.items, it.mode)
items = it.items
when (val modeCopy = mode) {
is HistoryState.Mode.Normal -> setUIForNormalMode(items.isEmpty())
is HistoryState.Mode.Editing -> setUIForSelectingMode(modeCopy)
}
}
private fun setUIForSelectingMode(
mode: HistoryState.Mode.Editing
) {
activity?.title =
context.getString(R.string.history_multi_select_title, mode.selectedItems.size)
setToolbarColors(
R.color.white_color,
R.attr.accentHighContrast.getColorIntFromAttr(context!!)
)
}
private fun setUIForNormalMode(isEmpty: Boolean) {
activity?.title = context.getString(R.string.library_history)
delete_history_button?.isVisible = !isEmpty
history_empty_view.isVisible = isEmpty
setToolbarColors(
R.attr.primaryText.getColorIntFromAttr(context!!),
R.attr.foundation.getColorIntFromAttr(context)
)
}
override fun onBackPressed(): Boolean {
return when (mode) {
is HistoryState.Mode.Editing -> {
mode = HistoryState.Mode.Normal
historyAdapter.updateData(items, mode)
setUIForNormalMode(items.isEmpty())
actionEmitter.onNext(HistoryAction.BackPressed)
true
}
else -> false
}
}
}

View File

@ -0,0 +1,200 @@
/* 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.library.history
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_history.*
import kotlinx.android.synthetic.main.component_history.view.*
import kotlinx.android.synthetic.main.delete_history_button.*
import mozilla.components.support.base.feature.BackHandler
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.asActivity
import org.mozilla.fenix.ext.getColorIntFromAttr
/**
* Interface for the HistoryViewInteractor. This interface is implemented by objects that want
* to respond to user interaction on the HistoryView
*/
interface HistoryViewInteractor {
/**
* Called whenever a history item is tapped to open that history entry in the browser
* @param item the history item to open in browser
*/
fun onHistoryItemOpened(item: HistoryItem)
/**
* Called when a history item is long pressed and edit mode is launched
* @param selectedItem the history item to start selected for deletion in edit mode
*/
fun onEnterEditMode(selectedItem: HistoryItem)
/**
* Called on backpressed to exit edit mode
*/
fun onBackPressed()
/**
* Called when a history item is tapped in edit mode and added for removal
* @param item the history item to add to selected items for deletion in edit mode
*/
fun onItemAddedForRemoval(item: HistoryItem)
/**
* Called when a selected history item is tapped in edit mode and removed from removal
* @param item the history item to remove from the selected items for deletion in edit mode
*/
fun onItemRemovedForRemoval(item: HistoryItem)
/**
* Called when the mode is switched so we can invalidate the menu
*/
fun onModeSwitched()
/**
* Called when delete all is tapped
*/
fun onDeleteAll()
/**
* Called when one history item is deleted
* @param item the history item to delete
*/
fun onDeleteOne(item: HistoryItem)
/**
* Called when multiple history items are deleted
* @param items the history items to delete
*/
fun onDeleteSome(items: List<HistoryItem>)
}
/**
* View that contains and configures the History List
*/
class HistoryView(
private val container: ViewGroup,
val interactor: HistoryInteractor
) : LayoutContainer, BackHandler {
val view: ConstraintLayout = LayoutInflater.from(container.context)
.inflate(R.layout.component_history, container, true)
.findViewById(R.id.history_wrapper)
override val containerView: View?
get() = container
private val historyAdapter: HistoryAdapter
private var items: List<HistoryItem> = listOf()
private val context = container.context
var mode: HistoryState.Mode = HistoryState.Mode.Normal
private set
private val activity = context?.asActivity()
init {
view.history_list.apply {
historyAdapter = HistoryAdapter(interactor)
adapter = historyAdapter
layoutManager = LinearLayoutManager(container.context)
}
}
fun update(state: HistoryState) {
view.progress_bar.visibility =
if (state.mode is HistoryState.Mode.Deleting) View.VISIBLE else View.GONE
if (state.mode != mode) {
mode = state.mode
interactor.onModeSwitched()
}
(view.history_list.adapter as HistoryAdapter).updateData(state.items, state.mode)
items = state.items
when (val mode = mode) {
is HistoryState.Mode.Normal -> setUIForNormalMode(items.isEmpty())
is HistoryState.Mode.Editing -> setUIForSelectingMode(mode.selectedItems.size)
}
}
private fun setUIForSelectingMode(selectedItemSize: Int) {
activity?.title =
context.getString(R.string.history_multi_select_title, selectedItemSize)
setToolbarColors(
R.color.white_color,
R.attr.accentHighContrast.getColorIntFromAttr(context!!)
)
}
private fun setUIForNormalMode(isEmpty: Boolean) {
activity?.title = context.getString(R.string.library_history)
delete_history_button?.isVisible = !isEmpty
history_empty_view.isVisible = isEmpty
setToolbarColors(
R.attr.primaryText.getColorIntFromAttr(context!!),
R.attr.foundation.getColorIntFromAttr(context)
)
}
private fun setToolbarColors(foreground: Int, background: Int) {
val toolbar = (activity as AppCompatActivity).findViewById<Toolbar>(R.id.navigationToolbar)
val colorFilter = PorterDuffColorFilter(
ContextCompat.getColor(context, foreground),
PorterDuff.Mode.SRC_IN
)
toolbar.setBackgroundColor(ContextCompat.getColor(context, background))
toolbar.setTitleTextColor(ContextCompat.getColor(context, foreground))
themeToolbar(
toolbar, foreground,
background, colorFilter
)
}
private fun themeToolbar(
toolbar: Toolbar,
textColor: Int,
backgroundColor: Int,
colorFilter: PorterDuffColorFilter? = null
) {
toolbar.setTitleTextColor(ContextCompat.getColor(context!!, textColor))
toolbar.setBackgroundColor(ContextCompat.getColor(context, backgroundColor))
if (colorFilter == null) {
return
}
toolbar.overflowIcon?.colorFilter = colorFilter
(0 until toolbar.childCount).forEach {
when (val item = toolbar.getChildAt(it)) {
is ImageButton -> item.drawable.colorFilter = colorFilter
}
}
}
override fun onBackPressed(): Boolean {
return when (mode) {
is HistoryState.Mode.Editing -> {
mode = HistoryState.Mode.Normal
historyAdapter.updateData(items, mode)
setUIForNormalMode(items.isEmpty())
interactor.onBackPressed()
true
}
else -> false
}
}
}

View File

@ -6,15 +6,14 @@ package org.mozilla.fenix.library.history.viewholders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.delete_history_button.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.library.history.HistoryAction
import org.mozilla.fenix.library.history.HistoryInteractor
import org.mozilla.fenix.library.history.HistoryState
class HistoryDeleteButtonViewHolder(
view: View,
private val actionEmitter: Observer<HistoryAction>
historyInteractor: HistoryInteractor
) : RecyclerView.ViewHolder(view) {
private var mode: HistoryState.Mode? = null
private val buttonView = view.delete_history_button
@ -22,13 +21,10 @@ class HistoryDeleteButtonViewHolder(
init {
buttonView.setOnClickListener {
mode?.also {
val action = when (it) {
is HistoryState.Mode.Normal -> HistoryAction.Delete.All
is HistoryState.Mode.Editing -> HistoryAction.Delete.Some(it.selectedItems)
is HistoryState.Mode.Deleting -> null
} ?: return@also
actionEmitter.onNext(action)
when (it) {
is HistoryState.Mode.Normal -> historyInteractor.onDeleteAll()
is HistoryState.Mode.Editing -> historyInteractor.onDeleteSome(it.selectedItems)
}
}
}
}

View File

@ -8,7 +8,6 @@ import android.view.View
import android.widget.CompoundButton
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.history_list_item.view.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -19,7 +18,7 @@ import mozilla.components.browser.menu.BrowserMenu
import org.mozilla.fenix.R
import org.mozilla.fenix.ThemeManager
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.library.history.HistoryAction
import org.mozilla.fenix.library.history.HistoryInteractor
import org.mozilla.fenix.library.history.HistoryItem
import org.mozilla.fenix.library.history.HistoryItemMenu
import org.mozilla.fenix.library.history.HistoryState
@ -27,7 +26,7 @@ import kotlin.coroutines.CoroutineContext
class HistoryListItemViewHolder(
view: View,
private val actionEmitter: Observer<HistoryAction>,
private val historyInteractor: HistoryInteractor,
val job: Job
) : RecyclerView.ViewHolder(view), CoroutineScope {
@ -48,13 +47,11 @@ class HistoryListItemViewHolder(
}
item?.apply {
val action = if (isChecked) {
HistoryAction.AddItemForRemoval(this)
if (isChecked) {
historyInteractor.onItemAddedForRemoval(this)
} else {
HistoryAction.RemoveItemForRemoval(this)
historyInteractor.onItemRemovedForRemoval(this)
}
actionEmitter.onNext(action)
}
}
@ -63,7 +60,7 @@ class HistoryListItemViewHolder(
view.setOnLongClickListener {
item?.apply {
actionEmitter.onNext(HistoryAction.EnterEditMode(this))
historyInteractor.onEnterEditMode(this)
}
true
@ -72,7 +69,8 @@ class HistoryListItemViewHolder(
menuButton.setOnClickListener {
historyMenu.menuBuilder.build(view.context).show(
anchor = it,
orientation = BrowserMenu.Orientation.DOWN)
orientation = BrowserMenu.Orientation.DOWN
)
}
}
@ -97,7 +95,8 @@ class HistoryListItemViewHolder(
} else {
ThemeManager.resolveAttribute(R.attr.neutral, itemView.context)
}
val backgroundTintList = ContextCompat.getColorStateList(itemView.context, backgroundTint)
val backgroundTintList =
ContextCompat.getColorStateList(itemView.context, backgroundTint)
favicon.backgroundTintList = backgroundTintList
if (selected) {
@ -107,7 +106,8 @@ class HistoryListItemViewHolder(
}
} else {
val backgroundTint = ThemeManager.resolveAttribute(R.attr.neutral, itemView.context)
val backgroundTintList = ContextCompat.getColorStateList(itemView.context, backgroundTint)
val backgroundTintList =
ContextCompat.getColorStateList(itemView.context, backgroundTint)
favicon.backgroundTintList = backgroundTintList
updateFavIcon(item.url)
}
@ -117,7 +117,7 @@ class HistoryListItemViewHolder(
this.historyMenu = HistoryItemMenu(itemView.context) {
when (it) {
is HistoryItemMenu.Item.Delete -> {
item?.apply { actionEmitter.onNext(HistoryAction.Delete.One(this)) }
item?.apply { historyInteractor.onDeleteOne(this) }
}
}
}
@ -139,11 +139,13 @@ class HistoryListItemViewHolder(
) {
itemView.history_layout.setOnClickListener {
if (mode == HistoryState.Mode.Normal) {
actionEmitter.onNext(HistoryAction.Open(item))
historyInteractor.onHistoryItemOpened(item)
} else {
if (selected) actionEmitter.onNext(HistoryAction.RemoveItemForRemoval(item)) else actionEmitter.onNext(
HistoryAction.AddItemForRemoval(item)
)
if (selected) {
historyInteractor.onItemRemovedForRemoval(item)
} else {
historyInteractor.onItemAddedForRemoval(item)
}
}
}
}

View File

@ -0,0 +1,129 @@
/* 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.library.history
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Test
class HistoryInteractorTest {
@Test
fun onHistoryItemOpened() {
var historyItemReceived: HistoryItem? = null
val historyItem = HistoryItem(0, "title", "url", 0.toLong())
val interactor = HistoryInteractor(
mockk(),
{ historyItemReceived = it },
mockk(),
mockk(),
mockk()
)
interactor.onHistoryItemOpened(historyItem)
assertEquals(historyItem, historyItemReceived)
}
@Test
fun onEnterEditMode() {
val store: HistoryStore = mockk(relaxed = true)
val newHistoryItem: HistoryItem = mockk(relaxed = true)
val interactor =
HistoryInteractor(store, mockk(), mockk(), mockk(), mockk())
interactor.onEnterEditMode(newHistoryItem)
verify { store.dispatch(HistoryAction.EnterEditMode(newHistoryItem)) }
}
@Test
fun onBackPressed() {
val store: HistoryStore = mockk(relaxed = true)
val interactor =
HistoryInteractor(store, mockk(), mockk(), mockk(), mockk())
interactor.onBackPressed()
verify { store.dispatch(HistoryAction.ExitEditMode) }
}
@Test
fun onItemAddedForRemoval() {
val store: HistoryStore = mockk(relaxed = true)
val newHistoryItem: HistoryItem = mockk(relaxed = true)
val interactor =
HistoryInteractor(store, mockk(), mockk(), mockk(), mockk())
interactor.onItemAddedForRemoval(newHistoryItem)
verify { store.dispatch(HistoryAction.AddItemForRemoval(newHistoryItem)) }
}
@Test
fun onItemRemovedForRemoval() {
val store: HistoryStore = mockk(relaxed = true)
val newHistoryItem: HistoryItem = mockk(relaxed = true)
val interactor =
HistoryInteractor(store, mockk(), mockk(), mockk(), mockk())
interactor.onItemRemovedForRemoval(newHistoryItem)
verify { store.dispatch(HistoryAction.RemoveItemForRemoval(newHistoryItem)) }
}
@Test
fun onModeSwitched() {
var menuInvalidated = false
val interactor = HistoryInteractor(
mockk(),
mockk(),
mockk(),
{ menuInvalidated = true },
mockk()
)
interactor.onModeSwitched()
assertEquals(true, menuInvalidated)
}
@Test
fun onDeleteAll() {
var deleteAllDialogShown = false
val interactor = HistoryInteractor(
mockk(),
mockk(),
{ deleteAllDialogShown = true },
mockk(),
mockk()
)
interactor.onDeleteAll()
assertEquals(true, deleteAllDialogShown)
}
@Test
fun onDeleteOne() {
var itemsToDelete: List<HistoryItem>? = null
val historyItem = HistoryItem(0, "title", "url", 0.toLong())
val interactor =
HistoryInteractor(
mockk(),
mockk(),
mockk(),
mockk(),
{ itemsToDelete = it }
)
interactor.onDeleteOne(historyItem)
assertEquals(itemsToDelete, listOf(historyItem))
}
@Test
fun onDeleteSome() {
var itemsToDelete: List<HistoryItem>? = null
val historyItem = HistoryItem(0, "title", "url", 0.toLong())
val newHistoryItem = HistoryItem(1, "title", "url", 0.toLong())
val interactor =
HistoryInteractor(
mockk(),
mockk(),
mockk(),
mockk(),
{ itemsToDelete = it }
)
interactor.onDeleteSome(listOf(historyItem, newHistoryItem))
assertEquals(itemsToDelete, listOf(historyItem, newHistoryItem))
}
}

View File

@ -0,0 +1,73 @@
/* 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.library.history
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Test
class HistoryStoreTest {
private val historyItem = HistoryItem(0, "title", "url", 0.toLong())
private val newHistoryItem = HistoryItem(1, "title", "url", 0.toLong())
@Test
fun enterEditMode() = runBlocking {
val initialState = emptyDefaultState()
val store = HistoryStore(initialState)
store.dispatch(HistoryAction.EnterEditMode(historyItem)).join()
assertNotSame(initialState, store.state)
assertEquals(store.state.mode, HistoryState.Mode.Editing(listOf(historyItem)))
}
@Test
fun exitEditMode() = runBlocking {
val initialState = oneItemEditState()
val store = HistoryStore(initialState)
store.dispatch(HistoryAction.ExitEditMode).join()
assertNotSame(initialState, store.state)
assertEquals(store.state.mode, HistoryState.Mode.Normal)
}
@Test
fun itemAddedForRemoval() = runBlocking {
val initialState = oneItemEditState()
val store = HistoryStore(initialState)
store.dispatch(HistoryAction.AddItemForRemoval(newHistoryItem)).join()
assertNotSame(initialState, store.state)
assertEquals(
store.state.mode,
HistoryState.Mode.Editing(listOf(historyItem, newHistoryItem))
)
}
@Test
fun removeItemForRemoval() = runBlocking {
val initialState = twoItemEditState()
val store = HistoryStore(initialState)
store.dispatch(HistoryAction.RemoveItemForRemoval(newHistoryItem)).join()
assertNotSame(initialState, store.state)
assertEquals(store.state.mode, HistoryState.Mode.Editing(listOf(historyItem)))
}
private fun emptyDefaultState(): HistoryState = HistoryState(
items = listOf(),
mode = HistoryState.Mode.Normal
)
private fun oneItemEditState(): HistoryState = HistoryState(
items = listOf(),
mode = HistoryState.Mode.Editing(listOf(historyItem))
)
private fun twoItemEditState(): HistoryState = HistoryState(
items = listOf(),
mode = HistoryState.Mode.Editing(listOf(historyItem, newHistoryItem))
)
}

View File

@ -1,95 +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.library.history
import io.mockk.MockKAnnotations
import io.reactivex.Observer
import io.reactivex.observers.TestObserver
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.TestUtils.bus
import org.mozilla.fenix.TestUtils.owner
import org.mozilla.fenix.TestUtils.setRxSchedulers
import org.mozilla.fenix.mvi.getManagedEmitter
class HistoryViewModelTest {
private lateinit var historyViewModel: HistoryViewModel
private lateinit var historyObserver: TestObserver<HistoryState>
private lateinit var emitter: Observer<HistoryChange>
@Before
fun setup() {
MockKAnnotations.init(this)
setRxSchedulers()
historyViewModel = HistoryViewModel.create()
historyObserver = historyViewModel.state.test()
bus.getSafeManagedObservable(HistoryChange::class.java)
.subscribe(historyViewModel.changes::onNext)
emitter = owner.getManagedEmitter()
}
@Test
fun `select two items for removal, then deselect one, then select it again`() {
val historyItem = HistoryItem(1, "Mozilla", "http://mozilla.org", 0)
val historyItem2 = HistoryItem(2, "Mozilla", "http://mozilla.org", 0)
emitter.onNext(HistoryChange.Change(listOf(historyItem, historyItem2)))
emitter.onNext(HistoryChange.EnterEditMode(historyItem))
emitter.onNext(HistoryChange.AddItemForRemoval(historyItem2))
emitter.onNext(HistoryChange.RemoveItemForRemoval(historyItem))
emitter.onNext(HistoryChange.AddItemForRemoval(historyItem))
emitter.onNext(HistoryChange.ExitEditMode)
historyObserver.assertSubscribed().awaitCount(7).assertNoErrors()
.assertValues(
HistoryState(listOf(), HistoryState.Mode.Normal),
HistoryState(listOf(historyItem, historyItem2), HistoryState.Mode.Normal),
HistoryState(listOf(historyItem, historyItem2), HistoryState.Mode.Editing(listOf(historyItem))),
HistoryState(listOf(historyItem, historyItem2), HistoryState.Mode.Editing(listOf(historyItem, historyItem2))),
HistoryState(listOf(historyItem, historyItem2), HistoryState.Mode.Editing(listOf(historyItem2))),
HistoryState(listOf(historyItem, historyItem2), HistoryState.Mode.Editing(listOf(historyItem2, historyItem))),
HistoryState(listOf(historyItem, historyItem2), HistoryState.Mode.Normal)
)
}
@Test
fun `deselecting all items triggers normal mode`() {
val historyItem = HistoryItem(123, "Mozilla", "http://mozilla.org", 0)
emitter.onNext(HistoryChange.Change(listOf(historyItem)))
emitter.onNext(HistoryChange.EnterEditMode(historyItem))
emitter.onNext(HistoryChange.RemoveItemForRemoval(historyItem))
historyObserver.assertSubscribed().awaitCount(6).assertNoErrors()
.assertValues(
HistoryState(listOf(), HistoryState.Mode.Normal),
HistoryState(listOf(historyItem), HistoryState.Mode.Normal),
HistoryState(listOf(historyItem), HistoryState.Mode.Editing(listOf(historyItem))),
HistoryState(listOf(historyItem), HistoryState.Mode.Normal)
)
}
@Test
fun `try making changes when not in edit mode`() {
val historyItems = listOf(
HistoryItem(1337, "Reddit", "http://reddit.com", 0),
HistoryItem(31337, "Haxor", "http://leethaxor.com", 0)
)
emitter.onNext(HistoryChange.Change(historyItems))
emitter.onNext(HistoryChange.AddItemForRemoval(historyItems[0]))
emitter.onNext(HistoryChange.EnterEditMode(historyItems[0]))
emitter.onNext(HistoryChange.ExitEditMode)
historyObserver.assertSubscribed().awaitCount(4).assertNoErrors()
.assertValues(
HistoryState(listOf(), HistoryState.Mode.Normal),
HistoryState(historyItems, HistoryState.Mode.Normal),
HistoryState(historyItems, HistoryState.Mode.Editing(listOf(historyItems[0]))),
HistoryState(historyItems, HistoryState.Mode.Normal)
)
}
}