1
0
Fork 0

#4596 migrate collections (#5911)

* For #4596: move code from CollectionCreationComponent to CollectionCreationStore

Other than adding comments, no changes were made. The code will be updated in a following commit. This is in order to make the commit diff more readable.

* For 4596: update CollectionCreateStore to libstate

* For 4596: copied CollectionCreationUIView into CollectionCreationView

Otherwise, no code was changed. The next commit will update this code. This is in order to make the commit diff more readable.

* For 4596: update CollectionCreationView to LibState

Note that the minimal changes possible to enable migration were made.  Refactoring will happen in a later commit.

* For 4596: updated CollectionCreationTabListAdapter to work with the new View

* For 4596: updated SaveCollectionListAdapter to work with the new View

* For 4596: implemented CollectionCreationController

For now, it has an identical interface to the interactor. In a later commit several of its responsibilities will be moved around, some to the interactor and some to the reducer

* For 4596: copied over previous reducer code

No other changes were made. The code will be updated in the following commit. This is done to make changes more readable for the reviewer

* For 4596: update reducer code param names

Otherwise, no changes at this time

* For 4596: add arguments to CreateCollectionFragment in nav_graph

These will be used to replace the current CreateCollectionViewModel, which shares data between fragments in a way that doesn't fit within our architecture.

* For 4596: pass arguments to collection via transaction instead of VM

The VM will be removed in a later commit

* For 4596: update BrowserToolbarController to share state to collection via its Direction

* For 4596: removed CreateCollectionViewModel

* For 4596: test tab retrieval in CreateCollectionFragment

* For 4596: fix crashing CreateCollectionFragmentTest

* For 4596: removed classes create collection classes used by old architecture

* For 4596: collection interactor rename + kdoc

* For 4596: moved collection interactor interface

* For 4596: renamed CreateCollectionFragment

All related classes followed the pattern of CollectionCreationX

* For 4596: kdoc CollectionCreationController

There's no effective difference between these calls and their interactor equivalent, so I linked to them

* For 4596: fix bug that caused rename to not work

* For 4596: removed unused collection actions

These were unused before the LibState refactor

* For 4596: kdoc StepChanged

* For 4596: removed todos about moving logic to the reducer

saveTabsToCollection: this could be moved, but that would involve creating a new action. SaveCollectionStep should probably be refactored out, so adding this layer of indirection seemed counterproductive

handleBackPress: needs to be able to call dismiss(). The reducer doesn't (and shouldn't) be able to do that, so this needs to live here

stepBack: called by handleBackPress. See above

* For 4596: wrote tests for CollectionCreationController#stepback

* For 4596: fixed tests broken by changes to collections

* For 4596: small readability refactor for CollectionController#stepBack

No change to functionality (see tests)

* For 4596: broke apart CollectionView#update

There's probably a lot more that could be done here, but smaller changes were made to reduce scope

* For 4596: remove unnecessary todos

It looks like we don't follow the suggested pattern in this project

* For 4596: test CollectionCreationController#normalSessionSize

* For 4596: updated naming in CollectionCreationController per review
master
Severin Rudie 2019-10-22 17:33:54 -07:00 committed by GitHub
parent 40cda1d758
commit aa8642f534
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1158 additions and 944 deletions

View File

