HomeFragment.kt 32.9 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/. */
Jeff Boek's avatar
Jeff Boek committed
4
5
6

package org.mozilla.fenix.home

7
import android.animation.Animator
8
import android.content.DialogInterface
9
import android.graphics.Bitmap
10
import android.graphics.drawable.BitmapDrawable
Jeff Boek's avatar
Jeff Boek committed
11
import android.os.Bundle
12
import android.view.Gravity
Jeff Boek's avatar
Jeff Boek committed
13
14
15
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
16
17
18
import android.widget.Button
import android.widget.LinearLayout
import android.widget.PopupWindow
19
import androidx.annotation.StringRes
20
import androidx.appcompat.app.AlertDialog
21
22
23
24
import androidx.constraintlayout.widget.ConstraintSet
import androidx.constraintlayout.widget.ConstraintSet.BOTTOM
import androidx.constraintlayout.widget.ConstraintSet.END
import androidx.constraintlayout.widget.ConstraintSet.PARENT_ID
25
26
import androidx.constraintlayout.widget.ConstraintSet.START
import androidx.constraintlayout.widget.ConstraintSet.TOP
27
import androidx.fragment.app.Fragment
28
import androidx.fragment.app.activityViewModels
Tiger Oakes's avatar
Tiger Oakes committed
29
30
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
31
import androidx.lifecycle.Observer
Tiger Oakes's avatar
Tiger Oakes committed
32
import androidx.lifecycle.OnLifecycleEvent
33
import androidx.lifecycle.ViewModelProvider
34
import androidx.lifecycle.lifecycleScope
35
import androidx.navigation.fragment.FragmentNavigator
36
import androidx.navigation.fragment.findNavController
37
38
39
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
40
import androidx.transition.TransitionInflater
41
import com.google.android.material.snackbar.Snackbar
42
import kotlinx.android.synthetic.main.fragment_home.*
43
import kotlinx.android.synthetic.main.fragment_home.view.*
44
45
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
46
import kotlinx.coroutines.ExperimentalCoroutinesApi
47
import kotlinx.coroutines.delay
48
import kotlinx.coroutines.launch
Tiger Oakes's avatar
Tiger Oakes committed
49
import kotlinx.coroutines.withContext
50
import mozilla.appservices.places.BookmarkRoot
51
import mozilla.components.browser.menu.BrowserMenu
52
53
import mozilla.components.browser.session.Session
import mozilla.components.browser.session.SessionManager
54
import mozilla.components.concept.sync.AccountObserver
55
import mozilla.components.concept.sync.AuthType
56
import mozilla.components.concept.sync.OAuthAccount
Sawyer Blatz's avatar
Sawyer Blatz committed
57
58
59
import mozilla.components.feature.media.ext.getSession
import mozilla.components.feature.media.state.MediaState
import mozilla.components.feature.media.state.MediaStateMachine
Tiger Oakes's avatar
Tiger Oakes committed
60
import mozilla.components.feature.tab.collections.TabCollection
61
import org.mozilla.fenix.BrowserDirection
62
import org.mozilla.fenix.HomeActivity
Jeff Boek's avatar
Jeff Boek committed
63
import org.mozilla.fenix.R
64
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
65
import org.mozilla.fenix.components.FenixSnackbar
66
import org.mozilla.fenix.components.PrivateShortcutCreateManager
67
import org.mozilla.fenix.components.StoreProvider
68
import org.mozilla.fenix.components.TabCollectionStorage
69
import org.mozilla.fenix.components.metrics.Event
70
import org.mozilla.fenix.ext.components
Tiger Oakes's avatar
Tiger Oakes committed
71
import org.mozilla.fenix.ext.hideToolbar
72
import org.mozilla.fenix.ext.metrics
73
import org.mozilla.fenix.ext.nav
74
import org.mozilla.fenix.ext.requireComponents
75
import org.mozilla.fenix.ext.sessionsOfType
76
import org.mozilla.fenix.ext.settings
77
import org.mozilla.fenix.ext.toTab
78
79
80
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
import org.mozilla.fenix.home.sessioncontrol.SessionControlView
81
import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder
82
import org.mozilla.fenix.onboarding.FenixOnboarding
83
import org.mozilla.fenix.settings.SupportUtils
84
import org.mozilla.fenix.settings.deletebrowsingdata.deleteAndQuit
85
import org.mozilla.fenix.utils.FragmentPreDrawManager
Grisha Kruglov's avatar
Grisha Kruglov committed
86
import org.mozilla.fenix.utils.allowUndo
87
import org.mozilla.fenix.whatsnew.WhatsNew
88
import kotlin.math.min
89

