/* 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.graphics.PorterDuff.Mode.SRC_IN import android.graphics.PorterDuffColorFilter import android.os.Bundle import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment 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.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import mozilla.appservices.places.BookmarkRoot 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 mozilla.components.lib.state.ext.observe import mozilla.components.support.base.feature.BackHandler 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.StoreProvider import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.bookmarkStorage import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.minus 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.utils.allowUndo @SuppressWarnings("TooManyFunctions", "LargeClass") class BookmarkFragment : Fragment(), BackHandler, AccountObserver { 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 = NavController.OnDestinationChangedListener { _, destination, args -> if (destination.id != R.id.bookmarkFragment || args != null && BookmarkFragmentArgs.fromBundle(args).currentRoot != currentRoot?.guid ) bookmarkInteractor.deselectAll() } lateinit var initialJob: Job private var pendingBookmarkDeletionJob: (suspend () -> Unit)? = null 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) 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) setHasOptionsMenu(true) } override fun onResume() { super.onResume() context?.let { setRootTitles(it) } (activity as? AppCompatActivity)?.supportActionBar?.show() checkIfSignedIn() navigation.addOnDestinationChangedListener(onDestinationChangedListener) val currentGuid = BookmarkFragmentArgs.fromBundle(arguments!!).currentRoot.ifEmpty { BookmarkRoot.Mobile.id } initialJob = loadInitialBookmarkFolder(currentGuid) } private fun loadInitialBookmarkFolder(currentGuid: String): Job { return viewLifecycleOwner.lifecycleScope.launch(IO) { currentRoot = context?.bookmarkStorage()?.getTree(currentGuid).withOptionalDesktopFolders(context) as BookmarkNode if (!isActive) return@launch launch(Main) { bookmarkInteractor.change(currentRoot!!) sharedViewModel.selectedFolder = currentRoot } } } private fun checkIfSignedIn() { context?.components?.backgroundServices?.accountManager?.let { it.register(this, owner = this) it.authenticatedAccount()?.let { bookmarkInteractor.signedIn() } ?: bookmarkInteractor.signedOut() } } override fun onDestroyView() { super.onDestroyView() navigation.removeOnDestinationChangedListener(onDestinationChangedListener) } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { when (val mode = bookmarkView.mode) { BookmarkState.Mode.Normal -> { inflater.inflate(R.menu.bookmarks_menu, menu) } is BookmarkState.Mode.Selecting -> { inflater.inflate(R.menu.bookmarks_select_multi, menu) menu.findItem(R.id.edit_bookmark_multi_select)?.run { isVisible = mode.selectedItems.size == 1 icon.colorFilter = PorterDuffColorFilter( ContextCompat.getColor(context!!, R.color.white_color), SRC_IN ) } } } } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.libraryClose -> { navigation .popBackStack(R.id.libraryFragment, true) true } R.id.add_bookmark_folder -> { nav( R.id.bookmarkFragment, BookmarkFragmentDirections .actionBookmarkFragmentToBookmarkAddFolderFragment() ) true } R.id.open_bookmarks_in_new_tabs_multi_select -> { getSelectedBookmarks().forEach { node -> node.url?.let { context?.components?.useCases?.tabsUseCases?.addTab?.invoke(it) } } (activity as HomeActivity).browsingModeManager.mode = BrowsingModeManager.Mode.Normal (activity as HomeActivity).supportActionBar?.hide() nav(R.id.bookmarkFragment, BookmarkFragmentDirections.actionBookmarkFragmentToHomeFragment()) metrics?.track(Event.OpenedBookmarksInNewTabs) true } R.id.edit_bookmark_multi_select -> { val bookmark = getSelectedBookmarks().first() nav( R.id.bookmarkFragment, BookmarkFragmentDirections .actionBookmarkFragmentToBookmarkEditFragment(bookmark.guid) ) true } R.id.open_bookmarks_in_private_tabs_multi_select -> { getSelectedBookmarks().forEach { node -> node.url?.let { context?.components?.useCases?.tabsUseCases?.addPrivateTab?.invoke(it) } } (activity as HomeActivity).browsingModeManager.mode = BrowsingModeManager.Mode.Private (activity as HomeActivity).supportActionBar?.hide() nav(R.id.bookmarkFragment, BookmarkFragmentDirections.actionBookmarkFragmentToHomeFragment()) metrics?.track(Event.OpenedBookmarksInPrivateTabs) true } R.id.delete_bookmarks_multi_select -> { deleteMulti(getSelectedBookmarks()) true } else -> super.onOptionsItemSelected(item) } } override fun onBackPressed(): Boolean = bookmarkView.onBackPressed() override fun onAuthenticated(account: OAuthAccount) { bookmarkInteractor.signedIn() lifecycleScope.launch { refreshBookmarks() } } override fun onLoggedOut() { bookmarkInteractor.signedOut() } override fun onAuthenticationProblems() { } override fun onProfileUpdated(profile: Profile) { } 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 { context?.bookmarkStorage()?.deleteNode(it.guid) } } private fun deleteMulti(selected: Set, eventType: Event = Event.RemoveBookmarks) { pendingBookmarksToDelete.addAll(selected) 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() { pendingBookmarkDeletionJob?.let { viewLifecycleOwner.lifecycleScope.launch { it.invoke() }.invokeOnCompletion { pendingBookmarkDeletionJob = null } } } }