BookmarkFragment.kt 15.2 KB
Newer Older
1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
3
 * 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/. */
4
5
6

package org.mozilla.fenix.library.bookmarks

Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
7
import android.content.DialogInterface
8
9
10
11
12
13
14
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
Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
15
import androidx.appcompat.app.AlertDialog
16
import androidx.core.content.getSystemService
17
import androidx.core.view.isVisible
18
import androidx.fragment.app.activityViewModels
19
import androidx.lifecycle.ViewModelProvider
20
import androidx.lifecycle.lifecycleScope
21
import androidx.navigation.NavDirections
22
import androidx.navigation.fragment.findNavController
23
import androidx.navigation.fragment.navArgs
24
import kotlinx.android.synthetic.main.component_bookmark.view.*
25
import kotlinx.android.synthetic.main.fragment_bookmark.view.*
26
import kotlinx.coroutines.CoroutineScope
27
28
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
29
import kotlinx.coroutines.ExperimentalCoroutinesApi
30
31
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
32
import kotlinx.coroutines.isActive
33
import kotlinx.coroutines.launch
34
import kotlinx.coroutines.withContext
35
import mozilla.appservices.places.BookmarkRoot
36
import mozilla.components.concept.engine.prompt.ShareData
37
import mozilla.components.concept.storage.BookmarkNode
38
import mozilla.components.concept.storage.BookmarkNodeType
39
import mozilla.components.lib.state.ext.consumeFrom
40
import mozilla.components.support.base.feature.UserInteractionHandler
41
import org.mozilla.fenix.HomeActivity
42
import org.mozilla.fenix.NavHostActivity
43
import org.mozilla.fenix.R
44
import org.mozilla.fenix.components.FenixSnackbar
45
import org.mozilla.fenix.components.StoreProvider
Colin Lee's avatar
Colin Lee committed
46
import org.mozilla.fenix.components.metrics.Event
47
import org.mozilla.fenix.ext.bookmarkStorage
48
import org.mozilla.fenix.ext.components
49
import org.mozilla.fenix.ext.minus
50
import org.mozilla.fenix.ext.nav
Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
51
import org.mozilla.fenix.ext.toShortUrl
52
import org.mozilla.fenix.library.LibraryPageFragment
Grisha Kruglov's avatar
Grisha Kruglov committed
53
import org.mozilla.fenix.utils.allowUndo
54

55
56
57
/**
 * The screen that displays the user's bookmark list in their Library.
 */
58
@Suppress("TooManyFunctions", "LargeClass")
59
class BookmarkFragment : LibraryPageFragment<BookmarkNode>(), UserInteractionHandler {
60

61
    private lateinit var bookmarkStore: BookmarkFragmentStore
62
    private lateinit var bookmarkView: BookmarkView
63
    private var _bookmarkInteractor: BookmarkFragmentInteractor? = null
64
    private val bookmarkInteractor: BookmarkFragmentInteractor
65
        get() = _bookmarkInteractor!!
66

67
68
69
    private val sharedViewModel: BookmarksSharedViewModel by activityViewModels {
        ViewModelProvider.NewInstanceFactory() // this is a workaround for #4652
    }
70
    private val desktopFolders by lazy { DesktopFolders(requireContext(), showMobileRoot = false) }
71

72
    private var pendingBookmarkDeletionJob: (suspend () -> Unit)? = null
73
74
75
76
    private var pendingBookmarksToDelete: MutableSet<BookmarkNode> = mutableSetOf()

    private val metrics
        get() = context?.components?.analytics?.metrics
77

78
79
    override val selectedItems get() = bookmarkStore.state.mode.selectedItems

80
81
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val view = inflater.inflate(R.layout.fragment_bookmark, container, false)
82
83