90
@ExperimentalCoroutinesApi
91
@SuppressWarnings("TooManyFunctions", "LargeClass")
92
class HomeFragment : Fragment() {
93
94
    private val browsingModeManager get() = (activity as HomeActivity).browsingModeManager

95
96
    private val singleSessionObserver = object : Session.Observer {
        override fun onTitleChanged(session: Session, title: String) {
Tiger Oakes's avatar
Tiger Oakes committed
97
            if (deleteAllSessionsJob == null) emitSessionChanges()
98
        }
99
100
101
102

        override fun onIconChanged(session: Session, icon: Bitmap?) {
            if (deleteAllSessionsJob == null) emitSessionChanges()
        }
103
    }
104

105
106
107
108
109
    private val collectionStorageObserver = object : TabCollectionStorage.Observer {
        override fun onCollectionCreated(title: String, sessions: List<Session>) {
            scrollAndAnimateCollection(sessions.size)
        }

Tiger Oakes's avatar
Tiger Oakes committed
110
        override fun onTabsAdded(tabCollection: TabCollection, sessions: List<Session>) {
111
112
113
            scrollAndAnimateCollection(sessions.size, tabCollection)
        }

Tiger Oakes's avatar
Tiger Oakes committed
114
        override fun onCollectionRenamed(tabCollection: TabCollection, title: String) {
115
116
117
118
            showRenamedSnackbar()
        }
    }

119
    private var homeMenu: HomeMenu? = null
120

121
122
123
    private val sessionManager: SessionManager
        get() = requireComponents.core.sessionManager

124
    var deleteAllSessionsJob: (suspend () -> Unit)? = null
125
126
127
    private var pendingSessionDeletion: PendingSessionDeletion? = null

    data class PendingSessionDeletion(val deletionJob: (suspend () -> Unit), val sessionId: String)
128

129
    private val onboarding by lazy { FenixOnboarding(requireContext()) }
130
131
132
    private lateinit var homeFragmentStore: HomeFragmentStore
    private lateinit var sessionControlInteractor: SessionControlInteractor
    private lateinit var sessionControlView: SessionControlView
133
    private lateinit var currentMode: CurrentMode
134

135
136
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
137
138
139
140
        postponeEnterTransition()
        sharedElementEnterTransition =
            TransitionInflater.from(context).inflateTransition(android.R.transition.move)
                .setDuration(SHARED_TRANSITION_MS)
141

Tiger Oakes's avatar
Tiger Oakes committed
142
        val sessionObserver = BrowserSessionsObserver(sessionManager, singleSessionObserver) {
143
144
            emitSessionChanges()
        }
Sawyer Blatz's avatar
Sawyer Blatz committed
145

Tiger Oakes's avatar
Tiger Oakes committed
146
        lifecycle.addObserver(sessionObserver)
147
148
149
150

        if (!onboarding.userHasBeenOnboarded()) {
            requireComponents.analytics.metrics.track(Event.OpenedAppFirstRun)
        }
151
152
    }

Jeff Boek's avatar
Jeff Boek committed
153
    override fun onCreateView(
154
155
        inflater: LayoutInflater,
        container: ViewGroup?,
Jeff Boek's avatar
Jeff Boek committed
156
157
        savedInstanceState: Bundle?
    ): View? {
158
        val view = inflater.inflate(R.layout.fragment_home, container, false)
159
        val activity = activity as HomeActivity
160

161
162
163
164
        currentMode = CurrentMode(
            view.context,
            onboarding,
            browsingModeManager,
165
            ::dispatchModeChanges
166
        )
167

168
169
170
171
172
173
174
        homeFragmentStore = StoreProvider.get(this) {
            HomeFragmentStore(
                HomeFragmentState(
                    collections = requireComponents.core.tabCollectionStorage.cachedTabCollections,
                    expandedCollections = emptySet(),
                    mode = currentMode.getCurrentMode(),
                    tabs = emptyList()
175
                )
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
            )
        }

        sessionControlInteractor = SessionControlInteractor(
            DefaultSessionControlController(
                activity = activity,
                store = homeFragmentStore,
                navController = findNavController(),
                homeLayout = view.homeLayout,
                browsingModeManager = browsingModeManager,
                lifecycleScope = viewLifecycleOwner.lifecycleScope,
                closeTab = ::closeTab,
                closeAllTabs = ::closeAllTabs,
                getListOfTabs = ::getListOfTabs,
                hideOnboarding = ::hideOnboarding,
                invokePendingDeleteJobs = ::invokePendingDeleteJobs,
                registerCollectionStorageObserver = ::registerCollectionStorageObserver,
                scrollToTheTop = ::scrollToTheTop,
                showDeleteCollectionPrompt = ::showDeleteCollectionPrompt
            )
196
        )
197

198
199
        sessionControlView = SessionControlView(homeFragmentStore, view.homeLayout, sessionControlInteractor)

200
201
202
203
204
205
206
        ConstraintSet().apply {
            clone(view.homeLayout)
            connect(sessionControlView.view.id, TOP, view.wordmark_spacer.id, BOTTOM)
            connect(sessionControlView.view.id, START, PARENT_ID, START)
            connect(sessionControlView.view.id, END, PARENT_ID, END)
            connect(sessionControlView.view.id, BOTTOM, view.bottom_bar.id, TOP)
            applyTo(view.homeLayout)
207
        }
208

209
        activity.themeManager.applyStatusBarTheme(activity)
210

211
        return view
Jeff Boek's avatar
Jeff Boek committed
212
213
    }

