1
0
Fork 0

For #4128: Migrate Bookmarks to LibState (#4254)

master
Colin Lee 2019-07-25 21:55:51 -05:00 committed by GitHub
parent 4747f2c165
commit 3dc20543e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1245 additions and 843 deletions

View File

@ -412,6 +412,7 @@ dependencies {
testImplementation Deps.mockito_core
androidTestImplementation Deps.mockito_android
testImplementation Deps.mockk
testImplementation Deps.assertk
debugImplementation Deps.flipper
debugImplementation Deps.soLoader

View File

@ -137,3 +137,18 @@ private class FenixSnackbarCallback(
private const val animateOutDuration = 150L
}
}
class FenixSnackbarPresenter(
private val view: View
) {
fun present(
text: String,
length: Int = FenixSnackbar.LENGTH_LONG,
action: (() -> Unit)? = null,
actionName: String? = null
) {
FenixSnackbar.make(view, length).setText(text).let {
if (action != null && actionName != null) it.setAction(actionName, action) else it
}.show()
}
}

View File

@ -4,8 +4,16 @@
package org.mozilla.fenix.components
import android.content.Context
import androidx.navigation.NavController
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.support.ktx.android.content.hasCamera
import org.mozilla.fenix.Experiments
import org.mozilla.fenix.NavGraphDirections
import org.mozilla.fenix.components.features.FirefoxAccountsAuthFeature
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.isInExperiment
import org.mozilla.fenix.test.Mockable
/**
@ -21,4 +29,26 @@ class Services(
redirectUrl = BackgroundServices.REDIRECT_URL
)
}
/**
* Launches the sign in and pairing custom tab from any screen in the app.
* @param context the current Context
* @param navController the navController to use for navigation
*/
fun launchPairingSignIn(context: Context, navController: NavController) {
// Do not navigate to pairing UI if camera not available or pairing is disabled
if (context.hasCamera() && !context.isInExperiment(Experiments.asFeatureFxAPairingDisabled)
) {
val directions = NavGraphDirections.actionGlobalTurnOnSync()
navController.navigate(directions)
} else {
context.components.services.accountsAuthFeature.beginAuthentication(context)
// TODO The sign-in web content populates session history,
// so pressing "back" after signing in won't take us back into the settings screen, but rather up the
// session history stack.
// We could auto-close this tab once we get to the end of the authentication process?
// Via an interceptor, perhaps.
context.components.analytics.metrics.track(Event.SyncAuthSignIn)
}
}
}

View File