        bookmarkStore = StoreProvider.get(this) {
84
            BookmarkFragmentStore(BookmarkFragmentState(null))
85
        }
86

87
        _bookmarkInteractor = BookmarkFragmentInteractor(
88
            bookmarksController = DefaultBookmarkController(
89
                activity = requireActivity() as HomeActivity,
90
                navController = findNavController(),
91
                clipboardManager = requireContext().getSystemService(),
92
93
94
95
                scope = viewLifecycleOwner.lifecycleScope,
                store = bookmarkStore,
                sharedViewModel = sharedViewModel,
                loadBookmarkNode = ::loadBookmarkNode,
96
                showSnackbar = ::showSnackBarWithText,
97
                deleteBookmarkNodes = ::deleteMulti,
Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
98
                deleteBookmarkFolder = ::showRemoveFolderDialog,
99
                invokePendingDeletion = ::invokePendingDeletion
100
101
            ),
            metrics = metrics!!
102
        )
103

104
105
        bookmarkView = BookmarkView(view.bookmarkLayout, bookmarkInteractor, findNavController())
        bookmarkView.view.bookmark_folders_sign_in.visibility = View.GONE
106

107
        viewLifecycleOwner.lifecycle.addObserver(
108
109
110
111
112
113
114
            BookmarkDeselectNavigationListener(
                findNavController(),
                sharedViewModel,
                bookmarkInteractor
            )
        )

115
116
117
        return view
    }

118
119
120
121
122
123
124
125
126
127
    private fun showSnackBarWithText(text: String) {
        view?.let {
            FenixSnackbar.make(
                view = it,
                duration = FenixSnackbar.LENGTH_LONG,
                isDisplayedWithBrowserToolbar = false
            ).setText(text).show()
        }
    }

128
    @ExperimentalCoroutinesApi
129
130
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
131
132
        consumeFrom(bookmarkStore) {
            bookmarkView.update(it)
133
            bookmarkView.view.bookmark_folders_sign_in.isVisible = false
134
135
136
        }
    }

137
138
139
140
141
142
143
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setHasOptionsMenu(true)
    }

    override fun onResume() {
        super.onResume()
144

145
        (activity as NavHostActivity).getSupportActionBarAndInflateIfNecessary().show()
Colin Lee's avatar
Colin Lee committed
146

147
148
149
150
151
152
153
154
155
        // Reload bookmarks when returning to this fragment in case they have been edited
        val args by navArgs<BookmarkFragmentArgs>()
        val currentGuid = bookmarkStore.state.tree?.guid
            ?: if (args.currentRoot.isNotEmpty()) {
                args.currentRoot
            } else {
                BookmarkRoot.Mobile.id
            }
        loadInitialBookmarkFolder(currentGuid)
156
157
    }

158
159
160
    private fun loadInitialBookmarkFolder(currentGuid: String) {
        viewLifecycleOwner.lifecycleScope.launch(Main) {
            val currentRoot = loadBookmarkNode(currentGuid)
Colin Lee's avatar
Colin Lee committed
161

162
            if (isActive && currentRoot != null) {
163
                bookmarkInteractor.onBookmarksChanged(currentRoot)
Colin Lee's avatar
Colin Lee committed
164
165
            }
        }
166
167
    }

168
    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
169
        when (val mode = bookmarkStore.state.mode) {
170
171
172
173
            is BookmarkFragmentState.Mode.Normal -> {
                if (mode.showMenu) {
                    inflater.inflate(R.menu.bookmarks_menu, menu)
                }
174
            }
175
            is BookmarkFragmentState.Mode.Selecting -> {
176
177
178
179
180
                if (mode.selectedItems.any { it.type != BookmarkNodeType.ITEM }) {
                    inflater.inflate(R.menu.bookmarks_select_multi_not_item, menu)
                } else {
                    inflater.inflate(R.menu.bookmarks_select_multi, menu)
                }
181
182
            }
        }