214
    @ExperimentalCoroutinesApi
215
    @SuppressWarnings("LongMethod")
Jeff Boek's avatar
Jeff Boek committed
216
217
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
218

219
        FragmentPreDrawManager(this).execute {
220
221
222
            val homeViewModel: HomeScreenViewModel by activityViewModels {
                ViewModelProvider.NewInstanceFactory() // this is a workaround for #4652
            }
223
            homeViewModel.layoutManagerState?.also { parcelable ->
224
                sessionControlView.view.layoutManager?.onRestoreInstanceState(parcelable)
225
226
227
228
229
            }
            homeLayout?.progress = homeViewModel.motionLayoutProgress
            homeViewModel.layoutManagerState = null
        }

230
        setupHomeMenu()
231

232
233
        viewLifecycleOwner.lifecycleScope.launch(IO) {
            val iconSize = resources.getDimensionPixelSize(R.dimen.preference_icon_drawable_size)
234

Jeff Boek's avatar
Jeff Boek committed
235
            val searchEngine = requireComponents.search.provider.getDefaultEngine(requireContext())
Tiger Oakes's avatar
Tiger Oakes committed
236
            val searchIcon = BitmapDrawable(resources, searchEngine.icon)
237
238
            searchIcon.setBounds(0, 0, iconSize, iconSize)

239
            withContext(Main) {
240
                search_engine_icon?.setImageDrawable(searchIcon)
241
            }
242
        }
243

244
245
246
247
248
249
        with(view.menuButton) {
            var menu: PopupWindow? = null
            setOnClickListener {
                if (menu == null) {
                    menu = homeMenu?.menuBuilder?.build(requireContext())?.show(
                        anchor = it,
250
                        orientation = BrowserMenu.Orientation.UP,
251
252
253
254
255
256
                        onDismiss = { menu = null }
                    )
                } else {
                    menu?.dismiss()
                }
            }
257
258
        }
        view.toolbar.compoundDrawablePadding =
259
            view.resources.getDimensionPixelSize(R.dimen.search_bar_search_engine_icon_padding)
260
261
        view.toolbar_wrapper.setOnClickListener {
            invokePendingDeleteJobs()
262
            hideOnboardingIfNeeded()
263
            val directions = HomeFragmentDirections.actionHomeFragmentToSearchFragment(
264
                sessionId = null
265
266
267
268
269
270
271
272
            )
            val extras =
                FragmentNavigator.Extras.Builder()
                    .addSharedElement(toolbar_wrapper, "toolbar_wrapper_transition")
                    .build()
            nav(R.id.homeFragment, directions, extras)
            requireComponents.analytics.metrics.track(Event.SearchBarTapped(Event.SearchBarTapped.Source.HOME))
        }
273

274
275
        view.add_tab_button.setOnClickListener {
            invokePendingDeleteJobs()
276
            hideOnboardingIfNeeded()
277
            val directions = HomeFragmentDirections.actionHomeFragmentToSearchFragment(
278
                sessionId = null
279
280
281
282
            )
            nav(R.id.homeFragment, directions)
        }

283
284
285
286
        PrivateBrowsingButtonView(
            privateBrowsingButton,
            browsingModeManager
        ) { newMode ->
287
            invokePendingDeleteJobs()
Colin Lee's avatar
Colin Lee committed
288

289
            if (newMode == BrowsingMode.Private) {
290
                requireContext().settings().incrementNumTimesPrivateModeOpened()
291
292
            }

293
            if (onboarding.userHasBeenOnboarded()) {
294
295
                homeFragmentStore.dispatch(
                    HomeFragmentAction.ModeChange(Mode.fromBrowsingMode(newMode)))
296
            }
297
        }