@ -17,8 +17,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavDirections
import androidx.navigation.fragment.findNavController
@ -60,7 +58,6 @@ import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.IntentReceiverActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.collections.CreateCollectionViewModel
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.FindInPageIntegration
import org.mozilla.fenix.components.StoreProvider
@ -115,10 +112,6 @@ abstract class BaseBrowserFragment : Fragment(), BackHandler, SessionManager.Obs
private var browserInitialized: Boolean = false
private var initUIJob: Job? = null
val viewModel: CreateCollectionViewModel by activityViewModels {
ViewModelProvider.NewInstanceFactory() // this is a workaround for #4652
}
@CallSuper
override fun onCreateView(
inflater: LayoutInflater,
@ -185,7 +178,6 @@ abstract class BaseBrowserFragment : Fragment(), BackHandler, SessionManager.Obs
swipeRefresh = swipeRefresh,
adjustBackgroundAndNavigate = ::adjustBackgroundAndNavigate,
customTabSession = customTabSessionId?.let { sessionManager.findSessionById(it) },
viewModel = viewModel,
getSupportUrl = {
SupportUtils.getSumoURLForTopic(
context,

View File

@ -1,98 +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.collections
import android.view.ViewGroup
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.TabCollection
import org.mozilla.fenix.mvi.Action
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.Change
import org.mozilla.fenix.mvi.Reducer
import org.mozilla.fenix.mvi.UIComponent
import org.mozilla.fenix.mvi.UIComponentViewModelBase
import org.mozilla.fenix.mvi.UIComponentViewModelProvider
import org.mozilla.fenix.mvi.ViewState
enum class SaveCollectionStep {
SelectTabs,
SelectCollection,
NameCollection,
RenameCollection
}
data class CollectionCreationState(
val tabs: List<Tab> = emptyList(),
val selectedTabs: Set<Tab> = emptySet(),
val saveCollectionStep: SaveCollectionStep = SaveCollectionStep.SelectTabs,
val tabCollections: List<TabCollection> = emptyList(),
val selectedTabCollection: TabCollection? = null
) : ViewState
sealed class CollectionCreationChange : Change {
data class TabListChange(val tabs: List<Tab>) : CollectionCreationChange()
object AddAllTabs : CollectionCreationChange()
object RemoveAllTabs : CollectionCreationChange()
data class TabAdded(val tab: Tab) : CollectionCreationChange()
data class TabRemoved(val tab: Tab) : CollectionCreationChange()
data class StepChanged(val saveCollectionStep: SaveCollectionStep) : CollectionCreationChange()
data class CollectionSelected(val collection: TabCollection) : CollectionCreationChange()
}
sealed class CollectionCreationAction : Action {
object Close : CollectionCreationAction()
object SelectAllTapped : CollectionCreationAction()
object DeselectAllTapped : CollectionCreationAction()
object AddNewCollection : CollectionCreationAction()
data class AddTabToSelection(val tab: Tab) : CollectionCreationAction()
data class RemoveTabFromSelection(val tab: Tab) : CollectionCreationAction()
data class SaveTabsToCollection(val tabs: List<Tab>) : CollectionCreationAction()
data class BackPressed(val backPressFrom: SaveCollectionStep) : CollectionCreationAction()
data class SaveCollectionName(val tabs: List<Tab>, val name: String) :
CollectionCreationAction()
data class RenameCollection(val collection: TabCollection, val name: String) :
CollectionCreationAction()
data class SelectCollection(val collection: TabCollection, val tabs: List<Tab>) :
CollectionCreationAction()
}
class CollectionCreationComponent(
private val container: ViewGroup,
bus: ActionBusFactory,
viewModelProvider: UIComponentViewModelProvider<CollectionCreationState, CollectionCreationChange>
) : UIComponent<CollectionCreationState, CollectionCreationAction, CollectionCreationChange>(
bus.getManagedEmitter(CollectionCreationAction::class.java),
bus.getSafeManagedObservable(CollectionCreationChange::class.java),
viewModelProvider
) {
override fun initView() = CollectionCreationUIView(container, actionEmitter, changesObservable)
init {
bind()
}
}
class CollectionCreationViewModel(
initialState: CollectionCreationState
) :
UIComponentViewModelBase<CollectionCreationState, CollectionCreationChange>(
initialState,
reducer
) {
companion object {
val reducer: Reducer<CollectionCreationState, CollectionCreationChange> = { state, change ->
when (change) {
is CollectionCreationChange.AddAllTabs -> state.copy(selectedTabs = state.tabs.toSet())
is CollectionCreationChange.RemoveAllTabs -> state.copy(selectedTabs = emptySet())
is CollectionCreationChange.TabListChange -> state.copy(tabs = change.tabs)
is CollectionCreationChange.TabAdded -> state.copy(selectedTabs = state.selectedTabs + change.tab)
is CollectionCreationChange.TabRemoved -> state.copy(selectedTabs = state.selectedTabs - change.tab)
is CollectionCreationChange.StepChanged -> state.copy(saveCollectionStep = change.saveCollectionStep)
is CollectionCreationChange.CollectionSelected -> state.copy(selectedTabCollection = change.collection)
}
}
}
}

View File

@ -0,0 +1,202 @@
/* 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/. */
@file:Suppress("TooManyFunctions")
package org.mozilla.fenix.collections
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mozilla.components.browser.session.SessionManager
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.tabs.TabsUseCases
import org.mozilla.fenix.R
import org.mozilla.fenix.components.Analytics
import org.mozilla.fenix.components.TabCollectionStorage
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.toSessionBundle
interface CollectionCreationController {
fun saveCollectionName(tabs: List<Tab>, name: String)
fun renameCollection(collection: TabCollection, name: String)
/**
* See [CollectionCreationInteractor.onBackPressed]
*/
fun backPressed(fromStep: SaveCollectionStep)
/**
* See [CollectionCreationInteractor.selectAllTapped]
*/
fun selectAllTabs()
/**
* See [CollectionCreationInteractor.deselectAllTapped]
*/
fun deselectAllTabs()
/**
* See [CollectionCreationInteractor.close]
*/
fun close()
fun selectCollection(collection: TabCollection, tabs: List<Tab>)
/**
* See [CollectionCreationInteractor.saveTabsToCollection]
*/
fun saveTabsToCollection(tabs: List<Tab>)
fun addNewCollection()
fun addTabToSelection(tab: Tab)
fun removeTabFromSelection(tab: Tab)
}
class DefaultCollectionCreationController(
private val store: CollectionCreationStore,
private val dismiss: () -> Unit,
private val analytics: Analytics,
private val tabCollectionStorage: TabCollectionStorage,
private val tabsUseCases: TabsUseCases,
private val sessionManager: SessionManager,
private val lifecycleScope: CoroutineScope
) : CollectionCreationController {
override fun saveCollectionName(tabs: List<Tab>, name: String) {
dismiss()
val sessionBundle = tabs.toList().toSessionBundle(sessionManager)
lifecycleScope.launch(Dispatchers.IO) {
tabCollectionStorage.createCollection(name, sessionBundle)
}
analytics.metrics.track(
Event.CollectionSaved(normalSessionSize(sessionManager), sessionBundle.size)
)
closeTabsIfNecessary(tabs, sessionManager, tabsUseCases)
}
override fun renameCollection(collection: TabCollection, name: String) {
dismiss()
lifecycleScope.launch(Dispatchers.IO) {
tabCollectionStorage.renameCollection(collection, name)
analytics.metrics.track(Event.CollectionRenamed)
}
}
override fun backPressed(fromStep: SaveCollectionStep) {
handleBackPress(fromStep)
}
override fun selectAllTabs() {
store.dispatch(CollectionCreationAction.AddAllTabs)
}
override fun deselectAllTabs() {
store.dispatch(CollectionCreationAction.RemoveAllTabs)
}
override fun close() {
dismiss()
}
override fun selectCollection(collection: TabCollection, tabs: List<Tab>) {
dismiss()
val sessionBundle = tabs.toList().toSessionBundle(sessionManager)
lifecycleScope.launch(Dispatchers.IO) {
tabCollectionStorage
.addTabsToCollection(collection, sessionBundle)
}
analytics.metrics.track(
Event.CollectionTabsAdded(normalSessionSize(sessionManager), sessionBundle.size)
)
closeTabsIfNecessary(tabs, sessionManager, tabsUseCases)
}
override fun saveTabsToCollection(tabs: List<Tab>) {
store.dispatch(CollectionCreationAction.StepChanged(
saveCollectionStep = if (store.state.tabCollections.isEmpty()) {
SaveCollectionStep.NameCollection
} else {
SaveCollectionStep.SelectCollection
}
))
}
override fun addNewCollection() {
store.dispatch(CollectionCreationAction.StepChanged(SaveCollectionStep.NameCollection))
}
override fun addTabToSelection(tab: Tab) {
store.dispatch(CollectionCreationAction.TabAdded(tab))
}
override fun removeTabFromSelection(tab: Tab) {
store.dispatch(CollectionCreationAction.TabRemoved(tab))
}
private fun handleBackPress(backFromStep: SaveCollectionStep) {
val newStep = stepBack(backFromStep)
if (newStep != null) {
store.dispatch(CollectionCreationAction.StepChanged(newStep))
} else {
dismiss()
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun stepBack(
backFromStep: SaveCollectionStep
): SaveCollectionStep? {
/*
Will return the next valid state according to this diagram.
Name Collection -> Select Collection -> Select Tabs -> (dismiss fragment) <- Rename Collection
*/
val tabCollectionCount = store.state.tabCollections.size
val tabCount = store.state.tabs.size
return when (backFromStep) {
SaveCollectionStep.NameCollection -> if (tabCollectionCount > 0) {
SaveCollectionStep.SelectCollection
} else {
stepBack(SaveCollectionStep.SelectCollection)
}
SaveCollectionStep.SelectCollection -> if (tabCount > 1) {
SaveCollectionStep.SelectTabs
} else {
stepBack(SaveCollectionStep.SelectTabs)
}
SaveCollectionStep.SelectTabs, SaveCollectionStep.RenameCollection -> null
}
}
/**
* @return the number of currently active sessions that are neither custom nor private
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun normalSessionSize(sessionManager: SessionManager): Int {
return sessionManager.sessions.filter { session ->
(!session.isCustomTabSession() && !session.private)
}.size
}
private fun closeTabsIfNecessary(tabs: List<Tab>, sessionManager: SessionManager, tabsUseCases: TabsUseCases) {
// Only close the tabs if the user is not on the BrowserFragment
if (store.state.previousFragmentId == R.id.browserFragment) { return }
tabs.asSequence()
.mapNotNull { tab -> sessionManager.findSessionById(tab.sessionId) }
.forEach { session -> tabsUseCases.removeTab(session) }
}
}

View File

@ -0,0 +1,112 @@
/* 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.collections
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
import kotlinx.android.synthetic.main.fragment_create_collection.view.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.components.browser.session.SessionManager
import mozilla.components.lib.publicsuffixlist.PublicSuffixList
import mozilla.components.lib.state.ext.consumeFrom
import org.mozilla.fenix.R
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.toTab
import org.mozilla.fenix.home.sessioncontrol.Tab
@ExperimentalCoroutinesApi
class CollectionCreationFragment : DialogFragment() {
private lateinit var collectionCreationView: CollectionCreationView
private lateinit var collectionCreationStore: CollectionCreationStore
private lateinit var collectionCreationInteractor: CollectionCreationInteractor
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
isCancelable = false
setStyle(STYLE_NO_TITLE, R.style.CreateCollectionDialogStyle)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_create_collection, container, false)
val args: CollectionCreationFragmentArgs by navArgs()
val sessionManager = requireComponents.core.sessionManager
val publicSuffixList = requireComponents.publicSuffixList
val tabs = sessionManager.getTabs(args.tabIds, publicSuffixList)
val selectedTabs = sessionManager.getTabs(args.selectedTabIds, publicSuffixList)
.toSet()
val tabCollections = requireComponents.core.tabCollectionStorage.cachedTabCollections
val selectedTabCollection = args.selectedTabCollectionId
.let { id -> tabCollections.firstOrNull { it.id == id } }
collectionCreationStore = StoreProvider.get(this) {
CollectionCreationStore(
CollectionCreationState(
previousFragmentId = args.previousFragmentId,
tabs = tabs,
selectedTabs = selectedTabs,
saveCollectionStep = args.saveCollectionStep,
tabCollections = tabCollections,
selectedTabCollection = selectedTabCollection
)
)
}
collectionCreationInteractor = DefaultCollectionCreationInteractor(
DefaultCollectionCreationController(
collectionCreationStore,
::dismiss,
requireComponents.analytics,
requireComponents.core.tabCollectionStorage,
requireComponents.useCases.tabsUseCases,
requireComponents.core.sessionManager,
viewLifecycleOwner.lifecycleScope
)
)
collectionCreationView = CollectionCreationView(view.createCollectionWrapper, collectionCreationInteractor)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
consumeFrom(collectionCreationStore) { newState ->
collectionCreationView.update(newState)
}
}
override fun onResume() {
super.onResume()
collectionCreationView.onResumed()
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.setOnKeyListener { _, keyCode, event ->
collectionCreationView.onKey(keyCode, event)
}
return dialog
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun SessionManager.getTabs(tabIds: Array<String>?, publicSuffixList: PublicSuffixList): List<Tab> {
return tabIds
?.mapNotNull { this.findSessionById(it) }
?.map { it.toTab(publicSuffixList) }
?: emptyList()
}

View File

@ -0,0 +1,107 @@
/* 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/. */
@file:Suppress("TooManyFunctions")
package org.mozilla.fenix.collections
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.TabCollection
interface CollectionCreationInteractor {
fun onNewCollectionNameSaved(tabs: List<Tab>, name: String)
fun onCollectionRenamed(collection: TabCollection, name: String)
/**
* Called when either the physical back button, or the back arrow are clicked.
*
* Note that this is not called when the close button on the snackbar is clicked. See [close].
*/
fun onBackPressed(fromStep: SaveCollectionStep)
/**
* Called when a user hits 'Select All' from the 'Select Tabs' step. This affects which tabs
* have been 'selected' to be saved into a collection.
*/
fun selectAllTapped()
/**
* Called when a user hits 'Deselect All' from the 'Select Tabs' step. This affects which tabs
* have been 'selected' to be saved into a collection.
*/
fun deselectAllTapped()
/**
* Called when a user hits the close button on the snackbar.
*
* Note that this is not called when the back arrow is clicked. See [onBackPressed].
*/
fun close()
fun selectCollection(collection: TabCollection, tabs: List<Tab>)
/**
* Called when the user decides to save tabs to the currently selected session.
*/
fun saveTabsToCollection(tabs: List<Tab>)
fun addNewCollection()
fun addTabToSelection(tab: Tab)
fun removeTabFromSelection(tab: Tab)
}
/**
* Forwards all method calls to their equivalents in [CollectionCreationController].
*/
class DefaultCollectionCreationInteractor(
private val controller: CollectionCreationController
) : CollectionCreationInteractor {
override fun onNewCollectionNameSaved(tabs: List<Tab>, name: String) {
controller.saveCollectionName(tabs, name)
}
override fun onCollectionRenamed(collection: TabCollection, name: String) {
controller.renameCollection(collection, name)
}
override fun onBackPressed(fromStep: SaveCollectionStep) {
controller.backPressed(fromStep)
}
override fun selectAllTapped() {
controller.selectAllTabs()
}
override fun deselectAllTapped() {
controller.deselectAllTabs()
}
override fun close() {
controller.close()
}
override fun selectCollection(collection: TabCollection, tabs: List<Tab>) {
controller.selectCollection(collection, tabs)
}
override fun saveTabsToCollection(tabs: List<Tab>) {
controller.saveTabsToCollection(tabs)
}
override fun addNewCollection() {
controller.addNewCollection()
}
override fun addTabToSelection(tab: Tab) {
controller.addTabToSelection(tab)
}
override fun removeTabFromSelection(tab: Tab) {
controller.removeTabFromSelection(tab)
}
}

View File

@ -0,0 +1,68 @@
/* 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.collections
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
import org.mozilla.fenix.collections.CollectionCreationAction.StepChanged
import org.mozilla.fenix.home.sessioncontrol.Tab
class CollectionCreationStore(
initialState: CollectionCreationState
) : Store<CollectionCreationState, CollectionCreationAction>(
initialState,
::collectionCreationReducer
)
/**
* Represents the current purpose of the screen. This determines what options are shown to the
* user.
*
* TODO refactor [CollectionCreationState] into a sealed class with four implementations, each
* replacing a [SaveCollectionStep] value. These will not need null / emptyCollection default
* values. Handle changes bebtween these state changes internally, here and in the controller,
* instead of exposing [StepChanged], which currently acts as a setter.
*/
enum class SaveCollectionStep {
SelectTabs,
SelectCollection,
NameCollection,
RenameCollection
}
data class CollectionCreationState(
val previousFragmentId: Int,
val tabs: List<Tab> = emptyList(),
val selectedTabs: Set<Tab> = emptySet(),
val saveCollectionStep: SaveCollectionStep = SaveCollectionStep.SelectTabs,
val tabCollections: List<TabCollection> = emptyList(),
val selectedTabCollection: TabCollection? = null
) : State
sealed class CollectionCreationAction : Action {
object AddAllTabs : CollectionCreationAction()
object RemoveAllTabs : CollectionCreationAction()
data class TabAdded(val tab: Tab) : CollectionCreationAction()
data class TabRemoved(val tab: Tab) : CollectionCreationAction()
/**
* Used as a setter for [SaveCollectionStep].
*
* This should be refactored, see kdoc on [SaveCollectionStep].
*/
data class StepChanged(val saveCollectionStep: SaveCollectionStep) : CollectionCreationAction()
}
private fun collectionCreationReducer(
prevState: CollectionCreationState,
action: CollectionCreationAction
): CollectionCreationState = when (action) {
is CollectionCreationAction.AddAllTabs -> prevState.copy(selectedTabs = prevState.tabs.toSet())
is CollectionCreationAction.RemoveAllTabs -> prevState.copy(selectedTabs = emptySet())
is CollectionCreationAction.TabAdded -> prevState.copy(selectedTabs = prevState.selectedTabs + action.tab)
is CollectionCreationAction.TabRemoved -> prevState.copy(selectedTabs = prevState.selectedTabs - action.tab)
is CollectionCreationAction.StepChanged -> prevState.copy(saveCollectionStep = action.saveCollectionStep)
}

View File

@ -11,7 +11,6 @@ import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.collection_tab_list_row.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
@ -19,7 +18,7 @@ import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.home.sessioncontrol.Tab
class CollectionCreationTabListAdapter(
val actionEmitter: Observer<CollectionCreationAction>
private val interactor: CollectionCreationInteractor
) : RecyclerView.Adapter<TabViewHolder>() {
private var tabs: List<Tab> = listOf()
private var selectedTabs: MutableSet<Tab> = mutableSetOf()
@ -54,14 +53,13 @@ class CollectionCreationTabListAdapter(
val tab = tabs[position]
val isSelected = selectedTabs.contains(tab)
holder.itemView.tab_selected_checkbox.setOnCheckedChangeListener { _, isChecked ->
val action = if (isChecked) {
if (isChecked) {
selectedTabs.add(tab)
CollectionCreationAction.AddTabToSelection(tab)
interactor.addTabToSelection(tab)
} else {
selectedTabs.remove(tab)
CollectionCreationAction.RemoveTabFromSelection(tab)
interactor.removeTabFromSelection(tab)
}
actionEmitter.onNext(action)
}
holder.bind(tab, isSelected, hideCheckboxes)
}

View File

@ -1,332 +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.collections
import android.os.Handler
import android.text.InputFilter
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.AutoTransition
import androidx.transition.Transition
import androidx.transition.TransitionManager
import io.reactivex.Observable
import io.reactivex.Observer
import io.reactivex.functions.Consumer
import kotlinx.android.synthetic.main.component_collection_creation.*
import kotlinx.android.synthetic.main.component_collection_creation.view.*
import mozilla.components.support.ktx.android.view.hideKeyboard
import mozilla.components.support.ktx.android.view.showKeyboard
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.ext.urlToTrimmedHost
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.TabCollection
import org.mozilla.fenix.mvi.UIView
@SuppressWarnings("LargeClass")
class CollectionCreationUIView(
container: ViewGroup,
actionEmitter: Observer<CollectionCreationAction>,
changesObservable: Observable<CollectionCreationChange>
) : UIView<CollectionCreationState, CollectionCreationAction, CollectionCreationChange>(
container,
actionEmitter,
changesObservable
) {
override val view = LayoutInflater.from(container.context)
.inflate(R.layout.component_collection_creation, container, true)
var step: SaveCollectionStep = SaveCollectionStep.SelectTabs
private set
private val collectionCreationTabListAdapter = CollectionCreationTabListAdapter(actionEmitter)
private val collectionSaveListAdapter = SaveCollectionListAdapter(actionEmitter)
private var selectedCollection: TabCollection? = null
private var selectedTabs: Set<Tab> = setOf()
private val selectTabsConstraints = ConstraintSet()
private val selectCollectionConstraints = ConstraintSet()
private val nameCollectionConstraints = ConstraintSet()
private val transition = AutoTransition()
init {
transition.duration = TRANSITION_DURATION
selectTabsConstraints.clone(collection_constraint_layout)
selectCollectionConstraints.clone(
view.context,
R.layout.component_collection_creation_select_collection
)
nameCollectionConstraints.clone(
view.context,
R.layout.component_collection_creation_name_collection
)
view.bottom_bar_icon_button.apply {
increaseTapArea(increaseButtonByDps)
}
view.name_collection_edittext.filters += InputFilter.LengthFilter(COLLECTION_NAME_MAX_LENGTH)
view.name_collection_edittext.setOnEditorActionListener { view, actionId, _ ->
val text = view.text.toString()
if (actionId == EditorInfo.IME_ACTION_DONE && text.isNotBlank()) {
when (step) {
SaveCollectionStep.NameCollection ->
CollectionCreationAction.SaveCollectionName(selectedTabs.toList(), text)
SaveCollectionStep.RenameCollection ->
selectedCollection?.let { CollectionCreationAction.RenameCollection(it, text) }
else -> null
}?.let { action ->
actionEmitter.onNext(action)
}
}
false
}
view.tab_list.run {
adapter = collectionCreationTabListAdapter
itemAnimator = null
layoutManager = LinearLayoutManager(container.context, RecyclerView.VERTICAL, true)
}
view.collections_list.run {
adapter = collectionSaveListAdapter
layoutManager = LinearLayoutManager(container.context, RecyclerView.VERTICAL, true)
}
}
@Suppress("ComplexMethod", "LongMethod")
override fun updateView() = Consumer<CollectionCreationState> {
step = it.saveCollectionStep
selectedTabs = it.selectedTabs
selectedCollection = it.selectedTabCollection
when (it.saveCollectionStep) {
SaveCollectionStep.SelectTabs -> {
view.context.components.analytics.metrics.track(Event.CollectionTabSelectOpened)
view.tab_list.isClickable = true
back_button.setOnClickListener {
actionEmitter.onNext(CollectionCreationAction.BackPressed(SaveCollectionStep.SelectTabs))
}
val allSelected = it.selectedTabs.size == it.tabs.size
select_all_button.text =
if (allSelected)
view.context.getString(R.string.create_collection_deselect_all) else
view.context.getString(R.string.create_collection_select_all)
view.select_all_button.setOnClickListener {
if (allSelected) {
actionEmitter.onNext(CollectionCreationAction.DeselectAllTapped)
} else {
actionEmitter.onNext(CollectionCreationAction.SelectAllTapped)
}
}
view.bottom_button_bar_layout.setOnClickListener(null)
view.bottom_button_bar_layout.isClickable = false
val drawable = view.context.getDrawable(R.drawable.ic_close)
drawable?.setTint(ContextCompat.getColor(view.context, R.color.photonWhite))
view.bottom_bar_icon_button.setImageDrawable(drawable)
view.bottom_bar_icon_button.contentDescription =
view.context.getString(R.string.create_collection_close)
view.bottom_bar_icon_button.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
view.bottom_bar_icon_button.setOnClickListener {
actionEmitter.onNext(CollectionCreationAction.Close)
}
TransitionManager.beginDelayedTransition(
view.collection_constraint_layout,
transition
)
val constraint = selectTabsConstraints
constraint.applyTo(view.collection_constraint_layout)
collectionCreationTabListAdapter.updateData(it.tabs, it.selectedTabs)
back_button.text = view.context.getString(R.string.create_collection_select_tabs)
val selectTabsText = if (it.selectedTabs.isEmpty()) {
view.context.getString(R.string.create_collection_save_to_collection_empty)
} else {
view.context.getString(
if (it.selectedTabs.size == 1)
R.string.create_collection_save_to_collection_tab_selected else
R.string.create_collection_save_to_collection_tabs_selected,
it.selectedTabs.size
)
}
view.bottom_bar_text.text = selectTabsText
save_button.setOnClickListener { _ ->
if (selectedCollection != null) {
actionEmitter.onNext(
CollectionCreationAction.SelectCollection(
selectedCollection!!,
it.selectedTabs.toList()
)
)
} else {
actionEmitter.onNext(CollectionCreationAction.SaveTabsToCollection(selectedTabs.toList()))
}
}
save_button.visibility = if (it.selectedTabs.isEmpty()) {
View.GONE
} else {
View.VISIBLE
}
}
SaveCollectionStep.SelectCollection -> {
view.tab_list.isClickable = false
save_button.visibility = View.GONE
view.bottom_bar_text.text =
view.context.getString(R.string.create_collection_add_new_collection)
val drawable = view.context.getDrawable(R.drawable.ic_new)
drawable?.setTint(ContextCompat.getColor(view.context, R.color.photonWhite))
view.bottom_bar_icon_button.setImageDrawable(drawable)
view.bottom_bar_icon_button.contentDescription = null
view.bottom_bar_icon_button.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
view.bottom_button_bar_layout.isClickable = true
view.bottom_button_bar_layout.setOnClickListener {
actionEmitter.onNext(CollectionCreationAction.AddNewCollection)
}
back_button.setOnClickListener {
actionEmitter.onNext(CollectionCreationAction.BackPressed(SaveCollectionStep.SelectCollection))
}
TransitionManager.beginDelayedTransition(
view.collection_constraint_layout,
transition
)
val constraint = selectCollectionConstraints
constraint.applyTo(view.collection_constraint_layout)
back_button.text =
view.context.getString(R.string.create_collection_select_collection)
}
SaveCollectionStep.NameCollection -> {
view.tab_list.isClickable = false
collectionCreationTabListAdapter.updateData(it.selectedTabs.toList(), it.selectedTabs, true)
back_button.setOnClickListener {
name_collection_edittext.hideKeyboard()
val handler = Handler()
handler.postDelayed({
actionEmitter.onNext(CollectionCreationAction.BackPressed(SaveCollectionStep.NameCollection))
}, TRANSITION_DURATION)
}
transition.addListener(object : Transition.TransitionListener {
override fun onTransitionStart(transition: Transition) { /* noop */ }
override fun onTransitionEnd(transition: Transition) {
view.name_collection_edittext.showKeyboard()
transition.removeListener(this)
}
override fun onTransitionCancel(transition: Transition) { /* noop */ }
override fun onTransitionPause(transition: Transition) { /* noop */ }
override fun onTransitionResume(transition: Transition) { /* noop */ }
})
TransitionManager.beginDelayedTransition(
view.collection_constraint_layout,
transition
)
val constraint = nameCollectionConstraints
constraint.applyTo(view.collection_constraint_layout)
name_collection_edittext.setText(
view.context.getString(
R.string.create_collection_default_name,
it.tabCollections.size + 1
)
)
name_collection_edittext.setSelection(0, name_collection_edittext.text.length)
back_button.text =
view.context.getString(R.string.create_collection_name_collection)
}
SaveCollectionStep.RenameCollection -> {
view.tab_list.isClickable = false
it.selectedTabCollection?.let { tabCollection ->
tabCollection.tabs.map { tab ->
Tab(
tab.id.toString(),
tab.url,
tab.url.urlToTrimmedHost(view.context),
tab.title
)
}.let { tabs ->
collectionCreationTabListAdapter.updateData(tabs, tabs.toSet(), true)
}
}
val constraint = nameCollectionConstraints
constraint.applyTo(view.collection_constraint_layout)
name_collection_edittext.setText(it.selectedTabCollection?.title)
name_collection_edittext.setSelection(0, name_collection_edittext.text.length)
back_button.text =
view.context.getString(R.string.collection_rename)
back_button.setOnClickListener {
name_collection_edittext.hideKeyboard()
val handler = Handler()
handler.postDelayed({
actionEmitter.onNext(CollectionCreationAction.BackPressed(SaveCollectionStep.RenameCollection))
}, TRANSITION_DURATION)
}
transition.addListener(object : Transition.TransitionListener {
override fun onTransitionStart(transition: Transition) { /* noop */ }
override fun onTransitionEnd(transition: Transition) {
view.name_collection_edittext.showKeyboard()
transition.removeListener(this)
}
override fun onTransitionCancel(transition: Transition) { /* noop */ }
override fun onTransitionPause(transition: Transition) { /* noop */ }
override fun onTransitionResume(transition: Transition) { /* noop */ }
})
TransitionManager.beginDelayedTransition(
view.collection_constraint_layout,
transition
)
}
}
collectionSaveListAdapter.updateData(it.tabCollections, it.selectedTabs)
}
fun onResumed() {
if (step == SaveCollectionStep.NameCollection || step == SaveCollectionStep.RenameCollection) {
view.name_collection_edittext.showKeyboard()
}
}
fun onKey(keyCode: Int, event: KeyEvent?): Boolean {
return if (event?.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
actionEmitter.onNext(CollectionCreationAction.BackPressed(step))
true
} else {
false
}
}
companion object {
private const val TRANSITION_DURATION = 200L
private const val increaseButtonByDps = 16
private const val COLLECTION_NAME_MAX_LENGTH = 128
}
}

View File

@ -0,0 +1,330 @@
/* 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.collections
import android.os.Handler
import android.text.InputFilter
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.AutoTransition
import androidx.transition.Transition
import androidx.transition.TransitionManager
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_collection_creation.*
import kotlinx.android.synthetic.main.component_collection_creation.view.*
import mozilla.components.support.ktx.android.view.hideKeyboard
import mozilla.components.support.ktx.android.view.showKeyboard
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.ext.urlToTrimmedHost
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.TabCollection
@SuppressWarnings("LargeClass")
class CollectionCreationView(
override val containerView: ViewGroup,
private val interactor: CollectionCreationInteractor
) : LayoutContainer {
val view: View = LayoutInflater.from(containerView.context)
.inflate(R.layout.component_collection_creation, containerView, true)
private val collectionCreationTabListAdapter = CollectionCreationTabListAdapter(interactor)
private val collectionSaveListAdapter = SaveCollectionListAdapter(interactor)
private val selectTabsConstraints = ConstraintSet()
private val selectCollectionConstraints = ConstraintSet()
private val nameCollectionConstraints = ConstraintSet()
private val transition = AutoTransition()
private var selectedCollection: TabCollection? = null
private var selectedTabs: Set<Tab> = setOf()
var step: SaveCollectionStep = SaveCollectionStep.SelectTabs
private set
init {
transition.duration = TRANSITION_DURATION
selectTabsConstraints.clone(collection_constraint_layout)
selectCollectionConstraints.clone(
view.context,
R.layout.component_collection_creation_select_collection
)
nameCollectionConstraints.clone(
view.context,
R.layout.component_collection_creation_name_collection
)
view.bottom_bar_icon_button.apply {
increaseTapArea(increaseButtonByDps)
}
view.name_collection_edittext.filters += InputFilter.LengthFilter(COLLECTION_NAME_MAX_LENGTH)
view.name_collection_edittext.setOnEditorActionListener { view, actionId, _ ->
val text = view.text.toString()
if (actionId == EditorInfo.IME_ACTION_DONE && text.isNotBlank()) {
when (step) {
SaveCollectionStep.NameCollection ->
interactor.onNewCollectionNameSaved(selectedTabs.toList(), text)
SaveCollectionStep.RenameCollection ->
selectedCollection?.let { interactor.onCollectionRenamed(it, text) }
else -> { /* noop */ }
}
}
false
}
view.tab_list.run {
adapter = collectionCreationTabListAdapter
itemAnimator = null
layoutManager = LinearLayoutManager(containerView.context, RecyclerView.VERTICAL, true)
}
view.collections_list.run {
adapter = collectionSaveListAdapter
layoutManager = LinearLayoutManager(containerView.context, RecyclerView.VERTICAL, true)
}
}
fun update(state: CollectionCreationState) {
cacheState(state)
when (step) {
SaveCollectionStep.SelectTabs -> updateForSelectTabs(state)
SaveCollectionStep.SelectCollection -> updateForSelectCollection()
SaveCollectionStep.NameCollection -> updateForNameCollection(state)
SaveCollectionStep.RenameCollection -> updateForRenameCollection(state)
}
collectionSaveListAdapter.updateData(state.tabCollections, state.selectedTabs)
}
private fun cacheState(state: CollectionCreationState) {
step = state.saveCollectionStep
selectedTabs = state.selectedTabs
selectedCollection = state.selectedTabCollection
}
@SuppressWarnings("ComplexMethod")
private fun updateForSelectTabs(state: CollectionCreationState) {
view.context.components.analytics.metrics.track(Event.CollectionTabSelectOpened)
view.tab_list.isClickable = true
back_button.setOnClickListener {
interactor.onBackPressed(SaveCollectionStep.SelectTabs)
}
val allSelected = state.selectedTabs.size == state.tabs.size
select_all_button.text =
if (allSelected) view.context.getString(R.string.create_collection_deselect_all)
else view.context.getString(R.string.create_collection_select_all)
view.select_all_button.setOnClickListener {
if (allSelected) interactor.deselectAllTapped()
else interactor.selectAllTapped()
}
view.bottom_button_bar_layout.setOnClickListener(null)
view.bottom_button_bar_layout.isClickable = false
val drawable = view.context.getDrawable(R.drawable.ic_close)
drawable?.setTint(ContextCompat.getColor(view.context, R.color.photonWhite))
view.bottom_bar_icon_button.setImageDrawable(drawable)
view.bottom_bar_icon_button.contentDescription =
view.context.getString(R.string.create_collection_close)
view.bottom_bar_icon_button.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
view.bottom_bar_icon_button.setOnClickListener {
interactor.close()
}
TransitionManager.beginDelayedTransition(
view.collection_constraint_layout,
transition
)
val constraint = selectTabsConstraints
constraint.applyTo(view.collection_constraint_layout)
collectionCreationTabListAdapter.updateData(state.tabs, state.selectedTabs)
back_button.text = view.context.getString(R.string.create_collection_select_tabs)
val selectTabsText = if (state.selectedTabs.isEmpty()) {
view.context.getString(R.string.create_collection_save_to_collection_empty)
} else {
view.context.getString(
if (state.selectedTabs.size == 1)
R.string.create_collection_save_to_collection_tab_selected else
R.string.create_collection_save_to_collection_tabs_selected,
state.selectedTabs.size
)
}
view.bottom_bar_text.text = selectTabsText
save_button.setOnClickListener { _ ->
if (selectedCollection != null) {
interactor.selectCollection(
collection = selectedCollection!!,
tabs = state.selectedTabs.toList()
)
} else {
interactor.saveTabsToCollection(tabs = selectedTabs.toList())
}
}
save_button.visibility = if (state.selectedTabs.isEmpty()) {
View.GONE
} else {
View.VISIBLE
}
}
private fun updateForSelectCollection() {
view.tab_list.isClickable = false
save_button.visibility = View.GONE
view.bottom_bar_text.text =
view.context.getString(R.string.create_collection_add_new_collection)
val drawable = view.context.getDrawable(R.drawable.ic_new)
drawable?.setTint(ContextCompat.getColor(view.context, R.color.photonWhite))
view.bottom_bar_icon_button.setImageDrawable(drawable)
view.bottom_bar_icon_button.contentDescription = null
view.bottom_bar_icon_button.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
view.bottom_button_bar_layout.isClickable = true
view.bottom_button_bar_layout.setOnClickListener {
interactor.addNewCollection()
}
back_button.setOnClickListener {
interactor.onBackPressed(SaveCollectionStep.SelectCollection)
}
TransitionManager.beginDelayedTransition(
view.collection_constraint_layout,
transition
)
val constraint = selectCollectionConstraints
constraint.applyTo(view.collection_constraint_layout)
back_button.text =
view.context.getString(R.string.create_collection_select_collection)
}
private fun updateForNameCollection(state: CollectionCreationState) {
view.tab_list.isClickable = false
collectionCreationTabListAdapter.updateData(state.selectedTabs.toList(), state.selectedTabs, true)
back_button.setOnClickListener {
name_collection_edittext.hideKeyboard()
val handler = Handler()
handler.postDelayed({
interactor.onBackPressed(SaveCollectionStep.NameCollection)
}, TRANSITION_DURATION)
}
transition.addListener(object : Transition.TransitionListener {
override fun onTransitionStart(transition: Transition) { /* noop */ }
override fun onTransitionEnd(transition: Transition) {
view.name_collection_edittext.showKeyboard()
transition.removeListener(this)
}
override fun onTransitionCancel(transition: Transition) { /* noop */ }
override fun onTransitionPause(transition: Transition) { /* noop */ }
override fun onTransitionResume(transition: Transition) { /* noop */ }
})
TransitionManager.beginDelayedTransition(
view.collection_constraint_layout,
transition
)
val constraint = nameCollectionConstraints
constraint.applyTo(view.collection_constraint_layout)
name_collection_edittext.setText(
view.context.getString(
R.string.create_collection_default_name,
state.tabCollections.size + 1
)
)
name_collection_edittext.setSelection(0, name_collection_edittext.text.length)
back_button.text =
view.context.getString(R.string.create_collection_name_collection)
}
private fun updateForRenameCollection(state: CollectionCreationState) {
view.tab_list.isClickable = false
state.selectedTabCollection?.let { tabCollection ->
tabCollection.tabs.map { tab ->
Tab(
tab.id.toString(),
tab.url,
tab.url.urlToTrimmedHost(view.context),
tab.title
)
}.let { tabs ->
collectionCreationTabListAdapter.updateData(tabs, tabs.toSet(), true)
}
}
val constraint = nameCollectionConstraints
constraint.applyTo(view.collection_constraint_layout)
name_collection_edittext.setText(state.selectedTabCollection?.title)
name_collection_edittext.setSelection(0, name_collection_edittext.text.length)
back_button.text =
view.context.getString(R.string.collection_rename)
back_button.setOnClickListener {
name_collection_edittext.hideKeyboard()
val handler = Handler()
handler.postDelayed({
interactor.onBackPressed(SaveCollectionStep.RenameCollection)
}, TRANSITION_DURATION)
}
transition.addListener(object : Transition.TransitionListener {
override fun onTransitionStart(transition: Transition) { /* noop */ }
override fun onTransitionEnd(transition: Transition) {
view.name_collection_edittext.showKeyboard()
transition.removeListener(this)
}
override fun onTransitionCancel(transition: Transition) { /* noop */ }
override fun onTransitionPause(transition: Transition) { /* noop */ }
override fun onTransitionResume(transition: Transition) { /* noop */ }
})
TransitionManager.beginDelayedTransition(
view.collection_constraint_layout,
transition
)
}
fun onResumed() {
if (step == SaveCollectionStep.NameCollection || step == SaveCollectionStep.RenameCollection) {
view.name_collection_edittext.showKeyboard()
}
}
fun onKey(keyCode: Int, event: KeyEvent?): Boolean {
return if (event?.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
interactor.onBackPressed(step)
true
} else {
false
}
}
companion object {
private const val TRANSITION_DURATION = 200L
private const val increaseButtonByDps = 16
private const val COLLECTION_NAME_MAX_LENGTH = 128
}
}

View File

@ -1,196 +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.collections
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import kotlinx.android.synthetic.main.fragment_create_collection.view.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.mozilla.fenix.FenixViewModelProvider
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.toSessionBundle
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.getAutoDisposeObservable
import org.mozilla.fenix.mvi.getManagedEmitter
class CreateCollectionFragment : DialogFragment() {
private lateinit var collectionCreationComponent: CollectionCreationComponent
private val viewModel: CreateCollectionViewModel by activityViewModels {
ViewModelProvider.NewInstanceFactory() // this is a workaround for #4652
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
isCancelable = false
setStyle(STYLE_NO_TITLE, R.style.CreateCollectionDialogStyle)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_create_collection, container, false)
collectionCreationComponent = CollectionCreationComponent(
view.createCollectionWrapper,
ActionBusFactory.get(this),
FenixViewModelProvider.create(
this,
CollectionCreationViewModel::class.java
) {
CollectionCreationViewModel(viewModel.state)
}
)
return view
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.setOnKeyListener { _, keyCode, event ->
(collectionCreationComponent.uiView as CollectionCreationUIView).onKey(keyCode, event)
}
return dialog
}
override fun onResume() {
super.onResume()
(collectionCreationComponent.uiView as CollectionCreationUIView).onResumed()
subscribeToActions()
}
@Suppress("ComplexMethod")
private fun subscribeToActions() {
getAutoDisposeObservable<CollectionCreationAction>().subscribe {
when (it) {
is CollectionCreationAction.Close -> dismiss()
is CollectionCreationAction.SaveTabsToCollection -> {
getManagedEmitter<CollectionCreationChange>()
.onNext(
CollectionCreationChange.StepChanged(
if (viewModel.state.tabCollections.isEmpty()) {
SaveCollectionStep.NameCollection
} else {
SaveCollectionStep.SelectCollection
}
)
)
}
is CollectionCreationAction.AddTabToSelection -> {
getManagedEmitter<CollectionCreationChange>()
.onNext(CollectionCreationChange.TabAdded(it.tab))
}
is CollectionCreationAction.RemoveTabFromSelection -> {
getManagedEmitter<CollectionCreationChange>()
.onNext(CollectionCreationChange.TabRemoved(it.tab))
}
is CollectionCreationAction.SelectAllTapped -> {
getManagedEmitter<CollectionCreationChange>()
.onNext(CollectionCreationChange.AddAllTabs)
}
is CollectionCreationAction.DeselectAllTapped -> {
getManagedEmitter<CollectionCreationChange>()
.onNext(CollectionCreationChange.RemoveAllTabs)
}
is CollectionCreationAction.AddNewCollection -> getManagedEmitter<CollectionCreationChange>().onNext(
CollectionCreationChange.StepChanged(SaveCollectionStep.NameCollection)
)
is CollectionCreationAction.BackPressed -> handleBackPress(backPressFrom = it.backPressFrom)
is CollectionCreationAction.SaveCollectionName -> {
dismiss()
context?.let { context ->
val sessionBundle = it.tabs.toList().toSessionBundle(context)
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
context.components.core.tabCollectionStorage.createCollection(it.name, sessionBundle)
}
context.components.analytics.metrics.track(
Event.CollectionSaved(normalSessionSize(), sessionBundle.size)
)
closeTabsIfNecessary(it.tabs)
}
}
is CollectionCreationAction.SelectCollection -> {
dismiss()
context?.let { context ->
val sessionBundle = it.tabs.toList().toSessionBundle(context)
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
context.components.core.tabCollectionStorage
.addTabsToCollection(it.collection, sessionBundle)
}
context.components.analytics.metrics.track(
Event.CollectionTabsAdded(normalSessionSize(), sessionBundle.size)
)
closeTabsIfNecessary(it.tabs)
}
}
is CollectionCreationAction.RenameCollection -> {
dismiss()
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
context?.components?.core?.tabCollectionStorage?.renameCollection(it.collection, it.name)
context?.components?.analytics?.metrics?.track(Event.CollectionRenamed)
}
}
}
}
}
private fun normalSessionSize(): Int {
return requireComponents.core.sessionManager.sessions.filter { session ->
(!session.isCustomTabSession() && !session.private)
}.size
}
private fun handleBackPress(backPressFrom: SaveCollectionStep) {
val newStep = stepBack(backPressFrom)
if (newStep != null) {
getManagedEmitter<CollectionCreationChange>().onNext(CollectionCreationChange.StepChanged(newStep))
} else {
dismiss()
}
}
private fun stepBack(backFromStep: SaveCollectionStep): SaveCollectionStep? {
val state = viewModel.state
return when (backFromStep) {
SaveCollectionStep.SelectTabs, SaveCollectionStep.RenameCollection -> null
SaveCollectionStep.SelectCollection -> if (state.tabs.size <= 1) {
stepBack(SaveCollectionStep.SelectTabs)
} else {
SaveCollectionStep.SelectTabs
}
SaveCollectionStep.NameCollection -> if (state.tabCollections.isEmpty()) {
stepBack(SaveCollectionStep.SelectCollection)
} else {
SaveCollectionStep.SelectCollection
}
}
}
private fun closeTabsIfNecessary(tabs: List<Tab>) {
// Only close the tabs if the user is not on the BrowserFragment
if (viewModel.previousFragmentId == R.id.browserFragment) { return }
val components = requireComponents
tabs.asSequence()
.mapNotNull { tab -> components.core.sessionManager.findSessionById(tab.sessionId) }
.forEach { session -> components.useCases.tabsUseCases.removeTab(session) }
}
}

