diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/AccessibleNewTabButtonBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/AccessibleNewTabButtonBinding.kt new file mode 100644 index 0000000000..bf33a4fe22 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/AccessibleNewTabButtonBinding.kt @@ -0,0 +1,104 @@ +/* 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 android.view.View +import android.widget.ImageButton +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 +import org.mozilla.fenix.utils.Settings + +/** + * Do not show accessible new tab button when accessibility service is disabled + * + * This binding is coupled with [FloatingActionButtonBinding]. + * When [FloatingActionButtonBinding] is visible this should not be visible + */ +class AccessibleNewTabButtonBinding( + private val store: TabsTrayStore, + private val settings: Settings, + private val newTabButton: ImageButton, + private val browserTrayInteractor: BrowserTrayInteractor, + private val syncedTabsInteractor: SyncedTabsInteractor +) : LifecycleAwareFeature { + + private var scope: CoroutineScope? = null + + @OptIn(ExperimentalCoroutinesApi::class) + override fun start() { + if (!settings.accessibilityServicesEnabled) { + newTabButton.visibility = View.GONE + return + } + + scope = store.flowScoped { flow -> + flow.map { it } + .ifAnyChanged { state -> + arrayOf( + state.selectedPage, + state.syncing + ) + } + .collect { state -> + setAccessibleNewTabButton(state.selectedPage, state.syncing) + } + } + } + + override fun stop() { + scope?.cancel() + } + + private fun setAccessibleNewTabButton(selectedPage: Page, syncing: Boolean) { + when (selectedPage) { + Page.NormalTabs -> { + newTabButton.apply { + visibility = View.VISIBLE + setImageResource(R.drawable.ic_new) + setOnClickListener { + browserTrayInteractor.onFabClicked(false) + } + } + } + Page.PrivateTabs -> { + newTabButton.apply { + visibility = View.VISIBLE + setImageResource(R.drawable.ic_new) + setOnClickListener { + browserTrayInteractor.onFabClicked(true) + } + } + } + Page.SyncedTabs -> { + newTabButton.apply { + visibility = + when (syncing) { + true -> View.GONE + false -> View.VISIBLE + } + + setImageResource(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/FloatingActionButtonBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/FloatingActionButtonBinding.kt index d212dbe05b..b3f82ffbcb 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/FloatingActionButtonBinding.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/FloatingActionButtonBinding.kt @@ -4,7 +4,6 @@ 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 @@ -17,9 +16,17 @@ 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 +import org.mozilla.fenix.utils.Settings +/** + * Do not show fab when accessibility service is enabled + * + * This binding is coupled with [AccessibleNewTabButtonBinding]. + * When [AccessibleNewTabButtonBinding] is visible this should not be visible + */ class FloatingActionButtonBinding( private val store: TabsTrayStore, + private val settings: Settings, private val actionButton: ExtendedFloatingActionButton, private val browserTrayInteractor: BrowserTrayInteractor, private val syncedTabsInteractor: SyncedTabsInteractor @@ -29,7 +36,11 @@ class FloatingActionButtonBinding( @OptIn(ExperimentalCoroutinesApi::class) override fun start() { - setFab(store.state.selectedPage, store.state.syncing) + if (settings.accessibilityServicesEnabled) { + actionButton.hide() + return + } + scope = store.flowScoped { flow -> flow.map { it } .ifAnyChanged { state -> @@ -54,7 +65,7 @@ class FloatingActionButtonBinding( actionButton.apply { shrink() show() - icon = AppCompatResources.getDrawable(context, R.drawable.ic_new) + setIconResource(R.drawable.ic_new) setOnClickListener { browserTrayInteractor.onFabClicked(false) } @@ -62,10 +73,10 @@ class FloatingActionButtonBinding( } Page.PrivateTabs -> { actionButton.apply { - text = context.getText(R.string.tab_drawer_fab_content) + setText(R.string.tab_drawer_fab_content) extend() show() - icon = AppCompatResources.getDrawable(context, R.drawable.ic_new) + setIconResource(R.drawable.ic_new) setOnClickListener { browserTrayInteractor.onFabClicked(true) } @@ -73,11 +84,15 @@ class FloatingActionButtonBinding( } Page.SyncedTabs -> { actionButton.apply { - text = if (syncing) context.getText(R.string.sync_syncing_in_progress) - else context.getText(R.string.tab_drawer_fab_sync) + setText( + when (syncing) { + true -> R.string.sync_syncing_in_progress + false -> R.string.tab_drawer_fab_sync + } + ) extend() show() - icon = AppCompatResources.getDrawable(context, R.drawable.ic_fab_sync) + setIconResource(R.drawable.ic_fab_sync) setOnClickListener { // Notify the store observers (one of which is the SyncedTabsFeature), that // a sync was requested. 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 e7dceb3620..f15754ecc9 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt @@ -12,6 +12,7 @@ import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AppCompatDialogFragment import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController @@ -68,6 +69,7 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { private val tabLayoutMediator = ViewBoundFeatureWrapper() private val tabCounterBinding = ViewBoundFeatureWrapper() private val floatingActionButtonBinding = ViewBoundFeatureWrapper() + private val newTabButtonBinding = ViewBoundFeatureWrapper() private val selectionBannerBinding = ViewBoundFeatureWrapper() private val selectionHandleBinding = ViewBoundFeatureWrapper() private val tabsTrayCtaBinding = ViewBoundFeatureWrapper() @@ -211,6 +213,7 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { floatingActionButtonBinding.set( feature = FloatingActionButtonBinding( store = tabsTrayStore, + settings = requireComponents.settings, actionButton = new_tab_button, browserTrayInteractor = browserTrayInteractor, syncedTabsInteractor = syncedTabsTrayInteractor @@ -219,6 +222,18 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { view = view ) + newTabButtonBinding.set( + feature = AccessibleNewTabButtonBinding( + store = tabsTrayStore, + settings = requireComponents.settings, + newTabButton = tab_tray_new_tab, + browserTrayInteractor = browserTrayInteractor, + syncedTabsInteractor = syncedTabsTrayInteractor + ), + owner = this, + view = view + ) + selectionBannerBinding.set( feature = SelectionBannerBinding( context = requireContext(), @@ -312,7 +327,7 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { }, operation = { }, elevation = ELEVATION, - anchorView = new_tab_button + anchorView = if (new_tab_button.isVisible) new_tab_button else null ) } diff --git a/app/src/main/res/layout/component_tabstray_fab.xml b/app/src/main/res/layout/component_tabstray_fab.xml index 6a6fbfcf09..ce63c8aa65 100644 --- a/app/src/main/res/layout/component_tabstray_fab.xml +++ b/app/src/main/res/layout/component_tabstray_fab.xml @@ -17,6 +17,7 @@ android:elevation="99dp" android:text="@string/tab_drawer_fab_content" android:textColor="@color/photonWhite" + android:visibility="gone" app:elevation="99dp" app:borderWidth="0dp" app:icon="@drawable/ic_new" diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/AccessibleNewTabButtonBindingTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/AccessibleNewTabButtonBindingTest.kt new file mode 100644 index 0000000000..df91bd25c0 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabstray/AccessibleNewTabButtonBindingTest.kt @@ -0,0 +1,140 @@ +/* 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 android.view.View +import android.widget.ImageButton +import androidx.appcompat.content.res.AppCompatResources +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import mozilla.components.support.test.libstate.ext.waitUntilIdle +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mozilla.fenix.R +import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor +import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsInteractor +import org.mozilla.fenix.utils.Settings + +class AccessibleNewTabButtonBindingTest { + + @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val coroutinesTestRule = MainCoroutineRule(TestCoroutineDispatcher()) + + private val settings: Settings = mockk(relaxed = true) + private val newTabButton: ImageButton = mockk(relaxed = true) + private val browserTrayInteractor: BrowserTrayInteractor = mockk(relaxed = true) + private val syncedTabsInteractor: SyncedTabsInteractor = mockk(relaxed = true) + + @Before + fun setup() { + mockkStatic(AppCompatResources::class) + every { AppCompatResources.getDrawable(any(), any()) } returns mockk(relaxed = true) + } + + @Test + fun `WHEN tab selected page is normal tab THEN new tab button is visible`() { + val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.NormalTabs)) + val newTabButtonBinding = AccessibleNewTabButtonBinding( + tabsTrayStore, settings, newTabButton, browserTrayInteractor, syncedTabsInteractor + ) + every { settings.accessibilityServicesEnabled } returns true + + newTabButtonBinding.start() + + verify(exactly = 1) { newTabButton.visibility = View.VISIBLE } + } + + @Test + fun `WHEN tab selected page is private tab THEN new tab button is visible`() { + val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.PrivateTabs)) + val newTabButtonBinding = AccessibleNewTabButtonBinding( + tabsTrayStore, settings, newTabButton, browserTrayInteractor, syncedTabsInteractor + ) + every { settings.accessibilityServicesEnabled } returns true + + newTabButtonBinding.start() + + verify(exactly = 1) { newTabButton.visibility = View.VISIBLE } + } + + @Test + fun `WHEN tab selected page is sync tab THEN new tab button is visible`() { + val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.SyncedTabs)) + val newTabButtonBinding = AccessibleNewTabButtonBinding( + tabsTrayStore, settings, newTabButton, browserTrayInteractor, syncedTabsInteractor + ) + every { settings.accessibilityServicesEnabled } returns true + + newTabButtonBinding.start() + + verify(exactly = 1) { newTabButton.visibility = View.VISIBLE } + } + + @Test + fun `WHEN accessibility is disabled THEN new tab button is not visible`() { + var tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.NormalTabs)) + var newTabButtonBinding = AccessibleNewTabButtonBinding( + tabsTrayStore, settings, newTabButton, browserTrayInteractor, syncedTabsInteractor + ) + every { settings.accessibilityServicesEnabled } returns false + + newTabButtonBinding.start() + + verify(exactly = 1) { newTabButton.visibility = View.GONE } + + tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.PrivateTabs)) + newTabButtonBinding = AccessibleNewTabButtonBinding( + tabsTrayStore, settings, newTabButton, browserTrayInteractor, syncedTabsInteractor + ) + + newTabButtonBinding.start() + + verify(exactly = 2) { newTabButton.visibility = View.GONE } + + tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.SyncedTabs)) + newTabButtonBinding = AccessibleNewTabButtonBinding( + tabsTrayStore, settings, newTabButton, browserTrayInteractor, syncedTabsInteractor + ) + + newTabButtonBinding.start() + + verify(exactly = 3) { newTabButton.visibility = View.GONE } + } + + @Test + fun `WHEN selected page is updated THEN button is updated`() { + val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.NormalTabs)) + val newTabButtonBinding = AccessibleNewTabButtonBinding( + tabsTrayStore, settings, newTabButton, browserTrayInteractor, syncedTabsInteractor + ) + every { settings.accessibilityServicesEnabled } returns true + + newTabButtonBinding.start() + + verify(exactly = 1) { newTabButton.setImageResource(R.drawable.ic_new) } + + tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(Page.PrivateTabs.ordinal))) + tabsTrayStore.waitUntilIdle() + + verify(exactly = 2) { newTabButton.setImageResource(R.drawable.ic_new) } + + tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(Page.SyncedTabs.ordinal))) + tabsTrayStore.waitUntilIdle() + + verify(exactly = 1) { newTabButton.setImageResource(R.drawable.ic_fab_sync) } + + tabsTrayStore.dispatch(TabsTrayAction.SyncNow) + tabsTrayStore.waitUntilIdle() + + verify(exactly = 1) { newTabButton.visibility = View.GONE } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/FloatingActionButtonBindingTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/FloatingActionButtonBindingTest.kt new file mode 100644 index 0000000000..9f6495c82a --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabstray/FloatingActionButtonBindingTest.kt @@ -0,0 +1,170 @@ +/* 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 io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import mozilla.components.support.test.libstate.ext.waitUntilIdle +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mozilla.fenix.R +import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor +import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsInteractor +import org.mozilla.fenix.utils.Settings + +class FloatingActionButtonBindingTest { + + @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val coroutinesTestRule = MainCoroutineRule(TestCoroutineDispatcher()) + + private val settings: Settings = mockk(relaxed = true) + private val actionButton: ExtendedFloatingActionButton = mockk(relaxed = true) + private val browserTrayInteractor: BrowserTrayInteractor = mockk(relaxed = true) + private val syncedTabsInteractor: SyncedTabsInteractor = mockk(relaxed = true) + + @Before + fun setup() { + mockkStatic(AppCompatResources::class) + every { AppCompatResources.getDrawable(any(), any()) } returns mockk(relaxed = true) + } + + @Test + fun `WHEN tab selected page is normal tab THEN shrink and show is called`() { + val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.NormalTabs)) + val fabBinding = FloatingActionButtonBinding( + tabsTrayStore, settings, actionButton, browserTrayInteractor, syncedTabsInteractor + ) + every { settings.accessibilityServicesEnabled } returns false + + fabBinding.start() + + verify(exactly = 1) { actionButton.shrink() } + verify(exactly = 1) { actionButton.show() } + verify(exactly = 0) { actionButton.extend() } + verify(exactly = 0) { actionButton.hide() } + } + + @Test + fun `WHEN tab selected page is private tab THEN extend and show is called`() { + val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.PrivateTabs)) + val fabBinding = FloatingActionButtonBinding( + tabsTrayStore, settings, actionButton, browserTrayInteractor, syncedTabsInteractor + ) + every { settings.accessibilityServicesEnabled } returns false + + fabBinding.start() + + verify(exactly = 1) { actionButton.extend() } + verify(exactly = 1) { actionButton.show() } + verify(exactly = 0) { actionButton.shrink() } + verify(exactly = 0) { actionButton.hide() } + } + + @Test + fun `WHEN tab selected page is sync tab THEN extend and show is called`() { + val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.SyncedTabs)) + val fabBinding = FloatingActionButtonBinding( + tabsTrayStore, settings, actionButton, browserTrayInteractor, syncedTabsInteractor + ) + every { settings.accessibilityServicesEnabled } returns false + + fabBinding.start() + + verify(exactly = 1) { actionButton.extend() } + verify(exactly = 1) { actionButton.show() } + verify(exactly = 0) { actionButton.shrink() } + verify(exactly = 0) { actionButton.hide() } + } + + @Test + fun `WHEN accessibility is enabled THEN show is not called`() { + var tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.NormalTabs)) + var fabBinding = FloatingActionButtonBinding( + tabsTrayStore, settings, actionButton, browserTrayInteractor, syncedTabsInteractor + ) + every { settings.accessibilityServicesEnabled } returns true + + fabBinding.start() + + verify(exactly = 0) { actionButton.show() } + verify(exactly = 1) { actionButton.hide() } + + tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.PrivateTabs)) + fabBinding = FloatingActionButtonBinding( + tabsTrayStore, settings, actionButton, browserTrayInteractor, syncedTabsInteractor + ) + + fabBinding.start() + + verify(exactly = 0) { actionButton.show() } + verify(exactly = 2) { actionButton.hide() } + + tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.SyncedTabs)) + fabBinding = FloatingActionButtonBinding( + tabsTrayStore, settings, actionButton, browserTrayInteractor, syncedTabsInteractor + ) + + fabBinding.start() + + verify(exactly = 0) { actionButton.show() } + verify(exactly = 3) { actionButton.hide() } + } + + @Test + fun `WHEN selected page is updated THEN button is updated`() { + val tabsTrayStore = TabsTrayStore(TabsTrayState(selectedPage = Page.NormalTabs)) + val fabBinding = FloatingActionButtonBinding( + tabsTrayStore, settings, actionButton, browserTrayInteractor, syncedTabsInteractor + ) + every { settings.accessibilityServicesEnabled } returns false + + fabBinding.start() + + verify(exactly = 1) { actionButton.shrink() } + verify(exactly = 1) { actionButton.show() } + verify(exactly = 0) { actionButton.extend() } + verify(exactly = 0) { actionButton.hide() } + verify(exactly = 1) { actionButton.setIconResource(R.drawable.ic_new) } + + tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(Page.PrivateTabs.ordinal))) + tabsTrayStore.waitUntilIdle() + + verify(exactly = 1) { actionButton.shrink() } + verify(exactly = 2) { actionButton.show() } + verify(exactly = 1) { actionButton.extend() } + verify(exactly = 0) { actionButton.hide() } + verify(exactly = 1) { actionButton.setText(R.string.tab_drawer_fab_content) } + verify(exactly = 2) { actionButton.setIconResource(R.drawable.ic_new) } + + tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(Page.SyncedTabs.ordinal))) + tabsTrayStore.waitUntilIdle() + + verify(exactly = 1) { actionButton.shrink() } + verify(exactly = 3) { actionButton.show() } + verify(exactly = 2) { actionButton.extend() } + verify(exactly = 0) { actionButton.hide() } + verify(exactly = 1) { actionButton.setText(R.string.tab_drawer_fab_sync) } + verify(exactly = 1) { actionButton.setIconResource(R.drawable.ic_fab_sync) } + + tabsTrayStore.dispatch(TabsTrayAction.SyncNow) + tabsTrayStore.waitUntilIdle() + + verify(exactly = 1) { actionButton.shrink() } + verify(exactly = 4) { actionButton.show() } + verify(exactly = 3) { actionButton.extend() } + verify(exactly = 0) { actionButton.hide() } + verify(exactly = 1) { actionButton.setText(R.string.sync_syncing_in_progress) } + verify(exactly = 2) { actionButton.setIconResource(R.drawable.ic_fab_sync) } + } +}