298
299
300

        // We need the shadow to be above the components.
        bottomBarShadow.bringToFront()
301
302
    }

303
304
    override fun onDestroyView() {
        homeMenu = null
305
        super.onDestroyView()
306
307
    }

308
309
310
    override fun onStart() {
        super.onStart()
        subscribeToTabCollections()
311

312
313
314
        val context = requireContext()
        val components = context.components

315
316
317
318
319
        homeFragmentStore.dispatch(HomeFragmentAction.Change(
            collections = components.core.tabCollectionStorage.cachedTabCollections,
            mode = currentMode.getCurrentMode(),
            tabs = getListOfSessions().toTabs()
        ))
Colin Lee's avatar
Colin Lee committed
320

321
322
323
324
325
        requireComponents.backgroundServices.accountManager.register(currentMode, owner = this)
        requireComponents.backgroundServices.accountManager.register(object : AccountObserver {
            override fun onAuthenticated(account: OAuthAccount, authType: AuthType) {
                if (authType != AuthType.Existing) {
                    view?.let {
326
327
328
329
                        FenixSnackbar.make(it, Snackbar.LENGTH_SHORT)
                            .setText(it.context.getString(R.string.onboarding_firefox_account_sync_is_on))
                            .setAnchorView(bottom_bar)
                            .show()
330
331
332
333
                    }
                }
            }
        }, owner = this)
334

335
        if (context.settings().showPrivateModeContextualFeatureRecommender &&
336
337
            browsingModeManager.mode.isPrivate &&
            !PrivateShortcutCreateManager.doesPrivateBrowsingPinnedShortcutExist(context)) {
338
339
            recommendPrivateBrowsingShortcut()
        }
340
341
342

        // We only want this observer live just before we navigate away to the collection creation screen
        requireComponents.core.tabCollectionStorage.unregister(collectionStorageObserver)
343
344
    }

345
346
347
348
349
350
351
352
353
354
355
    private fun closeTab(sessionId: String) {
        val deletionJob = pendingSessionDeletion?.deletionJob

        if (deletionJob == null) {
            removeTabWithUndo(sessionId, browsingModeManager.mode.isPrivate)
        } else {
            viewLifecycleOwner.lifecycleScope.launch {
                deletionJob.invoke()
            }.invokeOnCompletion {
                pendingSessionDeletion = null
                removeTabWithUndo(sessionId, browsingModeManager.mode.isPrivate)
356
357
358
359
            }
        }
    }

360
361
    private fun closeAllTabs(isPrivateMode: Boolean) {
        val deletionJob = pendingSessionDeletion?.deletionJob
362

363
364
365
366
367
368
369
370
371
372
373
374
375
376
        if (deletionJob == null) {
            removeAllTabsWithUndo(
                sessionManager.sessionsOfType(private = isPrivateMode),
                isPrivateMode
            )
        } else {
            viewLifecycleOwner.lifecycleScope.launch {
                deletionJob.invoke()
            }.invokeOnCompletion {
                pendingSessionDeletion = null
                removeAllTabsWithUndo(
                    sessionManager.sessionsOfType(private = isPrivateMode),
                    isPrivateMode
                )
377
            }
378
379
380
        }
    }

381
    private fun dispatchModeChanges(mode: Mode) {
382
383
384
        if (mode != Mode.fromBrowsingMode(browsingModeManager.mode)) {
            homeFragmentStore.dispatch(HomeFragmentAction.ModeChange(mode))
        }
385
386
    }

387
    private fun invokePendingDeleteJobs() {
388
        pendingSessionDeletion?.deletionJob?.let {
389
            viewLifecycleOwner.lifecycleScope.launch {
390
391
                it.invoke()
            }.invokeOnCompletion {
392
                pendingSessionDeletion = null
393
394
            }
        }
395

396
        deleteAllSessionsJob?.let {
397
            viewLifecycleOwner.lifecycleScope.launch {
398
399
400
401
402
                it.invoke()
            }.invokeOnCompletion {
                deleteAllSessionsJob = null
            }
        }
403
    }
404

405
    private fun showDeleteCollectionPrompt(tabCollection: TabCollection) {
406
407
408
409
410
411
412
413
414
415
416
417
418
419
        val context = context ?: return
        AlertDialog.Builder(context).apply {
            val message =
                context.getString(R.string.tab_collection_dialog_message, tabCollection.title)
            setMessage(message)
            setNegativeButton(R.string.tab_collection_dialog_negative) { dialog: DialogInterface, _ ->
                dialog.cancel()
            }
            setPositiveButton(R.string.tab_collection_dialog_positive) { dialog: DialogInterface, _ ->
                viewLifecycleOwner.lifecycleScope.launch(IO) {
                    context.components.core.tabCollectionStorage.removeCollection(tabCollection)
                    context.components.analytics.metrics.track(Event.CollectionRemoved)
                }.invokeOnCompletion {
                    dialog.dismiss()
420
                }
421
422
423
            }
            create()
        }.show()
424
425
    }