183
184
185
186
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return when (item.itemId) {
187
188
189
190
191
            R.id.close_bookmarks -> {
                invokePendingDeletion()
                close()
                true
            }
192
            R.id.add_bookmark_folder -> {
193
                navigate(
194
195
196
197
198
                    BookmarkFragmentDirections
                        .actionBookmarkFragmentToBookmarkAddFolderFragment()
                )
                true
            }
199
            R.id.open_bookmarks_in_new_tabs_multi_select -> {
200
                openItemsInNewTab { node -> node.url }
201

202
                showTabTray()
203
                metrics?.track(Event.OpenedBookmarksInNewTabs)
204
205
                true
            }
206
207
208
            R.id.open_bookmarks_in_private_tabs_multi_select -> {
                openItemsInNewTab(private = true) { node -> node.url }

209
                showTabTray()
210
211
212
                metrics?.track(Event.OpenedBookmarksInPrivateTabs)
                true
            }
213
            R.id.share_bookmark_multi_select -> {
214
215
216
                val shareTabs = bookmarkStore.state.mode.selectedItems.map {
                    ShareData(url = it.url, title = it.title)
                }
217
                navigate(
Jeff Boek's avatar
Jeff Boek committed
218
                    BookmarkFragmentDirections.actionGlobalShareFragment(
219
                        data = shareTabs.toTypedArray()
220
                    )
221
                )
222
223
224
                true
            }
            R.id.delete_bookmarks_multi_select -> {
225
                deleteMulti(bookmarkStore.state.mode.selectedItems)
226
227
                true
            }
228
229
230
231
            else -> super.onOptionsItemSelected(item)
        }
    }

232
233
    private fun showTabTray() {
        invokePendingDeletion()
234
        navigate(BookmarkFragmentDirections.actionGlobalTabTrayDialogFragment())
235
236
    }

237
238
    private fun navigate(directions: NavDirections) {
        invokePendingDeletion()
Jeff Boek's avatar
Jeff Boek committed
239
240
241
242
        findNavController().nav(
            R.id.bookmarkFragment,
            directions
        )
243
244
    }

245
246
247
248
    override fun onBackPressed(): Boolean {
        invokePendingDeletion()
        return bookmarkView.onBackPressed()
    }
249

250
251
252
253
254
255
    private suspend fun loadBookmarkNode(guid: String): BookmarkNode? = withContext(IO) {
        requireContext().bookmarkStorage
            .getTree(guid, false)
            ?.let { desktopFolders.withOptionalDesktopFolders(it) }
    }

256
    private suspend fun refreshBookmarks() {
257
258
259
260
        // The bookmark tree in our 'state' can be null - meaning, no bookmark tree has been selected.
        // If that's the case, we don't know what node to refresh, and so we bail out.
        // See https://github.com/mozilla-mobile/fenix/issues/4671
        val currentGuid = bookmarkStore.state.tree?.guid ?: return
261
        loadBookmarkNode(currentGuid)
262
            ?.let { node ->
263
                val rootNode = node - pendingBookmarksToDelete
264
                bookmarkInteractor.onBookmarksChanged(rootNode)
265
266
267
            }
    }

268
269
270
271
272
    override fun onPause() {
        invokePendingDeletion()
        super.onPause()
    }

273
    private suspend fun deleteSelectedBookmarks(selected: Set<BookmarkNode>) {
274
275
        CoroutineScope(IO).launch {
            val tempStorage = context?.bookmarkStorage
276
277
278
            selected.map {
                async { tempStorage?.deleteNode(it.guid) }
            }.awaitAll()
279
280
281
282
        }
    }

    private fun deleteMulti(selected: Set<BookmarkNode>, eventType: Event = Event.RemoveBookmarks) {
283
284
285
286
        selected.forEach { if (it.type == BookmarkNodeType.FOLDER) {
            showRemoveFolderDialog(selected)
            return
        } }
Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
287
        updatePendingBookmarksToDelete(selected)
288

Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
289
        pendingBookmarkDeletionJob = getDeleteOperation(eventType)
290
291
292

        val message = when (eventType) {
            is Event.RemoveBookmarks -> {
293
                getRemoveBookmarksSnackBarMessage(selected, containsFolders = false)
294
295
296
297
298
299
            }
            is Event.RemoveBookmarkFolder,
            is Event.RemoveBookmark -> {
                val bookmarkNode = selected.first()
                getString(
                    R.string.bookmark_deletion_snackbar_message,
300
                    bookmarkNode.url?.toShortUrl(requireContext().components.publicSuffixList) ?: bookmarkNode.title
301
302
                )
            }
303
            else -> throw IllegalStateException("Illegal event type in onDeleteSome")
304
305
        }

306
        viewLifecycleOwner.lifecycleScope.allowUndo(
307
            requireView(), message,
308
            getString(R.string.bookmark_undo_deletion), {
Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
309
310
                undoPendingDeletion(selected)
            }, operation = getDeleteOperation(eventType)
311
312
313
        )
    }

