diff --git a/app/build.gradle b/app/build.gradle index 0e1cdad95..2d8c6a0e8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 diff --git a/app/src/main/java/org/mozilla/fenix/components/FenixSnackbar.kt b/app/src/main/java/org/mozilla/fenix/components/FenixSnackbar.kt index ad20cb315..a1c7db50d 100644 --- a/app/src/main/java/org/mozilla/fenix/components/FenixSnackbar.kt +++ b/app/src/main/java/org/mozilla/fenix/components/FenixSnackbar.kt @@ -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() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/components/Services.kt b/app/src/main/java/org/mozilla/fenix/components/Services.kt index 5454f16f2..f070d038d 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Services.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Services.kt @@ -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) + } + } } diff --git a/app/src/main/java/org/mozilla/fenix/ext/BookmarkNode.kt b/app/src/main/java/org/mozilla/fenix/ext/BookmarkNode.kt index f389f9709..35c136b7f 100644 --- a/app/src/main/java/org/mozilla/fenix/ext/BookmarkNode.kt +++ b/app/src/main/java/org/mozilla/fenix/ext/BookmarkNode.kt @@ -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 { 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()?.apply { + primaryClip = ClipData.newPlainText(url, url) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/LibraryPageUIView.kt b/app/src/main/java/org/mozilla/fenix/library/LibraryPageView.kt similarity index 74% rename from app/src/main/java/org/mozilla/fenix/library/LibraryPageUIView.kt rename to app/src/main/java/org/mozilla/fenix/library/LibraryPageView.kt index 3448c6ee2..138179705 100644 --- a/app/src/main/java/org/mozilla/fenix/library/LibraryPageUIView.kt +++ b/app/src/main/java/org/mozilla/fenix/library/LibraryPageView.kt @@ -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( - container: ViewGroup, - actionEmitter: Observer, - changesObservable: Observable -) : UIView(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. diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkAdapter.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkAdapter.kt index 2cfd258e4..054d7f612 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkAdapter.kt @@ -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) : +class BookmarkAdapter(val emptyView: View, val interactor: BookmarkViewInteractor) : AdapterWithJob() { private var tree: List = listOf() @@ -87,13 +86,13 @@ class BookmarkAdapter(val emptyView: View, val actionEmitter: Observer 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, + val interactor: BookmarkViewInteractor, private val job: Job, override val containerView: View? = view ) : @@ -134,11 +133,11 @@ class BookmarkAdapter(val emptyView: View, val actionEmitter: Observer, + 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 { - 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, + 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 { - 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, + 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 { - actionEmitter.onNext(BookmarkAction.Delete(item)) + interactor.delete(item) } } } diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkComponent.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkComponent.kt deleted file mode 100644 index 6cddd8c69..000000000 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkComponent.kt +++ /dev/null @@ -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 -) : - UIComponent( - bus.getManagedEmitter(BookmarkAction::class.java), - bus.getSafeManagedObservable(BookmarkChange::class.java), - viewModelProvider - ) { - override fun initView(): UIView = - 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) : 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(initialState, reducer) { - - companion object { - fun create() = BookmarkViewModel(BookmarkState(null, BookmarkState.Mode.Normal)) - - val reducer: Reducer = { 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) - } - } - } -} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt index 30c93c3dd..81211bfe6 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragment.kt @@ -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().onNext(BookmarkChange.ClearSelection) + bookmarkInteractor.deselectAll() } lateinit var initialJob: Job private var pendingBookmarkDeletionJob: (suspend () -> Unit)? = null - private var pendingBookmarksToDelete: MutableSet = HashSet() + private var pendingBookmarksToDelete: MutableSet = 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 { + 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().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().onNext(SignInChange.SignedIn) } - ?: getManagedEmitter().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() - .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().onNext(BookmarkChange.IsSelected(it.item)) - } - is BookmarkAction.Deselect -> { - getManagedEmitter().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().onNext(BookmarkChange.ClearSelection) - } - } - - getAutoDisposeObservable() - .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().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().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().onNext(SignInChange.SignedIn) + bookmarkInteractor.signedIn() lifecycleScope.launch { refreshBookmarks() } } override fun onLoggedOut() { - getManagedEmitter().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 = 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().onNext(BookmarkChange.Change(rootNode)) - } - } + private fun deleteMulti(selected: Set, 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()?.apply { - primaryClip = ClipData.newPlainText(url, url) - } - } - - private fun metrics(): MetricController? { - return context?.components?.analytics?.metrics - } } diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt new file mode 100644 index 000000000..ad91c7aeb --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractor.kt @@ -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, 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) { + 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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkStore.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkStore.kt new file mode 100644 index 000000000..57ae6dd42 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkStore.kt @@ -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( + 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) : 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 +} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkUIView.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkUIView.kt deleted file mode 100644 index 20542df81..000000000 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkUIView.kt +++ /dev/null @@ -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, - changesObservable: Observable -) : - LibraryPageUIView(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 { - 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 = 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 - } - } -} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkView.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkView.kt new file mode 100644 index 000000000..3e80df7b3 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarkView.kt @@ -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) + + /** + * 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 = 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 + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarksSharedViewModel.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarksSharedViewModel.kt index 8a78aa46b..4cf1fd500 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarksSharedViewModel.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/BookmarksSharedViewModel.kt @@ -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().apply { postValue(true) } var selectedFolder: BookmarkNode? = null } diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/SignInComponent.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/SignInComponent.kt deleted file mode 100644 index 39c349099..000000000 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/SignInComponent.kt +++ /dev/null @@ -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 -) : UIComponent( - bus.getManagedEmitter(SignInAction::class.java), - bus.getSafeManagedObservable(SignInChange::class.java), - viewModelProvider -) { - override fun initView(): UIView = - 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(initialState, reducer) { - companion object { - val reducer = object : Reducer { - override fun invoke(state: SignInState, change: SignInChange): SignInState { - return when (change) { - SignInChange.SignedIn -> state.copy(signedIn = true) - SignInChange.SignedOut -> state.copy(signedIn = false) - } - } - } - } -} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/SignInUIView.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/SignInUIView.kt deleted file mode 100644 index f303d56f7..000000000 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/SignInUIView.kt +++ /dev/null @@ -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, - changesObservable: Observable -) : UIView(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 { - view.visibility = if (it.signedIn) View.GONE else View.VISIBLE - } -} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/SignInView.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/SignInView.kt new file mode 100644 index 000000000..fcc8e4250 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/SignInView.kt @@ -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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderFragment.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderFragment.kt index a7c781fc9..ca360330f 100644 --- a/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderFragment.kt @@ -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() - .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 { + 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().onNext(SignInChange.SignedIn) } - ?: getManagedEmitter().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().onNext(SignInChange.SignedIn) + bookmarkInteractor.signedIn() } override fun onLoggedOut() { - getManagedEmitter().onNext(SignInChange.SignedOut) + bookmarkInteractor.signedOut() } override fun onProfileUpdated(profile: Profile) { diff --git a/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderInteractor.kt new file mode 100644 index 000000000..2d198cacc --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/bookmarks/selectfolder/SelectBookmarkFolderInteractor.kt @@ -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) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt index c0a59728b..270b9ba4f 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt @@ -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 } } diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 0695259f9..6942e148f 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -15,6 +15,10 @@ android:id="@+id/action_global_crash_reporter" app:destination="@id/crashReporterFragment" /> + + @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() - bookmarkAdapter setProperty "mode" value BookmarkState.Mode.Normal + bookmarkAdapter.notifyItemRangeRemoved(0, 3) } } } diff --git a/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractorTest.kt new file mode 100644 index 000000000..4f18ce8dc --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentInteractorTest.kt @@ -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, 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().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().getSystemService() } 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) + } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentTest.kt b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentTest.kt deleted file mode 100644 index 480dc8eeb..000000000 --- a/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkFragmentTest.kt +++ /dev/null @@ -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 - - @Before - fun setup() { - TestUtils.setRxSchedulers() - - val mockNavController = mockk() - every { mockNavController.addOnDestinationChangedListener(any()) } just Runs - - val args = BookmarkFragmentArgs(BookmarkRoot.Mobile.id).toBundle() - scenario = - launchFragmentInContainer(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) - } - } -} diff --git a/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkStoreTest.kt b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkStoreTest.kt new file mode 100644 index 000000000..ed801e73b --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkStoreTest.kt @@ -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) + ) +} diff --git a/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkViewModelTest.kt b/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkViewModelTest.kt deleted file mode 100644 index bef8d68ed..000000000 --- a/app/src/test/java/org/mozilla/fenix/library/bookmarks/BookmarkViewModelTest.kt +++ /dev/null @@ -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 - private lateinit var emitter: Observer - - @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) - ) - } -} diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 873e5a129..7c272ece2 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -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}"