426
    override fun onStop() {
427
        invokePendingDeleteJobs()
428
        super.onStop()
429
430
431
        val homeViewModel: HomeScreenViewModel by activityViewModels {
            ViewModelProvider.NewInstanceFactory() // this is a workaround for #4652
        }
432
        homeViewModel.layoutManagerState =
433
            sessionControlView.view.layoutManager?.onSaveInstanceState()
434
        homeViewModel.motionLayoutProgress = homeLayout?.progress ?: 0F
435
436
    }

437
438
439
440
441
    override fun onResume() {
        super.onResume()
        hideToolbar()
    }

442
443
444
445
    private fun recommendPrivateBrowsingShortcut() {
        context?.let {
            val layout = LayoutInflater.from(it)
                .inflate(R.layout.pbm_shortcut_popup, null)
446
            val privateBrowsingRecommend =
447
448
                PopupWindow(
                    layout,
449
450
451
452
                    min(
                        (resources.displayMetrics.widthPixels / CFR_WIDTH_DIVIDER).toInt(),
                        (resources.displayMetrics.heightPixels / CFR_WIDTH_DIVIDER).toInt()
                    ),
453
454
455
456
457
                    LinearLayout.LayoutParams.WRAP_CONTENT,
                    true
                )
            layout.findViewById<Button>(R.id.cfr_pos_button).apply {
                setOnClickListener {
458
                    context.metrics.track(Event.PrivateBrowsingAddShortcutCFR)
459
                    PrivateShortcutCreateManager.createPrivateShortcut(context)
460
                    privateBrowsingRecommend.dismiss()
461
462
463
                }
            }
            layout.findViewById<Button>(R.id.cfr_neg_button).apply {
464
465
                setOnClickListener {
                    context.metrics.track(Event.PrivateBrowsingCancelCFR)
466
                    privateBrowsingRecommend.dismiss()
467
                }
468
469
470
471
            }
            // We want to show the popup only after privateBrowsingButton is available.
            // Otherwise, we will encounter an activity token error.
            privateBrowsingButton.post {
472
473
                privateBrowsingRecommend.showAsDropDown(
                    privateBrowsingButton, 0, CFR_Y_OFFSET, Gravity.TOP or Gravity.END)
474
475
476
477
            }
        }
    }

478
    private fun hideOnboardingIfNeeded() {
479
480
481
482
483
        if (!onboarding.userHasBeenOnboarded()) hideOnboarding()
    }

    private fun hideOnboarding() {
        onboarding.finish()
484
485
        homeFragmentStore.dispatch(
            HomeFragmentAction.ModeChange(currentMode.getCurrentMode()))
486
487
    }

488
    private fun setupHomeMenu() {
489
490
        val context = requireContext()
        homeMenu = HomeMenu(context) {
491
            when (it) {
492
                HomeMenu.Item.Settings -> {
493
                    invokePendingDeleteJobs()
494
                    hideOnboardingIfNeeded()
495
496
                    nav(
                        R.id.homeFragment,
497
498
499
                        HomeFragmentDirections.actionHomeFragmentToSettingsFragment()
                    )
                }
500
                HomeMenu.Item.Bookmarks -> {
501
                    invokePendingDeleteJobs()
502
                    hideOnboardingIfNeeded()
503
504
                    nav(
                        R.id.homeFragment,
505
506
507
508
509
510
511
512
513
                        HomeFragmentDirections.actionHomeFragmentToBookmarksFragment(BookmarkRoot.Mobile.id)
                    )
                }
                HomeMenu.Item.History -> {
                    invokePendingDeleteJobs()
                    hideOnboardingIfNeeded()
                    nav(
                        R.id.homeFragment,
                        HomeFragmentDirections.actionHomeFragmentToHistoryFragment()
514
515
                    )
                }
516
                HomeMenu.Item.Help -> {
517
                    invokePendingDeleteJobs()
518
                    hideOnboardingIfNeeded()
519
                    (activity as HomeActivity).openToBrowserAndLoad(
520
                        searchTermOrURL = SupportUtils.getSumoURLForTopic(
521
                            context,
522
523
                            SupportUtils.SumoTopic.HELP
                        ),
524
525
                        newTab = true,
                        from = BrowserDirection.FromHome
526
527
                    )
                }
528
529
530
                HomeMenu.Item.WhatsNew -> {
                    invokePendingDeleteJobs()
                    hideOnboardingIfNeeded()
531
532
                    WhatsNew.userViewedWhatsNew(context)
                    context.metrics.track(Event.WhatsNewTapped(Event.WhatsNewTapped.Source.HOME))
533
534
                    (activity as HomeActivity).openToBrowserAndLoad(
                        searchTermOrURL = SupportUtils.getSumoURLForTopic(
535
                            context,
536
537
538
539
540
541
                            SupportUtils.SumoTopic.WHATS_NEW
                        ),
                        newTab = true,
                        from = BrowserDirection.FromHome
                    )
                }
542
543
544
                // We need to show the snackbar while the browsing data is deleting(if "Delete
                // browsing data on quit" is activated). After the deletion is over, the snackbar
                // is dismissed.
545
546
547
                HomeMenu.Item.Quit -> activity?.let { activity ->
                    deleteAndQuit(
                        activity,
548
                        lifecycleScope,
549
                        view?.let { view -> FenixSnackbar.makeWithToolbarPadding(view) }
550
551
                    )
                }
552
553
554
555
            }
        }
    }

