tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit e0aa985553334691cefff090c7024074164c03eb
parent 4c51134ff3ccd71bd04ccf170fcd99434657de9d
Author: Noah Bond <nbond@mozilla.com>
Date:   Thu,  4 Dec 2025 21:32:36 +0000

Bug 1994280 - Add an icon to the Tab Manager's bottom app bar which navigates to the Tab Search feature r=android-reviewers,android-l10n-reviewers,flod,calu

Differential Revision: https://phabricator.services.mozilla.com/D273472

Diffstat:
Mgradle/libs.versions.toml | 3+++
Mmobile/android/fenix/app/build.gradle | 2++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabManagementFeatureHelper.kt | 8++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayStore.kt | 37+++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayTestTag.kt | 3+++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/navigation/TabManagerNavDestination.kt | 25+++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagementFragment.kt | 273++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/animation/NavigationAnimation.kt | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/fab/TabManagerFloatingToolbar.kt | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabsearch/TabSearchScreen.kt | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/res/values/strings.xml | 2++
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStateTest.kt | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreReducerTest.kt | 13+++++++++++++
13 files changed, 526 insertions(+), 124 deletions(-)

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ fragment = "1.8.9" lifecycle = "2.10.0" localbroadcastmanager = "1.0.0" media = "1.7.1" +nav3Core = "1.0.0" navigation = "2.9.5" paging = "3.3.6" palette = "1.0.0" @@ -155,6 +156,8 @@ androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-localbroadcastmanager = { group = "androidx.localbroadcastmanager", name = "localbroadcastmanager", version.ref = "localbroadcastmanager" } androidx-media = { group = "androidx.media", name = "media", version.ref = "media" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } androidx-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" } androidx-navigation-safeargs = { group = "androidx.navigation", name = "navigation-safe-args-gradle-plugin", version.ref = "navigation" } diff --git a/mobile/android/fenix/app/build.gradle b/mobile/android/fenix/app/build.gradle @@ -682,6 +682,8 @@ dependencies { implementation libs.androidx.navigation.compose implementation libs.androidx.navigation.fragment implementation libs.androidx.navigation.ui + implementation libs.androidx.navigation3.ui + implementation libs.androidx.navigation3.runtime implementation libs.androidx.paging implementation libs.androidx.preferences implementation libs.androidx.profileinstaller diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabManagementFeatureHelper.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabManagementFeatureHelper.kt @@ -36,6 +36,11 @@ interface TabManagementFeatureHelper { * Whether the Tab Manager opening animation is enabled. */ val openingAnimationEnabled: Boolean + + /** + * Whether the Tab Search feature is enabled. + */ + val tabSearchEnabled: Boolean } /** @@ -63,4 +68,7 @@ data object DefaultTabManagementFeatureHelper : TabManagementFeatureHelper { override val openingAnimationEnabled: Boolean get() = Config.channel.isDebug || FxNimbus.features.tabManagementEnhancements.value().openingAnimationEnabled + + override val tabSearchEnabled: Boolean + get() = Config.channel.isDebug } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayStore.kt @@ -10,6 +10,7 @@ import mozilla.components.lib.state.Action import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.State import mozilla.components.lib.state.Store +import org.mozilla.fenix.tabstray.navigation.TabManagerNavDestination import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsListItem /** @@ -26,6 +27,7 @@ import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsListItem * @property syncedTabs The list of synced tabs. * @property syncing Whether the Synced Tabs feature should fetch the latest tabs from paired devices. * @property selectedTabId The ID of the currently selected (active) tab. + * @property backStack The navigation history of the Tab Manager feature. */ data class TabsTrayState( val selectedPage: Page = Page.NormalTabs, @@ -37,6 +39,7 @@ data class TabsTrayState( val syncedTabs: List<SyncedTabsListItem> = emptyList(), val syncing: Boolean = false, val selectedTabId: String? = null, + val backStack: List<TabManagerNavDestination> = listOf(TabManagerNavDestination.Root), ) : State { /** @@ -60,6 +63,22 @@ data class TabsTrayState( */ data class Select(override val selectedTabs: Set<TabSessionState>) : Mode() } + + /** + * Whether the Tab Search button is visible. + */ + val searchIconVisible: Boolean + get() = selectedPage != Page.SyncedTabs + + /** + * Whether the Tab Search button is enabled. + */ + val searchIconEnabled: Boolean + get() = when { + selectedPage == Page.NormalTabs && normalTabs.isNotEmpty() -> true + selectedPage == Page.PrivateTabs && privateTabs.isNotEmpty() -> true + else -> false + } } /** @@ -242,6 +261,16 @@ sealed class TabsTrayAction : Action { * [TabsTrayAction] fired when the user requests to bookmark selected tabs. */ data class BookmarkSelectedTabs(val tabCount: Int) : TabsTrayAction() + + /** + * [TabsTrayAction] fired when the user clicks on the Tab Search icon. + */ + object TabSearchClicked : TabsTrayAction() + + /** + * [TabsTrayAction] fired when the user clicks on the back button or swipes to navigate back. + */ + object NavigateBackInvoked : TabsTrayAction() } /** @@ -291,6 +320,14 @@ internal object TabsTrayReducer { is TabsTrayAction.CloseAllPrivateTabs -> state is TabsTrayAction.BookmarkSelectedTabs -> state is TabsTrayAction.ThreeDotMenuShown -> state + is TabsTrayAction.TabSearchClicked -> + state.copy(backStack = state.backStack + TabManagerNavDestination.TabSearch) + is TabsTrayAction.NavigateBackInvoked -> { + when { + state.mode is TabsTrayState.Mode.Select -> state.copy(mode = TabsTrayState.Mode.Normal) + else -> state.copy(backStack = state.backStack.dropLast(1)) + } + } } } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayTestTag.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayTestTag.kt @@ -45,4 +45,7 @@ internal object TabsTrayTestTag { const val TAB_ITEM_ROOT = "$TABS_TRAY.tabItem" const val TAB_ITEM_CLOSE = "$TAB_ITEM_ROOT.close" const val TAB_ITEM_THUMBNAIL = "$TAB_ITEM_ROOT.thumbnail" + + // Bottom app bar + const val TAB_SEARCH_ICON = "$TABS_TRAY.tabSearchIcon" } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/navigation/TabManagerNavDestination.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/navigation/TabManagerNavDestination.kt @@ -0,0 +1,25 @@ +/* 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.navigation + +import org.mozilla.fenix.tabstray.ui.tabsearch.TabSearchScreen +import org.mozilla.fenix.tabstray.ui.tabstray.TabsTray + +/** + * Destinations the user can visit within the Tab Manager + */ +sealed interface TabManagerNavDestination { + + /** + * [TabManagerNavDestination] representing the root screen of the Tab Manager, [TabsTray], where + * users access their tabs. + */ + data object Root : TabManagerNavDestination + + /** + * [TabManagerNavDestination] representing the [TabSearchScreen]. + */ + data object TabSearch : TabManagerNavDestination +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagementFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/TabManagementFragment.kt @@ -30,6 +30,8 @@ import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import mozilla.appservices.places.BookmarkRoot @@ -84,7 +86,12 @@ import org.mozilla.fenix.tabstray.controller.DefaultTabManagerController import org.mozilla.fenix.tabstray.controller.DefaultTabManagerInteractor import org.mozilla.fenix.tabstray.controller.TabManagerController import org.mozilla.fenix.tabstray.controller.TabManagerInteractor +import org.mozilla.fenix.tabstray.navigation.TabManagerNavDestination import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsIntegration +import org.mozilla.fenix.tabstray.ui.animation.defaultPredictivePopTransitionSpec +import org.mozilla.fenix.tabstray.ui.animation.defaultTransitionSpec +import org.mozilla.fenix.tabstray.ui.animation.popTransitionSpec +import org.mozilla.fenix.tabstray.ui.tabsearch.TabSearchScreen import org.mozilla.fenix.tabstray.ui.tabstray.TabsTray import org.mozilla.fenix.tabstray.ui.theme.getTabManagerTheme import org.mozilla.fenix.theme.FirefoxTheme @@ -205,7 +212,7 @@ class TabManagementFragment : DialogFragment() { ) return content { - val page by tabsTrayStore.observeAsState(tabsTrayStore.state.selectedPage) { it.selectedPage } + val state by tabsTrayStore.observeAsState(tabsTrayStore.state) { it } val isPbmLocked by requireComponents.appStore .observeAsState(initialValue = requireComponents.appStore.state.isPrivateScreenLocked) { it.isPrivateScreenLocked @@ -241,11 +248,11 @@ class TabManagementFragment : DialogFragment() { } } - FirefoxTheme(theme = getTabManagerTheme(page = page)) { + FirefoxTheme(theme = getTabManagerTheme(page = state.selectedPage)) { val navBarColor = MaterialTheme.colorScheme.surfaceContainerHigh.toArgb() val statusBarColor = MaterialTheme.colorScheme.surface.toArgb() - LaunchedEffect(page) { + LaunchedEffect(state.selectedPage) { dialog?.window?.setSystemBarsBackground( statusBarColor = statusBarColor, navigationBarColor = navBarColor, @@ -265,128 +272,150 @@ class TabManagementFragment : DialogFragment() { onTabClick(tab = safeState.tab) }, ) { - TabsTray( - tabsTrayStore = tabsTrayStore, - displayTabsInGrid = requireContext().settings().gridTabView, - isInDebugMode = Config.channel.isDebug || - requireComponents.settings.showSecretDebugMenuThisSession, - shouldShowTabAutoCloseBanner = requireContext().settings().shouldShowAutoCloseTabsBanner && - requireContext().settings().canShowCfr, - shouldShowLockPbmBanner = shouldShowLockPbmBanner( - isPrivateMode = (activity as HomeActivity).browsingModeManager.mode.isPrivate, - hasPrivateTabs = requireComponents.core.store.state.privateTabs.isNotEmpty(), - biometricAvailable = BiometricManager.from(requireContext()) - .isHardwareAvailable(), - privateLockEnabled = requireContext().settings().privateBrowsingModeLocked, - shouldShowBanner = shouldShowBanner(requireContext().settings()), - ), - snackbarHostState = snackbarHostState, - isSignedIn = requireContext().settings().signedInFxaAccount, - isPbmLocked = isPbmLocked, - shouldShowInactiveTabsAutoCloseDialog = - requireContext().settings()::shouldShowInactiveTabsAutoCloseDialog, - onTabPageClick = { page -> - onTabPageClick( - tabsTrayInteractor = tabManagerInteractor, - page = page, - ) - }, - onTabClose = { tab -> - tabManagerInteractor.onTabClosed(tab, TAB_MANAGER_FEATURE_NAME) - }, - onTabClick = { tab -> - if (tabManagerAnimationHelper.animationsEnabled && - tabsTrayStore.state.mode is TabsTrayState.Mode.Normal - ) { - tabManagerAnimationHelper.transitionToThumbnail(tab = tab) - } else { - onTabClick(tab = tab) - } - }, - onTabLongClick = tabManagerInteractor::onTabLongClicked, - onInactiveTabsHeaderClick = tabManagerInteractor::onInactiveTabsHeaderClicked, - onDeleteAllInactiveTabsClick = tabManagerInteractor::onDeleteAllInactiveTabsClicked, - onInactiveTabsAutoCloseDialogShown = { - tabsTrayStore.dispatch(TabsTrayAction.TabAutoCloseDialogShown) - }, - onInactiveTabAutoCloseDialogCloseButtonClick = - tabManagerInteractor::onAutoCloseDialogCloseButtonClicked, - onEnableInactiveTabAutoCloseClick = { - tabManagerInteractor.onEnableAutoCloseClicked() - showInactiveTabsAutoCloseConfirmationSnackbar() - }, - onInactiveTabClick = tabManagerInteractor::onInactiveTabClicked, - onInactiveTabClose = tabManagerInteractor::onInactiveTabClosed, - onSyncedTabClick = tabManagerInteractor::onSyncedTabClicked, - onSyncedTabClose = tabManagerInteractor::onSyncedTabClosed, - onSignInClick = tabManagerInteractor::onSignInClicked, - onSaveToCollectionClick = tabManagerInteractor::onAddSelectedTabsToCollectionClicked, - onShareSelectedTabsClick = tabManagerInteractor::onShareSelectedTabs, - - onTabSettingsClick = tabManagerController::onTabSettingsClicked, - onRecentlyClosedClick = tabManagerController::onOpenRecentlyClosedClicked, - onAccountSettingsClick = tabManagerController::onAccountSettingsClicked, - onDeleteAllTabsClick = { - if (tabsTrayStore.state.selectedPage == Page.NormalTabs) { - tabsTrayStore.dispatch(TabsTrayAction.CloseAllNormalTabs) - } else if (tabsTrayStore.state.selectedPage == Page.PrivateTabs) { - tabsTrayStore.dispatch(TabsTrayAction.CloseAllPrivateTabs) + NavDisplay( + backStack = state.backStack, + onBack = { tabsTrayStore.dispatch(TabsTrayAction.NavigateBackInvoked) }, + transitionSpec = defaultTransitionSpec(), + popTransitionSpec = popTransitionSpec(), + predictivePopTransitionSpec = defaultPredictivePopTransitionSpec(), + entryProvider = entryProvider { + entry<TabManagerNavDestination.Root> { + TabsTray( + tabsTrayStore = tabsTrayStore, + displayTabsInGrid = requireContext().settings().gridTabView, + isInDebugMode = Config.channel.isDebug || + requireComponents.settings.showSecretDebugMenuThisSession, + shouldShowTabAutoCloseBanner = + requireContext().settings().shouldShowAutoCloseTabsBanner && + requireContext().settings().canShowCfr, + shouldShowLockPbmBanner = shouldShowLockPbmBanner( + isPrivateMode = (activity as HomeActivity).browsingModeManager.mode.isPrivate, + hasPrivateTabs = requireComponents.core.store.state.privateTabs.isNotEmpty(), + biometricAvailable = BiometricManager.from(requireContext()) + .isHardwareAvailable(), + privateLockEnabled = requireContext().settings().privateBrowsingModeLocked, + shouldShowBanner = shouldShowBanner(requireContext().settings()), + ), + snackbarHostState = snackbarHostState, + isSignedIn = requireContext().settings().signedInFxaAccount, + isPbmLocked = isPbmLocked, + shouldShowInactiveTabsAutoCloseDialog = + requireContext().settings()::shouldShowInactiveTabsAutoCloseDialog, + onTabPageClick = { page -> + onTabPageClick( + tabsTrayInteractor = tabManagerInteractor, + page = page, + ) + }, + onTabClose = { tab -> + tabManagerInteractor.onTabClosed(tab, TAB_MANAGER_FEATURE_NAME) + }, + onTabClick = { tab -> + if (tabManagerAnimationHelper.animationsEnabled && + tabsTrayStore.state.mode is TabsTrayState.Mode.Normal + ) { + tabManagerAnimationHelper.transitionToThumbnail(tab = tab) + } else { + onTabClick(tab = tab) + } + }, + onTabLongClick = tabManagerInteractor::onTabLongClicked, + onInactiveTabsHeaderClick = tabManagerInteractor::onInactiveTabsHeaderClicked, + onDeleteAllInactiveTabsClick = tabManagerInteractor::onDeleteAllInactiveTabsClicked, + onInactiveTabsAutoCloseDialogShown = { + tabsTrayStore.dispatch(TabsTrayAction.TabAutoCloseDialogShown) + }, + onInactiveTabAutoCloseDialogCloseButtonClick = + tabManagerInteractor::onAutoCloseDialogCloseButtonClicked, + onEnableInactiveTabAutoCloseClick = { + tabManagerInteractor.onEnableAutoCloseClicked() + showInactiveTabsAutoCloseConfirmationSnackbar() + }, + onInactiveTabClick = tabManagerInteractor::onInactiveTabClicked, + onInactiveTabClose = tabManagerInteractor::onInactiveTabClosed, + onSyncedTabClick = tabManagerInteractor::onSyncedTabClicked, + onSyncedTabClose = tabManagerInteractor::onSyncedTabClosed, + onSignInClick = tabManagerInteractor::onSignInClicked, + onSaveToCollectionClick = + tabManagerInteractor::onAddSelectedTabsToCollectionClicked, + onShareSelectedTabsClick = tabManagerInteractor::onShareSelectedTabs, + + onTabSettingsClick = tabManagerController::onTabSettingsClicked, + onRecentlyClosedClick = tabManagerController::onOpenRecentlyClosedClicked, + onAccountSettingsClick = tabManagerController::onAccountSettingsClicked, + onDeleteAllTabsClick = { + if (tabsTrayStore.state.selectedPage == Page.NormalTabs) { + tabsTrayStore.dispatch(TabsTrayAction.CloseAllNormalTabs) + } else if (tabsTrayStore.state.selectedPage == Page.PrivateTabs) { + tabsTrayStore.dispatch(TabsTrayAction.CloseAllPrivateTabs) + } + + tabManagerController.onCloseAllTabsClicked( + private = tabsTrayStore.state.selectedPage == Page.PrivateTabs, + ) + }, + onDeleteSelectedTabsClick = + tabManagerInteractor::onDeleteSelectedTabsClicked, + onBookmarkSelectedTabsClick = + tabManagerInteractor::onBookmarkSelectedTabsClicked, + onForceSelectedTabsAsInactiveClick = + tabManagerInteractor::onForceSelectedTabsAsInactiveClicked, + + onTabsTrayPbmLockedClick = ::onTabsTrayPbmLockedClick, + onTabsTrayPbmLockedDismiss = { + requireContext().settings().shouldShowLockPbmBanner = false + PrivateBrowsingLocked.bannerNegativeClicked.record() + }, + onTabAutoCloseBannerViewOptionsClick = { + tabManagerController.onTabSettingsClicked() + requireContext().settings().shouldShowAutoCloseTabsBanner = + false + requireContext().settings().lastCfrShownTimeInMillis = + System.currentTimeMillis() + }, + onTabAutoCloseBannerDismiss = { + requireContext().settings().shouldShowAutoCloseTabsBanner = + false + requireContext().settings().lastCfrShownTimeInMillis = + System.currentTimeMillis() + }, + onTabAutoCloseBannerShown = {}, + onMove = tabManagerInteractor::onTabsMove, + shouldShowInactiveTabsCFR = { + requireContext().settings().shouldShowInactiveTabsOnboardingPopup && + requireContext().settings().canShowCfr + }, + onInactiveTabsCFRShown = { + TabsTray.inactiveTabsCfrVisible.record(NoExtras()) + }, + onInactiveTabsCFRClick = { + requireContext().settings().shouldShowInactiveTabsOnboardingPopup = + false + requireContext().settings().lastCfrShownTimeInMillis = + System.currentTimeMillis() + tabManagerController.onTabSettingsClicked() + TabsTray.inactiveTabsCfrSettings.record(NoExtras()) + }, + onInactiveTabsCFRDismiss = { + requireContext().settings().shouldShowInactiveTabsOnboardingPopup = + false + requireContext().settings().lastCfrShownTimeInMillis = + System.currentTimeMillis() + TabsTray.inactiveTabsCfrDismissed.record(NoExtras()) + }, + onOpenNewNormalTabClicked = tabManagerInteractor::onNormalTabsFabClicked, + onOpenNewPrivateTabClicked = tabManagerInteractor::onPrivateTabsFabClicked, + onSyncedTabsFabClicked = tabManagerInteractor::onSyncedTabsFabClicked, + onUnlockPbmClick = { + verifyUser(fallbackVerification = verificationResultLauncher) + }, + ) } - tabManagerController.onCloseAllTabsClicked( - private = tabsTrayStore.state.selectedPage == Page.PrivateTabs, - ) - }, - onDeleteSelectedTabsClick = tabManagerInteractor::onDeleteSelectedTabsClicked, - onBookmarkSelectedTabsClick = tabManagerInteractor::onBookmarkSelectedTabsClicked, - onForceSelectedTabsAsInactiveClick = tabManagerInteractor::onForceSelectedTabsAsInactiveClicked, - - onTabsTrayPbmLockedClick = ::onTabsTrayPbmLockedClick, - onTabsTrayPbmLockedDismiss = { - requireContext().settings().shouldShowLockPbmBanner = false - PrivateBrowsingLocked.bannerNegativeClicked.record() - }, - onTabAutoCloseBannerViewOptionsClick = { - tabManagerController.onTabSettingsClicked() - requireContext().settings().shouldShowAutoCloseTabsBanner = - false - requireContext().settings().lastCfrShownTimeInMillis = - System.currentTimeMillis() - }, - onTabAutoCloseBannerDismiss = { - requireContext().settings().shouldShowAutoCloseTabsBanner = - false - requireContext().settings().lastCfrShownTimeInMillis = - System.currentTimeMillis() - }, - onTabAutoCloseBannerShown = {}, - onMove = tabManagerInteractor::onTabsMove, - shouldShowInactiveTabsCFR = { - requireContext().settings().shouldShowInactiveTabsOnboardingPopup && - requireContext().settings().canShowCfr - }, - onInactiveTabsCFRShown = { - TabsTray.inactiveTabsCfrVisible.record(NoExtras()) - }, - onInactiveTabsCFRClick = { - requireContext().settings().shouldShowInactiveTabsOnboardingPopup = - false - requireContext().settings().lastCfrShownTimeInMillis = - System.currentTimeMillis() - tabManagerController.onTabSettingsClicked() - TabsTray.inactiveTabsCfrSettings.record(NoExtras()) - }, - onInactiveTabsCFRDismiss = { - requireContext().settings().shouldShowInactiveTabsOnboardingPopup = - false - requireContext().settings().lastCfrShownTimeInMillis = - System.currentTimeMillis() - TabsTray.inactiveTabsCfrDismissed.record(NoExtras()) + entry<TabManagerNavDestination.TabSearch> { + TabSearchScreen(store = tabsTrayStore) + } }, - onOpenNewNormalTabClicked = tabManagerInteractor::onNormalTabsFabClicked, - onOpenNewPrivateTabClicked = tabManagerInteractor::onPrivateTabsFabClicked, - onSyncedTabsFabClicked = tabManagerInteractor::onSyncedTabsFabClicked, - onUnlockPbmClick = { verifyUser(fallbackVerification = verificationResultLauncher) }, ) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/animation/NavigationAnimation.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/animation/NavigationAnimation.kt @@ -0,0 +1,84 @@ +/* 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.ui.animation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleOut +import androidx.compose.ui.unit.IntOffset +import androidx.navigation3.scene.Scene +import androidx.navigationevent.NavigationEvent + +/** + * Value representing the animation curve's spring stiffness. + */ +private const val ANIMATION_STIFFNESS = 150f + +/** + * An animation spec with spring animation curve. + */ +private val SpringAnimationSpec: FiniteAnimationSpec<IntOffset> = spring(stiffness = ANIMATION_STIFFNESS) + +private val EnteringTransitionDirection = AnimatedContentTransitionScope.SlideDirection.Start + +private val LeavingTransitionDirection = AnimatedContentTransitionScope.SlideDirection.End + +/** + * Animation spec for transitioning from the Tab Manager's root screen to other tab screens, + * such as Tab Search. + * + * This performs a right-to-left transition when navigating to a new screen. + */ +internal fun <T : Any> defaultTransitionSpec(): AnimatedContentTransitionScope<Scene<T>>.() -> ContentTransform = { + ContentTransform( + targetContentEnter = slideIntoContainer( + towards = EnteringTransitionDirection, + animationSpec = SpringAnimationSpec, + ), + initialContentExit = slideOutOfContainer( + towards = EnteringTransitionDirection, + animationSpec = SpringAnimationSpec, + ), + ) +} + +/** + * Animation spec for transitioning from a screen, such as Tab Search, back to the Tab Manager's + * root screen via the back button. + * + * This performs a left-to-right transition when leaving a screen. + */ +internal fun <T : Any> popTransitionSpec(): AnimatedContentTransitionScope<Scene<T>>.() -> ContentTransform = { + ContentTransform( + targetContentEnter = slideIntoContainer( + towards = LeavingTransitionDirection, + animationSpec = SpringAnimationSpec, + ), + initialContentExit = slideOutOfContainer( + towards = LeavingTransitionDirection, + animationSpec = SpringAnimationSpec, + ), + ) +} + +/** + * Animation spec for transitioning from a screen, such as Tab Search, back to the Tab Manager's + * root screen via the navigate back gesture. + * + * This scales out the the current screen, so the previous screen becomes visible as the user + * performs the navigation gesture. + */ +internal fun <T : Any> defaultPredictivePopTransitionSpec(): AnimatedContentTransitionScope<Scene<T>>.( + @NavigationEvent.SwipeEdge Int, +) -> ContentTransform = + { + ContentTransform( + targetContentEnter = fadeIn(initialAlpha = 1f), + initialContentExit = scaleOut(targetScale = 0.7f), + ) + } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/fab/TabManagerFloatingToolbar.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/fab/TabManagerFloatingToolbar.kt @@ -12,8 +12,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.AlertDialog @@ -39,6 +39,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import mozilla.components.browser.state.state.createTab import mozilla.components.compose.base.button.ExtendedFloatingActionButton import mozilla.components.compose.base.button.FloatingActionButtonDefaults import mozilla.components.compose.base.menu.DropdownMenu @@ -48,7 +49,9 @@ import mozilla.components.compose.base.text.Text import mozilla.components.compose.base.theme.surfaceDimVariant import mozilla.components.lib.state.ext.observeAsState import org.mozilla.fenix.R +import org.mozilla.fenix.tabstray.DefaultTabManagementFeatureHelper import org.mozilla.fenix.tabstray.Page +import org.mozilla.fenix.tabstray.TabManagementFeatureHelper import org.mozilla.fenix.tabstray.TabsTrayAction import org.mozilla.fenix.tabstray.TabsTrayState import org.mozilla.fenix.tabstray.TabsTrayState.Mode @@ -67,6 +70,7 @@ import mozilla.components.ui.icons.R as iconsR * @param expanded Controls the expansion state of this FAB. In an expanded state, the FAB will * show both the icon and text. In a collapsed state, the FAB will show only the icon. * @param pbmLocked Whether the private browsing mode is currently locked. + * @param featureHelper The feature flag helper for the Tab Manager feature. * @param onOpenNewNormalTabClicked Invoked when the fab is clicked in [Page.NormalTabs]. * @param onOpenNewPrivateTabClicked Invoked when the fab is clicked in [Page.PrivateTabs]. * @param onSyncedTabsFabClicked Invoked when the fab is clicked in [Page.SyncedTabs]. @@ -83,6 +87,7 @@ internal fun TabManagerFloatingToolbar( modifier: Modifier = Modifier, expanded: Boolean = true, pbmLocked: Boolean = false, + featureHelper: TabManagementFeatureHelper = DefaultTabManagementFeatureHelper, onOpenNewNormalTabClicked: () -> Unit, onOpenNewPrivateTabClicked: () -> Unit, onSyncedTabsFabClicked: () -> Unit, @@ -109,6 +114,7 @@ internal fun TabManagerFloatingToolbar( ) { FloatingToolbarActions( state = state, + featureHelper = featureHelper, onMenuShown = { tabsTrayStore.dispatch(TabsTrayAction.ThreeDotMenuShown) }, @@ -119,6 +125,9 @@ internal fun TabManagerFloatingToolbar( onRecentlyClosedClick = onRecentlyClosedClick, onAccountSettingsClick = onAccountSettingsClick, onDeleteAllTabsClick = onDeleteAllTabsClick, + onSearchClicked = { + tabsTrayStore.dispatch(TabsTrayAction.TabSearchClicked) + }, ) } @@ -141,15 +150,18 @@ internal fun TabManagerFloatingToolbar( } } +@Suppress("LongParameterList") @Composable private fun FloatingToolbarActions( state: TabsTrayState, + featureHelper: TabManagementFeatureHelper, onMenuShown: () -> Unit, onEnterMultiselectModeClick: () -> Unit, onTabSettingsClick: () -> Unit, onRecentlyClosedClick: () -> Unit, onAccountSettingsClick: () -> Unit, onDeleteAllTabsClick: () -> Unit, + onSearchClicked: () -> Unit, ) { var showBottomAppBarMenu by remember { mutableStateOf(false) } var showCloseAllTabsDialog by remember { mutableStateOf(false) } @@ -166,7 +178,7 @@ private fun FloatingToolbarActions( ) Card( - modifier = Modifier.size(56.dp), + modifier = Modifier.height(56.dp), shape = CircleShape, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceDimVariant, @@ -178,6 +190,19 @@ private fun FloatingToolbarActions( modifier = Modifier.padding(all = FirefoxTheme.layout.space.static100), horizontalArrangement = Arrangement.spacedBy(FirefoxTheme.layout.space.static50), ) { + if (featureHelper.tabSearchEnabled && state.searchIconVisible) { + IconButton( + onClick = onSearchClicked, + modifier = Modifier.testTag(TabsTrayTestTag.TAB_SEARCH_ICON), + enabled = state.searchIconEnabled, + ) { + Icon( + painter = painterResource(iconsR.drawable.mozac_ic_search_24), + contentDescription = stringResource(id = R.string.tab_manager_open_tab_search), + ) + } + } + IconButton( onClick = { onMenuShown() @@ -407,35 +432,75 @@ private class TabManagerFloatingToolbarParameterProvider : PreviewParameterProvider<TabManagerFloatingToolbarPreviewModel> { override val values: Sequence<TabManagerFloatingToolbarPreviewModel> get() = sequenceOf( + // Normal tab page, disabled search icon, collapsed fab TabManagerFloatingToolbarPreviewModel( state = TabsTrayState(selectedPage = Page.NormalTabs), expanded = false, ), + // Normal tab page, disabled search icon, expanded fab TabManagerFloatingToolbarPreviewModel( state = TabsTrayState(selectedPage = Page.NormalTabs), expanded = true, ), + // Normal tab page, enabled search icon, collapsed fab + TabManagerFloatingToolbarPreviewModel( + state = TabsTrayState( + selectedPage = Page.NormalTabs, + normalTabs = listOf(createTab(url = "url")), + ), + expanded = false, + ), + // Normal tab page, enabled search icon, expanded fab + TabManagerFloatingToolbarPreviewModel( + state = TabsTrayState( + selectedPage = Page.NormalTabs, + normalTabs = listOf(createTab(url = "url")), + ), + expanded = true, + ), + // Private tab page, disabled search icon, collapsed fab TabManagerFloatingToolbarPreviewModel( state = TabsTrayState(selectedPage = Page.PrivateTabs), expanded = false, ), + // Private tab page, disabled search icon, expanded fab TabManagerFloatingToolbarPreviewModel( state = TabsTrayState(selectedPage = Page.PrivateTabs), expanded = true, ), + // Private tab page, enabled search icon, collapsed fab + TabManagerFloatingToolbarPreviewModel( + state = TabsTrayState( + selectedPage = Page.PrivateTabs, + privateTabs = listOf(createTab(url = "url")), + ), + expanded = false, + ), + // Private tab page, enabled search icon, expanded fab + TabManagerFloatingToolbarPreviewModel( + state = TabsTrayState( + selectedPage = Page.PrivateTabs, + privateTabs = listOf(createTab(url = "url")), + ), + expanded = true, + ), + // Synced tab page, signed-in, collapsed fab TabManagerFloatingToolbarPreviewModel( state = TabsTrayState(selectedPage = Page.SyncedTabs), expanded = false, ), + // Synced tab page, signed-in, expanded fab TabManagerFloatingToolbarPreviewModel( state = TabsTrayState(selectedPage = Page.SyncedTabs), expanded = true, ), + // Synced tab page, signed-out, collapsed fab TabManagerFloatingToolbarPreviewModel( state = TabsTrayState(selectedPage = Page.SyncedTabs), expanded = false, isSignedIn = false, ), + // Synced tab page, signed-out, expanded fab TabManagerFloatingToolbarPreviewModel( state = TabsTrayState(selectedPage = Page.SyncedTabs), expanded = true, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabsearch/TabSearchScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabsearch/TabSearchScreen.kt @@ -0,0 +1,68 @@ +/* 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.ui.tabsearch + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import mozilla.components.compose.base.button.FilledButton +import org.mozilla.fenix.tabstray.TabsTrayAction +import org.mozilla.fenix.tabstray.TabsTrayState +import org.mozilla.fenix.tabstray.TabsTrayStore +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * The top-level Composable for the Tab Search feature within the Tab Manager. + * + * @param store [TabsTrayStore] used to listen for changes to [TabsTrayState]. + */ +@Composable +fun TabSearchScreen( + store: TabsTrayStore, +) { + Scaffold { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .padding(all = 16.dp), + ) { + Text("Welcome to tab search!") + + Spacer(modifier = Modifier.height(16.dp)) + + FilledButton( + text = "Return to tab manager", + ) { store.dispatch(TabsTrayAction.NavigateBackInvoked) } + } + } +} + +private class TabSearchParameterProvider : PreviewParameterProvider<TabsTrayState> { + override val values = sequenceOf( + TabsTrayState(), + ) +} + +@PreviewLightDark +@Composable +private fun TabSearchScreenPreview( + @PreviewParameter(TabSearchParameterProvider::class) state: TabsTrayState, +) { + val store = remember { TabsTrayStore(initialState = state) } + + FirefoxTheme { + TabSearchScreen(store) + } +} diff --git a/mobile/android/fenix/app/src/main/res/values/strings.xml b/mobile/android/fenix/app/src/main/res/values/strings.xml @@ -1387,6 +1387,8 @@ <string name="delete_from_history">Delete from history</string> <!-- Postfix for private WebApp titles. %1$s is the name of the app (for example "Firefox"). --> <string name="pwa_site_controls_title_private">%1$s (Private Mode)</string> + <!-- Content description (not visible, for screen readers etc.) for the button to open the Tab Search feature to search through tabs. --> + <string name="tab_manager_open_tab_search">Open tab search</string> <!-- History --> <!-- Text for the button to search all history --> diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStateTest.kt @@ -5,6 +5,7 @@ package org.mozilla.fenix.tabstray import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.browser.state.state.createTab import mozilla.components.compose.base.menu.MenuItem import mozilla.components.compose.base.text.Text import org.junit.Assert.assertEquals @@ -214,6 +215,68 @@ class TabsTrayStateTest { ) } + /** + * [TabsTrayState.searchIconVisible] coverage + */ + + @Test + fun `WHEN the user is on the normal tabs page THEN the search icon is visible`() { + val testState = TabsTrayState(selectedPage = Page.NormalTabs) + assertTrue(testState.searchIconVisible) + } + + @Test + fun `WHEN the user is on the private tabs page THEN the search icon is visible`() { + val testState = TabsTrayState(selectedPage = Page.PrivateTabs) + assertTrue(testState.searchIconVisible) + } + + @Test + fun `WHEN the user is on the synced tabs page THEN the search icon is not visible`() { + val testState = TabsTrayState(selectedPage = Page.SyncedTabs) + assertFalse(testState.searchIconVisible) + } + + /** + * [TabsTrayState.searchIconEnabled] coverage + */ + + @Test + fun `GIVEN the user has no normal tabs open WHEN the user is on the normal tabs page THEN the search icon is disabled`() { + val testState = TabsTrayState( + selectedPage = Page.NormalTabs, + normalTabs = emptyList(), + ) + assertFalse(testState.searchIconEnabled) + } + + @Test + fun `GIVEN the user has at least one normal tab open WHEN the user is on the normal tabs page THEN the search icon is disabled`() { + val testState = TabsTrayState( + selectedPage = Page.NormalTabs, + normalTabs = listOf(createTab(url = "url")), + ) + assertTrue(testState.searchIconEnabled) + } + + @Test + fun `GIVEN the user has no private tabs open WHEN the user is on the private tabs page THEN the search icon is disabled`() { + val testState = TabsTrayState( + selectedPage = Page.PrivateTabs, + privateTabs = emptyList(), + ) + assertFalse(testState.searchIconEnabled) + } + + @Test + fun `GIVEN the user has at least one private tab open WHEN the user is on the private tabs page THEN the search icon is disabled`() { + val testState = TabsTrayState( + selectedPage = Page.PrivateTabs, + privateTabs = listOf(createTab(url = "url")), + ) + assertTrue(testState.searchIconEnabled) + } + private fun initMenuItems( mode: TabsTrayState.Mode, shouldShowInactiveButton: Boolean = false, diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreReducerTest.kt @@ -9,6 +9,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.mozilla.fenix.tabstray.navigation.TabManagerNavDestination import org.mozilla.fenix.tabstray.syncedtabs.getFakeSyncedTabList class TabsTrayStoreReducerTest { @@ -90,4 +91,16 @@ class TabsTrayStoreReducerTest { assertEquals(expectedState, resultState) } + + @Test + fun `WHEN the tab search button is pressed THEN the tab search destination is added to the back stack`() { + val initialState = TabsTrayState() + val resultState = TabsTrayReducer.reduce( + state = initialState, + action = TabsTrayAction.TabSearchClicked, + ) + + assertTrue(initialState.backStack.none { it == TabManagerNavDestination.TabSearch }) + assertTrue(resultState.backStack.last() == TabManagerNavDestination.TabSearch) + } }