View File

@ -1,50 +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.collections
import androidx.lifecycle.ViewModel
import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.TabCollection
class CreateCollectionViewModel : ViewModel() {
var state = CollectionCreationState()
private set
var previousFragmentId: Int? = null
fun updateCollection(
tabs: List<Tab>,
saveCollectionStep: SaveCollectionStep,
selectedTabCollection: TabCollection,
cachedTabCollections: List<TabCollection>
) {
state = CollectionCreationState(
tabs = tabs,
selectedTabs = if (tabs.size == 1) setOf(tabs.first()) else emptySet(),
tabCollections = cachedTabCollections.reversed(),
selectedTabCollection = selectedTabCollection,
saveCollectionStep = saveCollectionStep
)
}
fun saveTabToCollection(
tabs: List<Tab>,
selectedTab: Tab?,
cachedTabCollections: List<TabCollection>
) {
val tabCollections = cachedTabCollections.reversed()
state = CollectionCreationState(
tabs = tabs,
selectedTabs = selectedTab?.let { setOf(it) } ?: emptySet(),
tabCollections = tabCollections,
selectedTabCollection = null,
saveCollectionStep = when {
tabs.size > 1 -> SaveCollectionStep.SelectTabs
tabCollections.isNotEmpty() -> SaveCollectionStep.SelectCollection
else -> SaveCollectionStep.NameCollection
}
)
}
}