556
    private fun subscribeToTabCollections(): Observer<List<TabCollection>> {
Tiger Oakes's avatar
Tiger Oakes committed
557
        return Observer<List<TabCollection>> {
558
            requireComponents.core.tabCollectionStorage.cachedTabCollections = it
559
            homeFragmentStore.dispatch(HomeFragmentAction.CollectionsChange(it))
Tiger Oakes's avatar
Tiger Oakes committed
560
561
        }.also { observer ->
            requireComponents.core.tabCollectionStorage.getCollections().observe(this, observer)
562
563
564
        }
    }

565
    private fun removeAllTabsWithUndo(listOfSessionsToDelete: Sequence<Session>, private: Boolean) {
566
        homeFragmentStore.dispatch(HomeFragmentAction.TabsChange(emptyList()))
567
568

        val deleteOperation: (suspend () -> Unit) = {
569
570
            listOfSessionsToDelete.forEach {
                sessionManager.remove(it)
571
            }
572
        }
573
574
        deleteAllSessionsJob = deleteOperation

575
        val snackbarMessage = if (private) {
576
            getString(R.string.snackbar_private_tabs_closed)
577
        } else {
578
            getString(R.string.snackbar_tabs_closed)
579
580
        }

581
        viewLifecycleOwner.lifecycleScope.allowUndo(
582
            view!!,
583
            snackbarMessage,
584
            getString(R.string.snackbar_deleted_undo), {
585
586
587
                if (private) {
                    requireComponents.analytics.metrics.track(Event.PrivateBrowsingSnackbarUndoTapped)
                }
588
589
                deleteAllSessionsJob = null
                emitSessionChanges()
590
            },
591
592
            operation = deleteOperation,
            anchorView = bottom_bar
593
        )
594
595
    }

596
    private fun removeTabWithUndo(sessionId: String, private: Boolean) {
597
        val sessionManager = requireComponents.core.sessionManager
598
599
600
        val deleteOperation: (suspend () -> Unit) = {
            sessionManager.findSessionById(sessionId)
                ?.let { session ->
601
                    pendingSessionDeletion = null
602
603
604
                    sessionManager.remove(session)
                }
        }
605

606
        pendingSessionDeletion = PendingSessionDeletion(deleteOperation, sessionId)
607

608
609
610
611
612
613
        val snackbarMessage = if (private) {
            getString(R.string.snackbar_private_tab_closed)
        } else {
            getString(R.string.snackbar_tab_closed)
        }

614
        viewLifecycleOwner.lifecycleScope.allowUndo(
615
            view!!,
616
            snackbarMessage,
617
            getString(R.string.snackbar_deleted_undo), {
618
                pendingSessionDeletion = null
619
620
                emitSessionChanges()
            },
621
622
            operation = deleteOperation,
            anchorView = bottom_bar
623
        )
624

625
626
627
        // Update the UI with the tab removed, but don't remove it from storage yet
        emitSessionChanges()
    }
628

629
    private fun emitSessionChanges() {
630
        homeFragmentStore.dispatch(HomeFragmentAction.TabsChange(getListOfTabs()))
631
632
    }

633
    private fun getListOfSessions(): List<Session> {
634
635
636
        return sessionManager.sessionsOfType(private = browsingModeManager.mode.isPrivate)
            .filter { session: Session -> session.id != pendingSessionDeletion?.sessionId }
            .toList()
637
638
    }