@ -4,7 +4,10 @@
package org.mozilla.fenix.ext
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.core.content.getSystemService
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.browser.storage.sync.PlacesBookmarksStorage
import mozilla.components.concept.storage.BookmarkNode
@ -132,3 +135,13 @@ operator fun BookmarkNode?.minus(child: String): BookmarkNode {
operator fun BookmarkNode?.minus(children: Set<BookmarkNode>): BookmarkNode {
return this!!.copy(children = this.children?.filter { it !in children })
}
/**
* Copies the URL of the given bookmarkNode into the copy and paste buffer.
* @param context the current Context
*/
fun BookmarkNode.copyUrl(context: Context) {
context.getSystemService<ClipboardManager>()?.apply {
primaryClip = ClipData.newPlainText(url, url)
}
}

View File

@ -4,6 +4,7 @@
package org.mozilla.fenix.library
import android.content.Context
import android.graphics.ColorFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
@ -15,27 +16,14 @@ import androidx.appcompat.view.menu.ActionMenuItemView
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.view.forEach
import io.reactivex.Observable
import io.reactivex.Observer
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.asActivity
import org.mozilla.fenix.mvi.Action
import org.mozilla.fenix.mvi.Change
import org.mozilla.fenix.mvi.UIView
import org.mozilla.fenix.mvi.ViewState
/**
* Shared base class for [org.mozilla.fenix.library.bookmarks.BookmarkUIView] and
* [org.mozilla.fenix.library.history.HistoryUIView].
*/
abstract class LibraryPageUIView<S : ViewState, A : Action, C : Change>(
container: ViewGroup,
actionEmitter: Observer<A>,
changesObservable: Observable<C>
) : UIView<S, A, C>(container, actionEmitter, changesObservable) {
protected val context = container.context
protected val activity = context?.asActivity()
open class LibraryPageView(
container: ViewGroup
) {
protected val context: Context = container.context
protected val activity = context.asActivity()
/**
* Adjust the colors of the [Toolbar] on the top of the screen.

View File

@ -11,7 +11,6 @@ import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.Observer
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.bookmark_row.*
import kotlinx.coroutines.CoroutineScope
@ -30,7 +29,7 @@ import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.utils.AdapterWithJob
import kotlin.coroutines.CoroutineContext
class BookmarkAdapter(val emptyView: View, val actionEmitter: Observer<BookmarkAction>) :
class BookmarkAdapter(val emptyView: View, val interactor: BookmarkViewInteractor) :
AdapterWithJob<BookmarkAdapter.BookmarkNodeViewHolder>() {
private var tree: List<BookmarkNode> = listOf()
@ -87,13 +86,13 @@ class BookmarkAdapter(val emptyView: View, val actionEmitter: Observer<BookmarkA
return when (viewType) {
BookmarkItemViewHolder.viewType.ordinal -> BookmarkItemViewHolder(
view, actionEmitter, adapterJob
view, interactor, adapterJob
)
BookmarkFolderViewHolder.viewType.ordinal -> BookmarkFolderViewHolder(
view, actionEmitter, adapterJob
view, interactor, adapterJob
)
BookmarkSeparatorViewHolder.viewType.ordinal -> BookmarkSeparatorViewHolder(
view, actionEmitter, adapterJob
view, interactor, adapterJob
)
else -> throw IllegalStateException("ViewType $viewType does not match to a ViewHolder")
}
@ -120,7 +119,7 @@ class BookmarkAdapter(val emptyView: View, val actionEmitter: Observer<BookmarkA
open class BookmarkNodeViewHolder(
view: View,
val actionEmitter: Observer<BookmarkAction>,
val interactor: BookmarkViewInteractor,
private val job: Job,
override val containerView: View? = view
) :
@ -134,11 +133,11 @@ class BookmarkAdapter(val emptyView: View, val actionEmitter: Observer<BookmarkA
class BookmarkItemViewHolder(
view: View,
actionEmitter: Observer<BookmarkAction>,
interactor: BookmarkViewInteractor,
job: Job,
override val containerView: View? = view
) :
BookmarkNodeViewHolder(view, actionEmitter, job, containerView) {
BookmarkNodeViewHolder(view, interactor, job, containerView) {
@Suppress("ComplexMethod")
override fun bind(item: BookmarkNode, mode: BookmarkState.Mode, selected: Boolean) {
@ -160,25 +159,25 @@ class BookmarkAdapter(val emptyView: View, val actionEmitter: Observer<BookmarkA
val bookmarkItemMenu = BookmarkItemMenu(containerView.context, item) {
when (it) {
is BookmarkItemMenu.Item.Edit -> {
actionEmitter.onNext(BookmarkAction.Edit(item))
interactor.edit(item)
}
is BookmarkItemMenu.Item.Select -> {
actionEmitter.onNext(BookmarkAction.Select(item))
interactor.select(item)
}
is BookmarkItemMenu.Item.Copy -> {
actionEmitter.onNext(BookmarkAction.Copy(item))
interactor.copy(item)
}
is BookmarkItemMenu.Item.Share -> {
actionEmitter.onNext(BookmarkAction.Share(item))
interactor.share(item)
}
is BookmarkItemMenu.Item.OpenInNewTab -> {
actionEmitter.onNext(BookmarkAction.OpenInNewTab(item))
interactor.openInNewTab(item)
}
is BookmarkItemMenu.Item.OpenInPrivateTab -> {
actionEmitter.onNext(BookmarkAction.OpenInPrivateTab(item))
interactor.openInPrivateTab(item)
}
is BookmarkItemMenu.Item.Delete -> {
actionEmitter.onNext(BookmarkAction.Delete(item))
interactor.delete(item)
}
}
}
@ -228,19 +227,15 @@ class BookmarkAdapter(val emptyView: View, val actionEmitter: Observer<BookmarkA
) {
bookmark_layout.setOnClickListener {
if (mode == BookmarkState.Mode.Normal) {
actionEmitter.onNext(BookmarkAction.Open(item))
interactor.open(item)
} else {
if (selected) actionEmitter.onNext(BookmarkAction.Deselect(item)) else actionEmitter.onNext(
BookmarkAction.Select(item)
)
if (selected) interactor.deselect(item) else interactor.select(item)
}
}
bookmark_layout.setOnLongClickListener {
if (mode == BookmarkState.Mode.Normal) {
if (selected) actionEmitter.onNext(BookmarkAction.Deselect(item)) else actionEmitter.onNext(
BookmarkAction.Select(item)
)
if (selected) interactor.deselect(item) else interactor.select(item)
true
} else false
}
@ -255,11 +250,11 @@ class BookmarkAdapter(val emptyView: View, val actionEmitter: Observer<BookmarkA
class BookmarkFolderViewHolder(
view: View,
actionEmitter: Observer<BookmarkAction>,
interactor: BookmarkViewInteractor,
job: Job,
override val containerView: View? = view
) :
BookmarkNodeViewHolder(view, actionEmitter, job, containerView) {
BookmarkNodeViewHolder(view, interactor, job, containerView) {
override fun bind(item: BookmarkNode, mode: BookmarkState.Mode, selected: Boolean) {
containerView?.context?.let {
@ -304,13 +299,13 @@ class BookmarkAdapter(val emptyView: View, val actionEmitter: Observer<BookmarkA
val bookmarkItemMenu = BookmarkItemMenu(containerView.context, item) {
when (it) {
is BookmarkItemMenu.Item.Edit -> {
actionEmitter.onNext(BookmarkAction.Edit(item))
interactor.edit(item)
}
is BookmarkItemMenu.Item.Select -> {
actionEmitter.onNext(BookmarkAction.Select(item))
interactor.select(item)
}
is BookmarkItemMenu.Item.Delete -> {
actionEmitter.onNext(BookmarkAction.Delete(item))
interactor.delete(item)
}
}
}
@ -336,19 +331,15 @@ class BookmarkAdapter(val emptyView: View, val actionEmitter: Observer<BookmarkA
) {
bookmark_layout.setOnClickListener {
if (mode == BookmarkState.Mode.Normal) {
actionEmitter.onNext(BookmarkAction.Expand(item))
interactor.expand(item)
} else {
if (selected) actionEmitter.onNext(BookmarkAction.Deselect(item)) else actionEmitter.onNext(
BookmarkAction.Select(item)
)
if (selected) interactor.deselect(item) else interactor.select(item)
}
}
bookmark_layout.setOnLongClickListener {
if (mode == BookmarkState.Mode.Normal && !item.inRoots()) {
if (selected) actionEmitter.onNext(BookmarkAction.Deselect(item)) else actionEmitter.onNext(
BookmarkAction.Select(item)
)
if (selected) interactor.deselect(item) else interactor.select(item)
true
} else false
}
@ -361,10 +352,10 @@ class BookmarkAdapter(val emptyView: View, val actionEmitter: Observer<BookmarkA
class BookmarkSeparatorViewHolder(
view: View,
actionEmitter: Observer<BookmarkAction>,
interactor: BookmarkViewInteractor,
job: Job,
override val containerView: View? = view
) : BookmarkNodeViewHolder(view, actionEmitter, job, containerView) {
) : BookmarkNodeViewHolder(view, interactor, job, containerView) {
override fun bind(item: BookmarkNode, mode: BookmarkState.Mode, selected: Boolean) {
@ -379,7 +370,7 @@ class BookmarkAdapter(val emptyView: View, val actionEmitter: Observer<BookmarkA
val bookmarkItemMenu = BookmarkItemMenu(containerView!!.context, item) {
when (it) {
is BookmarkItemMenu.Item.Delete -> {
actionEmitter.onNext(BookmarkAction.Delete(item))
interactor.delete(item)
}
}
}

View File

@ -1,110 +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.bookmarks
import android.view.ViewGroup
import mozilla.components.concept.storage.BookmarkNode
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.Reducer
import org.mozilla.fenix.mvi.UIComponent
import org.mozilla.fenix.mvi.UIComponentViewModelBase
import org.mozilla.fenix.mvi.UIComponentViewModelProvider
import org.mozilla.fenix.mvi.UIView
import org.mozilla.fenix.test.Mockable
@Mockable
class BookmarkComponent(
private val container: ViewGroup,
bus: ActionBusFactory,
viewModelProvider: UIComponentViewModelProvider<BookmarkState, BookmarkChange>
) :
UIComponent<BookmarkState, BookmarkAction, BookmarkChange>(
bus.getManagedEmitter(BookmarkAction::class.java),
bus.getSafeManagedObservable(BookmarkChange::class.java),
viewModelProvider
) {
override fun initView(): UIView<BookmarkState, BookmarkAction, BookmarkChange> =
BookmarkUIView(container, actionEmitter, changesObservable)
init {
bind()
}
}
data class BookmarkState(val tree: BookmarkNode?, val mode: Mode) : ViewState {
sealed class Mode {
object Normal : Mode()
data class Selecting(val selectedItems: Set<BookmarkNode>) : Mode()
}
}
sealed class BookmarkAction : Action {
data class Open(val item: BookmarkNode) : BookmarkAction()
data class Expand(val folder: BookmarkNode) : BookmarkAction()
data class Edit(val item: BookmarkNode) : BookmarkAction()
data class Copy(val item: BookmarkNode) : BookmarkAction()
data class Share(val item: BookmarkNode) : BookmarkAction()
data class OpenInNewTab(val item: BookmarkNode) : BookmarkAction()
data class OpenInPrivateTab(val item: BookmarkNode) : BookmarkAction()
data class Select(val item: BookmarkNode) : BookmarkAction()
data class Deselect(val item: BookmarkNode) : BookmarkAction()
data class Delete(val item: BookmarkNode) : BookmarkAction()
object BackPressed : BookmarkAction()
object SwitchMode : BookmarkAction()
object DeselectAll : BookmarkAction()
}
sealed class BookmarkChange : Change {
data class Change(val tree: BookmarkNode) : BookmarkChange()
data class IsSelected(val newlySelectedItem: BookmarkNode) : BookmarkChange()
data class IsDeselected(val newlyDeselectedItem: BookmarkNode) : BookmarkChange()
object ClearSelection : BookmarkChange()
}
operator fun BookmarkNode.contains(item: BookmarkNode): Boolean {
return children?.contains(item) ?: false
}
class BookmarkViewModel(initialState: BookmarkState) :
UIComponentViewModelBase<BookmarkState, BookmarkChange>(initialState, reducer) {
companion object {
fun create() = BookmarkViewModel(BookmarkState(null, BookmarkState.Mode.Normal))
val reducer: Reducer<BookmarkState, BookmarkChange> = { state, change ->
when (change) {
is BookmarkChange.Change -> {
val mode =
if (state.mode is BookmarkState.Mode.Selecting) {
val items = state.mode.selectedItems.filter {
it in change.tree
}.toSet()
if (items.isEmpty()) BookmarkState.Mode.Normal else BookmarkState.Mode.Selecting(items)
} else state.mode
state.copy(tree = change.tree, mode = mode)
}
is BookmarkChange.IsSelected -> {
val selectedItems = if (state.mode is BookmarkState.Mode.Selecting) {
state.mode.selectedItems + change.newlySelectedItem
} else setOf(change.newlySelectedItem)
state.copy(mode = BookmarkState.Mode.Selecting(selectedItems))
}
is BookmarkChange.IsDeselected -> {
val selectedItems = if (state.mode is BookmarkState.Mode.Selecting) {
state.mode.selectedItems - change.newlyDeselectedItem
} else setOf()
val mode = if (selectedItems.isEmpty()) BookmarkState.Mode.Normal else BookmarkState.Mode.Selecting(
selectedItems
)
state.copy(mode = mode)
}
is BookmarkChange.ClearSelection -> state.copy(mode = BookmarkState.Mode.Normal)
}
}
}
}

View File

@ -4,9 +4,6 @@
package org.mozilla.fenix.library.bookmarks
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.PorterDuff.Mode.SRC_IN
import android.graphics.PorterDuffColorFilter
import android.os.Bundle
@ -18,10 +15,11 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProviders
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenStarted
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import kotlinx.android.synthetic.main.fragment_bookmark.view.*
@ -32,19 +30,17 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.storage.BookmarkNodeType
import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.concept.sync.Profile
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.FenixSnackbar
import org.mozilla.fenix.components.FenixSnackbarPresenter
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.bookmarkStorage
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.minus
@ -52,16 +48,18 @@ import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.setRootTitles
import org.mozilla.fenix.ext.urlToTrimmedHost
import org.mozilla.fenix.ext.withOptionalDesktopFolders
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.getAutoDisposeObservable
import org.mozilla.fenix.mvi.getManagedEmitter
import org.mozilla.fenix.utils.allowUndo
@SuppressWarnings("TooManyFunctions", "LargeClass")
class BookmarkFragment : Fragment(), BackHandler, AccountObserver {
private lateinit var bookmarkComponent: BookmarkComponent
private lateinit var signInComponent: SignInComponent
private lateinit var bookmarkStore: BookmarkStore
private lateinit var bookmarkView: BookmarkView
private lateinit var signInView: SignInView
private lateinit var bookmarkInteractor: BookmarkFragmentInteractor
private val sharedViewModel: BookmarksSharedViewModel by activityViewModels()
var currentRoot: BookmarkNode? = null
private val navigation by lazy { findNavController() }
private val onDestinationChangedListener =
@ -69,36 +67,51 @@ class BookmarkFragment : Fragment(), BackHandler, AccountObserver {
if (destination.id != R.id.bookmarkFragment ||
args != null && BookmarkFragmentArgs.fromBundle(args).currentRoot != currentRoot?.guid
)
getManagedEmitter<BookmarkChange>().onNext(BookmarkChange.ClearSelection)
bookmarkInteractor.deselectAll()
}
lateinit var initialJob: Job
private var pendingBookmarkDeletionJob: (suspend () -> Unit)? = null
private var pendingBookmarksToDelete: MutableSet<BookmarkNode> = HashSet()
private var pendingBookmarksToDelete: MutableSet<BookmarkNode> = mutableSetOf()
private val metrics
get() = context?.components?.analytics?.metrics
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_bookmark, container, false)
bookmarkComponent = BookmarkComponent(
view.bookmark_layout,
ActionBusFactory.get(this),
FenixViewModelProvider.create(
this,
BookmarkViewModel::class.java,
BookmarkViewModel.Companion::create
)
)
signInComponent = SignInComponent(
view.bookmark_layout,
ActionBusFactory.get(this),
FenixViewModelProvider.create(
this,
SignInViewModel::class.java
) {
SignInViewModel(SignInState(false))
}
bookmarkStore = StoreProvider.get(this) {
BookmarkStore(BookmarkState(null))
}
bookmarkInteractor = BookmarkFragmentInteractor(
context!!,
findNavController(),
bookmarkStore,
sharedViewModel,
FenixSnackbarPresenter(view),
::deleteMulti
)
bookmarkView = BookmarkView(view.bookmark_layout, bookmarkInteractor)
signInView = SignInView(view.bookmark_layout, bookmarkInteractor)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bookmarkStore.observe(view) {
viewLifecycleOwner.lifecycleScope.launch {
whenStarted {
bookmarkView.update(it)
}
}
}
sharedViewModel.apply {
signedIn.observe(this@BookmarkFragment, Observer<Boolean> {
signInView.update(it)
})
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activity?.title = getString(R.string.library_bookmarks)
@ -125,11 +138,8 @@ class BookmarkFragment : Fragment(), BackHandler, AccountObserver {
if (!isActive) return@launch
launch(Main) {
getManagedEmitter<BookmarkChange>().onNext(BookmarkChange.Change(currentRoot!!))
activity?.run {
ViewModelProviders.of(this).get(BookmarksSharedViewModel::class.java)
}!!.selectedFolder = currentRoot
bookmarkInteractor.change(currentRoot!!)
sharedViewModel.selectedFolder = currentRoot
}
}
}
@ -137,8 +147,8 @@ class BookmarkFragment : Fragment(), BackHandler, AccountObserver {
private fun checkIfSignedIn() {
context?.components?.backgroundServices?.accountManager?.let {
it.register(this, owner = this)
it.authenticatedAccount()?.let { getManagedEmitter<SignInChange>().onNext(SignInChange.SignedIn) }
?: getManagedEmitter<SignInChange>().onNext(SignInChange.SignedOut)
it.authenticatedAccount()?.let { bookmarkInteractor.signedIn() }
?: bookmarkInteractor.signedOut()
}
}
@ -148,7 +158,7 @@ class BookmarkFragment : Fragment(), BackHandler, AccountObserver {
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
when (val mode = (bookmarkComponent.uiView as BookmarkUIView).mode) {
when (val mode = bookmarkView.mode) {
BookmarkState.Mode.Normal -> {
inflater.inflate(R.menu.bookmarks_menu, menu)
}
@ -165,162 +175,6 @@ class BookmarkFragment : Fragment(), BackHandler, AccountObserver {
}
}
@SuppressWarnings("ComplexMethod")
override fun onStart() {
super.onStart()
getAutoDisposeObservable<BookmarkAction>()
.subscribe {
when (it) {
is BookmarkAction.Open -> {
if (it.item.type == BookmarkNodeType.ITEM) {
it.item.url?.let { url ->
(activity as HomeActivity)
.openToBrowserAndLoad(
searchTermOrURL = url,
newTab = true,
from = BrowserDirection.FromBookmarks
)
}
}
metrics()?.track(Event.OpenedBookmark)
}
is BookmarkAction.Expand -> {
nav(
R.id.bookmarkFragment,
BookmarkFragmentDirections.actionBookmarkFragmentSelf(it.folder.guid)
)
}
is BookmarkAction.BackPressed -> {
navigation.popBackStack()
}
is BookmarkAction.Edit -> {
nav(
R.id.bookmarkFragment,
BookmarkFragmentDirections
.actionBookmarkFragmentToBookmarkEditFragment(it.item.guid)
)
}
is BookmarkAction.Select -> {
getManagedEmitter<BookmarkChange>().onNext(BookmarkChange.IsSelected(it.item))
}
is BookmarkAction.Deselect -> {
getManagedEmitter<BookmarkChange>().onNext(BookmarkChange.IsDeselected(it.item))
}
is BookmarkAction.Copy -> {
it.item.copyUrl(context!!)
FenixSnackbar.make(view!!, FenixSnackbar.LENGTH_LONG)
.setText(context!!.getString(R.string.url_copied)).show()
metrics()?.track(Event.CopyBookmark)
}
is BookmarkAction.Share -> {
it.item.url?.apply {
nav(
R.id.bookmarkFragment,
BookmarkFragmentDirections.actionBookmarkFragmentToShareFragment(
url = this,
title = it.item.title
)
)
metrics()?.track(Event.ShareBookmark)
}
}
is BookmarkAction.OpenInNewTab -> {
it.item.url?.let { url ->
(activity as HomeActivity).browsingModeManager.mode =
BrowsingModeManager.Mode.Normal
(activity as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = url,
newTab = true,
from = BrowserDirection.FromBookmarks
)
metrics()?.track(Event.OpenedBookmarkInNewTab)
}
}
is BookmarkAction.OpenInPrivateTab -> {
it.item.url?.let { url ->
(activity as HomeActivity).browsingModeManager.mode =
BrowsingModeManager.Mode.Private
(activity as HomeActivity).openToBrowserAndLoad(
searchTermOrURL = url,
newTab = true,
from = BrowserDirection.FromBookmarks
)
metrics()?.track(Event.OpenedBookmarkInPrivateTab)
}
}
is BookmarkAction.Delete -> {
val bookmarkItem = it.item
if (pendingBookmarkDeletionJob == null) {
removeBookmarkWithUndo(bookmarkItem)
} else {
pendingBookmarkDeletionJob?.let {
viewLifecycleOwner.lifecycleScope.launch {
it.invoke()
}.invokeOnCompletion {
removeBookmarkWithUndo(bookmarkItem)
}
}
}
}
is BookmarkAction.SwitchMode -> {
activity?.invalidateOptionsMenu()
}
is BookmarkAction.DeselectAll ->
getManagedEmitter<BookmarkChange>().onNext(BookmarkChange.ClearSelection)
}
}
getAutoDisposeObservable<SignInAction>()
.subscribe {
when (it) {
is SignInAction.ClickedSignIn -> {
context?.components?.services?.accountsAuthFeature?.beginAuthentication(requireContext())
(activity as HomeActivity).openToBrowser(BrowserDirection.FromBookmarks)
}
}
}
}
private fun removeBookmarkWithUndo(bookmarkNode: BookmarkNode) {
val bookmarkStorage = context.bookmarkStorage()
pendingBookmarksToDelete.add(bookmarkNode)
var bookmarkTree = currentRoot
pendingBookmarksToDelete.forEach {
bookmarkTree -= it.guid
}
getManagedEmitter<BookmarkChange>().onNext(BookmarkChange.Change(bookmarkTree!!))
val deleteOperation: (suspend () -> Unit) = {
bookmarkStorage?.deleteNode(bookmarkNode.guid)
when (bookmarkNode.type) {
BookmarkNodeType.FOLDER -> metrics()?.track(Event.RemoveBookmarkFolder)
BookmarkNodeType.ITEM -> metrics()?.track(Event.RemoveBookmark)
else -> { }
}
pendingBookmarkDeletionJob = null
refreshBookmarks()
}
pendingBookmarkDeletionJob = deleteOperation
lifecycleScope.allowUndo(
view!!,
getString(
R.string.bookmark_deletion_snackbar_message,
bookmarkNode.url?.urlToTrimmedHost(context!!) ?: bookmarkNode.title
),
getString(R.string.bookmark_undo_deletion),
onCancel = {
pendingBookmarkDeletionJob = null
pendingBookmarksToDelete.remove(bookmarkNode)
refreshBookmarks()
},
operation = deleteOperation
)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.libraryClose -> {
@ -346,7 +200,7 @@ class BookmarkFragment : Fragment(), BackHandler, AccountObserver {
(activity as HomeActivity).browsingModeManager.mode = BrowsingModeManager.Mode.Normal
(activity as HomeActivity).supportActionBar?.hide()
nav(R.id.bookmarkFragment, BookmarkFragmentDirections.actionBookmarkFragmentToHomeFragment())
metrics()?.track(Event.OpenedBookmarksInNewTabs)
metrics?.track(Event.OpenedBookmarksInNewTabs)
true
}
R.id.edit_bookmark_multi_select -> {
@ -368,54 +222,28 @@ class BookmarkFragment : Fragment(), BackHandler, AccountObserver {
(activity as HomeActivity).browsingModeManager.mode = BrowsingModeManager.Mode.Private
(activity as HomeActivity).supportActionBar?.hide()
nav(R.id.bookmarkFragment, BookmarkFragmentDirections.actionBookmarkFragmentToHomeFragment())
metrics()?.track(Event.OpenedBookmarksInPrivateTabs)
metrics?.track(Event.OpenedBookmarksInPrivateTabs)
true
}
R.id.delete_bookmarks_multi_select -> {
val selectedBookmarks = getSelectedBookmarks()
pendingBookmarksToDelete.addAll(selectedBookmarks)
var bookmarkTree = currentRoot
pendingBookmarksToDelete.forEach {
bookmarkTree -= it.guid
}
getManagedEmitter<BookmarkChange>().onNext(BookmarkChange.Change(bookmarkTree!!))
val deleteOperation: (suspend () -> Unit) = {
deleteSelectedBookmarks(selectedBookmarks)
pendingBookmarkDeletionJob = null
// Since this runs in a coroutine, we can't depend on the fragment still being attached.
metrics()?.track(Event.RemoveBookmarks)
refreshBookmarks()
}
pendingBookmarkDeletionJob = deleteOperation
lifecycleScope.allowUndo(
view!!, getString(R.string.bookmark_deletion_multiple_snackbar_message),
getString(R.string.bookmark_undo_deletion), {
pendingBookmarksToDelete.removeAll(selectedBookmarks)
pendingBookmarkDeletionJob = null
refreshBookmarks()
}, operation = deleteOperation
)
deleteMulti(getSelectedBookmarks())
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onBackPressed(): Boolean = (bookmarkComponent.uiView as BookmarkUIView).onBackPressed()
override fun onBackPressed(): Boolean = bookmarkView.onBackPressed()
override fun onAuthenticated(account: OAuthAccount) {
getManagedEmitter<SignInChange>().onNext(SignInChange.SignedIn)
bookmarkInteractor.signedIn()
lifecycleScope.launch {
refreshBookmarks()
}
}
override fun onLoggedOut() {
getManagedEmitter<SignInChange>().onNext(SignInChange.SignedOut)
bookmarkInteractor.signedOut()
}
override fun onAuthenticationProblems() {
@ -424,7 +252,23 @@ class BookmarkFragment : Fragment(), BackHandler, AccountObserver {
override fun onProfileUpdated(profile: Profile) {
}
private fun getSelectedBookmarks() = (bookmarkComponent.uiView as BookmarkUIView).getSelected()
private fun getSelectedBookmarks() = bookmarkView.getSelected()
private suspend fun refreshBookmarks() {
context?.bookmarkStorage()?.getTree(bookmarkStore.state.tree!!.guid, false).withOptionalDesktopFolders(context)
?.let { node ->
var rootNode = node
pendingBookmarksToDelete.forEach {
rootNode -= it.guid
}
bookmarkInteractor.change(rootNode)
}
}
override fun onPause() {
invokePendingDeletion()
super.onPause()
}
private suspend fun deleteSelectedBookmarks(selected: Set<BookmarkNode> = getSelectedBookmarks()) {
selected.forEach {
@ -432,20 +276,48 @@ class BookmarkFragment : Fragment(), BackHandler, AccountObserver {
}
}
private suspend fun refreshBookmarks() {
context?.bookmarkStorage()?.getTree(currentRoot!!.guid, false).withOptionalDesktopFolders(context)
?.let { node ->
var rootNode = node
pendingBookmarksToDelete.forEach {
rootNode -= it.guid
}
getManagedEmitter<BookmarkChange>().onNext(BookmarkChange.Change(rootNode))
}
}
private fun deleteMulti(selected: Set<BookmarkNode>, eventType: Event = Event.RemoveBookmarks) {
pendingBookmarksToDelete.addAll(selected)
override fun onPause() {
invokePendingDeletion()
super.onPause()
var bookmarkTree = currentRoot
pendingBookmarksToDelete.forEach {
bookmarkTree -= it.guid
}
bookmarkInteractor.change(bookmarkTree!!)
val deleteOperation: (suspend () -> Unit) = {
deleteSelectedBookmarks(selected)
pendingBookmarkDeletionJob = null
// Since this runs in a coroutine, we can't depend upon the fragment still being attached
metrics?.track(Event.RemoveBookmarks)
refreshBookmarks()
}
pendingBookmarkDeletionJob = deleteOperation
val message = when (eventType) {
is Event.RemoveBookmarks -> {
getString(R.string.bookmark_deletion_multiple_snackbar_message)
}
is Event.RemoveBookmarkFolder,
is Event.RemoveBookmark -> {
val bookmarkNode = selected.first()
getString(
R.string.bookmark_deletion_snackbar_message,
bookmarkNode.url?.urlToTrimmedHost(context!!) ?: bookmarkNode.title
)
}
else -> throw IllegalStateException("Illegal event type in deleteMulti")
}
lifecycleScope.allowUndo(
view!!, message,
getString(R.string.bookmark_undo_deletion), {
pendingBookmarksToDelete.removeAll(selected)
pendingBookmarkDeletionJob = null
refreshBookmarks()
}, operation = deleteOperation
)
}
private fun invokePendingDeletion() {
@ -457,14 +329,4 @@ class BookmarkFragment : Fragment(), BackHandler, AccountObserver {
}
}
}
private fun BookmarkNode.copyUrl(context: Context) {
context.getSystemService<ClipboardManager>()?.apply {
primaryClip = ClipData.newPlainText(url, url)
}
}
private fun metrics(): MetricController? {
return context?.components?.analytics?.metrics
}
}

View File

@ -0,0 +1,181 @@
/* 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.bookmarks
import android.content.Context
import androidx.navigation.NavController
import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.storage.BookmarkNodeType
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.BrowsingModeManager
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbarPresenter
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.asActivity
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.copyUrl
import org.mozilla.fenix.ext.nav
/**
* Interactor for the Bookmarks screen.
* Provides implementations for the BookmarkViewInteractor.
*
* @property context The current Android Context
* @property navController The Android Navigation NavController
* @property bookmarkStore The BookmarkStore
* @property sharedViewModel The shared ViewModel used between the Bookmarks screens
* @property snackbarPresenter A presenter for the FenixSnackBar
* @property deleteBookmarkNodes A lambda function for deleting bookmark nodes with undo
*/
@SuppressWarnings("TooManyFunctions")
class BookmarkFragmentInteractor(
private val context: Context,
private val navController: NavController,
private val bookmarkStore: BookmarkStore,
private val sharedViewModel: BookmarksSharedViewModel,
private val snackbarPresenter: FenixSnackbarPresenter,
private val deleteBookmarkNodes: (Set<BookmarkNode>, Event) -> Unit
) : BookmarkViewInteractor, SignInInteractor {
val activity: HomeActivity?
get() = context.asActivity() as? HomeActivity
val metrics: MetricController
get() = context.components.analytics.metrics
override fun change(node: BookmarkNode) {
bookmarkStore.dispatch(BookmarkAction.Change(node))
}
override fun open(item: BookmarkNode) {
require(item.type == BookmarkNodeType.ITEM)
item.url?.let { url ->
activity!!
.openToBrowserAndLoad(
searchTermOrURL = url,
newTab = true,
from = BrowserDirection.FromBookmarks
)
}
metrics.track(Event.OpenedBookmark)
}
override fun expand(folder: BookmarkNode) {
require(folder.type == BookmarkNodeType.FOLDER)
navController.nav(
R.id.bookmarkFragment,
BookmarkFragmentDirections.actionBookmarkFragmentSelf(folder.guid)
)
}
override fun switchMode(mode: BookmarkState.Mode) {
activity?.invalidateOptionsMenu()
}
override fun edit(node: BookmarkNode) {
navController.nav(
R.id.bookmarkFragment,
BookmarkFragmentDirections
.actionBookmarkFragmentToBookmarkEditFragment(node.guid)
)
}
override fun select(node: BookmarkNode) {
bookmarkStore.dispatch(BookmarkAction.Select(node))
}
override fun deselect(node: BookmarkNode) {
bookmarkStore.dispatch(BookmarkAction.Deselect(node))
}
override fun deselectAll() {
bookmarkStore.dispatch(BookmarkAction.DeselectAll)
}
override fun copy(item: BookmarkNode) {
require(item.type == BookmarkNodeType.ITEM)
item.copyUrl(activity!!)
snackbarPresenter.present(context.getString(R.string.url_copied))
metrics.track(Event.CopyBookmark)
}
override fun share(item: BookmarkNode) {
require(item.type == BookmarkNodeType.ITEM)
item.url?.apply {
navController.nav(
R.id.bookmarkFragment,
BookmarkFragmentDirections.actionBookmarkFragmentToShareFragment(
url = this,
title = item.title
)
)
metrics.track(Event.ShareBookmark)
}
}
override fun openInNewTab(item: BookmarkNode) {
require(item.type == BookmarkNodeType.ITEM)
item.url?.let { url ->
activity?.browsingModeManager?.mode =
BrowsingModeManager.Mode.Normal
activity?.openToBrowserAndLoad(
searchTermOrURL = url,
newTab = true,
from = BrowserDirection.FromBookmarks
)
metrics.track(Event.OpenedBookmarkInNewTab)
}
}
override fun openInPrivateTab(item: BookmarkNode) {
require(item.type == BookmarkNodeType.ITEM)
item.url?.let { url ->
activity?.browsingModeManager?.mode =
BrowsingModeManager.Mode.Private
activity?.openToBrowserAndLoad(
searchTermOrURL = url,
newTab = true,
from = BrowserDirection.FromBookmarks
)
metrics.track(Event.OpenedBookmarkInPrivateTab)
}
}
override fun delete(node: BookmarkNode) {
val eventType = when (node.type) {
BookmarkNodeType.ITEM -> {
Event.RemoveBookmark
}
BookmarkNodeType.FOLDER -> {
Event.RemoveBookmarkFolder
}
BookmarkNodeType.SEPARATOR -> {
throw IllegalStateException("Cannot delete separators")
}
}
deleteBookmarkNodes(setOf(node), eventType)
}
override fun deleteMulti(nodes: Set<BookmarkNode>) {
deleteBookmarkNodes(nodes, Event.RemoveBookmarks)
}
override fun backPressed() {
navController.popBackStack()
}
override fun clickedSignIn() {
context.components.services.launchPairingSignIn(context, navController)
}
override fun signedIn() {
sharedViewModel.signedIn.postValue(true)
}
override fun signedOut() {
sharedViewModel.signedIn.postValue(false)
}
}

View File

@ -0,0 +1,80 @@
/* 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.bookmarks
import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
class BookmarkStore(
initalState: BookmarkState
) : Store<BookmarkState, BookmarkAction>(
initalState, ::bookmarkStateReducer
)
/**
* The complete state of the bookmarks tree and multi-selection mode
* @property tree The current tree of bookmarks, if one is loaded
* @property mode The current bookmark multi-selection mode
*/
data class BookmarkState(val tree: BookmarkNode?, val mode: Mode = Mode.Normal) : State {
sealed class Mode {
object Normal : Mode()
data class Selecting(val selectedItems: Set<BookmarkNode>) : Mode()
}
}
/**
* Actions to dispatch through the `BookmarkStore` to modify `BookmarkState` through the reducer.
*/
sealed class BookmarkAction : Action {
data class Change(val tree: BookmarkNode) : BookmarkAction()
data class Select(val item: BookmarkNode) : BookmarkAction()
data class Deselect(val item: BookmarkNode) : BookmarkAction()
object DeselectAll : BookmarkAction()
}
/**
* Reduces the bookmarks state from the current state and an action performed on it.
* @param state the current bookmarks state
* @param action the action to perform
* @return the new bookmarks state
*/
fun bookmarkStateReducer(state: BookmarkState, action: BookmarkAction): BookmarkState {
return when (action) {
is BookmarkAction.Change -> {
val mode =
if (state.mode is BookmarkState.Mode.Selecting) {
val items = state.mode.selectedItems.filter {
it in action.tree
}.toSet()
if (items.isEmpty()) BookmarkState.Mode.Normal else BookmarkState.Mode.Selecting(items)
} else state.mode
state.copy(tree = action.tree, mode = mode)
}
is BookmarkAction.Select -> {
val selectedItems = if (state.mode is BookmarkState.Mode.Selecting) {
state.mode.selectedItems + action.item
} else setOf(action.item)
state.copy(mode = BookmarkState.Mode.Selecting(selectedItems))
}
is BookmarkAction.Deselect -> {
val selectedItems = if (state.mode is BookmarkState.Mode.Selecting) {
state.mode.selectedItems - action.item
} else setOf()
val mode =
if (selectedItems.isEmpty()) BookmarkState.Mode.Normal else BookmarkState.Mode.Selecting(selectedItems)
state.copy(mode = mode)
}
BookmarkAction.DeselectAll -> {
state.copy(mode = BookmarkState.Mode.Normal)
}
}
}
operator fun BookmarkNode.contains(item: BookmarkNode): Boolean {
return children?.contains(item) ?: false
}

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.bookmarks
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.LinearLayout
import io.reactivex.Observable
import io.reactivex.Observer
import io.reactivex.functions.Consumer
import kotlinx.android.synthetic.main.component_bookmark.view.*
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.concept.storage.BookmarkNode
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 BookmarkUIView(
container: ViewGroup,
actionEmitter: Observer<BookmarkAction>,
changesObservable: Observable<BookmarkChange>
) :
LibraryPageUIView<BookmarkState, BookmarkAction, BookmarkChange>(container, actionEmitter, changesObservable),
BackHandler {
var mode: BookmarkState.Mode = BookmarkState.Mode.Normal
private set
var tree: BookmarkNode? = null
private set
private var canGoBack = false
override val view: LinearLayout = LayoutInflater.from(container.context)
.inflate(R.layout.component_bookmark, container, true) as LinearLayout
private val bookmarkAdapter: BookmarkAdapter
init {
view.bookmark_list.apply {
bookmarkAdapter = BookmarkAdapter(view.bookmarks_empty_view, actionEmitter)
adapter = bookmarkAdapter
}
}
override fun updateView() = Consumer<BookmarkState> {
canGoBack = !(listOf(null, BookmarkRoot.Root.id).contains(it.tree?.guid))
if (it.tree != tree) {
tree = it.tree
}
if (it.mode != mode) {
mode = it.mode
actionEmitter.onNext(BookmarkAction.SwitchMode)
}
when (val modeCopy = it.mode) {
is BookmarkState.Mode.Normal -> setUIForNormalMode(it.tree)
is BookmarkState.Mode.Selecting -> setUIForSelectingMode(it.tree, modeCopy)
}
}
override fun onBackPressed(): Boolean {
return when {
mode is BookmarkState.Mode.Selecting -> {
actionEmitter.onNext(BookmarkAction.DeselectAll)
true
}
canGoBack -> {
actionEmitter.onNext(BookmarkAction.BackPressed)
true
}
else -> false
}
}
fun getSelected(): Set<BookmarkNode> = bookmarkAdapter.selected
private fun setUIForSelectingMode(
root: BookmarkNode?,
mode: BookmarkState.Mode.Selecting
) {
bookmarkAdapter.updateData(root, mode)
activity?.title =
context.getString(R.string.bookmarks_multi_select_title, mode.selectedItems.size)
setToolbarColors(
R.color.white_color,
R.attr.accentHighContrast.getColorIntFromAttr(context!!)
)
}
private fun setUIForNormalMode(root: BookmarkNode?) {
bookmarkAdapter.updateData(root, BookmarkState.Mode.Normal)
setTitle(root)
setToolbarColors(
R.attr.primaryText.getColorIntFromAttr(context!!),
R.attr.foundation.getColorIntFromAttr(context)
)
}
private fun setTitle(root: BookmarkNode?) {
activity?.title = when (root?.guid) {
BookmarkRoot.Mobile.id, null -> context.getString(R.string.library_bookmarks)
else -> root.title
}
}
}

View File

@ -0,0 +1,216 @@
/* 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.bookmarks
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_bookmark.view.*
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.support.base.feature.BackHandler
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.getColorIntFromAttr
import org.mozilla.fenix.library.LibraryPageView
/**
* Interface for the Bookmarks view.
* This interface is implemented by objects that want to respond to user interaction on the bookmarks management UI.
*/
@SuppressWarnings("TooManyFunctions")
interface BookmarkViewInteractor {
/**
* Swaps the head of the bookmarks tree, replacing it with a new, updated bookmarks tree.
*
* @param node the head node of the new bookmarks tree
*/
fun change(node: BookmarkNode)
/**
* Opens a tab for a bookmark item.
*
* @param item the bookmark item to open
*/
fun open(item: BookmarkNode)
/**
* Expands a bookmark folder in the bookmarks tree, providing a view of a different folder elsewhere in the tree.
*
* @param folder the bookmark folder to expand
*/
fun expand(folder: BookmarkNode)
/**
* Switches the current bookmark multi-selection mode.
*
* @param mode the multi-select mode to switch to
*/
fun switchMode(mode: BookmarkState.Mode)
/**
* Opens up an interface to edit a bookmark node.
*
* @param node the bookmark node to edit
*/
fun edit(node: BookmarkNode)
/**
* Selects a bookmark node in multi-selection.
*
* @param node the bookmark node to select
*/
fun select(node: BookmarkNode)
/**
* De-selects a bookmark node in multi-selection.
*
* @param node the bookmark node to deselect
*/
fun deselect(node: BookmarkNode)
/**
* De-selects all bookmark nodes, clearing the multi-selection mode.
*
*/
fun deselectAll()
/**
* Copies the URL of a bookmark item to the copy-paste buffer.
*
* @param item the bookmark item to copy the URL from
*/
fun copy(item: BookmarkNode)
/**
* Opens the share sheet for a bookmark item.
*
* @param item the bookmark item to share
*/
fun share(item: BookmarkNode)
/**
* Opens a bookmark item in a new tab.
*
* @param item the bookmark item to open in a new tab
*/
fun openInNewTab(item: BookmarkNode)
/**
* Opens a bookmark item in a private tab.
*
* @param item the bookmark item to open in a private tab
*/
fun openInPrivateTab(item: BookmarkNode)
/**
* Deletes a bookmark node.
*
* @param node the bookmark node to delete
*/
fun delete(node: BookmarkNode)
/**
* Deletes a set of bookmark nodes.
*
* @param nodes the set of bookmark nodes to delete
*/
fun deleteMulti(nodes: Set<BookmarkNode>)
/**
* Handles back presses for the bookmark screen, so navigation up the tree is possible.
*
*/
fun backPressed()
}
class BookmarkView(
private val container: ViewGroup,
val interactor: BookmarkViewInteractor
) : LibraryPageView(container), LayoutContainer, BackHandler {
override val containerView: View?
get() = container
var mode: BookmarkState.Mode = BookmarkState.Mode.Normal
private set
var tree: BookmarkNode? = null
private set
private var canGoBack = false
val view: LinearLayout = LayoutInflater.from(container.context)
.inflate(R.layout.component_bookmark, container, true) as LinearLayout
private val bookmarkAdapter: BookmarkAdapter
init {
view.bookmark_list.apply {
bookmarkAdapter = BookmarkAdapter(view.bookmarks_empty_view, interactor)
adapter = bookmarkAdapter
}
}
fun update(state: BookmarkState) {
canGoBack = !(listOf(null, BookmarkRoot.Root.id).contains(state.tree?.guid))
if (state.tree != tree) {
tree = state.tree
}
if (state.mode != mode) {
mode = state.mode
interactor.switchMode(mode)
}
when (val modeCopy = state.mode) {
is BookmarkState.Mode.Normal -> setUIForNormalMode(state.tree)
is BookmarkState.Mode.Selecting -> setUIForSelectingMode(state.tree, modeCopy)
}
}
override fun onBackPressed(): Boolean {
return when {
mode is BookmarkState.Mode.Selecting -> {
interactor.deselectAll()
true
}
canGoBack -> {
interactor.backPressed()
true
}
else -> false
}
}
fun getSelected(): Set<BookmarkNode> = bookmarkAdapter.selected
private fun setUIForSelectingMode(
root: BookmarkNode?,
mode: BookmarkState.Mode.Selecting
) {
bookmarkAdapter.updateData(root, mode)
activity?.title =
context.getString(R.string.bookmarks_multi_select_title, mode.selectedItems.size)
setToolbarColors(
R.color.white_color,
R.attr.accentHighContrast.getColorIntFromAttr(context)
)
}
private fun setUIForNormalMode(root: BookmarkNode?) {
bookmarkAdapter.updateData(root, BookmarkState.Mode.Normal)
setTitle(root)
setToolbarColors(
R.attr.primaryText.getColorIntFromAttr(context),
R.attr.foundation.getColorIntFromAttr(context)
)
}
private fun setTitle(root: BookmarkNode?) {
activity?.title = when (root?.guid) {
BookmarkRoot.Mobile.id, null -> context.getString(R.string.library_bookmarks)
else -> root.title
}
}
}

View File

@ -4,9 +4,11 @@
package org.mozilla.fenix.library.bookmarks
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import mozilla.components.concept.storage.BookmarkNode
class BookmarksSharedViewModel : ViewModel() {
var signedIn = MutableLiveData<Boolean>().apply { postValue(true) }
var selectedFolder: BookmarkNode? = null
}

View File

@ -1,59 +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.bookmarks
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.Reducer
import org.mozilla.fenix.mvi.UIComponent
import org.mozilla.fenix.mvi.UIComponentViewModelBase
import org.mozilla.fenix.mvi.UIComponentViewModelProvider
import org.mozilla.fenix.mvi.UIView
class SignInComponent(
private val container: ViewGroup,
bus: ActionBusFactory,
viewModelProvider: UIComponentViewModelProvider<SignInState, SignInChange>
) : UIComponent<SignInState, SignInAction, SignInChange>(
bus.getManagedEmitter(SignInAction::class.java),
bus.getSafeManagedObservable(SignInChange::class.java),
viewModelProvider
) {
override fun initView(): UIView<SignInState, SignInAction, SignInChange> =
SignInUIView(container, actionEmitter, changesObservable)
init {
bind()
}
}
data class SignInState(val signedIn: Boolean) : ViewState
sealed class SignInAction : Action {
object ClickedSignIn : SignInAction()
}
sealed class SignInChange : Change {
object SignedIn : SignInChange()
object SignedOut : SignInChange()
}
class SignInViewModel(
initialState: SignInState
) : UIComponentViewModelBase<SignInState, SignInChange>(initialState, reducer) {
companion object {
val reducer = object : Reducer<SignInState, SignInChange> {
override fun invoke(state: SignInState, change: SignInChange): SignInState {
return when (change) {
SignInChange.SignedIn -> state.copy(signedIn = true)
SignInChange.SignedOut -> state.copy(signedIn = false)
}
}
}
}
}

View File

@ -1,36 +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.bookmarks
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.material.button.MaterialButton
import io.reactivex.Observable
import io.reactivex.Observer
import io.reactivex.functions.Consumer
import org.mozilla.fenix.R
import org.mozilla.fenix.mvi.UIView
class SignInUIView(
container: ViewGroup,
actionEmitter: Observer<SignInAction>,
changesObservable: Observable<SignInChange>
) : UIView<SignInState, SignInAction, SignInChange>(container, actionEmitter, changesObservable) {
override val view: MaterialButton = LayoutInflater.from(container.context)
.inflate(R.layout.component_sign_in, container, true)
.findViewById(R.id.bookmark_folders_sign_in)
init {
view.setOnClickListener {
actionEmitter.onNext(SignInAction.ClickedSignIn)
}
}
override fun updateView() = Consumer<SignInState> {
view.visibility = if (it.signedIn) View.GONE else View.VISIBLE
}
}

View File

@ -0,0 +1,41 @@
/* 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.bookmarks
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.google.android.material.button.MaterialButton
import kotlinx.android.extensions.LayoutContainer
import org.mozilla.fenix.R
interface SignInInteractor {
fun clickedSignIn()
fun signedIn()
fun signedOut()
}
class SignInView(
private val container: ViewGroup,
private val interactor: SignInInteractor
) : LayoutContainer {
override val containerView: View?
get() = container
val view: MaterialButton = LayoutInflater.from(container.context)
.inflate(R.layout.component_sign_in, container, true)
.findViewById(R.id.bookmark_folders_sign_in)
init {
view.setOnClickListener {
interactor.clickedSignIn()
}
}
fun update(signedIn: Boolean) {
view.visibility = if (signedIn) View.GONE else View.VISIBLE
}
}

View File

@ -16,7 +16,9 @@ import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import kotlinx.android.synthetic.main.fragment_select_bookmark_folder.*
import kotlinx.android.synthetic.main.fragment_select_bookmark_folder.view.*
import kotlinx.coroutines.Dispatchers.IO
@ -27,8 +29,6 @@ import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.concept.sync.Profile
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.FenixViewModelProvider
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.getColorFromAttr
@ -37,14 +37,7 @@ import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.setRootTitles
import org.mozilla.fenix.ext.withOptionalDesktopFolders
import org.mozilla.fenix.library.bookmarks.BookmarksSharedViewModel
import org.mozilla.fenix.library.bookmarks.SignInAction
import org.mozilla.fenix.library.bookmarks.SignInChange
import org.mozilla.fenix.library.bookmarks.SignInComponent
import org.mozilla.fenix.library.bookmarks.SignInState
import org.mozilla.fenix.library.bookmarks.SignInViewModel
import org.mozilla.fenix.mvi.ActionBusFactory
import org.mozilla.fenix.mvi.getAutoDisposeObservable
import org.mozilla.fenix.mvi.getManagedEmitter
import org.mozilla.fenix.library.bookmarks.SignInView
@SuppressWarnings("TooManyFunctions")
class SelectBookmarkFolderFragment : Fragment(), AccountObserver {
@ -52,8 +45,8 @@ class SelectBookmarkFolderFragment : Fragment(), AccountObserver {
private val sharedViewModel: BookmarksSharedViewModel by activityViewModels()
private var folderGuid: String? = null
private var bookmarkNode: BookmarkNode? = null
private lateinit var signInComponent: SignInComponent
private lateinit var signInView: SignInView
private lateinit var bookmarkInteractor: SelectBookmarkFolderInteractor
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -62,32 +55,22 @@ class SelectBookmarkFolderFragment : Fragment(), AccountObserver {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_select_bookmark_folder, container, false)
signInComponent = SignInComponent(
view.select_bookmark_layout,
ActionBusFactory.get(this),
FenixViewModelProvider.create(
this,
SignInViewModel::class.java
) {
SignInViewModel(SignInState(false))
}
bookmarkInteractor = SelectBookmarkFolderInteractor(
context!!,
findNavController(),
sharedViewModel
)
signInView = SignInView(view.select_bookmark_layout, bookmarkInteractor)
return view
}
override fun onStart() {
super.onStart()
getAutoDisposeObservable<SignInAction>()
.subscribe {
when (it) {
is SignInAction.ClickedSignIn -> {
requireComponents.services.accountsAuthFeature.beginAuthentication(requireContext())
view?.let {
(activity as HomeActivity).openToBrowser(BrowserDirection.FromBookmarksFolderSelect)
}
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sharedViewModel.signedIn.observe(this@SelectBookmarkFolderFragment, Observer<Boolean> {
signInView.update(it)
})
}
override fun onResume() {
@ -118,8 +101,8 @@ class SelectBookmarkFolderFragment : Fragment(), AccountObserver {
private fun checkIfSignedIn() {
val accountManager = requireComponents.backgroundServices.accountManager
accountManager.register(this, owner = this)
accountManager.authenticatedAccount()?.let { getManagedEmitter<SignInChange>().onNext(SignInChange.SignedIn) }
?: getManagedEmitter<SignInChange>().onNext(SignInChange.SignedOut)
accountManager.authenticatedAccount()?.let { bookmarkInteractor.signedIn() }
?: bookmarkInteractor.signedOut()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -151,11 +134,11 @@ class SelectBookmarkFolderFragment : Fragment(), AccountObserver {
}
override fun onAuthenticated(account: OAuthAccount) {
getManagedEmitter<SignInChange>().onNext(SignInChange.SignedIn)
bookmarkInteractor.signedIn()
}
override fun onLoggedOut() {
getManagedEmitter<SignInChange>().onNext(SignInChange.SignedOut)
bookmarkInteractor.signedOut()
}
override fun onProfileUpdated(profile: Profile) {

View File

@ -0,0 +1,30 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.library.bookmarks.selectfolder
import android.content.Context
import androidx.navigation.NavController
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.library.bookmarks.BookmarksSharedViewModel
import org.mozilla.fenix.library.bookmarks.SignInInteractor
class SelectBookmarkFolderInteractor(
private val context: Context,
private val navController: NavController,
private val sharedViewModel: BookmarksSharedViewModel
) : SignInInteractor {
override fun clickedSignIn() {
context.components.services.launchPairingSignIn(context, navController)
}
override fun signedIn() {
sharedViewModel.signedIn.postValue(true)
}
override fun signedOut() {
sharedViewModel.signedIn.postValue(false)
}
}

View File

@ -17,6 +17,7 @@ import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference
import androidx.preference.Preference.OnPreferenceClickListener
import androidx.preference.PreferenceCategory
@ -25,10 +26,8 @@ import kotlinx.coroutines.launch
import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.concept.sync.Profile
import mozilla.components.support.ktx.android.content.hasCamera
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.Config
import org.mozilla.fenix.Experiments
import org.mozilla.fenix.FenixApplication
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
@ -56,7 +55,6 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.isInExperiment
import org.mozilla.fenix.utils.ItsNotBrokenSnack
@SuppressWarnings("TooManyFunctions", "LargeClass")
@ -225,21 +223,7 @@ class SettingsFragment : PreferenceFragmentCompat(), AccountObserver {
private fun getClickListenerForSignIn(): OnPreferenceClickListener {
return OnPreferenceClickListener {
// Do not navigate to pairing UI if camera not available or pairing is disabled
if (context?.hasCamera() == true &&
context?.isInExperiment(Experiments.asFeatureFxAPairingDisabled) == false
) {
val directions = SettingsFragmentDirections.actionSettingsFragmentToTurnOnSyncFragment()
Navigation.findNavController(view!!).navigate(directions)
} else {
requireComponents.services.accountsAuthFeature.beginAuthentication(requireContext())
// TODO The sign-in web content populates session history,
// so pressing "back" after signing in won't take us back into the settings screen, but rather up the
// session history stack.
// We could auto-close this tab once we get to the end of the authentication process?
// Via an interceptor, perhaps.
requireComponents.analytics.metrics.track(Event.SyncAuthSignIn)
}
context!!.components.services.launchPairingSignIn(context!!, findNavController())
true
}
}

View File

@ -15,6 +15,10 @@
android:id="@+id/action_global_crash_reporter"
app:destination="@id/crashReporterFragment" />
<action
android:id="@+id/action_global_turn_on_sync"
app:destination="@id/turnOnSyncFragment" />
<fragment
android:id="@+id/homeFragment"
android:name="org.mozilla.fenix.home.HomeFragment"

View File

@ -4,22 +4,15 @@
package org.mozilla.fenix.library.bookmarks
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verifyOrder
import io.mockk.verifySequence
import io.reactivex.Observer
import io.reactivex.observers.TestObserver
import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.storage.BookmarkNodeType
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.TestApplication
import org.mozilla.fenix.TestUtils.setRxSchedulers
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@ -28,20 +21,16 @@ import org.robolectric.annotation.Config
internal class BookmarkAdapterTest {
private lateinit var bookmarkAdapter: BookmarkAdapter
private lateinit var emitter: Observer<BookmarkAction>
@Before
fun setup() {
setRxSchedulers()
emitter = TestObserver()
bookmarkAdapter = spyk(
BookmarkAdapter(mockk(), emitter), recordPrivateCalls = true
BookmarkAdapter(mockk(relaxed = true), mockk())
)
every { bookmarkAdapter.notifyDataSetChanged() } just Runs
}
@Test
fun `update adapter from tree of bookmark nodes`() {
fun `update adapter from tree of bookmark nodes, null tree returns empty list`() {
val tree = BookmarkNode(
BookmarkNodeType.FOLDER, "123", null, 0, "Mobile", null, listOf(
BookmarkNode(BookmarkNodeType.ITEM, "456", "123", 0, "Mozilla", "http://mozilla.org", null),
@ -58,21 +47,12 @@ internal class BookmarkAdapterTest {
)
)
bookmarkAdapter.updateData(tree, BookmarkState.Mode.Normal)
bookmarkAdapter.updateData(null, BookmarkState.Mode.Normal)
verifyOrder {
bookmarkAdapter.updateData(tree, BookmarkState.Mode.Normal)
bookmarkAdapter setProperty "tree" value tree.children
bookmarkAdapter setProperty "mode" value BookmarkState.Mode.Normal
bookmarkAdapter.notifyItemRangeInserted(0, 3)
}
}
@Test
fun `passing null tree returns empty list`() {
bookmarkAdapter.updateData(null, BookmarkState.Mode.Normal)
verifySequence {
bookmarkAdapter.updateData(null, BookmarkState.Mode.Normal)
bookmarkAdapter setProperty "tree" value listOf<BookmarkNode?>()
bookmarkAdapter setProperty "mode" value BookmarkState.Mode.Normal
bookmarkAdapter.notifyItemRangeRemoved(0, 3)
}
}
}

View File

@ -0,0 +1,291 @@
/* 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.bookmarks
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.core.content.getSystemService
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.spyk
import io.mockk.verify
import io.mockk.verifyOrder
import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.storage.BookmarkNodeType
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.FenixApplication
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbarPresenter
import org.mozilla.fenix.components.Services
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.asActivity
import org.mozilla.fenix.ext.components
class BookmarkFragmentInteractorTest {
private lateinit var interactor: BookmarkFragmentInteractor
private val context: Context = mockk(relaxed = true)
private val navController: NavController = mockk(relaxed = true)
private val bookmarkStore = spyk(BookmarkStore(BookmarkState(null)))
private val sharedViewModel: BookmarksSharedViewModel = mockk(relaxed = true)
private val snackbarPresenter: FenixSnackbarPresenter = mockk(relaxed = true)
private val deleteBookmarkNodes: (Set<BookmarkNode>, Event) -> Unit = mockk(relaxed = true)
private val applicationContext: FenixApplication = mockk(relaxed = true)
private val homeActivity: HomeActivity = mockk(relaxed = true)
private val metrics: MetricController = mockk(relaxed = true)
private val item = BookmarkNode(BookmarkNodeType.ITEM, "456", "123", 0, "Mozilla", "http://mozilla.org", null)
private val separator = BookmarkNode(BookmarkNodeType.SEPARATOR, "789", "123", 1, null, null, null)
private val subfolder = BookmarkNode(BookmarkNodeType.FOLDER, "987", "123", 0, "Subfolder", null, listOf())
private val childItem = BookmarkNode(
BookmarkNodeType.ITEM,
"987",
"123",
2,
"Firefox",
"https://www.mozilla.org/en-US/firefox/",
null
)
private val tree = BookmarkNode(
BookmarkNodeType.FOLDER, "123", null, 0, "Mobile", null, listOf(item, separator, childItem, subfolder)
)
@Before
fun setup() {
mockkStatic(
"org.mozilla.fenix.ext.ContextKt",
"androidx.core.content.ContextCompat",
"android.content.ClipData"
)
every { any<Context>().asActivity() } returns homeActivity
every { context.applicationContext } returns applicationContext
every { applicationContext.components.analytics.metrics } returns metrics
every { navController.currentDestination } returns NavDestination("").apply { id = R.id.bookmarkFragment }
every { bookmarkStore.dispatch(any()) } returns mockk()
interactor =
BookmarkFragmentInteractor(
context,
navController,
bookmarkStore,
sharedViewModel,
snackbarPresenter,
deleteBookmarkNodes
)
}
@Test
fun `update bookmarks tree`() {
interactor.change(tree)
verify {
bookmarkStore.dispatch(BookmarkAction.Change(tree))
}
}
@Test
fun `open a bookmark item`() {
interactor.open(item)
val url = item.url!!
verifyOrder {
homeActivity.openToBrowserAndLoad(
searchTermOrURL = url,
newTab = true,
from = BrowserDirection.FromBookmarks
)
}
metrics.track(Event.OpenedBookmark)
}
@Test
fun `expand a level of bookmarks`() {
interactor.expand(tree)
verify {
navController.navigate(BookmarkFragmentDirections.actionBookmarkFragmentSelf(tree.guid))
}
}
@Test
fun `switch between bookmark selection modes`() {
interactor.switchMode(BookmarkState.Mode.Normal)
verify {
homeActivity.invalidateOptionsMenu()
}
}
@Test
fun `press the edit bookmark button`() {
interactor.edit(item)
verify {
navController.navigate(BookmarkFragmentDirections.actionBookmarkFragmentToBookmarkEditFragment(item.guid))
}
}
@Test
fun `select a bookmark item`() {
interactor.select(item)
verify {
bookmarkStore.dispatch(BookmarkAction.Select(item))
}
}
@Test
fun `deselect a bookmark item`() {
interactor.deselect(item)
verify {
bookmarkStore.dispatch(BookmarkAction.Deselect(item))
}
}
@Test
fun `deselectAll bookmark items`() {
interactor.deselectAll()
verify {
bookmarkStore.dispatch(BookmarkAction.DeselectAll)
}
}
@Test
fun `copy a bookmark item`() {
val clipboardManager: ClipboardManager = mockk(relaxed = true)
every { any<Context>().getSystemService<ClipboardManager>() } returns clipboardManager
every { ClipData.newPlainText(any(), any()) } returns mockk(relaxed = true)
interactor.copy(item)
verify {
metrics.track(Event.CopyBookmark)
}
}
@Test
fun `share a bookmark item`() {
interactor.share(item)
verifyOrder {
navController.navigate(
BookmarkFragmentDirections.actionBookmarkFragmentToShareFragment(
item.url,
item.title
)
)
metrics.track(Event.ShareBookmark)
}
}
@Test
fun `open a bookmark item in a new tab`() {
interactor.openInNewTab(item)
val url = item.url!!
verifyOrder {
homeActivity.openToBrowserAndLoad(
searchTermOrURL = url,
newTab = true,
from = BrowserDirection.FromBookmarks
)
metrics.track(Event.OpenedBookmarkInNewTab)
}
}
@Test
fun `open a bookmark item in a private tab`() {
interactor.openInPrivateTab(item)
val url = item.url!!
verifyOrder {
homeActivity.openToBrowserAndLoad(
searchTermOrURL = url,
newTab = true,
from = BrowserDirection.FromBookmarks
)
metrics.track(Event.OpenedBookmarkInPrivateTab)
}
}
@Test
fun `delete a bookmark item`() {
interactor.delete(item)
verify {
deleteBookmarkNodes(setOf(item), Event.RemoveBookmark)
}
}
@Test
fun `delete a bookmark folder`() {
interactor.delete(subfolder)
verify {
deleteBookmarkNodes(setOf(subfolder), Event.RemoveBookmarkFolder)
}
}
@Test
fun `delete multiple bookmarks`() {
interactor.deleteMulti(setOf(item, subfolder))
verify {
deleteBookmarkNodes(setOf(item, subfolder), Event.RemoveBookmarks)
}
}
@Test
fun `press the back button`() {
interactor.backPressed()
verify {
navController.popBackStack()
}
}
@Test
fun `clicked sign in on bookmarks screen`() {
val services: Services = mockk(relaxed = true)
every { context.components.services } returns services
interactor.clickedSignIn()
verify {
context.components.services
services.launchPairingSignIn(context, navController)
}
}
@Test
fun `got signed in signal on bookmarks screen`() {
interactor.signedIn()
verify {
sharedViewModel.signedIn.postValue(true)
}
}
@Test
fun `got signed out signal on bookmarks screen`() {
interactor.signedOut()
verify {
sharedViewModel.signedIn.postValue(false)
}
}
}

View File

@ -1,58 +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.bookmarks
import androidx.fragment.app.testing.FragmentScenario
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.navigation.NavController
import androidx.navigation.Navigation
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import mozilla.appservices.places.BookmarkRoot
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R
import org.mozilla.fenix.TestApplication
import org.mozilla.fenix.TestUtils
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(application = TestApplication::class)
class BookmarkFragmentTest {
private lateinit var scenario: FragmentScenario<BookmarkFragment>
@Before
fun setup() {
TestUtils.setRxSchedulers()
val mockNavController = mockk<NavController>()
every { mockNavController.addOnDestinationChangedListener(any()) } just Runs
val args = BookmarkFragmentArgs(BookmarkRoot.Mobile.id).toBundle()
scenario =
launchFragmentInContainer<BookmarkFragment>(fragmentArgs = args, themeResId = R.style.NormalTheme) {
BookmarkFragment().also { fragment ->
fragment.viewLifecycleOwnerLiveData.observeForever {
if (it != null) {
Navigation.setViewNavController(fragment.requireView(), mockNavController)
}
}
}
}
}
@Test
fun `test initial bookmarks fragment ui`() {
scenario.onFragment { fragment ->
assertEquals(fragment.getString(R.string.library_bookmarks), fragment.activity?.title)
}
}
}

View File

@ -0,0 +1,161 @@
/* 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.bookmarks
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlinx.coroutines.runBlocking
import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.storage.BookmarkNodeType
import org.junit.Assert.assertSame
import org.junit.Test
class BookmarkStoreTest {
@Test
fun `change the tree of bookmarks starting from an empty tree`() = runBlocking {
val initialState = BookmarkState(null)
val store = BookmarkStore(initialState)
assertThat(BookmarkState(null, BookmarkState.Mode.Normal)).isEqualTo(store.state)
store.dispatch(BookmarkAction.Change(tree)).join()
assertThat(initialState.copy(tree = tree)).isEqualTo(store.state)
}
@Test
fun `change the tree of bookmarks starting from an existing tree`() = runBlocking {
val initialState = BookmarkState(tree)
val store = BookmarkStore(initialState)
assertThat(BookmarkState(tree, BookmarkState.Mode.Normal)).isEqualTo(store.state)
store.dispatch(BookmarkAction.Change(newTree)).join()
assertThat(initialState.copy(tree = newTree)).isEqualTo(store.state)
}
@Test
fun `change the tree of bookmarks to the same value`() = runBlocking {
val initialState = BookmarkState(tree)
val store = BookmarkStore(initialState)
assertThat(BookmarkState(tree, BookmarkState.Mode.Normal)).isEqualTo(store.state)
store.dispatch(BookmarkAction.Change(tree)).join()
assertSame(initialState, store.state)
}
@Test
fun `ensure selected items remain selected after a tree change`() = runBlocking {
val initialState = BookmarkState(tree, BookmarkState.Mode.Selecting(setOf(item, subfolder)))
val store = BookmarkStore(initialState)
store.dispatch(BookmarkAction.Change(newTree)).join()
assertThat(BookmarkState(newTree, BookmarkState.Mode.Selecting(setOf(subfolder)))).isEqualTo(store.state)
}
@Test
fun `select and deselect bookmarks changes the mode`() = runBlocking {
val initialState = BookmarkState(tree)
val store = BookmarkStore(initialState)
store.dispatch(BookmarkAction.Select(childItem)).join()
assertThat(BookmarkState(tree, BookmarkState.Mode.Selecting(setOf(childItem)))).isEqualTo(store.state)
store.dispatch(BookmarkAction.Deselect(childItem)).join()
assertThat(BookmarkState(tree, BookmarkState.Mode.Normal)).isEqualTo(store.state)
}
@Test
fun `selecting the same item twice does nothing`() = runBlocking {
val initialState = BookmarkState(tree, BookmarkState.Mode.Selecting(setOf(item, subfolder)))
val store = BookmarkStore(initialState)
store.dispatch(BookmarkAction.Select(item)).join()
assertSame(initialState, store.state)
}
@Test
fun `deselecting an unselected bookmark does nothing`() = runBlocking {
val initialState = BookmarkState(tree, BookmarkState.Mode.Selecting(setOf(childItem)))
val store = BookmarkStore(initialState)
store.dispatch(BookmarkAction.Deselect(item)).join()
assertSame(initialState, store.state)
}
@Test
fun `deselecting while not in selecting mode does nothing`() = runBlocking {
val initialState = BookmarkState(tree, BookmarkState.Mode.Normal)
val store = BookmarkStore(initialState)
store.dispatch(BookmarkAction.Deselect(item)).join()
assertSame(initialState, store.state)
}
@Test
fun `deselect all bookmarks changes the mode`() = runBlocking {
val initialState = BookmarkState(tree, BookmarkState.Mode.Selecting(setOf(item, childItem)))
val store = BookmarkStore(initialState)
store.dispatch(BookmarkAction.DeselectAll).join()
assertThat(initialState.copy(mode = BookmarkState.Mode.Normal)).isEqualTo(store.state)
}
@Test
fun `deselect all bookmarks when none are selected`() = runBlocking {
val initialState = BookmarkState(tree, BookmarkState.Mode.Normal)
val store = BookmarkStore(initialState)
store.dispatch(BookmarkAction.DeselectAll)
assertSame(initialState, store.state)
}
@Test
fun `deleting bookmarks changes the mode`() = runBlocking {
val initialState = BookmarkState(tree, BookmarkState.Mode.Selecting(setOf(item, childItem)))
val store = BookmarkStore(initialState)
store.dispatch(BookmarkAction.Change(newTree)).join()
assertThat(initialState.copy(tree = newTree, mode = BookmarkState.Mode.Normal)).isEqualTo(store.state)
}
private val item = BookmarkNode(BookmarkNodeType.ITEM, "456", "123", 0, "Mozilla", "http://mozilla.org", null)
private val separator = BookmarkNode(BookmarkNodeType.SEPARATOR, "789", "123", 1, null, null, null)
private val subfolder = BookmarkNode(BookmarkNodeType.FOLDER, "987", "123", 0, "Subfolder", null, listOf())
private val childItem = BookmarkNode(
BookmarkNodeType.ITEM,
"987",
"123",
2,
"Firefox",
"https://www.mozilla.org/en-US/firefox/",
null
)
private val tree = BookmarkNode(
BookmarkNodeType.FOLDER, "123", null, 0, "Mobile", null, listOf(item, separator, childItem, subfolder)
)
private val newTree = BookmarkNode(
BookmarkNodeType.FOLDER,
"123",
null,
0,
"Mobile",
null,
listOf(separator, subfolder)
)
}

View File

@ -1,83 +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.bookmarks
import io.mockk.MockKAnnotations
import io.reactivex.Observer
import io.reactivex.observers.TestObserver
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.storage.BookmarkNodeType
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.TestUtils
import org.mozilla.fenix.TestUtils.bus
import org.mozilla.fenix.ext.minus
import org.mozilla.fenix.mvi.getManagedEmitter
class BookmarkViewModelTest {
private lateinit var bookmarkViewModel: BookmarkViewModel
private lateinit var bookmarkObserver: TestObserver<BookmarkState>
private lateinit var emitter: Observer<BookmarkChange>
@Before
fun setup() {
MockKAnnotations.init(this)
TestUtils.setRxSchedulers()
bookmarkViewModel = BookmarkViewModel.create()
bookmarkObserver = bookmarkViewModel.state.test()
bus.getSafeManagedObservable(BookmarkChange::class.java)
.subscribe(bookmarkViewModel.changes::onNext)
emitter = TestUtils.owner.getManagedEmitter()
}
@Test
fun `select and deselect a bookmark`() {
val itemToSelect = BookmarkNode(BookmarkNodeType.ITEM, "234", "123", 0, "Mozilla", "http://mozilla.org", null)
val separator = BookmarkNode(BookmarkNodeType.SEPARATOR, "345", "123", 1, null, null, null)
val innerFolder = BookmarkNode(BookmarkNodeType.FOLDER, "456", "123", 2, "Web Browsers", null, null)
val tree = BookmarkNode(
BookmarkNodeType.FOLDER, "123", BookmarkRoot.Mobile.id, 0, "Best Sites", null,
listOf(itemToSelect, separator, innerFolder)
)
emitter.onNext(BookmarkChange.Change(tree))
emitter.onNext(BookmarkChange.IsSelected(itemToSelect))
emitter.onNext(BookmarkChange.IsDeselected(itemToSelect))
bookmarkObserver.assertSubscribed().awaitCount(2).assertNoErrors()
.assertValues(
BookmarkState(null, BookmarkState.Mode.Normal),
BookmarkState(tree, BookmarkState.Mode.Normal),
BookmarkState(tree, BookmarkState.Mode.Selecting(setOf(itemToSelect))),
BookmarkState(tree, BookmarkState.Mode.Normal)
)
}
@Test
fun `select and delete a bookmark`() {
val itemToSelect = BookmarkNode(BookmarkNodeType.ITEM, "234", "123", 0, "Mozilla", "http://mozilla.org", null)
val separator = BookmarkNode(BookmarkNodeType.SEPARATOR, "345", "123", 1, null, null, null)
val innerFolder = BookmarkNode(BookmarkNodeType.FOLDER, "456", "123", 2, "Web Browsers", null, null)
val tree = BookmarkNode(
BookmarkNodeType.FOLDER, "123", BookmarkRoot.Mobile.id, 0, "Best Sites", null,
listOf(itemToSelect, separator, innerFolder)
)
emitter.onNext(BookmarkChange.Change(tree))
emitter.onNext(BookmarkChange.IsSelected(itemToSelect))
emitter.onNext(BookmarkChange.Change(tree - itemToSelect.guid))
bookmarkObserver.assertSubscribed().awaitCount(2).assertNoErrors()
.assertValues(
BookmarkState(null, BookmarkState.Mode.Normal),
BookmarkState(tree, BookmarkState.Mode.Normal),
BookmarkState(tree, BookmarkState.Mode.Selecting(setOf(itemToSelect))),
BookmarkState(tree - itemToSelect.guid, BookmarkState.Mode.Normal)
)
}
}

View File

@ -48,6 +48,7 @@ object Versions {
const val junit = "4.12"
const val mockito = "2.24.5"
const val mockk = "1.9.kotlin12"
const val assertk = "0.19"
const val flipper = "0.21.0"
const val soLoader = "0.5.1"
@ -182,6 +183,7 @@ object Deps {
const val mockito_core = "org.mockito:mockito-core:${Versions.mockito}"
const val mockito_android = "org.mockito:mockito-android:${Versions.mockito}"
const val mockk = "io.mockk:mockk:${Versions.mockk}"
const val assertk = "com.willowtreeapps.assertk:assertk-jvm:${Versions.assertk}"
const val flipper = "com.facebook.flipper:flipper:${Versions.flipper}"
const val flipper_noop = "com.facebook.flipper:flipper-noop:${Versions.flipper}"