View File

@ -9,7 +9,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.synthetic.main.collections_list_item.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.components.description
@ -18,7 +17,7 @@ import org.mozilla.fenix.home.sessioncontrol.Tab
import org.mozilla.fenix.home.sessioncontrol.TabCollection
class SaveCollectionListAdapter(
val actionEmitter: Observer<CollectionCreationAction>
private val interactor: CollectionCreationInteractor
) : RecyclerView.Adapter<CollectionViewHolder>() {
private var tabCollections = listOf<TabCollection>()
@ -35,8 +34,7 @@ class SaveCollectionListAdapter(
val collection = tabCollections[position]
holder.bind(collection)
holder.itemView.setOnClickListener {
val action = CollectionCreationAction.SelectCollection(collection, selectedTabs.toList())
actionEmitter.onNext(action)
interactor.selectCollection(collection, selectedTabs.toList())
}
}

View File

@ -31,12 +31,11 @@ import org.mozilla.fenix.browser.BrowserFragment
import org.mozilla.fenix.browser.BrowserFragmentDirections
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.collections.CreateCollectionViewModel
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.collections.SaveCollectionStep
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.toTab
import org.mozilla.fenix.lib.Do
import org.mozilla.fenix.quickactionsheet.QuickActionSheetBehavior
import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit
@ -64,7 +63,6 @@ class DefaultBrowserToolbarController(
private val adjustBackgroundAndNavigate: (NavDirections) -> Unit,
private val swipeRefresh: SwipeRefreshLayout,
private val customTabSession: Session?,
private val viewModel: CreateCollectionViewModel,
private val getSupportUrl: () -> String,
private val openInFenixIntent: Intent,
private val bottomSheetBehavior: QuickActionSheetBehavior<NestedScrollView>,
@ -181,16 +179,13 @@ class DefaultBrowserToolbarController(
activity.components.analytics.metrics
.track(Event.CollectionSaveButtonPressed(TELEMETRY_BROWSER_IDENTIFIER))
currentSession?.toTab(activity)?.let { currentSessionAsTab ->
viewModel.saveTabToCollection(
tabs = listOf(currentSessionAsTab),
selectedTab = currentSessionAsTab,
cachedTabCollections = activity.components.core.tabCollectionStorage.cachedTabCollections
currentSession?.let { currentSession ->
val directions = BrowserFragmentDirections.actionBrowserFragmentToCreateCollectionFragment(
previousFragmentId = R.id.browserFragment,
tabIds = arrayOf(currentSession.id),
selectedTabIds = arrayOf(currentSession.id),
saveCollectionStep = SaveCollectionStep.SelectCollection
)
viewModel.previousFragmentId = R.id.browserFragment
val directions =
BrowserFragmentDirections.actionBrowserFragmentToCreateCollectionFragment()
navController.nav(R.id.browserFragment, directions)
}
}

View File

@ -7,13 +7,17 @@ package org.mozilla.fenix.ext
import android.content.Context
import mozilla.components.browser.session.Session
import mozilla.components.feature.media.state.MediaState
import mozilla.components.lib.publicsuffixlist.PublicSuffixList
import org.mozilla.fenix.home.sessioncontrol.Tab
fun Session.toTab(context: Context, selected: Boolean? = null, mediaState: MediaState? = null): Tab {
fun Session.toTab(context: Context, selected: Boolean? = null, mediaState: MediaState? = null): Tab =
this.toTab(context.components.publicSuffixList, selected, mediaState)
fun Session.toTab(publicSuffixList: PublicSuffixList, selected: Boolean? = null, mediaState: MediaState? = null): Tab {
return Tab(
this.id,
this.url,
this.url.urlToTrimmedHost(context),
this.url.urlToTrimmedHost(publicSuffixList),
this.title,
selected,
mediaState,

View File

@ -7,6 +7,7 @@ package org.mozilla.fenix.ext
import android.content.Context
import androidx.core.net.toUri
import kotlinx.coroutines.runBlocking
import mozilla.components.lib.publicsuffixlist.PublicSuffixList
import mozilla.components.support.ktx.android.net.hostWithoutCommonPrefixes
import java.net.MalformedURLException
import java.net.URL
@ -33,11 +34,17 @@ fun String.tryGetHostFromUrl(): String = try {
/**
* Trim a host's prefix and suffix
*/
fun String.urlToTrimmedHost(context: Context): String {
fun String.urlToTrimmedHost(context: Context): String =
this.urlToTrimmedHost(context.components.publicSuffixList)
/**
* Trim a host's prefix and suffix
*/
fun String.urlToTrimmedHost(publicSuffixList: PublicSuffixList): String {
return try {
val host = toUri().hostWithoutCommonPrefixes ?: return this
runBlocking {
context.components.publicSuffixList.stripPublicSuffix(host).await()
publicSuffixList.stripPublicSuffix(host).await()
}
} catch (e: MalformedURLException) {
this

View File

@ -65,7 +65,6 @@ import org.mozilla.fenix.FenixViewModelProvider
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.collections.CreateCollectionViewModel
import org.mozilla.fenix.collections.SaveCollectionStep
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.PrivateShortcutCreateManager
@ -515,10 +514,16 @@ class HomeFragment : Fragment() {
}
is CollectionAction.AddTab -> {
requireComponents.analytics.metrics.track(Event.CollectionAddTabPressed)
updateCollection(action.collection, SaveCollectionStep.SelectTabs)
showCollectionCreationFragment(
step = SaveCollectionStep.SelectTabs,
selectedTabCollectionId = action.collection.id
)
}
is CollectionAction.Rename -> {
updateCollection(action.collection, SaveCollectionStep.RenameCollection)
showCollectionCreationFragment(
step = SaveCollectionStep.RenameCollection,
selectedTabCollectionId = action.collection.id
)
requireComponents.analytics.metrics.track(Event.CollectionRenamePressed)
}
is CollectionAction.OpenTab -> {
@ -805,48 +810,40 @@ class HomeFragment : Fragment() {
}
private fun showCollectionCreationFragment(
setupViewModel: (CreateCollectionViewModel, tabs: List<Tab>, cachedTabCollections: List<TabCollection>) -> Unit
step: SaveCollectionStep,
selectedTabIds: Array<String>? = null,
selectedTabCollectionId: Long? = null
) {
if (findNavController().currentDestination?.id == R.id.createCollectionFragment) return
if (findNavController().currentDestination?.id == R.id.collectionCreationFragment) return
val viewModel: CreateCollectionViewModel by activityViewModels {
ViewModelProvider.NewInstanceFactory() // this is a workaround for #4652
}
val tabs = getListOfSessions().toTabs()
val storage = requireComponents.core.tabCollectionStorage
setupViewModel(viewModel, tabs, storage.cachedTabCollections)
viewModel.previousFragmentId = R.id.homeFragment
// Only register the observer right before moving to collection creation
storage.register(collectionStorageObserver, this)
val tabIds = getListOfSessions().toTabs().map { it.sessionId }.toTypedArray()
view?.let {
val directions = HomeFragmentDirections.actionHomeFragmentToCreateCollectionFragment()
val directions = HomeFragmentDirections.actionHomeFragmentToCreateCollectionFragment(
tabIds = tabIds,
previousFragmentId = R.id.homeFragment,
saveCollectionStep = step,
selectedTabIds = selectedTabIds,
selectedTabCollectionId = selectedTabCollectionId ?: -1
)
nav(R.id.homeFragment, directions)
}
}
private fun saveTabToCollection(selectedTabId: String?) {
showCollectionCreationFragment { viewModel, tabs, cachedTabCollections ->
viewModel.saveTabToCollection(
tabs = tabs,
selectedTab = tabs.find { it.sessionId == selectedTabId } ?: if (tabs.size == 1) tabs[0] else null,
cachedTabCollections = cachedTabCollections
)
}
}
val tabs = getListOfSessions().toTabs()
val storage = requireComponents.core.tabCollectionStorage
private fun updateCollection(selectedTabCollection: TabCollection, step: SaveCollectionStep) {
showCollectionCreationFragment { viewModel, tabs, cachedTabCollections ->
viewModel.updateCollection(
tabs = tabs,
saveCollectionStep = step,
selectedTabCollection = selectedTabCollection,
cachedTabCollections = cachedTabCollections
)
val step = when {
tabs.size > 1 -> SaveCollectionStep.SelectTabs
storage.cachedTabCollections.isNotEmpty() -> SaveCollectionStep.SelectCollection
else -> SaveCollectionStep.NameCollection
}
showCollectionCreationFragment(step, selectedTabId?.let { arrayOf(it) })
}
private fun share(url: String? = null, tabs: List<ShareTab>? = null) {

View File

@ -11,6 +11,7 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.feature.media.state.MediaState
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.home.Mode
@ -55,10 +56,13 @@ data class Tab(
val icon: Bitmap? = null
)
fun List<Tab>.toSessionBundle(context: Context): MutableList<Session> {
fun List<Tab>.toSessionBundle(context: Context): MutableList<Session> =
this.toSessionBundle(context.components.core.sessionManager)
fun List<Tab>.toSessionBundle(sessionManager: SessionManager): MutableList<Session> {
val sessionBundle = mutableListOf<Session>()
this.forEach {
context.components.core.sessionManager.findSessionById(it.sessionId)?.let { session ->
sessionManager.findSessionById(it.sessionId)?.let { session ->
sessionBundle.add(session)
}
}

View File

@ -10,6 +10,6 @@
android:layout_height="match_parent"
android:background="@drawable/scrim_background"
android:fitsSystemWindows="true"
tools:context="org.mozilla.fenix.collections.CreateCollectionFragment">
tools:context="org.mozilla.fenix.collections.CollectionCreationFragment">
</FrameLayout>

View File

@ -78,7 +78,7 @@
app:destination="@id/settingsFragment" />
<action
android:id="@+id/action_homeFragment_to_createCollectionFragment"
app:destination="@id/createCollectionFragment" />
app:destination="@id/collectionCreationFragment" />
<action
android:id="@+id/action_homeFragment_to_shareFragment"
app:destination="@id/shareFragment" />
@ -179,7 +179,7 @@
app:destination="@id/bookmarkEditFragment" />
<action
android:id="@+id/action_browserFragment_to_createCollectionFragment"
app:destination="@id/createCollectionFragment" />
app:destination="@id/collectionCreationFragment" />
<action
android:id="@+id/action_browserFragment_to_createShortcutFragment"
app:destination="@id/createShortcutFragment" />
@ -465,10 +465,34 @@
app:popUpToInclusive="true" />
</fragment>
<dialog
android:id="@+id/createCollectionFragment"
android:name="org.mozilla.fenix.collections.CreateCollectionFragment"
android:id="@+id/collectionCreationFragment"
android:name="org.mozilla.fenix.collections.CollectionCreationFragment"
android:label="fragment_create_collection"
tools:layout="@layout/fragment_create_collection" />
tools:layout="@layout/fragment_create_collection" >
<argument
android:name="tabIds"
android:defaultValue="@null"
app:argType="string[]"
app:nullable="true" />
<argument
android:name="selectedTabIds"
android:defaultValue="@null"
app:argType="string[]"
app:nullable="true" />
<!-- nav_graph does not allow nullable Longs, so this defaults to -1 -->
<argument
android:name="selectedTabCollectionId"
android:defaultValue="-1L"
app:argType="long" />
<argument
android:name="previousFragmentId"
app:argType="reference"
app:nullable="false" />
<argument
android:name="saveCollectionStep"
app:argType="org.mozilla.fenix.collections.SaveCollectionStep"
app:nullable="false" />
</dialog>
<dialog
android:id="@+id/createShortcutFragment"
android:name="org.mozilla.fenix.shortcut.CreateShortcutFragment"

View File

@ -0,0 +1,117 @@
/* 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.collections
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import mozilla.components.support.test.robolectric.createAddedTestFragment
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.ObsoleteCoroutinesApi
import kotlinx.coroutines.async
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.feature.tab.collections.Tab
import mozilla.components.lib.publicsuffixlist.PublicSuffixList
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.TestApplication
import org.robolectric.annotation.Config
private const val URL_MOZILLA = "www.mozilla.org"
private const val SESSION_ID_MOZILLA = "0"
private const val URL_BCC = "www.bcc.co.uk"
private const val SESSION_ID_BCC = "1"
private const val SESSION_ID_BAD_1 = "not a real session id"
private const val SESSION_ID_BAD_2 = "definitely not a real session id"
@ExperimentalCoroutinesApi
@ObsoleteCoroutinesApi
@RunWith(AndroidJUnit4::class)
@Config(application = TestApplication::class)
class CollectionCreationFragmentTest {
@MockK private lateinit var sessionManager: SessionManager
@MockK private lateinit var publicSuffixList: PublicSuffixList
private val sessionMozilla = Session(initialUrl = URL_MOZILLA, id = SESSION_ID_MOZILLA)
private val sessionBcc = Session(initialUrl = URL_BCC, id = SESSION_ID_BCC)
@Before
fun before() {
MockKAnnotations.init(this)
every { sessionManager.findSessionById(SESSION_ID_MOZILLA) } answers { sessionMozilla }
every { sessionManager.findSessionById(SESSION_ID_BCC) } answers { sessionBcc }
every { sessionManager.findSessionById(SESSION_ID_BAD_1) } answers { null }
every { sessionManager.findSessionById(SESSION_ID_BAD_2) } answers { null }
every { publicSuffixList.stripPublicSuffix(URL_MOZILLA) } answers { GlobalScope.async { URL_MOZILLA } }
every { publicSuffixList.stripPublicSuffix(URL_BCC) } answers { GlobalScope.async { URL_BCC } }
}
@Test
fun `creation dialog shows and can be dismissed`() {
val fragment = createAddedTestFragment {
CollectionCreationFragment().apply {
arguments = CollectionCreationFragmentArgs(
// Fragment crashes if navArgs is null
previousFragmentId = 0,
saveCollectionStep = SaveCollectionStep.SelectTabs
).toBundle()
}
}
assertThat(fragment.dialog).isNotNull()
assertThat(fragment.requireDialog().isShowing).isTrue()
fragment.dismiss()
assertThat(fragment.dialog).isNull()
}
@Test
fun `GIVEN tabs are present in session manager WHEN getTabs is called THEN tabs will be returned`() {
val tabs = sessionManager
.getTabs(arrayOf(SESSION_ID_MOZILLA, SESSION_ID_BCC), publicSuffixList)
val hosts = tabs.map { it.hostname }
assertEquals(URL_MOZILLA, hosts[0])
assertEquals(URL_BCC, hosts[1])
}
@Test
fun `GIVEN some tabs are present in session manager WHEN getTabs is called THEN only valid tabs will be returned`() {
val tabs = sessionManager
.getTabs(arrayOf(SESSION_ID_MOZILLA, SESSION_ID_BAD_1), publicSuffixList)
val hosts = tabs.map { it.hostname }
assertEquals(URL_MOZILLA, hosts[0])
assertEquals(1, hosts.size)
}
@Test
fun `GIVEN tabs are not present in session manager WHEN getTabs is called THEN an empty list will be returned`() {
val tabs = sessionManager
.getTabs(arrayOf(SESSION_ID_BAD_1, SESSION_ID_BAD_2), publicSuffixList)
assertEquals(emptyList<Tab>(), tabs)
}
@Test
fun `WHEN getTabs is called will null tabIds THEN an empty list will be returned`() {
val tabs = sessionManager
.getTabs(null, publicSuffixList)
assertEquals(emptyList<Tab>(), tabs)
}
}

View File

@ -1,30 +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.collections
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import mozilla.components.support.test.robolectric.createAddedTestFragment
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.TestApplication
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
@Config(application = TestApplication::class)
class CreateCollectionFragmentTest {
@Test
fun `creation dialog shows and can be dismissed`() {
val fragment = createAddedTestFragment { CreateCollectionFragment() }
assertThat(fragment.dialog).isNotNull()
assertThat(fragment.requireDialog().isShowing).isTrue()
fragment.dismiss()
assertThat(fragment.dialog).isNull()
}
}

View File

@ -1,150 +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.collections
import io.mockk.MockKAnnotations
import io.mockk.mockk
import mozilla.components.feature.tab.collections.TabCollection
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.home.sessioncontrol.Tab
class CreateCollectionViewModelTest {
private lateinit var viewModel: CreateCollectionViewModel
@Before
fun setup() {
MockKAnnotations.init(this)
viewModel = CreateCollectionViewModel()
}
@Test
fun `initial state defaults`() {
assertEquals(
CollectionCreationState(
tabs = emptyList(),
selectedTabs = emptySet(),
saveCollectionStep = SaveCollectionStep.SelectTabs,
tabCollections = emptyList(),
selectedTabCollection = null
),
viewModel.state
)
assertNull(viewModel.previousFragmentId)
}
@Test
fun `updateCollection copies tabs to state`() {
val tabs = listOf<Tab>(mockk(), mockk())
val tabCollections = listOf<TabCollection>(mockk(), mockk())
val selectedCollection: TabCollection = mockk()
viewModel.updateCollection(
tabs = tabs,
saveCollectionStep = SaveCollectionStep.SelectCollection,
selectedTabCollection = selectedCollection,
cachedTabCollections = tabCollections
)
assertEquals(tabs, viewModel.state.tabs)
assertEquals(SaveCollectionStep.SelectCollection, viewModel.state.saveCollectionStep)
assertEquals(selectedCollection, viewModel.state.selectedTabCollection)
assertEquals(tabCollections.reversed(), viewModel.state.tabCollections)
}
@Test
fun `updateCollection selects the only tab`() {
val tab: Tab = mockk()
viewModel.updateCollection(
tabs = listOf(tab),
saveCollectionStep = mockk(),
selectedTabCollection = mockk(),
cachedTabCollections = emptyList()
)
assertEquals(setOf(tab), viewModel.state.selectedTabs)
viewModel.updateCollection(
tabs = listOf(tab, mockk()),
saveCollectionStep = mockk(),
selectedTabCollection = mockk(),
cachedTabCollections = emptyList()
)
assertEquals(emptySet<Tab>(), viewModel.state.selectedTabs)
viewModel.updateCollection(
tabs = emptyList(),
saveCollectionStep = mockk(),
selectedTabCollection = mockk(),
cachedTabCollections = emptyList()
)
assertEquals(emptySet<Tab>(), viewModel.state.selectedTabs)
}
@Test
fun `saveTabToCollection copies tabs to state`() {
val tabs = listOf<Tab>(mockk(), mockk())
val tabCollections = listOf<TabCollection>(mockk(), mockk())
viewModel.saveTabToCollection(
tabs = tabs,
selectedTab = null,
cachedTabCollections = tabCollections
)
assertEquals(tabs, viewModel.state.tabs)
assertEquals(SaveCollectionStep.SelectTabs, viewModel.state.saveCollectionStep)
assertNull(viewModel.state.selectedTabCollection)
assertEquals(tabCollections.reversed(), viewModel.state.tabCollections)
}
@Test
fun `saveTabToCollection selects selectedTab`() {
val tab: Tab = mockk()
viewModel.saveTabToCollection(
tabs = listOf(mockk()),
selectedTab = tab,
cachedTabCollections = emptyList()
)
assertEquals(setOf(tab), viewModel.state.selectedTabs)
viewModel.saveTabToCollection(
tabs = listOf(mockk()),
selectedTab = null,
cachedTabCollections = emptyList()
)
assertEquals(emptySet<Tab>(), viewModel.state.selectedTabs)
}
@Test
fun `saveTabToCollection sets saveCollectionStep`() {
viewModel.saveTabToCollection(
tabs = listOf(mockk(), mockk()),
selectedTab = null,
cachedTabCollections = listOf(mockk())
)
assertEquals(SaveCollectionStep.SelectTabs, viewModel.state.saveCollectionStep)
viewModel.saveTabToCollection(
tabs = listOf(mockk()),
selectedTab = null,
cachedTabCollections = listOf(mockk())
)
assertEquals(SaveCollectionStep.SelectCollection, viewModel.state.saveCollectionStep)
viewModel.saveTabToCollection(
tabs = emptyList(),
selectedTab = null,
cachedTabCollections = listOf(mockk())
)
assertEquals(SaveCollectionStep.SelectCollection, viewModel.state.saveCollectionStep)
viewModel.saveTabToCollection(
tabs = emptyList(),
selectedTab = null,
cachedTabCollections = emptyList()
)
assertEquals(SaveCollectionStep.NameCollection, viewModel.state.saveCollectionStep)
}
}

View File

@ -0,0 +1,120 @@
package org.mozilla.fenix.collections
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineScope
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
import mozilla.components.feature.tabs.TabsUseCases
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.components.Analytics
import org.mozilla.fenix.components.TabCollectionStorage
@ExperimentalCoroutinesApi
class DefaultCollectionCreationControllerTest {
private val testCoroutineScope = TestCoroutineScope()
private lateinit var controller: DefaultCollectionCreationController
@MockK private lateinit var store: CollectionCreationStore
@MockK(relaxed = true) private lateinit var dismiss: () -> Unit
@MockK(relaxed = true) private lateinit var analytics: Analytics
@MockK private lateinit var tabCollectionStorage: TabCollectionStorage
@MockK private lateinit var tabsUseCases: TabsUseCases
@MockK private lateinit var sessionManager: SessionManager
@MockK private lateinit var state: CollectionCreationState
@Before
fun before() {
MockKAnnotations.init(this)
every { state.previousFragmentId } returns 0
every { store.state } returns state
every { state.tabCollections } returns emptyList()
every { state.tabs } returns emptyList()
controller = DefaultCollectionCreationController(store, dismiss, analytics,
tabCollectionStorage, tabsUseCases, sessionManager, testCoroutineScope)
}
@Test
fun `GIVEN previous step was SelectTabs or RenameCollection WHEN stepBack is called THEN null should be returned`() {
assertNull(controller.stepBack(SaveCollectionStep.SelectTabs))
assertNull(controller.stepBack(SaveCollectionStep.RenameCollection))
}
@Test
fun `GIVEN previous step was SelectCollection AND more than one tab is open WHEN stepBack is called THEN SelectTabs should be returned`() {
every { state.tabs } returns listOf(mockk(), mockk())
assertEquals(SaveCollectionStep.SelectTabs, controller.stepBack(SaveCollectionStep.SelectCollection))
}
@Test
fun `GIVEN previous step was SelectCollection AND one or fewer tabs are open WHEN stepbback is called THEN null should be returned`() {
every { state.tabs } returns listOf(mockk())
assertNull(controller.stepBack(SaveCollectionStep.SelectCollection))
every { state.tabs } returns emptyList()
assertNull(controller.stepBack(SaveCollectionStep.SelectCollection))
}
@Test
fun `GIVEN previous step was NameCollection AND tabCollections is empty AND more than one tab is open WHEN stepBack is called THEN SelectTabs should be returned`() {
every { state.tabCollections } returns emptyList()
every { state.tabs } returns listOf(mockk(), mockk())
assertEquals(SaveCollectionStep.SelectTabs, controller.stepBack(SaveCollectionStep.NameCollection))
}
@Test
fun `GIVEN previous step was NameCollection AND tabCollections is empty AND one or fewer tabs are open WHEN stepBack is called THEN null should be returned`() {
every { state.tabCollections } returns emptyList()
every { state.tabs } returns listOf(mockk())
assertNull(controller.stepBack(SaveCollectionStep.NameCollection))
every { state.tabCollections } returns emptyList()
every { state.tabs } returns emptyList()
assertNull(controller.stepBack(SaveCollectionStep.NameCollection))
}
@Test
fun `GIVEN previous step was NameCollection AND tabCollections is not empty WHEN stepBack is called THEN SelectCollection should be returned`() {
every { state.tabCollections } returns listOf(mockk())
assertEquals(SaveCollectionStep.SelectCollection, controller.stepBack(SaveCollectionStep.NameCollection))
}
@Test
fun `normalSessionSize only counts non-private non-custom sessions`() {
fun session(isPrivate: Boolean, isCustom: Boolean) = mockk<Session>().apply {
every { private } returns isPrivate
every { isCustomTabSession() } returns isCustom
}
val normal1 = session(isPrivate = false, isCustom = false)
val normal2 = session(isPrivate = false, isCustom = false)
val normal3 = session(isPrivate = false, isCustom = false)
val private1 = session(isPrivate = true, isCustom = false)
val private2 = session(isPrivate = true, isCustom = false)
val custom1 = session(isPrivate = false, isCustom = true)
val custom2 = session(isPrivate = false, isCustom = true)
val custom3 = session(isPrivate = false, isCustom = true)
val privateCustom = session(isPrivate = true, isCustom = true)
every { sessionManager.sessions } returns listOf(normal1, private1, private2, custom1,
normal2, normal3, custom2, custom3, privateCustom)
assertEquals(3, controller.normalSessionSize(sessionManager))
}
}

View File

@ -4,6 +4,7 @@
package org.mozilla.fenix.components.toolbar
import android.content.Context
import android.content.Intent
import android.view.ViewGroup
import androidx.core.widget.NestedScrollView
@ -40,7 +41,7 @@ import org.mozilla.fenix.browser.BrowserFragment
import org.mozilla.fenix.browser.BrowserFragmentDirections
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.collections.CreateCollectionViewModel
import org.mozilla.fenix.collections.SaveCollectionStep
import org.mozilla.fenix.components.Analytics
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.metrics.Event
@ -67,7 +68,6 @@ class DefaultBrowserToolbarControllerTest {
private var findInPageLauncher: () -> Unit = mockk(relaxed = true)
private val engineView: EngineView = mockk(relaxed = true)
private val currentSession: Session = mockk(relaxed = true)
private val viewModel: CreateCollectionViewModel = mockk(relaxed = true)
private val getSupportUrl: () -> String = { "https://supportUrl.org" }
private val openInFenixIntent: Intent = mockk(relaxed = true)
private val currentSessionAsTab: Tab = mockk(relaxed = true)
@ -95,7 +95,6 @@ class DefaultBrowserToolbarControllerTest {
engineView = engineView,
adjustBackgroundAndNavigate = adjustBackgroundAndNavigate,
customTabSession = null,
viewModel = viewModel,
getSupportUrl = getSupportUrl,
openInFenixIntent = openInFenixIntent,
bottomSheetBehavior = bottomSheetBehavior,
@ -107,7 +106,7 @@ class DefaultBrowserToolbarControllerTest {
mockkStatic(
"org.mozilla.fenix.ext.SessionKt"
)
every { any<Session>().toTab(any()) } returns currentSessionAsTab
every { any<Session>().toTab(any<Context>()) } returns currentSessionAsTab
mockkStatic(
"org.mozilla.fenix.settings.deletebrowsingdata.DeleteAndQuitKt"
@ -414,16 +413,13 @@ class DefaultBrowserToolbarControllerTest {
verify { metrics.track(Event.BrowserMenuItemTapped(Event.BrowserMenuItemTapped.Item.SAVE_TO_COLLECTION)) }
verify { metrics.track(Event.CollectionSaveButtonPressed(DefaultBrowserToolbarController.TELEMETRY_BROWSER_IDENTIFIER)) }
verify {
viewModel.saveTabToCollection(
listOf(currentSessionAsTab),
currentSessionAsTab,
cachedTabCollections
)
}
verify { viewModel.previousFragmentId = R.id.browserFragment }
verify {
val directions = BrowserFragmentDirections
.actionBrowserFragmentToSearchFragment(sessionId = null)
val directions =
BrowserFragmentDirections.actionBrowserFragmentToCreateCollectionFragment(
previousFragmentId = R.id.browserFragment,
saveCollectionStep = SaveCollectionStep.SelectCollection,
tabIds = arrayOf(currentSession.id),
selectedTabIds = arrayOf(currentSession.id)
)
navController.nav(R.id.browserFragment, directions)
}
}
@ -439,7 +435,6 @@ class DefaultBrowserToolbarControllerTest {
engineView = engineView,
adjustBackgroundAndNavigate = adjustBackgroundAndNavigate,
customTabSession = currentSession,
viewModel = viewModel,
getSupportUrl = getSupportUrl,
openInFenixIntent = openInFenixIntent,
bottomSheetBehavior = bottomSheetBehavior,