639
640
    private fun getListOfTabs(): List<Tab> {
        return getListOfSessions().toTabs()
641
642
    }

643
644
    private fun registerCollectionStorageObserver() {
        requireComponents.core.tabCollectionStorage.register(collectionStorageObserver, this)
645
646
    }

647
648
649
650
651
    private fun scrollToTheTop() {
        lifecycleScope.launch(Main) {
            delay(ANIM_SCROLL_DELAY)
            sessionControlView.view.smoothScrollToPosition(0)
        }
652
653
    }

654
655
656
657
    private fun scrollAndAnimateCollection(
        tabsAddedToCollectionSize: Int,
        changedCollection: TabCollection? = null
    ) {
658
659
        if (view != null) {
            viewLifecycleOwner.lifecycleScope.launch {
660
                val recyclerView = sessionControlView.view
661
662
663
664
665
666
667
668
669
670
671
672
673
                delay(ANIM_SCROLL_DELAY)
                val tabsSize = getListOfSessions().size

                var indexOfCollection = tabsSize + NON_TAB_ITEM_NUM
                changedCollection?.let { changedCollection ->
                    requireComponents.core.tabCollectionStorage.cachedTabCollections
                        .filterIndexed { index, tabCollection ->
                            if (tabCollection.id == changedCollection.id) {
                                indexOfCollection = tabsSize + NON_TAB_ITEM_NUM + index
                                return@filterIndexed true
                            }
                            false
                        }
674
                }
675
                val lastVisiblePosition =
676
677
                    (recyclerView.layoutManager as? LinearLayoutManager)?.findLastCompletelyVisibleItemPosition()
                        ?: 0
678
679
                if (lastVisiblePosition < indexOfCollection) {
                    val onScrollListener = object : RecyclerView.OnScrollListener() {
680
681
682
683
                        override fun onScrollStateChanged(
                            recyclerView: RecyclerView,
                            newState: Int
                        ) {
684
685
686
687
688
                            super.onScrollStateChanged(recyclerView, newState)
                            if (newState == SCROLL_STATE_IDLE) {
                                animateCollection(tabsAddedToCollectionSize, indexOfCollection)
                                recyclerView.removeOnScrollListener(this)
                            }
689
690
                        }
                    }
691
692
693
694
                    recyclerView.addOnScrollListener(onScrollListener)
                    recyclerView.smoothScrollToPosition(indexOfCollection)
                } else {
                    animateCollection(tabsAddedToCollectionSize, indexOfCollection)
695
696
                }
            }
697
698
699
        }
    }

700
    private fun animateCollection(addedTabsSize: Int, indexOfCollection: Int) {
701
        viewLifecycleOwner.lifecycleScope.launch {
702
            val viewHolder =
703
                sessionControlView.view.findViewHolderForAdapterPosition(indexOfCollection)
704
705
            val border =
                (viewHolder as? CollectionViewHolder)?.view?.findViewById<View>(R.id.selected_border)
706
            val listener = object : Animator.AnimatorListener {
707
708
709
710
                override fun onAnimationCancel(animation: Animator?) {
                    border?.visibility = View.GONE
                }

711
712
                override fun onAnimationStart(animation: Animator?) { /* noop */ }
                override fun onAnimationRepeat(animation: Animator?) { /* noop */ }
713
                override fun onAnimationEnd(animation: Animator?) {
714
715
                    border?.animate()?.alpha(0.0F)?.setStartDelay(ANIM_ON_SCREEN_DELAY)
                        ?.setDuration(FADE_ANIM_DURATION)
716
717
718
                        ?.start()
                }
            }
719
720
            border?.animate()?.alpha(1.0F)?.setStartDelay(ANIM_ON_SCREEN_DELAY)
                ?.setDuration(FADE_ANIM_DURATION)
721
722
723
                ?.setListener(listener)?.start()
        }.invokeOnCompletion {
            showSavedSnackbar(addedTabsSize)
724
725
726
        }
    }

727
    private fun showSavedSnackbar(tabSize: Int) {
728
        viewLifecycleOwner.lifecycleScope.launch {
729
            delay(ANIM_SNACKBAR_DELAY)
730
731
732
733
734
735
            view?.let { view ->
                @StringRes
                val stringRes = if (tabSize > 1) {
                    R.string.create_collection_tabs_saved
                } else {
                    R.string.create_collection_tab_saved
736
                }
737
                FenixSnackbar.make(view, Snackbar.LENGTH_LONG)
738
739
740
                    .setText(view.context.getString(stringRes))
                    .setAnchorView(bottom_bar)
                    .show()
741
742
743
744
745
            }
        }
    }

    private fun showRenamedSnackbar() {
746
747
        view?.let { view ->
            val string = view.context.getString(R.string.snackbar_collection_renamed)
748
749
750
751
            FenixSnackbar.make(view, Snackbar.LENGTH_LONG)
                .setText(string)
                .setAnchorView(bottom_bar)
                .show()
752
753
754
        }
    }

