diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt index 32844de46..e33b709c2 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt @@ -40,7 +40,7 @@ class TrayPagerAdapter( */ private val normalAdapter by lazy { ConcatAdapter( - InactiveTabsAdapter(context, browserInteractor, interactor, INACTIVE_TABS_FEATURE_NAME), + InactiveTabsAdapter(context, browserInteractor, interactor, INACTIVE_TABS_FEATURE_NAME, context.settings()), TabGroupAdapter(context, browserInteractor, tabsTrayStore, TAB_GROUP_FEATURE_NAME), TitleHeaderAdapter(browserStore, context.settings()), BrowserTabsAdapter(context, browserInteractor, tabsTrayStore, TABS_TRAY_FEATURE_NAME) diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolder.kt index ddc575173..503edc99f 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolder.kt @@ -14,6 +14,7 @@ import org.mozilla.fenix.R import org.mozilla.fenix.databinding.InactiveFooterItemBinding import org.mozilla.fenix.databinding.InactiveHeaderItemBinding import org.mozilla.fenix.databinding.InactiveTabListItemBinding +import org.mozilla.fenix.databinding.InactiveTabsAutoCloseBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.loadIntoView import org.mozilla.fenix.ext.toShortUrl @@ -72,6 +73,27 @@ sealed class InactiveTabViewHolder(itemView: View) : RecyclerView.ViewHolder(ite } } + class AutoCloseDialogHolder( + itemView: View, + interactor: InactiveTabsAutoCloseDialogInteractor + ) : InactiveTabViewHolder(itemView) { + private val binding = InactiveTabsAutoCloseBinding.bind(itemView) + + init { + binding.closeButton.setOnClickListener { + interactor.onCloseClicked() + } + + binding.action.setOnClickListener { + interactor.onEnabledAutoCloseClicked() + } + } + + companion object { + const val LAYOUT_ID = R.layout.inactive_tabs_auto_close + } + } + /** * A RecyclerView ViewHolder implementation for an inactive tab view. * diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAdapter.kt index 473950a7e..8eb1fe01d 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAdapter.kt @@ -15,10 +15,12 @@ import mozilla.components.concept.tabstray.TabsTray import mozilla.components.support.base.observer.ObserverRegistry import org.mozilla.fenix.components.Components import org.mozilla.fenix.tabstray.TabsTrayInteractor +import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.AutoCloseDialogHolder import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.FooterHolder import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.HeaderHolder import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.TabViewHolder import org.mozilla.fenix.tabstray.ext.autoCloseInterval +import org.mozilla.fenix.utils.Settings import mozilla.components.support.base.observer.Observable as ComponentObservable /** @@ -44,16 +46,20 @@ class InactiveTabsAdapter( private val browserTrayInteractor: BrowserTrayInteractor, private val tabsTrayInteractor: TabsTrayInteractor, private val featureName: String, + private val settings: Settings, delegate: Observable = ObserverRegistry() ) : Adapter(DiffCallback), TabsTray, Observable by delegate { internal lateinit var inactiveTabsInteractor: InactiveTabsInteractor + internal lateinit var inactiveTabsAutoCloseDialogInteractor: InactiveTabsAutoCloseDialogInteractor + internal var inActiveTabsCount: Int = 0 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InactiveTabViewHolder { val view = LayoutInflater.from(parent.context) .inflate(viewType, parent, false) return when (viewType) { + AutoCloseDialogHolder.LAYOUT_ID -> AutoCloseDialogHolder(view, inactiveTabsAutoCloseDialogInteractor) HeaderHolder.LAYOUT_ID -> HeaderHolder(view, inactiveTabsInteractor, tabsTrayInteractor) TabViewHolder.LAYOUT_ID -> TabViewHolder(view, browserTrayInteractor, featureName) FooterHolder.LAYOUT_ID -> FooterHolder(view) @@ -71,7 +77,7 @@ class InactiveTabsAdapter( val item = getItem(position) as Item.Footer holder.bind(item.interval) } - is HeaderHolder -> { + is HeaderHolder, is AutoCloseDialogHolder -> { // do nothing. } } @@ -80,12 +86,19 @@ class InactiveTabsAdapter( override fun getItemViewType(position: Int): Int { return when (position) { 0 -> HeaderHolder.LAYOUT_ID + 1 -> if (settings.shouldShowInactiveTabsAutoCloseDialog(inActiveTabsCount)) { + AutoCloseDialogHolder.LAYOUT_ID + } else { + TabViewHolder.LAYOUT_ID + } itemCount - 1 -> FooterHolder.LAYOUT_ID else -> TabViewHolder.LAYOUT_ID } } override fun updateTabs(tabs: Tabs) { + inActiveTabsCount = tabs.list.size + // Early return with an empty list to remove the header/footer items. if (tabs.list.isEmpty()) { submitList(emptyList()) @@ -100,8 +113,12 @@ class InactiveTabsAdapter( val items = tabs.list.map { Item.Tab(it) } val footer = Item.Footer(context.autoCloseInterval) - - submitList(listOf(Item.Header) + items + listOf(footer)) + val headerItems = if (settings.shouldShowInactiveTabsAutoCloseDialog(items.size)) { + listOf(Item.Header, Item.AutoCloseMessage) + } else { + listOf(Item.Header) + } + submitList(headerItems + items + listOf(footer)) } override fun isTabSelected(tabs: Tabs, position: Int): Boolean = false @@ -136,6 +153,11 @@ class InactiveTabsAdapter( */ data class Tab(val tab: TabsTrayTab) : Item() + /** + * A dialog for when the inactive tabs section reach 20 tabs. + */ + object AutoCloseMessage : Item() + /** * A footer for the inactive tab section. This may be seen only * when at least one inactive tab is present. diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogController.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogController.kt new file mode 100644 index 000000000..137d7d318 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogController.kt @@ -0,0 +1,44 @@ +/* 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.tabstray.browser + +import androidx.annotation.VisibleForTesting +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.tabstray.TabsTray +import mozilla.components.feature.tabs.ext.toTabs +import org.mozilla.fenix.utils.Settings + +class InactiveTabsAutoCloseDialogController( + private val browserStore: BrowserStore, + private val settings: Settings, + private val tabFilter: (TabSessionState) -> Boolean, + private val tray: TabsTray +) { + /** + * Dismiss the auto-close dialog. + */ + fun close() { + settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true + refeshInactiveTabsSecion() + } + + /** + * Enable the auto-close feature with the after a month setting. + */ + fun enableAutoClosed() { + settings.closeTabsAfterOneMonth = true + settings.closeTabsAfterOneWeek = false + settings.closeTabsAfterOneDay = false + settings.manuallyCloseTabs = false + refeshInactiveTabsSecion() + } + + @VisibleForTesting + internal fun refeshInactiveTabsSecion() { + val tabs = browserStore.state.toTabs { tabFilter.invoke(it) } + tray.updateTabs(tabs) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogInteractor.kt new file mode 100644 index 000000000..ec3d930d9 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogInteractor.kt @@ -0,0 +1,22 @@ +/* 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.tabstray.browser + +interface InactiveTabsAutoCloseDialogInteractor { + fun onCloseClicked() + fun onEnabledAutoCloseClicked() +} + +class DefaultInactiveTabsAutoCloseDialogInteractor( + private val controller: InactiveTabsAutoCloseDialogController +) : InactiveTabsAutoCloseDialogInteractor { + override fun onCloseClicked() { + controller.close() + } + + override fun onEnabledAutoCloseClicked() { + controller.enableAutoClosed() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt index f24c5ce37..0c3b39af2 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt @@ -39,23 +39,35 @@ class NormalBrowserTrayList @JvmOverloads constructor( private val swipeDelegate = SwipeToDeleteDelegate() private val concatAdapter by lazy { adapter as ConcatAdapter } private val tabSorter by lazy { TabSorter(context, concatAdapter, context.components.core.store) } - private val inactiveTabsInteractor by lazy { - val tabFilter: (TabSessionState) -> Boolean = filter@{ - if (!context.settings().inactiveTabsAreEnabled) { - return@filter false - } - it.isNormalTabInactive(maxActiveTime) + private val inactiveTabsFilter: (TabSessionState) -> Boolean = filter@{ + if (!context.settings().inactiveTabsAreEnabled) { + return@filter false } + it.isNormalTabInactive(maxActiveTime) + } + + private val inactiveTabsInteractor by lazy { DefaultInactiveTabsInteractor( InactiveTabsController( context.components.core.store, - tabFilter, + inactiveTabsFilter, concatAdapter.inactiveTabsAdapter, context.components.analytics.metrics ) ) } + private val inactiveTabsAutoCloseInteractor by lazy { + DefaultInactiveTabsAutoCloseDialogInteractor( + InactiveTabsAutoCloseDialogController( + context.components.core.store, + context.settings(), + inactiveTabsFilter, + concatAdapter.inactiveTabsAdapter + ) + ) + } + override val tabsFeature by lazy { TabsFeature( tabSorter, @@ -81,6 +93,7 @@ class NormalBrowserTrayList @JvmOverloads constructor( super.onAttachedToWindow() concatAdapter.inactiveTabsAdapter.inactiveTabsInteractor = inactiveTabsInteractor + concatAdapter.inactiveTabsAdapter.inactiveTabsAutoCloseDialogInteractor = inactiveTabsAutoCloseInteractor tabsFeature.start() diff --git a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt index 1cf25e436..1c5a9c9d3 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -67,6 +67,7 @@ class Settings(private val appContext: Context) : PreferencesHolder { private const val CFR_COUNT_CONDITION_FOCUS_INSTALLED = 1 private const val CFR_COUNT_CONDITION_FOCUS_NOT_INSTALLED = 3 private const val APP_LAUNCHES_TO_SHOW_DEFAULT_BROWSER_CARD = 3 + private const val INACTIVE_TAB_MINIMUM_TO_SHOW_AUTO_CLOSE_DIALOG = 20 const val FOUR_HOURS_MS = 60 * 60 * 4 * 1000L const val ONE_DAY_MS = 60 * 60 * 24 * 1000L @@ -838,6 +839,26 @@ class Settings(private val appContext: Context) : PreferencesHolder { default = true ) + /** + * Indicates if the auto-close dialog for inactive tabs has been dismissed before. + */ + var hasInactiveTabsAutoCloseDialogBeenDismissed by booleanPreference( + appContext.getPreferenceKey(R.string.pref_key_has_inactive_tabs_auto_close_dialog_dismissed), + default = false + ) + + /** + * Indicates if the auto-close dialog should be visible based on + * if the user has dismissed it before [hasInactiveTabsAutoCloseDialogBeenDismissed], + * if the minimum number of tabs has been accumulated [numbersOfTabs] + * and if the auto-close setting is already set to [closeTabsAfterOneMonth]. + */ + fun shouldShowInactiveTabsAutoCloseDialog(numbersOfTabs: Int): Boolean { + return !hasInactiveTabsAutoCloseDialogBeenDismissed && + numbersOfTabs >= INACTIVE_TAB_MINIMUM_TO_SHOW_AUTO_CLOSE_DIALOG && + !closeTabsAfterOneMonth + } + /** * Indicates if the jump back in CRF should be shown. */ diff --git a/app/src/main/res/drawable/inactive_tab_auto_close_border_background.xml b/app/src/main/res/drawable/inactive_tab_auto_close_border_background.xml new file mode 100644 index 000000000..bc80384e3 --- /dev/null +++ b/app/src/main/res/drawable/inactive_tab_auto_close_border_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/inactive_tabs_auto_close.xml b/app/src/main/res/layout/inactive_tabs_auto_close.xml new file mode 100644 index 000000000..e8a1a4133 --- /dev/null +++ b/app/src/main/res/layout/inactive_tabs_auto_close.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index 659954185..96dbbdeae 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -227,6 +227,8 @@ pref_key_should_show_inactive_tabs_popup + + pref_key_has_inactive_tabs_auto_close_dialog_dismissed pref_key_should_show_jump_back_in_tabs_popup diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82a24648b..7e814fe35 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -110,6 +110,14 @@ Tabs you haven’t viewed for two weeks get moved here. Turn off in settings + + Auto-close after one month? + + Firefox can close tabs you haven’t viewed over the past month. + + Close + + Turn on auto close diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/browser/DefaultInactiveTabsAutoCloseDialogInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/browser/DefaultInactiveTabsAutoCloseDialogInteractorTest.kt new file mode 100644 index 000000000..110987efe --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabstray/browser/DefaultInactiveTabsAutoCloseDialogInteractorTest.kt @@ -0,0 +1,32 @@ +/* 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.tabstray.browser + +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +class DefaultInactiveTabsAutoCloseDialogInteractorTest { + + @Test + fun `WHEN onCloseClicked THEN close`() { + val controller: InactiveTabsAutoCloseDialogController = mockk(relaxed = true) + val interactor = DefaultInactiveTabsAutoCloseDialogInteractor(controller) + + interactor.onCloseClicked() + + verify { controller.close() } + } + + @Test + fun `WHEN onEnabledAutoCloseClicked THEN enableAutoClosed`() { + val controller: InactiveTabsAutoCloseDialogController = mockk(relaxed = true) + val interactor = DefaultInactiveTabsAutoCloseDialogInteractor(controller) + + interactor.onEnabledAutoCloseClicked() + + verify { controller.enableAutoClosed() } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogControllerTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogControllerTest.kt new file mode 100644 index 000000000..6629c7e98 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAutoCloseDialogControllerTest.kt @@ -0,0 +1,55 @@ +/* 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.tabstray.browser + +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.tabstray.TabsTray +import org.junit.Test +import org.mozilla.fenix.utils.Settings + +class InactiveTabsAutoCloseDialogControllerTest { + + @Test + fun `WHEN close THEN update settings and refresh`() { + val filter: (TabSessionState) -> Boolean = { !it.content.private } + val store = BrowserStore() + val settings: Settings = mockk(relaxed = true) + val tray: TabsTray = mockk(relaxed = true) + val controller = spyk(InactiveTabsAutoCloseDialogController(store, settings, filter, tray)) + + every { controller.refeshInactiveTabsSecion() } just Runs + + controller.close() + + verify { settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true } + verify { controller.refeshInactiveTabsSecion() } + } + + @Test + fun `WHEN enableAutoClosed THEN update closeTabsAfterOneMonth settings and refresh`() { + val filter: (TabSessionState) -> Boolean = { !it.content.private } + val store = BrowserStore() + val settings: Settings = mockk(relaxed = true) + val tray: TabsTray = mockk(relaxed = true) + val controller = spyk(InactiveTabsAutoCloseDialogController(store, settings, filter, tray)) + + every { controller.refeshInactiveTabsSecion() } just Runs + + controller.enableAutoClosed() + + verify { settings.closeTabsAfterOneMonth = true } + verify { settings.closeTabsAfterOneWeek = false } + verify { settings.closeTabsAfterOneDay = false } + verify { settings.manuallyCloseTabs = false } + verify { controller.refeshInactiveTabsSecion() } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/utils/SettingsTest.kt b/app/src/test/java/org/mozilla/fenix/utils/SettingsTest.kt index 313c3861e..d2bc07c4a 100644 --- a/app/src/test/java/org/mozilla/fenix/utils/SettingsTest.kt +++ b/app/src/test/java/org/mozilla/fenix/utils/SettingsTest.kt @@ -753,4 +753,34 @@ class SettingsTest { // Then assertTrue(settings.inactiveTabsAreEnabled) } + + @Test + fun `GIVEN shouldShowInactiveTabsAutoCloseDialog WHEN the dialog has been dismissed before THEN no show the dialog`() { + val settings = spyk(settings) + every { settings.hasInactiveTabsAutoCloseDialogBeenDismissed } returns true + + assertFalse(settings.shouldShowInactiveTabsAutoCloseDialog(20)) + } + + @Test + fun `GIVEN shouldShowInactiveTabsAutoCloseDialog WHEN the inactive tabs are less than the minimum THEN no show the dialog`() { + assertFalse(settings.shouldShowInactiveTabsAutoCloseDialog(19)) + } + + @Test + fun `GIVEN shouldShowInactiveTabsAutoCloseDialog WHEN closeTabsAfterOneMonth is already selected THEN no show the dialog`() { + val settings = spyk(settings) + every { settings.closeTabsAfterOneMonth } returns true + + assertFalse(settings.shouldShowInactiveTabsAutoCloseDialog(19)) + } + + @Test + fun `GIVEN shouldShowInactiveTabsAutoCloseDialog WHEN the dialog has not been dismissed, with more inactive tabs than the queried and closeTabsAfterOneMonth not set THEN show the dialog`() { + val settings = spyk(settings) + every { settings.closeTabsAfterOneMonth } returns false + every { settings.hasInactiveTabsAutoCloseDialogBeenDismissed } returns false + + assertTrue(settings.shouldShowInactiveTabsAutoCloseDialog(20)) + } }