diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/FloatingActionButtonBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/FloatingActionButtonBinding.kt new file mode 100644 index 0000000000..d212dbe05b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/FloatingActionButtonBinding.kt @@ -0,0 +1,93 @@ +/* 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 + +import androidx.appcompat.content.res.AppCompatResources +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.base.feature.LifecycleAwareFeature +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged +import org.mozilla.fenix.R +import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor +import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsInteractor + +class FloatingActionButtonBinding( + private val store: TabsTrayStore, + private val actionButton: ExtendedFloatingActionButton, + private val browserTrayInteractor: BrowserTrayInteractor, + private val syncedTabsInteractor: SyncedTabsInteractor +) : LifecycleAwareFeature { + + private var scope: CoroutineScope? = null + + @OptIn(ExperimentalCoroutinesApi::class) + override fun start() { + setFab(store.state.selectedPage, store.state.syncing) + scope = store.flowScoped { flow -> + flow.map { it } + .ifAnyChanged { state -> + arrayOf( + state.selectedPage, + state.syncing + ) + } + .collect { state -> + setFab(state.selectedPage, state.syncing) + } + } + } + + override fun stop() { + scope?.cancel() + } + + private fun setFab(selectedPage: Page, syncing: Boolean) { + when (selectedPage) { + Page.NormalTabs -> { + actionButton.apply { + shrink() + show() + icon = AppCompatResources.getDrawable(context, R.drawable.ic_new) + setOnClickListener { + browserTrayInteractor.onFabClicked(false) + } + } + } + Page.PrivateTabs -> { + actionButton.apply { + text = context.getText(R.string.tab_drawer_fab_content) + extend() + show() + icon = AppCompatResources.getDrawable(context, R.drawable.ic_new) + setOnClickListener { + browserTrayInteractor.onFabClicked(true) + } + } + } + Page.SyncedTabs -> { + actionButton.apply { + text = if (syncing) context.getText(R.string.sync_syncing_in_progress) + else context.getText(R.string.tab_drawer_fab_sync) + extend() + show() + icon = AppCompatResources.getDrawable(context, R.drawable.ic_fab_sync) + setOnClickListener { + // Notify the store observers (one of which is the SyncedTabsFeature), that + // a sync was requested. + if (!syncing) { + store.dispatch(TabsTrayAction.SyncNow) + syncedTabsInteractor.onRefresh() + } + } + } + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabLayoutMediator.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabLayoutMediator.kt index fb41c0235b..1156559bc8 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabLayoutMediator.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabLayoutMediator.kt @@ -18,11 +18,12 @@ import org.mozilla.fenix.tabstray.TrayPagerAdapter.Companion.POSITION_PRIVATE_TA */ class TabLayoutMediator( private val tabLayout: TabLayout, - private val interactor: TabsTrayInteractor, - private val store: BrowserStore + interactor: TabsTrayInteractor, + private val browserStore: BrowserStore, + trayStore: TabsTrayStore ) : LifecycleAwareFeature { - private val observer = TabLayoutObserver(interactor) + private val observer = TabLayoutObserver(interactor, trayStore) /** * Start observing the [TabLayout] and select the current tab for initial state. @@ -39,7 +40,7 @@ class TabLayoutMediator( @VisibleForTesting internal fun selectActivePage() { - val selectedTab = store.state.selectedTab ?: return + val selectedTab = browserStore.state.selectedTab ?: return val selectedPagerPosition = if (selectedTab.content.private) { POSITION_PRIVATE_TABS @@ -55,7 +56,8 @@ class TabLayoutMediator( * An observer for the [TabLayout] used for the Tabs Tray. */ internal class TabLayoutObserver( - private val interactor: TabsTrayInteractor + private val interactor: TabsTrayInteractor, + private val trayStore: TabsTrayStore ) : TabLayout.OnTabSelectedListener { private var initialScroll = true @@ -70,8 +72,16 @@ internal class TabLayoutObserver( } interactor.setCurrentTrayPosition(tab.position, animate) + + trayStore.dispatch(TabsTrayAction.PageSelected(tab.toPage())) } override fun onTabUnselected(tab: TabLayout.Tab) = Unit override fun onTabReselected(tab: TabLayout.Tab) = Unit } + +fun TabLayout.Tab.toPage() = when (this.position) { + 0 -> Page.NormalTabs + 1 -> Page.PrivateTabs + else -> Page.SyncedTabs +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayController.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayController.kt index adf327a4fc..9ed44a68c3 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayController.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayController.kt @@ -5,8 +5,15 @@ package org.mozilla.fenix.tabstray import androidx.navigation.NavController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import mozilla.components.concept.base.profiler.Profiler +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.service.fxa.sync.SyncReason import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.tabtray.TabTrayDialogFragmentDirections interface TabsTrayController { @@ -15,15 +22,48 @@ interface TabsTrayController { * Called when user clicks the new tab button. */ fun onNewTabTapped(isPrivate: Boolean) + + /** + * Starts user account tab syncing. + * */ + fun onSyncStarted() } class DefaultTabsTrayController( + private val store: TabsTrayStore, private val browsingModeManager: BrowsingModeManager, - private val navController: NavController + private val navController: NavController, + private val profiler: Profiler?, + private val dismissTabTray: () -> Unit, + private val metrics: MetricController, + private val ioScope: CoroutineScope, + private val accountManager: FxaAccountManager ) : TabsTrayController { override fun onNewTabTapped(isPrivate: Boolean) { + val startTime = profiler?.getProfilerTime() browsingModeManager.mode = BrowsingMode.fromBoolean(isPrivate) navController.navigate(TabTrayDialogFragmentDirections.actionGlobalHome(focusOnAddressBar = true)) + dismissTabTray() + profiler?.addMarker( + "DefaultTabTrayController.onNewTabTapped", + startTime + ) + } + + override fun onSyncStarted() { + ioScope.launch { + metrics.track(Event.SyncAccountSyncNow) + // Trigger a sync. + accountManager.syncNow(SyncReason.User) + // Poll for device events & update devices. + accountManager.authenticatedAccount() + ?.deviceConstellation()?.run { + refreshDevices() + pollForCommands() + } + }.invokeOnCompletion { + store.dispatch(TabsTrayAction.SyncCompleted) + } } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt index d0f7c32203..9e20a0b5a6 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt @@ -12,25 +12,24 @@ import android.view.ViewGroup import androidx.appcompat.app.AppCompatDialogFragment import androidx.constraintlayout.widget.ConstraintLayout import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.google.android.material.bottomsheet.BottomSheetBehavior import kotlinx.android.synthetic.main.component_tabstray2.* import kotlinx.android.synthetic.main.component_tabstray2.view.* +import kotlinx.android.synthetic.main.component_tabstray_fab.* import kotlinx.android.synthetic.main.tabs_tray_tab_counter2.* -import kotlinx.android.synthetic.main.component_tabstray2.tab_layout -import kotlinx.android.synthetic.main.component_tabstray2.tabsTray -import kotlinx.android.synthetic.main.component_tabstray2.view.tab_wrapper -import kotlinx.android.synthetic.main.component_tabstray_fab.view.new_tab_button +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.plus import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.R -import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.home.HomeScreenViewModel -import org.mozilla.fenix.ext.settings import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor import org.mozilla.fenix.tabstray.browser.DefaultBrowserTrayInteractor import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsInteractor @@ -42,10 +41,10 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { private lateinit var browserTrayInteractor: BrowserTrayInteractor private lateinit var tabsTrayController: DefaultTabsTrayController private lateinit var behavior: BottomSheetBehavior - private var hasAccessibilityEnabled: Boolean = false private val tabLayoutMediator = ViewBoundFeatureWrapper() private val tabCounterBinding = ViewBoundFeatureWrapper() + private val floatingActionButtonBinding = ViewBoundFeatureWrapper() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -78,14 +77,19 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val activity = activity as HomeActivity - hasAccessibilityEnabled = activity.settings().accessibilityServicesEnabled tabsTrayController = DefaultTabsTrayController( + store = tabsTrayStore, browsingModeManager = activity.browsingModeManager, - navController = findNavController() + navController = findNavController(), + dismissTabTray = ::dismissAllowingStateLoss, + profiler = requireComponents.core.engine.profiler, + accountManager = requireComponents.backgroundServices.accountManager, + metrics = requireComponents.analytics.metrics, + ioScope = lifecycleScope + Dispatchers.IO ) - val browserTrayInteractor = DefaultBrowserTrayInteractor( + browserTrayInteractor = DefaultBrowserTrayInteractor( tabsTrayStore, this@TabsTrayFragment, tabsTrayController, @@ -106,7 +110,8 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { val syncedTabsTrayInteractor = SyncedTabsInteractor( requireComponents.analytics.metrics, requireActivity() as HomeActivity, - this + this, + controller = tabsTrayController ) setupMenu(view, navigationInteractor) @@ -122,7 +127,8 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { feature = TabLayoutMediator( tabLayout = tab_layout, interactor = this, - store = requireComponents.core.store + browserStore = requireComponents.core.store, + trayStore = tabsTrayStore ), owner = this, view = view ) @@ -135,11 +141,21 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { owner = this, view = view ) + + floatingActionButtonBinding.set( + feature = FloatingActionButtonBinding( + store = tabsTrayStore, + actionButton = new_tab_button, + browserTrayInteractor = browserTrayInteractor, + syncedTabsInteractor = syncedTabsTrayInteractor + ), + owner = this, + view = view + ) } override fun setCurrentTrayPosition(position: Int, smoothScroll: Boolean) { tabsTray.setCurrentItem(position, smoothScroll) - setupNewTabButtons(tabsTray.currentItem) } override fun navigateToBrowser() { @@ -209,42 +225,4 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { findNavController().navigate(directions) dismissAllowingStateLoss() } - - private fun setupNewTabButtons(currentPage: Int) { - fabView?.let { fabView -> - when (currentPage) { - NORMAL -> { - fabView.new_tab_button.shrink() - fabView.new_tab_button.show() - fabView.new_tab_button.setOnClickListener { - browserTrayInteractor.onFabClicked(false) - } - } - PRIVATE -> { - fabView.new_tab_button.text = - requireContext().resources.getText(R.string.tab_drawer_fab_content) - fabView.new_tab_button.extend() - fabView.new_tab_button.show() - fabView.new_tab_button.setOnClickListener { - browserTrayInteractor.onFabClicked(true) - } - } - SYNC -> { - fabView.new_tab_button.text = - requireContext().resources.getText(R.string.preferences_sync_now) - fabView.new_tab_button.extend() - fabView.new_tab_button.show() - fabView.new_tab_button.setOnClickListener { - } - } - } - } - } - - companion object { - // TabsTray Pages - const val NORMAL = 0 - const val PRIVATE = 1 - const val SYNC = 2 - } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsInteractor.kt index c098ba0972..8e619742c3 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsInteractor.kt @@ -10,14 +10,18 @@ import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.tabstray.TabsTrayController import org.mozilla.fenix.tabstray.TabsTrayInteractor -internal class SyncedTabsInteractor( +class SyncedTabsInteractor( private val metrics: MetricController, private val activity: HomeActivity, - private val trayInteractor: TabsTrayInteractor + private val trayInteractor: TabsTrayInteractor, + private val controller: TabsTrayController ) : SyncedTabsView.Listener { - override fun onRefresh() = Unit + override fun onRefresh() { + controller.onSyncStarted() + } override fun onTabClicked(tab: Tab) { metrics.track(Event.SyncedTabOpened) activity.openToBrowserAndLoad( diff --git a/app/src/main/res/drawable/ic_fab_sync.xml b/app/src/main/res/drawable/ic_fab_sync.xml new file mode 100644 index 0000000000..10369da77e --- /dev/null +++ b/app/src/main/res/drawable/ic_fab_sync.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58f6ff7d36..6d882c688a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -598,6 +598,8 @@ Add private tab Private + + Sync Open Tabs diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutMediatorTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutMediatorTest.kt index 3ccb737d23..63d3fb7656 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutMediatorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutMediatorTest.kt @@ -23,10 +23,10 @@ class TabLayoutMediatorTest { @Test fun `page to normal tab position when selected tab is also normal`() { - val store = createState("123") + val store = createStore("123") val tabLayout: TabLayout = mockk(relaxed = true) val tab: TabLayout.Tab = mockk(relaxed = true) - val mediator = TabLayoutMediator(tabLayout, mockk(relaxed = true), store) + val mediator = TabLayoutMediator(tabLayout, mockk(relaxed = true), store, mockk()) every { tabLayout.getTabAt(POSITION_NORMAL_TABS) }.answers { tab } @@ -37,10 +37,10 @@ class TabLayoutMediatorTest { @Test fun `page to private tab position when selected tab is also private`() { - val store = createState("456") + val store = createStore("456") val tabLayout: TabLayout = mockk(relaxed = true) val tab: TabLayout.Tab = mockk(relaxed = true) - val mediator = TabLayoutMediator(tabLayout, mockk(relaxed = true), store) + val mediator = TabLayoutMediator(tabLayout, mockk(relaxed = true), store, mockk()) every { tabLayout.getTabAt(POSITION_PRIVATE_TABS) }.answers { tab } @@ -51,9 +51,9 @@ class TabLayoutMediatorTest { @Test fun `lifecycle methods adds and removes observer`() { - val store = createState("456") + val store = createStore("456") val tabLayout: TabLayout = mockk(relaxed = true) - val mediator = TabLayoutMediator(tabLayout, mockk(relaxed = true), store) + val mediator = TabLayoutMediator(tabLayout, mockk(relaxed = true), store, mockk()) mediator.start() @@ -64,7 +64,7 @@ class TabLayoutMediatorTest { verify { tabLayout.removeOnTabSelectedListener(any()) } } - private fun createState(selectedId: String) = BrowserStore( + private fun createStore(selectedId: String) = BrowserStore( initialState = BrowserState( tabs = listOf( TabSessionState( diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutObserverTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutObserverTest.kt index ba14eea72e..cc98d4e6d9 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutObserverTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/TabLayoutObserverTest.kt @@ -8,25 +8,43 @@ import com.google.android.material.tabs.TabLayout import io.mockk.every import io.mockk.mockk import io.mockk.verify +import mozilla.components.support.test.libstate.ext.waitUntilIdle +import mozilla.components.support.test.middleware.CaptureActionsMiddleware +import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test class TabLayoutObserverTest { private val interactor = mockk(relaxed = true) + private lateinit var store: TabsTrayStore + private val middleware = CaptureActionsMiddleware() + + @Before + fun setup() { + store = TabsTrayStore(middlewares = listOf(middleware)) + } @Test fun `WHEN tab is selected THEN notify the interactor`() { - val observer = TabLayoutObserver(interactor) + val observer = TabLayoutObserver(interactor, store) val tab = mockk() every { tab.position } returns 1 observer.onTabSelected(tab) + store.waitUntilIdle() + verify { interactor.setCurrentTrayPosition(1, false) } + + middleware.assertLastAction(TabsTrayAction.PageSelected::class) { + assertTrue(it.page == Page.PrivateTabs) + } } @Test fun `WHEN observer is first started THEN do not smooth scroll`() { - val observer = TabLayoutObserver(interactor) + val store = TabsTrayStore() + val observer = TabLayoutObserver(interactor, store) val tab = mockk() every { tab.position } returns 1 diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/browser/DefaultBrowserTrayInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/browser/DefaultBrowserTrayInteractorTest.kt index f10f02712b..41922e4315 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/browser/DefaultBrowserTrayInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/browser/DefaultBrowserTrayInteractorTest.kt @@ -32,7 +32,8 @@ class DefaultBrowserTrayInteractorTest { @Test fun `WHEN pager position is synced tabs THEN return a list layout manager`() { - val interactor = DefaultBrowserTrayInteractor(mockk(), mockk(), mockk(), mockk(), mockk()) + val interactor = + DefaultBrowserTrayInteractor(mockk(), mockk(), mockk(), mockk(), mockk(), mockk()) val result = interactor.getLayoutManagerForPosition( mockk(), @@ -46,7 +47,8 @@ class DefaultBrowserTrayInteractorTest { fun `WHEN setting is grid view THEN return grid layout manager`() { val context = mockk() val settings = mockk() - val interactor = DefaultBrowserTrayInteractor(mockk(), mockk(), mockk(), settings, mockk()) + val interactor = + DefaultBrowserTrayInteractor(mockk(), mockk(), mockk(), mockk(), settings, mockk()) every { context.numberOfGridColumns }.answers { 4 } every { settings.gridTabView }.answers { true } @@ -63,7 +65,8 @@ class DefaultBrowserTrayInteractorTest { fun `WHEN setting is list view THEN return list layout manager`() { val context = mockk() val settings = mockk() - val interactor = DefaultBrowserTrayInteractor(mockk(), mockk(), mockk(), settings, mockk()) + val interactor = + DefaultBrowserTrayInteractor(mockk(), mockk(), mockk(), mockk(), settings, mockk()) every { context.numberOfGridColumns }.answers { 4 } every { settings.gridTabView }.answers { false }