755
756
    private fun List<Session>.toTabs(): List<Tab> {
        val selected = sessionManager.selectedSession
Sawyer Blatz's avatar
Sawyer Blatz committed
757
758
759
760
761
762
763
764
765
766
767
        val mediaStateSession = MediaStateMachine.state.getSession()

        return this.map {
            val mediaState = if (mediaStateSession?.id == it.id) {
                MediaStateMachine.state
            } else {
                null
            }

            it.toTab(requireContext(), it == selected, mediaState)
        }
768
769
    }

770
    companion object {
771
772
773
774
        private const val NON_TAB_ITEM_NUM = 3
        private const val ANIM_SCROLL_DELAY = 100L
        private const val ANIM_ON_SCREEN_DELAY = 200L
        private const val FADE_ANIM_DURATION = 150L
775
        private const val ANIM_SNACKBAR_DELAY = 100L
776
        private const val SHARED_TRANSITION_MS = 200L
777
778
        private const val CFR_WIDTH_DIVIDER = 1.7
        private const val CFR_Y_OFFSET = -20
779
    }
Jeff Boek's avatar
Jeff Boek committed
780
}
781
782

/**
783
 * Wrapper around sessions manager to observe changes in sessions.
784
785
786
787
788
789
790
791
792
793
794
795
796
 * Similar to [mozilla.components.browser.session.utils.AllSessionsObserver] but ignores CustomTab sessions.
 *
 * Call [onStart] to start receiving updates into [onChanged] callback.
 * Call [onStop] to stop receiving updates.
 *
 * @param manager [SessionManager] instance to subscribe to.
 * @param observer [Session.Observer] instance that will recieve updates.
 * @param onChanged callback that will be called when any of [SessionManager.Observer]'s events are fired.
 */
private class BrowserSessionsObserver(
    private val manager: SessionManager,
    private val observer: Session.Observer,
    private val onChanged: () -> Unit
Tiger Oakes's avatar
Tiger Oakes committed
797
) : LifecycleObserver {
798
799
800
801

    /**
     * Start observing
     */
Tiger Oakes's avatar
Tiger Oakes committed
802
    @OnLifecycleEvent(Lifecycle.Event.ON_START)
803
    fun onStart() {
Sawyer Blatz's avatar
Sawyer Blatz committed
804
        MediaStateMachine.register(managerObserver)
805
806
807
808
809
810
811
        manager.register(managerObserver)
        subscribeToAll()
    }

    /**
     * Stop observing (will not receive updates till next [onStop] call)
     */
Tiger Oakes's avatar
Tiger Oakes committed
812
    @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
813
    fun onStop() {
Sawyer Blatz's avatar
Sawyer Blatz committed
814
        MediaStateMachine.unregister(managerObserver)
815
816
817
818
819
820
821
822
823
824
825
826
827
        manager.unregister(managerObserver)
        unsubscribeFromAll()
    }

    private fun subscribeToAll() {
        manager.sessions.forEach(::subscribeTo)
    }

    private fun unsubscribeFromAll() {
        manager.sessions.forEach(::unsubscribeFrom)
    }

    private fun subscribeTo(session: Session) {
Tiger Oakes's avatar
Tiger Oakes committed
828
        session.register(observer)
829
830
831
    }

    private fun unsubscribeFrom(session: Session) {
Tiger Oakes's avatar
Tiger Oakes committed
832
        session.unregister(observer)
833
834
    }

Sawyer Blatz's avatar
Sawyer Blatz committed
835
836
837
838
839
    private val managerObserver = object : SessionManager.Observer, MediaStateMachine.Observer {
        override fun onStateChanged(state: MediaState) {
            onChanged()
        }

840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
        override fun onSessionAdded(session: Session) {
            subscribeTo(session)
            onChanged()
        }

        override fun onSessionsRestored() {
            subscribeToAll()
            onChanged()
        }

        override fun onAllSessionsRemoved() {
            unsubscribeFromAll()
            onChanged()
        }

        override fun onSessionRemoved(session: Session) {
            unsubscribeFrom(session)
            onChanged()
        }

        override fun onSessionSelected(session: Session) {
            onChanged()
        }
    }
}