Skip to content

Commit 767c6e8

Browse files
0nkomikescamelldaxmobile
authored
Multi-selection: Tab switcher (#5464)
Task/Issue URL: https://app.asana.com/0/1207418217763355/1209148925370449/f ### Description This PR is the feature branch for the tab switcher multi-selection project. ### Steps to test this PR Nothing to test, this will serve as a feature branch. --------- Co-authored-by: Mike Scamell <[email protected]> Co-authored-by: Dax The Translator <[email protected]>
1 parent b5a8b6d commit 767c6e8

File tree

64 files changed

+4546
-427
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+4546
-427
lines changed

app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,18 @@ import androidx.activity.result.contract.ActivityResultContracts
3535
import androidx.annotation.VisibleForTesting
3636
import androidx.core.view.isVisible
3737
import androidx.core.view.postDelayed
38+
import androidx.lifecycle.Lifecycle.State.RESUMED
39+
import androidx.lifecycle.Lifecycle.State.STARTED
3840
import androidx.lifecycle.flowWithLifecycle
3941
import androidx.lifecycle.lifecycleScope
42+
import androidx.lifecycle.repeatOnLifecycle
4043
import androidx.viewpager2.widget.MarginPageTransformer
4144
import androidx.viewpager2.widget.ViewPager2
4245
import androidx.webkit.ServiceWorkerClientCompat
4346
import androidx.webkit.ServiceWorkerControllerCompat
4447
import androidx.webkit.WebViewFeature
4548
import com.duckduckgo.anvil.annotations.InjectWith
4649
import com.duckduckgo.app.browser.BrowserViewModel.Command
47-
import com.duckduckgo.app.browser.BrowserViewModel.Command.Query
48-
import com.duckduckgo.app.browser.BrowserViewModel.Command.ShowSystemDefaultAppsActivity
49-
import com.duckduckgo.app.browser.BrowserViewModel.Command.ShowSystemDefaultBrowserDialog
5050
import com.duckduckgo.app.browser.databinding.ActivityBrowserBinding
5151
import com.duckduckgo.app.browser.databinding.IncludeExperimentalOmnibarToolbarMockupBinding
5252
import com.duckduckgo.app.browser.databinding.IncludeExperimentalOmnibarToolbarMockupBottomBinding
@@ -79,7 +79,9 @@ import com.duckduckgo.app.pixels.AppPixelName.FIRE_DIALOG_CANCEL
7979
import com.duckduckgo.app.settings.SettingsActivity
8080
import com.duckduckgo.app.settings.db.SettingsDataStore
8181
import com.duckduckgo.app.statistics.pixels.Pixel
82+
import com.duckduckgo.app.tabs.TabManagerFeatureFlags
8283
import com.duckduckgo.app.tabs.model.TabEntity
84+
import com.duckduckgo.app.tabs.ui.TabSwitcherSnackbar
8385
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
8486
import com.duckduckgo.autofill.api.emailprotection.EmailProtectionLinkVerifier
8587
import com.duckduckgo.browser.api.ui.BrowserScreens.BookmarksScreenNoParams
@@ -100,6 +102,7 @@ import com.duckduckgo.site.permissions.impl.ui.SitePermissionScreenNoParams
100102
import javax.inject.Inject
101103
import kotlinx.coroutines.CoroutineScope
102104
import kotlinx.coroutines.Job
105+
import kotlinx.coroutines.flow.collect
103106
import kotlinx.coroutines.flow.collectLatest
104107
import kotlinx.coroutines.launch
105108
import kotlinx.coroutines.runBlocking
@@ -157,6 +160,9 @@ open class BrowserActivity : DuckDuckGoActivity() {
157160
@Inject
158161
lateinit var swipingTabsFeature: SwipingTabsFeatureProvider
159162

163+
@Inject
164+
lateinit var tabManagerFeatureFlags: TabManagerFeatureFlags
165+
160166
@Inject
161167
lateinit var tabManager: TabManager
162168

@@ -290,6 +296,10 @@ open class BrowserActivity : DuckDuckGoActivity() {
290296

291297
initializeTabs()
292298

299+
// LiveData observers are restarted on each showWebContent() call; we want to subscribe to
300+
// flows only once, so a separate initialization is necessary
301+
configureFlowCollectors()
302+
293303
viewModel.viewState.observe(this) {
294304
renderer.renderBrowserViewState(it)
295305
}
@@ -302,6 +312,38 @@ open class BrowserActivity : DuckDuckGoActivity() {
302312
configureOnBackPressedListener()
303313
}
304314

315+
private fun configureFlowCollectors() {
316+
if (swipingTabsFeature.isEnabled) {
317+
lifecycleScope.launch {
318+
repeatOnLifecycle(STARTED) {
319+
launch {
320+
viewModel.tabsFlow.collectLatest {
321+
tabManager.onTabsChanged(it)
322+
}
323+
}
324+
325+
launch {
326+
viewModel.selectedTabFlow.collectLatest {
327+
tabManager.onSelectedTabChanged(it)
328+
}
329+
}
330+
331+
launch {
332+
viewModel.selectedTabIndex.collectLatest {
333+
onMoveToTabRequested(it)
334+
}
335+
}
336+
}
337+
}
338+
}
339+
340+
if (tabManagerFeatureFlags.multiSelection().isEnabled()) {
341+
lifecycleScope.launch {
342+
viewModel.deletableTabsFlow.flowWithLifecycle(lifecycle, RESUMED).collect()
343+
}
344+
}
345+
}
346+
305347
override fun onStop() {
306348
openMessageInNewTabJob?.cancel()
307349

@@ -316,6 +358,11 @@ open class BrowserActivity : DuckDuckGoActivity() {
316358
binding.tabPager.unregisterOnPageChangeCallback(onTabPageChangeListener)
317359
}
318360

361+
// we don't want to purge during device rotation
362+
if (isFinishing && tabManagerFeatureFlags.multiSelection().isEnabled()) {
363+
viewModel.purgeDeletableTabs()
364+
}
365+
319366
super.onDestroy()
320367
}
321368

@@ -532,25 +579,8 @@ open class BrowserActivity : DuckDuckGoActivity() {
532579
processCommand(it)
533580
}
534581

535-
if (swipingTabsFeature.isEnabled) {
536-
lifecycleScope.launch {
537-
viewModel.tabsFlow.flowWithLifecycle(lifecycle).collectLatest {
538-
tabManager.onTabsChanged(it)
539-
}
540-
}
541-
542-
lifecycleScope.launch {
543-
viewModel.selectedTabFlow.flowWithLifecycle(lifecycle).collectLatest {
544-
tabManager.onSelectedTabChanged(it)
545-
}
546-
}
547-
548-
lifecycleScope.launch {
549-
viewModel.selectedTabIndex.flowWithLifecycle(lifecycle).collectLatest {
550-
onMoveToTabRequested(it)
551-
}
552-
}
553-
} else {
582+
if (!swipingTabsFeature.isEnabled) {
583+
// when swiping is enabled, the state is controlled be flows initialized in configureFlowCollectors()
554584
viewModel.selectedTab.observe(this) {
555585
if (it != null) {
556586
selectTab(it)
@@ -604,7 +634,7 @@ open class BrowserActivity : DuckDuckGoActivity() {
604634
private fun processCommand(command: Command) {
605635
Timber.i("Processing command: $command")
606636
when (command) {
607-
is Query -> currentTab?.submitQuery(command.query)
637+
is Command.Query -> currentTab?.submitQuery(command.query)
608638
is Command.LaunchPlayStore -> launchPlayStore()
609639
is Command.ShowAppEnjoymentPrompt -> showAppEnjoymentDialog(command.promptCount)
610640
is Command.ShowAppRatingPrompt -> showAppRatingDialog(command.promptCount)
@@ -615,11 +645,24 @@ open class BrowserActivity : DuckDuckGoActivity() {
615645
is Command.OpenSavedSite -> currentTab?.submitQuery(command.url)
616646
is Command.ShowSetAsDefaultBrowserDialog -> showSetAsDefaultBrowserDialog()
617647
is Command.DismissSetAsDefaultBrowserDialog -> dismissSetAsDefaultBrowserDialog()
618-
is ShowSystemDefaultAppsActivity -> showSystemDefaultAppsActivity(command.intent)
619-
is ShowSystemDefaultBrowserDialog -> showSystemDefaultBrowserDialog(command.intent)
648+
is Command.ShowSystemDefaultAppsActivity -> showSystemDefaultAppsActivity(command.intent)
649+
is Command.ShowSystemDefaultBrowserDialog -> showSystemDefaultBrowserDialog(command.intent)
650+
is Command.ShowUndoDeleteTabsMessage -> showTabsDeletedSnackbar(command.tabIds)
651+
Command.LaunchTabSwitcher -> currentTab?.launchTabSwitcherAfterTabsUndeleted()
620652
}
621653
}
622654

655+
private fun showTabsDeletedSnackbar(tabIds: List<String>) {
656+
TabSwitcherSnackbar(
657+
anchorView = binding.fragmentContainer,
658+
message = resources.getQuantityString(R.plurals.tabSwitcherCloseTabsSnackbar, tabIds.size, tabIds.size),
659+
action = getString(R.string.tabClosedUndo),
660+
showAction = true,
661+
onAction = { viewModel.undoDeletableTabs(tabIds) },
662+
onDismiss = { viewModel.purgeDeletableTabs() },
663+
).show()
664+
}
665+
623666
private fun launchNewSearch(intent: Intent): Boolean {
624667
return intent.getBooleanExtra(NEW_SEARCH_EXTRA, false)
625668
}

app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4330,6 +4330,10 @@ class BrowserTabFragment :
43304330
fun onTabSwipedAway() {
43314331
viewModel.onTabSwipedAway()
43324332
}
4333+
4334+
fun launchTabSwitcherAfterTabsUndeleted() {
4335+
viewModel.onLaunchTabSwitcherAfterTabsUndeletedRequest()
4336+
}
43334337
}
43344338

43354339
private class JsOrientationHandler {

app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2973,6 +2973,10 @@ class BrowserTabViewModel @Inject constructor(
29732973
onUserDismissedCta(ctaViewState.value?.cta)
29742974
}
29752975

2976+
fun onLaunchTabSwitcherAfterTabsUndeletedRequest() {
2977+
command.value = LaunchTabSwitcher
2978+
}
2979+
29762980
private fun fireDailyLaunchPixel() {
29772981
val tabCount = viewModelScope.async(dispatchers.io()) { tabStatsBucketing.getNumberOfOpenTabs() }
29782982
val activeTabCount = viewModelScope.async(dispatchers.io()) { tabStatsBucketing.getTabsActiveLastWeek() }

app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import androidx.lifecycle.viewModelScope
2525
import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
2626
import com.duckduckgo.anvil.annotations.ContributesViewModel
2727
import com.duckduckgo.app.browser.BrowserViewModel.Command.DismissSetAsDefaultBrowserDialog
28+
import com.duckduckgo.app.browser.BrowserViewModel.Command.LaunchTabSwitcher
29+
import com.duckduckgo.app.browser.BrowserViewModel.Command.ShowUndoDeleteTabsMessage
2830
import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector
2931
import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment
3032
import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment.Command.OpenMessageDialog
@@ -65,16 +67,20 @@ import com.duckduckgo.di.scopes.AppScope
6567
import com.duckduckgo.feature.toggles.api.Toggle
6668
import javax.inject.Inject
6769
import kotlin.coroutines.CoroutineContext
70+
import kotlin.time.Duration.Companion.milliseconds
6871
import kotlinx.coroutines.CoroutineScope
6972
import kotlinx.coroutines.FlowPreview
7073
import kotlinx.coroutines.flow.Flow
7174
import kotlinx.coroutines.flow.combine
75+
import kotlinx.coroutines.flow.conflate
7276
import kotlinx.coroutines.flow.debounce
7377
import kotlinx.coroutines.flow.distinctUntilChanged
78+
import kotlinx.coroutines.flow.filter
7479
import kotlinx.coroutines.flow.filterNot
7580
import kotlinx.coroutines.flow.filterNotNull
7681
import kotlinx.coroutines.flow.first
7782
import kotlinx.coroutines.flow.map
83+
import kotlinx.coroutines.flow.onEach
7884
import kotlinx.coroutines.launch
7985
import timber.log.Timber
8086

@@ -94,8 +100,7 @@ class BrowserViewModel @Inject constructor(
94100
private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler,
95101
private val defaultBrowserPromptsExperiment: DefaultBrowserPromptsExperiment,
96102
private val swipingTabsFeature: SwipingTabsFeatureProvider,
97-
) : ViewModel(),
98-
CoroutineScope {
103+
) : ViewModel(), CoroutineScope {
99104

100105
override val coroutineContext: CoroutineContext
101106
get() = dispatchers.main()
@@ -107,8 +112,9 @@ class BrowserViewModel @Inject constructor(
107112

108113
sealed class Command {
109114
data class Query(val query: String) : Command()
110-
object LaunchPlayStore : Command()
111-
object LaunchFeedbackView : Command()
115+
data object LaunchPlayStore : Command()
116+
data object LaunchFeedbackView : Command()
117+
data object LaunchTabSwitcher : Command()
112118
data class ShowAppEnjoymentPrompt(val promptCount: PromptCount) : Command()
113119
data class ShowAppRatingPrompt(val promptCount: PromptCount) : Command()
114120
data class ShowAppFeedbackPrompt(val promptCount: PromptCount) : Command()
@@ -119,6 +125,7 @@ class BrowserViewModel @Inject constructor(
119125
data object DismissSetAsDefaultBrowserDialog : Command()
120126
data class ShowSystemDefaultBrowserDialog(val intent: Intent) : Command()
121127
data class ShowSystemDefaultAppsActivity(val intent: Intent) : Command()
128+
data class ShowUndoDeleteTabsMessage(val tabIds: List<String>) : Command()
122129
}
123130

124131
var viewState: MutableLiveData<ViewState> = MutableLiveData<ViewState>().also {
@@ -146,6 +153,14 @@ class BrowserViewModel @Inject constructor(
146153
tabs.indexOf(selectedTab)
147154
}.filterNot { it == -1 }
148155

156+
val deletableTabsFlow = tabRepository.flowDeletableTabs
157+
.map { tabs -> tabs.map { tab -> tab.tabId } }
158+
.filter { it.isNotEmpty() }
159+
.distinctUntilChanged()
160+
.debounce(100.milliseconds)
161+
.conflate()
162+
.onEach { onDeletableTabsChanged(it) }
163+
149164
private var dataClearingObserver = Observer<ApplicationClearDataState> { state ->
150165
when (state) {
151166
ApplicationClearDataState.INITIALIZING -> {
@@ -425,6 +440,25 @@ class BrowserViewModel @Inject constructor(
425440
fun onOmnibarEditModeChanged(isInEditMode: Boolean) {
426441
viewState.value = currentViewState.copy(isTabSwipingEnabled = !isInEditMode)
427442
}
443+
444+
// user has not tapped the Undo action -> purge the deletable tabs and remove all data
445+
fun purgeDeletableTabs() {
446+
viewModelScope.launch {
447+
tabRepository.purgeDeletableTabs()
448+
}
449+
}
450+
451+
// user has tapped the Undo action -> restore the closed tabs
452+
fun undoDeletableTabs(tabIds: List<String>) {
453+
viewModelScope.launch {
454+
tabRepository.undoDeletable(tabIds, moveActiveTabToEnd = true)
455+
command.value = LaunchTabSwitcher
456+
}
457+
}
458+
459+
private fun onDeletableTabsChanged(deletableTabs: List<String>) {
460+
command.value = ShowUndoDeleteTabsMessage(deletableTabs)
461+
}
428462
}
429463

430464
/**

app/src/main/java/com/duckduckgo/app/browser/tabs/adapter/TabSwitcherItemDiffCallback.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package com.duckduckgo.app.browser.tabs.adapter
1919
import android.os.Bundle
2020
import androidx.recyclerview.widget.DiffUtil
2121
import com.duckduckgo.app.tabs.ui.TabSwitcherItem
22+
import com.duckduckgo.app.tabs.ui.TabSwitcherItem.Tab.SelectableTab
2223
import com.duckduckgo.app.tabs.ui.TabSwitcherItem.TrackerAnimationInfoPanel.Companion.ANIMATED_TILE_DEFAULT_ALPHA
2324
import com.duckduckgo.app.tabs.ui.TabSwitcherItem.TrackerAnimationInfoPanel.Companion.ANIMATED_TILE_NO_REPLACE_ALPHA
2425

@@ -48,7 +49,8 @@ class TabSwitcherItemDiffCallback(
4849
oldItem.tabEntity.tabPreviewFile == newItem.tabEntity.tabPreviewFile &&
4950
oldItem.tabEntity.viewed == newItem.tabEntity.viewed &&
5051
oldItem.tabEntity.title == newItem.tabEntity.title &&
51-
oldItem.tabEntity.url == newItem.tabEntity.url
52+
oldItem.tabEntity.url == newItem.tabEntity.url &&
53+
(oldItem as? SelectableTab)?.isSelected == (newItem as? SelectableTab)?.isSelected
5254
}
5355
else -> false
5456
}
@@ -77,6 +79,10 @@ class TabSwitcherItemDiffCallback(
7779
if (oldItem.tabEntity.tabPreviewFile != newItem.tabEntity.tabPreviewFile) {
7880
diffBundle.putString(DIFF_KEY_PREVIEW, newItem.tabEntity.tabPreviewFile)
7981
}
82+
83+
if ((oldItem as? SelectableTab)?.isSelected != (newItem as? SelectableTab)?.isSelected) {
84+
diffBundle.putString(DIFF_KEY_SELECTION, null)
85+
}
8086
}
8187
oldItem is TabSwitcherItem.TrackerAnimationInfoPanel && newItem is TabSwitcherItem.TrackerAnimationInfoPanel -> {
8288
diffBundle.putFloat(
@@ -127,5 +133,6 @@ class TabSwitcherItemDiffCallback(
127133
const val DIFF_KEY_PREVIEW = "previewImage"
128134
const val DIFF_KEY_VIEWED = "viewed"
129135
const val DIFF_ALPHA = "alpha"
136+
const val DIFF_KEY_SELECTION = "selection"
130137
}
131138
}

app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,9 +341,12 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName {
341341
TAB_MANAGER_UP_BUTTON_PRESSED("m_tab_manager_exit_back_arrow"),
342342
TAB_MANAGER_BACK_BUTTON_PRESSED("m_tab_manager_exit_other"),
343343
TAB_MANAGER_MENU_PRESSED("m_tab_manager_menu"),
344+
TAB_MANAGER_SELECT_MODE_MENU_PRESSED("m_tab_manager_select_mode_menu_clicked"),
344345
TAB_MANAGER_MENU_NEW_TAB_PRESSED("m_tab_manager_menu_new_tab"),
345346
TAB_MANAGER_MENU_CLOSE_ALL_TABS_PRESSED("m_tab_manager_menu_close_all_tabs"),
347+
TAB_MANAGER_MENU_CLOSE_ALL_TABS_PRESSED_DAILY("m_tab_manager_menu_close_all_tabs_daily"),
346348
TAB_MANAGER_MENU_CLOSE_ALL_TABS_CONFIRMED("m_tab_manager_menu_close_all_tabs_confirm"),
349+
TAB_MANAGER_MENU_CLOSE_ALL_TABS_CONFIRMED_DAILY("m_tab_manager_menu_close_all_tabs_confirm_daily"),
347350
TAB_MANAGER_MENU_DOWNLOADS_PRESSED("m_tab_manager_menu_downloads"),
348351
TAB_MANAGER_MENU_SETTINGS_PRESSED("m_tab_manager_menu_settings"),
349352
TAB_MANAGER_REARRANGE_TABS_DAILY("m_tab_manager_rearrange_tabs_daily"),
@@ -352,6 +355,26 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName {
352355
TAB_MANAGER_OPENED_FROM_SERP("m_tab_manager_open_from_serp"),
353356
TAB_MANAGER_OPENED_FROM_SITE("m_tab_manager_open_from_website"),
354357
TAB_MANAGER_OPENED_FROM_NEW_TAB("m_tab_manager_open_from_newtabpage"),
358+
TAB_MANAGER_CLOSE_TABS("m_tab_manager_close_tabs"),
359+
TAB_MANAGER_CLOSE_TABS_DAILY("m_tab_manager_close_tabs_daily"),
360+
TAB_MANAGER_CLOSE_TABS_CONFIRMED("m_tab_manager_confirm_close_tabs"),
361+
TAB_MANAGER_CLOSE_TABS_CONFIRMED_DAILY("m_tab_manager_confirm_close_tabs_daily"),
362+
TAB_MANAGER_MENU_SELECT_TABS("m_tab_manager_menu_select_tabs"),
363+
TAB_MANAGER_MENU_SELECT_TABS_DAILY("m_tab_manager_menu_select_tabs_daily"),
364+
TAB_MANAGER_TAB_SELECTED("m_tab_manager_tab_selected"),
365+
TAB_MANAGER_TAB_DESELECTED("m_tab_manager_tab_deselected"),
366+
TAB_MANAGER_SELECT_MODE_MENU_SELECT_ALL("m_tab_manager_select_mode_menu_select_all"),
367+
TAB_MANAGER_SELECT_MODE_MENU_SELECT_ALL_DAILY("m_tab_manager_select_mode_menu_select_all_daily"),
368+
TAB_MANAGER_SELECT_MODE_MENU_DESELECT_ALL("m_tab_manager_select_mode_menu_deselect_all"),
369+
TAB_MANAGER_SELECT_MODE_MENU_DESELECT_ALL_DAILY("m_tab_manager_select_mode_menu_deselect_all_daily"),
370+
TAB_MANAGER_SELECT_MODE_MENU_SHARE_LINKS("m_tab_manager_select_mode_menu_share_links"),
371+
TAB_MANAGER_SELECT_MODE_MENU_SHARE_LINKS_DAILY("m_tab_manager_select_mode_menu_share_links_daily"),
372+
TAB_MANAGER_SELECT_MODE_MENU_BOOKMARK_TABS("m_tab_manager_select_mode_menu_bookmark_tabs"),
373+
TAB_MANAGER_SELECT_MODE_MENU_BOOKMARK_TABS_DAILY("m_tab_manager_select_mode_menu_bookmark_tabs_daily"),
374+
TAB_MANAGER_SELECT_MODE_MENU_CLOSE_OTHER_TABS("m_tab_manager_select_mode_menu_close_other_tabs"),
375+
TAB_MANAGER_SELECT_MODE_MENU_CLOSE_OTHER_TABS_DAILY("m_tab_manager_select_mode_menu_close_other_tabs_daily"),
376+
TAB_MANAGER_SELECT_MODE_MENU_CLOSE_TABS("m_tab_manager_select_mode_menu_close_tabs"),
377+
TAB_MANAGER_SELECT_MODE_MENU_CLOSE_TABS_DAILY("m_tab_manager_select_mode_menu_close_other_daily"),
355378

356379
SWIPE_TABS_USED("m_swipe_tabs_used"),
357380
SWIPE_TABS_USED_DAILY("m_swipe_tabs_used_daily"),

0 commit comments

Comments
 (0)