314
315
316
317
    private fun getRemoveBookmarksSnackBarMessage(
        selected: Set<BookmarkNode>,
        containsFolders: Boolean
    ): String {
Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
318
        return if (selected.size > 1) {
319
320
321
322
323
            return if (containsFolders) {
                getString(R.string.bookmark_deletion_multiple_snackbar_message_3)
            } else {
                getString(R.string.bookmark_deletion_multiple_snackbar_message_2)
            }
Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
324
325
326
327
        } else {
            val bookmarkNode = selected.first()
            getString(
                R.string.bookmark_deletion_snackbar_message,
328
                bookmarkNode.url?.toShortUrl(requireContext().components.publicSuffixList)
Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
329
330
331
332
333
                    ?: bookmarkNode.title
            )
        }
    }

334
335
336
337
338
339
340
341
    private fun getDialogConfirmationMessage(selected: Set<BookmarkNode>): String {
        return if (selected.size > 1) {
            getString(R.string.bookmark_delete_multiple_folders_confirmation_dialog, getString(R.string.app_name))
        } else {
            getString(R.string.bookmark_delete_folder_confirmation_dialog)
        }
    }

342
343
344
345
346
    override fun onDestroyView() {
        super.onDestroyView()
        _bookmarkInteractor = null
    }

347
    private fun showRemoveFolderDialog(selected: Set<BookmarkNode>) {
Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
348
349
        activity?.let { activity ->
            AlertDialog.Builder(activity).apply {
350
351
                val dialogConfirmationMessage = getDialogConfirmationMessage(selected)
                setMessage(dialogConfirmationMessage)
Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
352
353
354
355
                setNegativeButton(R.string.delete_browsing_data_prompt_cancel) { dialog: DialogInterface, _ ->
                    dialog.cancel()
                }
                setPositiveButton(R.string.delete_browsing_data_prompt_allow) { dialog: DialogInterface, _ ->
356
                    updatePendingBookmarksToDelete(selected)
Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
357
358
                    pendingBookmarkDeletionJob = getDeleteOperation(Event.RemoveBookmarkFolder)
                    dialog.dismiss()
359
                    val snackbarMessage = getRemoveBookmarksSnackBarMessage(selected, containsFolders = true)
360
                    viewLifecycleOwner.lifecycleScope.allowUndo(
361
                        requireView(),
362
                        snackbarMessage,
Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
363
364
                        getString(R.string.bookmark_undo_deletion),
                        {
365
                            undoPendingDeletion(selected)
Mihai Eduard Badea's avatar
Mihai Eduard Badea committed
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
                        },
                        operation = getDeleteOperation(Event.RemoveBookmarkFolder)
                    )
                }
                create()
            }
                .show()
        }
    }

    private fun updatePendingBookmarksToDelete(selected: Set<BookmarkNode>) {
        pendingBookmarksToDelete.addAll(selected)
        val bookmarkTree = sharedViewModel.selectedFolder!! - pendingBookmarksToDelete
        bookmarkInteractor.onBookmarksChanged(bookmarkTree)
    }

    private suspend fun undoPendingDeletion(selected: Set<BookmarkNode>) {
        pendingBookmarksToDelete.removeAll(selected)
        pendingBookmarkDeletionJob = null
        refreshBookmarks()
    }

    private fun getDeleteOperation(event: Event): (suspend () -> Unit) {
        return {
            deleteSelectedBookmarks(pendingBookmarksToDelete)
            pendingBookmarkDeletionJob = null
            // Since this runs in a coroutine, we can't depend upon the fragment still being attached
            metrics?.track(event)
            refreshBookmarks()
        }
    }

398
399
400
401
402
403
404
405
406
    private fun invokePendingDeletion() {
        pendingBookmarkDeletionJob?.let {
            viewLifecycleOwner.lifecycleScope.launch {
                it.invoke()
            }.invokeOnCompletion {
                pendingBookmarkDeletionJob = null
            }
        }
    }
407
}