From 30ffce019d78a6b78e8e4be77256adf7c9e4704b Mon Sep 17 00:00:00 2001 From: Elise Richards Date: Thu, 29 Oct 2020 14:43:16 -0500 Subject: [PATCH] [components] Closes https://github.com/mozilla-mobile/android-components/issues/2743: Upstream tab counter changes from Fenix --- .../components/feature/tabs/build.gradle | 2 + .../tabs/toolbar/TabCounterToolbarButton.kt | 113 +++++-- .../tabs/toolbar/TabsToolbarFeature.kt | 37 ++- .../toolbar/TabCounterToolbarButtonTest.kt | 293 ++++++++++-------- .../tabs/toolbar/TabsToolbarFeatureTest.kt | 100 ++++-- .../components/ui/tabcounter/build.gradle | 6 + .../tabcounter/src/main/AndroidManifest.xml | 6 +- .../components/ui/tabcounter/TabCounter.kt | 264 ++++++---------- .../ui/tabcounter/TabCounterMenu.kt | 85 +++++ .../res/drawable/mozac_ui_tabcounter_box.xml | 17 +- .../drawable/mozac_ui_tabcounter_ic_close.xml | 13 + .../drawable/mozac_ui_tabcounter_ic_new.xml | 13 + ...ozac_ui_tabcounter_ic_private_browsing.xml | 13 + .../res/layout/mozac_ui_tabcounter_layout.xml | 32 +- .../tabcounter/src/main/res/values/colors.xml | 2 + .../tabcounter/src/main/res/values/dimens.xml | 4 + .../src/main/res/values/strings.xml | 18 ++ .../ui/tabcounter/TabCounterMenuTest.kt | 53 ++++ .../ui/tabcounter/TabCounterTest.kt | 35 +-- .../org.mockito.plugins.MockMaker | 2 + .../samples/browser/build.gradle | 1 + .../samples/browser/BrowserFragment.kt | 11 +- 22 files changed, 709 insertions(+), 411 deletions(-) create mode 100644 android-components/components/ui/tabcounter/src/main/java/mozilla/components/ui/tabcounter/TabCounterMenu.kt create mode 100644 android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_ic_close.xml create mode 100644 android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_ic_new.xml create mode 100644 android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_ic_private_browsing.xml create mode 100644 android-components/components/ui/tabcounter/src/main/res/values/dimens.xml create mode 100644 android-components/components/ui/tabcounter/src/main/res/values/strings.xml create mode 100644 android-components/components/ui/tabcounter/src/test/java/mozilla/components/ui/tabcounter/TabCounterMenuTest.kt create mode 100644 android-components/components/ui/tabcounter/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/android-components/components/feature/tabs/build.gradle b/android-components/components/feature/tabs/build.gradle index 4cd7f3529ca2..d560904d128c 100644 --- a/android-components/components/feature/tabs/build.gradle +++ b/android-components/components/feature/tabs/build.gradle @@ -30,12 +30,14 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { } dependencies { + implementation project(':browser-state') implementation project(':browser-session') implementation project(':browser-thumbnails') api project(':feature-session') implementation project(':concept-engine') implementation project(':concept-tabstray') implementation project(':concept-toolbar') + implementation project(':concept-menu') implementation project(':ui-icons') implementation project(':ui-tabcounter') implementation project(':support-ktx') diff --git a/android-components/components/feature/tabs/src/main/java/mozilla/components/feature/tabs/toolbar/TabCounterToolbarButton.kt b/android-components/components/feature/tabs/src/main/java/mozilla/components/feature/tabs/toolbar/TabCounterToolbarButton.kt index 6337e2e7693b..4a4bd684aecd 100644 --- a/android-components/components/feature/tabs/src/main/java/mozilla/components/feature/tabs/toolbar/TabCounterToolbarButton.kt +++ b/android-components/components/feature/tabs/src/main/java/mozilla/components/feature/tabs/toolbar/TabCounterToolbarButton.kt @@ -4,67 +4,112 @@ package mozilla.components.feature.tabs.toolbar -import android.view.HapticFeedbackConstants import android.view.View import android.view.ViewGroup -import mozilla.components.browser.session.Session -import mozilla.components.browser.session.SessionManager +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import mozilla.components.browser.state.selector.getNormalOrPrivateTabs +import mozilla.components.browser.state.selector.selectedTab +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.toolbar.Toolbar -import mozilla.components.feature.tabs.R +import mozilla.components.lib.state.ext.flowScoped import mozilla.components.support.ktx.android.content.res.resolveAttribute +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged import mozilla.components.ui.tabcounter.TabCounter +import mozilla.components.ui.tabcounter.TabCounterMenu import java.lang.ref.WeakReference /** * A [Toolbar.Action] implementation that shows a [TabCounter]. */ -class TabCounterToolbarButton( - private val sessionManager: SessionManager, - private val showTabs: () -> Unit +@ExperimentalCoroutinesApi +@Suppress("LongParameterList") +open class TabCounterToolbarButton( + private val lifecycleOwner: LifecycleOwner, + private val countBasedOnSelectedTabType: Boolean = true, + private val showTabs: () -> Unit, + private val store: BrowserStore, + private val menu: TabCounterMenu? = null, + private val privateColor: Int? = null ) : Toolbar.Action { - private var reference: WeakReference = WeakReference(null) + + private var reference = WeakReference(null) override fun createView(parent: ViewGroup): View { - sessionManager.register(sessionManagerObserver, view = parent) + store.flowScoped(lifecycleOwner) { flow -> + flow.map { state -> getTabCount(state) } + .ifChanged() + .collect { + tabs -> updateCount(tabs) + } + } - val view = TabCounter(parent.context).apply { + val tabCounter = TabCounter(parent.context).apply { reference = WeakReference(this) - setCount(sessionManager.sessions.size) setOnClickListener { - it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) showTabs.invoke() } - contentDescription = parent.context.getString(R.string.mozac_feature_tabs_toolbar_tabs_button) + + menu?.let { menu -> + setOnLongClickListener { + menu.menuController.show(anchor = it) + true + } + } + + privateColor?.let { + if (isPrivate(store)) { + setColor(it) + } + } + + addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View?) { + setCount(getTabCount(store.state)) + } + + override fun onViewDetachedFromWindow(v: View?) { /* no-op */ } + }) } // Set selectableItemBackgroundBorderless - val selectableItemBackgroundBorderless = - parent.context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless) - view.setBackgroundResource(selectableItemBackgroundBorderless) - return view - } - - override fun bind(view: View) = Unit + tabCounter.setBackgroundResource(parent.context.theme.resolveAttribute( + android.R.attr.selectableItemBackgroundBorderless + )) - private fun updateCount() { - reference.get()?.setCountWithAnimation(sessionManager.sessions.size) + return tabCounter } - private val sessionManagerObserver = object : SessionManager.Observer { - override fun onSessionAdded(session: Session) { - updateCount() - } + override fun bind(view: View) = Unit - override fun onSessionRemoved(session: Session) { - updateCount() + private fun getTabCount(state: BrowserState): Int { + return if (countBasedOnSelectedTabType) { + state.getNormalOrPrivateTabs(isPrivate(store)).size + } else { + state.tabs.size } + } - override fun onSessionsRestored() { - updateCount() - } + /** + * Update the tab counter button on the toolbar. + * + * @property count the updated tab count + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + fun updateCount(count: Int) { + reference.get()?.setCountWithAnimation(count) + } - override fun onAllSessionsRemoved() { - updateCount() - } + /** + * Check if the selected tab is private. + * + * @property store the [BrowserStore] associated with this instance + */ + fun isPrivate(store: BrowserStore): Boolean { + return store.state.selectedTab?.content?.private ?: false } } diff --git a/android-components/components/feature/tabs/src/main/java/mozilla/components/feature/tabs/toolbar/TabsToolbarFeature.kt b/android-components/components/feature/tabs/src/main/java/mozilla/components/feature/tabs/toolbar/TabsToolbarFeature.kt index e27197207743..e64b7384f1c0 100644 --- a/android-components/components/feature/tabs/src/main/java/mozilla/components/feature/tabs/toolbar/TabsToolbarFeature.kt +++ b/android-components/components/feature/tabs/src/main/java/mozilla/components/feature/tabs/toolbar/TabsToolbarFeature.kt @@ -4,30 +4,43 @@ package mozilla.components.feature.tabs.toolbar -import mozilla.components.browser.session.SessionManager -import mozilla.components.browser.session.runWithSession +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.browser.state.selector.findCustomTab +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.toolbar.Toolbar +import mozilla.components.ui.tabcounter.TabCounterMenu /** * Feature implementation for connecting a tabs tray implementation with a toolbar implementation. + * + * @param countBasedOnSelectedTabType if true the count is based on the selected tab i.e. if a + * private tab is selected private tabs will be counter, otherwise normal tabs. If false, all + * tabs will be counted. */ - +// TODO Refactor or remove this feature: https://github.com/mozilla-mobile/android-components/issues/9129 +@ExperimentalCoroutinesApi +@Suppress("LongParameterList") class TabsToolbarFeature( toolbar: Toolbar, - sessionManager: SessionManager, + store: BrowserStore, sessionId: String? = null, - showTabs: () -> Unit + lifecycleOwner: LifecycleOwner, + showTabs: () -> Unit, + tabCounterMenu: TabCounterMenu? = null, + countBasedOnSelectedTabType: Boolean = true ) { init { run { - sessionManager.runWithSession(sessionId) { - it.isCustomTabSession() - }.also { isCustomTab -> - if (isCustomTab) return@run - } + // this feature is not used for Custom Tabs + if (sessionId != null && store.state.findCustomTab(sessionId) != null) return@run + val tabsAction = TabCounterToolbarButton( - sessionManager, - showTabs + lifecycleOwner = lifecycleOwner, + showTabs = showTabs, + store = store, + menu = tabCounterMenu, + countBasedOnSelectedTabType = countBasedOnSelectedTabType ) toolbar.addBrowserAction(tabsAction) } diff --git a/android-components/components/feature/tabs/src/test/java/mozilla/components/feature/tabs/toolbar/TabCounterToolbarButtonTest.kt b/android-components/components/feature/tabs/src/test/java/mozilla/components/feature/tabs/toolbar/TabCounterToolbarButtonTest.kt index 3972b0221322..ec7394cd6cd9 100644 --- a/android-components/components/feature/tabs/src/test/java/mozilla/components/feature/tabs/toolbar/TabCounterToolbarButtonTest.kt +++ b/android-components/components/feature/tabs/src/test/java/mozilla/components/feature/tabs/toolbar/TabCounterToolbarButtonTest.kt @@ -6,170 +6,217 @@ package mozilla.components.feature.tabs.toolbar import android.view.ViewGroup import android.widget.LinearLayout +import android.widget.TextView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 -import mozilla.components.browser.session.Session -import mozilla.components.browser.session.SessionManager -import mozilla.components.support.test.any -import mozilla.components.support.test.eq +import kotlinx.coroutines.test.TestCoroutineDispatcher +import mozilla.components.browser.state.action.TabListAction +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.menu.MenuController +import mozilla.components.feature.tabs.R +import mozilla.components.support.test.ext.joinBlocking import mozilla.components.support.test.mock import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.whenever import mozilla.components.ui.tabcounter.TabCounter +import mozilla.components.ui.tabcounter.TabCounterMenu import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mockito.doReturn import org.mockito.Mockito.spy import org.mockito.Mockito.verify +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.eq @RunWith(AndroidJUnit4::class) class TabCounterToolbarButtonTest { + private val showTabs: () -> Unit = mock() + private val tabCounterMenu: TabCounterMenu = mock() + private val menuController: MenuController = mock() - @Test - fun `TabCounter has initial count set`() { - val sessionManager = SessionManager(mock()) - sessionManager.add(Session("about:blank")) - sessionManager.add(Session("about:blank")) + private lateinit var lifecycleOwner: MockedLifecycleOwner - val button = TabCounterToolbarButton(sessionManager) {} + private val testDispatcher = TestCoroutineDispatcher() - val view = button.createView(LinearLayout(testContext) as ViewGroup) - as TabCounter + @get:Rule + val coroutinesTestRule = MainCoroutineRule(testDispatcher) - assertEquals("2", view.getText()) - } - - @Test - fun `TabCounter registers on SessionManager with parent view lifetime`() { - val sessionManager = spy(SessionManager(mock())) - val button = TabCounterToolbarButton(sessionManager) {} + internal class MockedLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner { + val lifecycleRegistry = LifecycleRegistry(this).apply { + currentState = initialState + } - val parent = LinearLayout(testContext) - button.createView(parent) + override fun getLifecycle(): Lifecycle = lifecycleRegistry + } - verify(sessionManager).register(any(), eq(parent)) + @Before + fun setUp() { + whenever(tabCounterMenu.menuController).thenReturn(menuController) + lifecycleOwner = MockedLifecycleOwner(Lifecycle.State.STARTED) } @Test - fun `TabCounter updates when sessions get added`() { - val sessionManager = SessionManager(mock()) - - val button = TabCounterToolbarButton(sessionManager) {} - val parent = spy(LinearLayout(testContext)) - doReturn(true).`when`(parent).isAttachedToWindow - - val view = button.createView(parent) - as TabCounter - - assertEquals("0", view.getText()) - - sessionManager.add(Session("about:blank")) - - assertEquals("1", view.getText()) - - sessionManager.add(Session("about:blank")) - sessionManager.add(Session("about:blank")) - sessionManager.add(Session("about:blank")) - - assertEquals("4", view.getText()) + fun `WHEN tab counter is created THEN count is 0`() { + val button = spy( + TabCounterToolbarButton( + lifecycleOwner, + false, + showTabs = showTabs, + store = BrowserStore(), + menu = tabCounterMenu + ) + ) + + val view = button.createView(LinearLayout(testContext) as ViewGroup) as TabCounter + val counterText: TextView = view.findViewById(R.id.counter_text) + assertEquals("0", counterText.text) } @Test - fun `TabCounter updates when sessions get removed`() { - val sessionManager = SessionManager(mock()) - - val session1 = Session("about:blank") - val session2 = Session("about:blank") - val session3 = Session("about:blank") - - sessionManager.add(session1) - sessionManager.add(session2) - sessionManager.add(session3) - - val button = TabCounterToolbarButton(sessionManager) {} - val parent = spy(LinearLayout(testContext)) - doReturn(true).`when`(parent).isAttachedToWindow - - val view = button.createView(parent) - as TabCounter - - assertEquals("3", view.getText()) - - sessionManager.remove(session1) - - assertEquals("2", view.getText()) - - sessionManager.remove(session2) - sessionManager.remove(session3) - - assertEquals("0", view.getText()) + fun `WHEN tab is added THEN tab count is updated`() { + val store = BrowserStore() + val button = spy( + TabCounterToolbarButton( + lifecycleOwner, + false, + showTabs = showTabs, + store = store, + menu = tabCounterMenu + ) + ) + + whenever(button.updateCount(anyInt())).then { } + button.createView(LinearLayout(testContext) as ViewGroup) as TabCounter + + store.dispatch( + TabListAction.AddTabAction(createTab("https://www.mozilla.org")) + ).joinBlocking() + + verify(button).updateCount(eq(1)) } @Test - fun `TabCounter updates when all sessions get removed`() { - val sessionManager = SessionManager(mock()) - - val session1 = Session("about:blank") - val session2 = Session("about:blank") - val session3 = Session("about:blank") - - sessionManager.add(session1) - sessionManager.add(session2) - sessionManager.add(session3) - - val button = TabCounterToolbarButton(sessionManager) {} - val parent = spy(LinearLayout(testContext)) - doReturn(true).`when`(parent).isAttachedToWindow - - val view = button.createView(parent) - as TabCounter - - assertEquals("3", view.getText()) - - sessionManager.removeSessions() - - assertEquals("0", view.getText()) + fun `WHEN tab is restored THEN tab count is updated`() { + val store = BrowserStore() + val button = spy( + TabCounterToolbarButton( + lifecycleOwner, + false, + showTabs = showTabs, + store = store, + menu = tabCounterMenu + ) + ) + + whenever(button.updateCount(anyInt())).then { } + button.createView(LinearLayout(testContext) as ViewGroup) as TabCounter + + store.dispatch( + TabListAction.RestoreAction(listOf(createTab("https://www.mozilla.org"))) + ).joinBlocking() + + verify(button).updateCount(eq(1)) } @Test - fun `TabCounter updates when sessions get restored`() { - val sessionManager = SessionManager(mock()) - - val button = TabCounterToolbarButton(sessionManager) {} - val parent = spy(LinearLayout(testContext)) - doReturn(true).`when`(parent).isAttachedToWindow - - val view = button.createView(parent) - as TabCounter - - assertEquals("0", view.getText()) - - val snapshot = SessionManager.Snapshot(listOf( - SessionManager.Snapshot.Item(Session("about:blank")), - SessionManager.Snapshot.Item(Session("about:blank")), - SessionManager.Snapshot.Item(Session("about:blank")) - ), selectedSessionIndex = 0) - sessionManager.restore(snapshot) + fun `WHEN tab is removed THEN tab count is updated`() { + val tab = createTab("https://www.mozilla.org") + val store = BrowserStore(BrowserState(tabs = listOf(tab))) + val button = spy( + TabCounterToolbarButton( + lifecycleOwner, + false, + showTabs = showTabs, + store = store, + menu = tabCounterMenu + ) + ) + + whenever(button.updateCount(anyInt())).then { } + button.createView(LinearLayout(testContext) as ViewGroup) as TabCounter + + store.dispatch(TabListAction.RemoveTabAction(tab.id)).joinBlocking() + verify(button).updateCount(eq(0)) + } - assertEquals("3", view.getText()) + @Test + fun `WHEN private tab is added THEN tab count is updated`() { + val store = BrowserStore() + val button = spy( + TabCounterToolbarButton( + lifecycleOwner, + false, + showTabs = showTabs, + store = store, + menu = tabCounterMenu + ) + ) + + whenever(button.updateCount(anyInt())).then { } + whenever(button.isPrivate(store)).then { true } + + button.createView(LinearLayout(testContext) as ViewGroup) as TabCounter + + store.dispatch( + TabListAction.AddTabAction(createTab("https://www.mozilla.org", private = true)) + ).joinBlocking() + + verify(button).updateCount(eq(1)) } @Test - fun `Clicking TabCounter invokes showTabs function`() { - val sessionManager = SessionManager(mock()) + fun `WHEN private tab is removed THEN tab count is updated`() { + val tab = createTab("https://www.mozilla.org", private = true) + val store = BrowserStore(BrowserState(tabs = listOf(tab))) + val button = spy( + TabCounterToolbarButton( + lifecycleOwner, + false, + showTabs = showTabs, + store = store, + menu = tabCounterMenu + ) + ) + + whenever(button.updateCount(anyInt())).then { } + whenever(button.isPrivate(store)).then { true } + + button.createView(LinearLayout(testContext) as ViewGroup) as TabCounter + + store.dispatch(TabListAction.RemoveTabAction(tab.id)).joinBlocking() + verify(button).updateCount(eq(0)) + } + @Test + fun `WHEN tab counter is clicked THEN showTabs function is invoked`() { var callbackInvoked = false - val button = TabCounterToolbarButton(sessionManager) { - callbackInvoked = true - } + val store = BrowserStore(BrowserState(tabs = listOf())) + val button = spy( + TabCounterToolbarButton( + lifecycleOwner, + false, + showTabs = { + callbackInvoked = true + }, + store = store, + menu = tabCounterMenu + ) + ) + val parent = spy(LinearLayout(testContext)) doReturn(true).`when`(parent).isAttachedToWindow - val view = button.createView(parent) - as TabCounter - + val view = button.createView(parent) as TabCounter view.performClick() - assertTrue(callbackInvoked) } } diff --git a/android-components/components/feature/tabs/src/test/java/mozilla/components/feature/tabs/toolbar/TabsToolbarFeatureTest.kt b/android-components/components/feature/tabs/src/test/java/mozilla/components/feature/tabs/toolbar/TabsToolbarFeatureTest.kt index 4b0aad012aec..e7f2729c50b6 100644 --- a/android-components/components/feature/tabs/src/test/java/mozilla/components/feature/tabs/toolbar/TabsToolbarFeatureTest.kt +++ b/android-components/components/feature/tabs/src/test/java/mozilla/components/feature/tabs/toolbar/TabsToolbarFeatureTest.kt @@ -4,51 +4,111 @@ package mozilla.components.feature.tabs.toolbar -import mozilla.components.browser.session.Session -import mozilla.components.browser.session.SessionManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import mozilla.components.browser.state.state.CustomTabSessionState +import mozilla.components.browser.state.state.ContentState +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.toolbar.Toolbar import mozilla.components.support.test.any import mozilla.components.support.test.mock -import mozilla.components.support.test.whenever +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.ui.tabcounter.TabCounterMenu +import org.junit.Before +import org.junit.Rule import org.junit.Test -import org.mockito.Mockito.anyString import org.mockito.Mockito.never import org.mockito.Mockito.verify +@ExperimentalCoroutinesApi class TabsToolbarFeatureTest { + private val showTabs: () -> Unit = mock() + private val tabCounterMenu: TabCounterMenu = mock() + val toolbar: Toolbar = mock() + + private lateinit var tabsToolbarFeature: TabsToolbarFeature + private lateinit var lifecycleOwner: MockedLifecycleOwner + + private val testDispatcher = TestCoroutineDispatcher() + + @get:Rule + val coroutinesTestRule = MainCoroutineRule(testDispatcher) + + internal class MockedLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner { + val lifecycleRegistry = LifecycleRegistry(this).apply { + currentState = initialState + } + + override fun getLifecycle(): Lifecycle = lifecycleRegistry + } + + @Before + fun setUp() { + lifecycleOwner = MockedLifecycleOwner(Lifecycle.State.STARTED) + } @Test fun `feature adds "tabs" button to toolbar`() { - val toolbar: Toolbar = mock() - val sessionManager: SessionManager = mock() - TabsToolbarFeature(toolbar, sessionManager) {} + val store = BrowserStore() + val sessionId: String? = null + + tabsToolbarFeature = TabsToolbarFeature( + toolbar = toolbar, + store = store, + sessionId = sessionId, + lifecycleOwner = lifecycleOwner, + showTabs = showTabs, + tabCounterMenu = tabCounterMenu + ) verify(toolbar).addBrowserAction(any()) } @Test fun `feature does not add tabs button when session is a customtab`() { - val toolbar: Toolbar = mock() - val sessionManager: SessionManager = mock() - val session: Session = mock() - whenever(sessionManager.findSessionById(anyString())).thenReturn(session) - whenever(session.isCustomTabSession()).thenReturn(true) + val customTabId = "custom-id" + val customTabSessionState = + CustomTabSessionState( + id = customTabId, + content = ContentState("https://mozilla.org"), + config = mock() + ) - TabsToolbarFeature(toolbar, sessionManager, "123") {} + val browserState = BrowserState(customTabs = listOf(customTabSessionState)) + val store = BrowserStore(initialState = browserState) + + tabsToolbarFeature = TabsToolbarFeature( + toolbar = toolbar, + store = store, + sessionId = customTabId, + lifecycleOwner = lifecycleOwner, + showTabs = showTabs, + tabCounterMenu = tabCounterMenu + ) verify(toolbar, never()).addBrowserAction(any()) } @Test fun `feature adds tab button when session found but not a customtab`() { - val toolbar: Toolbar = mock() - val sessionManager: SessionManager = mock() - val session: Session = mock() - whenever(sessionManager.findSessionById(anyString())).thenReturn(session) - whenever(session.isCustomTabSession()).thenReturn(false) + val tabId = "tab-id" + + val browserState = BrowserState() + val store = BrowserStore(initialState = browserState) - TabsToolbarFeature(toolbar, sessionManager, "123") {} + tabsToolbarFeature = TabsToolbarFeature( + toolbar = toolbar, + store = store, + sessionId = tabId, + lifecycleOwner = lifecycleOwner, + showTabs = showTabs, + tabCounterMenu = tabCounterMenu + ) verify(toolbar).addBrowserAction(any()) } -} +} \ No newline at end of file diff --git a/android-components/components/ui/tabcounter/build.gradle b/android-components/components/ui/tabcounter/build.gradle index 5cffd86c060d..c8ad7960c100 100644 --- a/android-components/components/ui/tabcounter/build.gradle +++ b/android-components/components/ui/tabcounter/build.gradle @@ -4,6 +4,8 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' android { compileSdkVersion config.compileSdkVersion @@ -26,6 +28,10 @@ dependencies { implementation project(':support-utils') implementation Dependencies.kotlin_stdlib + implementation Dependencies.androidx_core_ktx + implementation project(':concept-menu') + implementation project(':browser-menu2') + implementation project(':support-base') testImplementation project(":support-test") diff --git a/android-components/components/ui/tabcounter/src/main/AndroidManifest.xml b/android-components/components/ui/tabcounter/src/main/AndroidManifest.xml index 935885fe4831..907afc5ee965 100644 --- a/android-components/components/ui/tabcounter/src/main/AndroidManifest.xml +++ b/android-components/components/ui/tabcounter/src/main/AndroidManifest.xml @@ -2,4 +2,8 @@ - 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="mozilla.components.ui.tabcounter" > + + + + diff --git a/android-components/components/ui/tabcounter/src/main/java/mozilla/components/ui/tabcounter/TabCounter.kt b/android-components/components/ui/tabcounter/src/main/java/mozilla/components/ui/tabcounter/TabCounter.kt index c51553fe69ef..ff8cb88fa878 100644 --- a/android-components/components/ui/tabcounter/src/main/java/mozilla/components/ui/tabcounter/TabCounter.kt +++ b/android-components/components/ui/tabcounter/src/main/java/mozilla/components/ui/tabcounter/TabCounter.kt @@ -7,15 +7,13 @@ package mozilla.components.ui.tabcounter import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.content.Context +import android.graphics.Typeface import android.util.AttributeSet import android.util.TypedValue import android.view.LayoutInflater -import android.widget.ImageView import android.widget.RelativeLayout -import android.widget.TextView -import androidx.annotation.ColorInt -import androidx.core.content.ContextCompat -import mozilla.components.support.ktx.android.view.onNextGlobalLayout +import androidx.core.view.updatePadding +import kotlinx.android.synthetic.main.mozac_ui_tabcounter_layout.view.* import mozilla.components.support.utils.DrawableUtils import java.text.NumberFormat @@ -25,67 +23,48 @@ open class TabCounter @JvmOverloads constructor( defStyle: Int = 0 ) : RelativeLayout(context, attrs, defStyle) { - private val box: ImageView - private val bar: ImageView - private val text: TextView - private val animationSet: AnimatorSet - private var count: Int = 0 - internal var currentTextRatio: Float = 0.toFloat() init { - // Default TabCounter tint, could be override by the caller - @ColorInt val defaultTabCounterTint = ContextCompat.getColor(context, R.color.mozac_ui_tabcounter_default_tint) - val typedArray = context.obtainStyledAttributes(attrs, R.styleable.TabCounter, defStyle, 0) - @ColorInt val tabCounterTint = typedArray.getColor(R.styleable.TabCounter_drawableColor, defaultTabCounterTint) - typedArray.recycle() - val inflater = LayoutInflater.from(context) inflater.inflate(R.layout.mozac_ui_tabcounter_layout, this) - box = findViewById(R.id.counter_box) - bar = findViewById(R.id.counter_bar) - text = findViewById(R.id.counter_text) - - setCount(count) - - if (tabCounterTint != defaultTabCounterTint) { - tintDrawables(tabCounterTint) - } + setCount(INTERNAL_COUNT) animationSet = createAnimatorSet() } /** - * Returns current tab count from tab counter view. + * Sets the colors of the tab counter box and text. */ - fun getText(): CharSequence { - return text.text + fun setColor(color: Int) { + val tabCounterBox = + DrawableUtils.loadAndTintDrawable(context, R.drawable.mozac_ui_tabcounter_box, color) + counter_box.setImageDrawable(tabCounterBox) + counter_text.setTextColor(color) } - fun setCountWithAnimation(count: Int) { - // Don't animate from initial state. - if (this.count == 0) { - setCount(count) - return + private fun updateContentDescription(count: Int) { + counter_root.contentDescription = if (count == 1) { + context?.getString(R.string.mozac_tab_counter_open_tab_tray_single) + } else { + String.format( + context.getString(R.string.mozac_tab_counter_open_tab_tray_plural), + count.toString() + ) } + } - if (this.count == count) { - return - } + fun setCountWithAnimation(count: Int) { + setCount(count) - // Don't animate if there are still over MAX_VISIBLE_TABS tabs open. - if (this.count > MAX_VISIBLE_TABS && count > MAX_VISIBLE_TABS) { - this.count = count - return + // No need to animate on these cases. + when { + INTERNAL_COUNT == 0 -> return // Initial state. + INTERNAL_COUNT == count -> return // There isn't any tab added or removed. + INTERNAL_COUNT > MAX_VISIBLE_TABS -> return // There are still over MAX_VISIBLE_TABS tabs open. } - adjustTextSize(count) - - text.setPadding(0, 0, 0, 0) - text.text = formatForDisplay(count) - this.count = count - // Cancel previous animations if necessary. if (animationSet.isRunning) { animationSet.cancel() @@ -95,70 +74,74 @@ open class TabCounter @JvmOverloads constructor( } fun setCount(count: Int) { + updateContentDescription(count) adjustTextSize(count) - - text.setPadding(0, 0, 0, 0) - text.text = formatForDisplay(count) - this.count = count - } - - private fun tintDrawables(tabCounterTint: Int) { - val tabCounterBox = DrawableUtils.loadAndTintDrawable(context, - R.drawable.mozac_ui_tabcounter_box, tabCounterTint) - box.setImageDrawable(tabCounterBox) - - val tabCounterBar = DrawableUtils.loadAndTintDrawable(context, - R.drawable.mozac_ui_tabcounter_bar, tabCounterTint) - bar.setImageDrawable(tabCounterBar) - - text.setTextColor(tabCounterTint) + counter_text.text = formatCountForDisplay(count) + INTERNAL_COUNT = count } private fun createAnimatorSet(): AnimatorSet { val animatorSet = AnimatorSet() createBoxAnimatorSet(animatorSet) - createBarAnimatorSet(animatorSet) createTextAnimatorSet(animatorSet) return animatorSet } private fun createBoxAnimatorSet(animatorSet: AnimatorSet) { // The first animator, fadeout in 33 ms (49~51, 2 frames). - val fadeOut = ObjectAnimator.ofFloat(box, "alpha", - ANIM_BOX_FADEOUT_FROM, ANIM_BOX_FADEOUT_TO).setDuration(ANIM_BOX_FADEOUT_DURATION) + val fadeOut = ObjectAnimator.ofFloat( + counter_box, "alpha", + ANIM_BOX_FADEOUT_FROM, ANIM_BOX_FADEOUT_TO + ).setDuration(ANIM_BOX_FADEOUT_DURATION) // Move up on y-axis, from 0.0 to -5.3 in 50ms, with fadeOut (49~52, 3 frames). - val moveUp1 = ObjectAnimator.ofFloat(box, "translationY", - ANIM_BOX_MOVEUP1_TO, ANIM_BOX_MOVEUP1_FROM).setDuration(ANIM_BOX_MOVEUP1_DURATION) + val moveUp1 = ObjectAnimator.ofFloat( + counter_box, "translationY", + ANIM_BOX_MOVEUP1_TO, ANIM_BOX_MOVEUP1_FROM + ).setDuration(ANIM_BOX_MOVEUP1_DURATION) // Move down on y-axis, from -5.3 to -1.0 in 116ms, after moveUp1 (52~59, 7 frames). - val moveDown2 = ObjectAnimator.ofFloat(box, "translationY", - ANIM_BOX_MOVEDOWN2_FROM, ANIM_BOX_MOVEDOWN2_TO).setDuration(ANIM_BOX_MOVEDOWN2_DURATION) + val moveDown2 = ObjectAnimator.ofFloat( + counter_box, "translationY", + ANIM_BOX_MOVEDOWN2_FROM, ANIM_BOX_MOVEDOWN2_TO + ).setDuration(ANIM_BOX_MOVEDOWN2_DURATION) // FadeIn in 66ms, with moveDown2 (52~56, 4 frames). - val fadeIn = ObjectAnimator.ofFloat(box, "alpha", - ANIM_BOX_FADEIN_FROM, ANIM_BOX_FADEIN_TO).setDuration(ANIM_BOX_FADEIN_DURATION) + val fadeIn = ObjectAnimator.ofFloat( + counter_box, "alpha", + ANIM_BOX_FADEIN_FROM, ANIM_BOX_FADEIN_TO + ).setDuration(ANIM_BOX_FADEIN_DURATION) // Move down on y-axis, from -1.0 to 2.7 in 116ms, after moveDown2 (59~66, 7 frames). - val moveDown3 = ObjectAnimator.ofFloat(box, "translationY", - ANIM_BOX_MOVEDOWN3_FROM, ANIM_BOX_MOVEDOWN3_TO).setDuration(ANIM_BOX_MOVEDOWN3_DURATION) + val moveDown3 = ObjectAnimator.ofFloat( + counter_box, "translationY", + ANIM_BOX_MOVEDOWN3_FROM, ANIM_BOX_MOVEDOWN3_TO + ).setDuration(ANIM_BOX_MOVEDOWN3_DURATION) // Move up on y-axis, from 2.7 to 0 in 133ms, after moveDown3 (66~74, 8 frames). - val moveUp4 = ObjectAnimator.ofFloat(box, "translationY", - ANIM_BOX_MOVEDOWN4_FROM, ANIM_BOX_MOVEDOWN4_TO).setDuration(ANIM_BOX_MOVEDOWN4_DURATION) + val moveUp4 = ObjectAnimator.ofFloat( + counter_box, "translationY", + ANIM_BOX_MOVEDOWN4_FROM, ANIM_BOX_MOVEDOWN4_TO + ).setDuration(ANIM_BOX_MOVEDOWN4_DURATION) // Scale up height from 2% to 105% in 100ms, after moveUp1 and delay 16ms (53~59, 6 frames). - val scaleUp1 = ObjectAnimator.ofFloat(box, "scaleY", - ANIM_BOX_SCALEUP1_FROM, ANIM_BOX_SCALEUP1_TO).setDuration(ANIM_BOX_SCALEUP1_DURATION) + val scaleUp1 = ObjectAnimator.ofFloat( + counter_box, "scaleY", + ANIM_BOX_SCALEUP1_FROM, ANIM_BOX_SCALEUP1_TO + ).setDuration(ANIM_BOX_SCALEUP1_DURATION) scaleUp1.startDelay = ANIM_BOX_SCALEUP1_DELAY // delay 1 frame after moveUp1 // Scale down height from 105% to 99% in 116ms, after scaleUp1 (59~66, 7 frames). - val scaleDown2 = ObjectAnimator.ofFloat(box, "scaleY", - ANIM_BOX_SCALEDOWN2_FROM, ANIM_BOX_SCALEDOWN2_TO).setDuration(ANIM_BOX_SCALEDOWN2_DURATION) + val scaleDown2 = ObjectAnimator.ofFloat( + counter_box, "scaleY", + ANIM_BOX_SCALEDOWN2_FROM, ANIM_BOX_SCALEDOWN2_TO + ).setDuration(ANIM_BOX_SCALEDOWN2_DURATION) // Scale up height from 99% to 100% in 133ms, after scaleDown2 (66~74, 8 frames). - val scaleUp3 = ObjectAnimator.ofFloat(box, "scaleY", - ANIM_BOX_SCALEUP3_FROM, ANIM_BOX_SCALEUP3_TO).setDuration(ANIM_BOX_SCALEUP3_DURATION) + val scaleUp3 = ObjectAnimator.ofFloat( + counter_box, "scaleY", + ANIM_BOX_SCALEUP3_FROM, ANIM_BOX_SCALEUP3_TO + ).setDuration(ANIM_BOX_SCALEUP3_DURATION) animatorSet.play(fadeOut).with(moveUp1) animatorSet.play(moveUp1).before(moveDown2) @@ -171,60 +154,34 @@ open class TabCounter @JvmOverloads constructor( animatorSet.play(scaleDown2).before(scaleUp3) } - private fun createBarAnimatorSet(animatorSet: AnimatorSet) { - val firstAnimator = animatorSet.childAnimations[0] - - // Move up on y-axis, from 0 to -7.0 in 100ms, with firstAnimator (49~55, 6 frames). - val moveUp1 = ObjectAnimator.ofFloat(bar, "translationY", - ANIM_BAR_MOVEUP1_FROM, ANIM_BAR_MOVEUP1_TO).setDuration(ANIM_BAR_MOVEUP1_DURATION) - - // Fadeout in 66ms, after firstAnimator with delay 32ms (54~58, 4 frames). - val fadeOut = ObjectAnimator.ofFloat(bar, "alpha", - ANIM_BAR_FADEOUT_FROM, ANIM_BAR_FADEOUT_TO).setDuration(ANIM_BAR_FADEOUT_DURATION) - fadeOut.startDelay = (ANIM_BAR_FADEOUT_DELAY) // delay 3 frames after firstAnimator - - // Move down on y-axis, from -7.0 to 0 in 16ms, after fadeOut (58~59 1 frame). - val moveDown2 = ObjectAnimator.ofFloat(bar, "translationY", - ANIM_BAR_MOVEDOWN2_FROM, ANIM_BAR_MOVEDOWN2_TO).setDuration(ANIM_BAR_MOVEDOWN2_DURATION) - - // Scale up width from 31% to 100% in 166ms, after moveDown2 with delay 176ms (70~80, 10 frames). - val scaleUp1 = ObjectAnimator.ofFloat(bar, "scaleX", - ANIM_BAR_SCALEUP1_FROM, ANIM_BAR_SCALEUP1_TO).setDuration(ANIM_BAR_SCALEUP1_DURATION) - scaleUp1.startDelay = (ANIM_BAR_SCALEUP1_DELAY) // delay 11 frames after moveDown2 - - // FadeIn in 166ms, with scaleUp1 (70~80, 10 frames). - val fadeIn = ObjectAnimator.ofFloat(bar, "alpha", - ANIM_BAR_FADEIN_FROM, ANIM_BAR_FADEIN_TO).setDuration(ANIM_BAR_FADEIN_DURATION) - fadeIn.startDelay = (ANIM_BAR_FADEIN_DELAY) // delay 11 frames after moveDown2 - - animatorSet.play(firstAnimator).with(moveUp1) - animatorSet.play(firstAnimator).before(fadeOut) - animatorSet.play(fadeOut).before(moveDown2) - - animatorSet.play(moveDown2).before(scaleUp1) - animatorSet.play(scaleUp1).with(fadeIn) - } - private fun createTextAnimatorSet(animatorSet: AnimatorSet) { val firstAnimator = animatorSet.childAnimations[0] // Fadeout in 100ms, with firstAnimator (49~51, 2 frames). - val fadeOut = ObjectAnimator.ofFloat(text, "alpha", - ANIM_TEXT_FADEOUT_FROM, ANIM_TEXT_FADEOUT_TO).setDuration(ANIM_TEXT_FADEOUT_DURATION) + val fadeOut = ObjectAnimator.ofFloat( + counter_text, "alpha", + ANIM_TEXT_FADEOUT_FROM, ANIM_TEXT_FADEOUT_TO + ).setDuration(ANIM_TEXT_FADEOUT_DURATION) // FadeIn in 66 ms, after fadeOut with delay 96ms (57~61, 4 frames). - val fadeIn = ObjectAnimator.ofFloat(text, "alpha", - ANIM_TEXT_FADEIN_FROM, ANIM_TEXT_FADEIN_TO).setDuration(ANIM_TEXT_FADEIN_DURATION) + val fadeIn = ObjectAnimator.ofFloat( + counter_text, "alpha", + ANIM_TEXT_FADEIN_FROM, ANIM_TEXT_FADEIN_TO + ).setDuration(ANIM_TEXT_FADEIN_DURATION) fadeIn.startDelay = (ANIM_TEXT_FADEIN_DELAY) // delay 6 frames after fadeOut // Move down on y-axis, from 0 to 4.4 in 66ms, with fadeIn (57~61, 4 frames). - val moveDown = ObjectAnimator.ofFloat(text, "translationY", - ANIM_TEXT_MOVEDOWN_FROM, ANIM_TEXT_MOVEDOWN_TO).setDuration(ANIM_TEXT_MOVEDOWN_DURATION) + val moveDown = ObjectAnimator.ofFloat( + counter_text, "translationY", + ANIM_TEXT_MOVEDOWN_FROM, ANIM_TEXT_MOVEDOWN_TO + ).setDuration(ANIM_TEXT_MOVEDOWN_DURATION) moveDown.startDelay = (ANIM_TEXT_MOVEDOWN_DELAY) // delay 6 frames after fadeOut // Move up on y-axis, from 0 to 4.4 in 66ms, after moveDown (61~69, 8 frames). - val moveUp = ObjectAnimator.ofFloat(text, "translationY", - ANIM_TEXT_MOVEUP_FROM, ANIM_TEXT_MOVEUP_TO).setDuration(ANIM_TEXT_MOVEUP_DURATION) + val moveUp = ObjectAnimator.ofFloat( + counter_text, "translationY", + ANIM_TEXT_MOVEUP_FROM, ANIM_TEXT_MOVEUP_TO + ).setDuration(ANIM_TEXT_MOVEUP_DURATION) animatorSet.play(firstAnimator).with(fadeOut) animatorSet.play(fadeOut).before(fadeIn) @@ -232,10 +189,13 @@ open class TabCounter @JvmOverloads constructor( animatorSet.play(moveDown).before(moveUp) } - private fun formatForDisplay(count: Int): String { + private fun formatCountForDisplay(count: Int): String { return if (count > MAX_VISIBLE_TABS) { + counter_text.updatePadding(bottom = INFINITE_CHAR_PADDING_BOTTOM) SO_MANY_TABS_OPEN - } else NumberFormat.getInstance().format(count.toLong()) + } else { + NumberFormat.getInstance().format(count.toLong()) + } } private fun adjustTextSize(newCount: Int) { @@ -245,26 +205,24 @@ open class TabCounter @JvmOverloads constructor( ONE_DIGIT_SIZE_RATIO } - if (newRatio != currentTextRatio) { - currentTextRatio = newRatio - text.onNextGlobalLayout { - val sizeInPixel = (box.width * newRatio).toInt() - if (sizeInPixel > 0) { - // Only apply the size when we calculate a valid value. - text.setTextSize(TypedValue.COMPLEX_UNIT_PX, sizeInPixel.toFloat()) - } - } - } + val counterBoxWidth = + context.resources.getDimensionPixelSize(R.dimen.mozac_tab_counter_box_width_height) + val textSize = newRatio * counterBoxWidth + counter_text.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize) + counter_text.setTypeface(null, Typeface.BOLD) + counter_text.setPadding(0, 0, 0, 0) } companion object { - internal const val MAX_VISIBLE_TABS = 99 + var INTERNAL_COUNT = 0 - internal const val SO_MANY_TABS_OPEN = "∞" + const val MAX_VISIBLE_TABS = 99 + const val SO_MANY_TABS_OPEN = "∞" + const val INFINITE_CHAR_PADDING_BOTTOM = 6 - internal const val ONE_DIGIT_SIZE_RATIO = 0.6f - internal const val TWO_DIGITS_SIZE_RATIO = 0.5f - internal const val TWO_DIGITS_TAB_COUNT_THRESHOLD = 10 + const val ONE_DIGIT_SIZE_RATIO = 0.5f + const val TWO_DIGITS_SIZE_RATIO = 0.4f + const val TWO_DIGITS_TAB_COUNT_THRESHOLD = 10 // createBoxAnimatorSet private const val ANIM_BOX_FADEOUT_FROM = 1.0f @@ -303,30 +261,6 @@ open class TabCounter @JvmOverloads constructor( private const val ANIM_BOX_SCALEUP3_TO = 1.00f private const val ANIM_BOX_SCALEUP3_DURATION = 133L - // createBarAnimatorSet - private const val ANIM_BAR_MOVEUP1_FROM = 0.0f - private const val ANIM_BAR_MOVEUP1_TO = -7.0f - private const val ANIM_BAR_MOVEUP1_DURATION = 100L - - private const val ANIM_BAR_FADEOUT_FROM = 1.0f - private const val ANIM_BAR_FADEOUT_TO = 0.0f - private const val ANIM_BAR_FADEOUT_DURATION = 66L - private const val ANIM_BAR_FADEOUT_DELAY = 16L * 3 - - private const val ANIM_BAR_MOVEDOWN2_FROM = -7.0f - private const val ANIM_BAR_MOVEDOWN2_TO = 0.0f - private const val ANIM_BAR_MOVEDOWN2_DURATION = 16L - - private const val ANIM_BAR_SCALEUP1_FROM = 0.31f - private const val ANIM_BAR_SCALEUP1_TO = 1.0f - private const val ANIM_BAR_SCALEUP1_DURATION = 166L - private const val ANIM_BAR_SCALEUP1_DELAY = 16L * 11 - - private const val ANIM_BAR_FADEIN_FROM = 0.0f - private const val ANIM_BAR_FADEIN_TO = 1.0f - private const val ANIM_BAR_FADEIN_DURATION = 166L - private const val ANIM_BAR_FADEIN_DELAY = 16L * 11 - // createTextAnimatorSet private const val ANIM_TEXT_FADEOUT_FROM = 1.0f private const val ANIM_TEXT_FADEOUT_TO = 0.0f diff --git a/android-components/components/ui/tabcounter/src/main/java/mozilla/components/ui/tabcounter/TabCounterMenu.kt b/android-components/components/ui/tabcounter/src/main/java/mozilla/components/ui/tabcounter/TabCounterMenu.kt new file mode 100644 index 000000000000..5d0d92968da5 --- /dev/null +++ b/android-components/components/ui/tabcounter/src/main/java/mozilla/components/ui/tabcounter/TabCounterMenu.kt @@ -0,0 +1,85 @@ +/* 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 mozilla.components.ui.tabcounter + +import android.content.Context +import androidx.core.content.ContextCompat.getColor +import mozilla.components.browser.menu2.BrowserMenuController +import mozilla.components.concept.menu.MenuController +import mozilla.components.concept.menu.candidate.DrawableMenuIcon +import mozilla.components.concept.menu.candidate.TextMenuCandidate +import mozilla.components.concept.menu.candidate.TextStyle + +/** + * The menu that is shown when clicking on the [TabCounterToolbarButton] + * + * @property context the context. + * @property onItemTapped behavior for when an item in the menu is tapped. + * @property iconColor optional color to specify tint of menu icons + */ +open class TabCounterMenu( + context: Context, + onItemTapped: (Item) -> Unit, + iconColor: Int? = null +) { + + /** + * Represents the menu items. + * + * [CloseTab] menu item for closing a tab. + * [NewTab] menu item for opening a new tab. + * [NewPrivateTab] menu item for opening a new private tab. + */ + @Suppress("UndocumentedPublicClass") + open class Item { + object CloseTab : Item() + object NewTab : Item() + object NewPrivateTab : Item() + } + + var newTabItem: TextMenuCandidate + var newPrivateTabItem: TextMenuCandidate + var closeTabItem: TextMenuCandidate + + val menuController: MenuController by lazy { BrowserMenuController() } + + init { + newTabItem = TextMenuCandidate( + text = context.getString(R.string.mozac_browser_menu_new_tab), + start = DrawableMenuIcon( + context, + R.drawable.mozac_ui_tabcounter_ic_new, + tint = iconColor ?: getColor(context, R.color.mozac_ui_tabcounter_default_text) + ), + textStyle = TextStyle() + ) { + onItemTapped(Item.NewTab) + } + + newPrivateTabItem = TextMenuCandidate( + text = context.getString(R.string.mozac_browser_menu_new_private_tab), + start = DrawableMenuIcon( + context, + R.drawable.mozac_ui_tabcounter_ic_private_browsing, + tint = iconColor ?: getColor(context, R.color.mozac_ui_tabcounter_default_text) + ), + textStyle = TextStyle() + ) { + onItemTapped(Item.NewPrivateTab) + } + + closeTabItem = TextMenuCandidate( + text = context.getString(R.string.mozac_close_tab), + start = DrawableMenuIcon( + context, + R.drawable.mozac_ui_tabcounter_ic_close, + tint = iconColor ?: getColor(context, R.color.mozac_ui_tabcounter_default_text) + ), + textStyle = TextStyle() + ) { + onItemTapped(Item.CloseTab) + } + } +} diff --git a/android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_box.xml b/android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_box.xml index 840423944b56..465bc81d8716 100644 --- a/android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_box.xml +++ b/android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_box.xml @@ -1,8 +1,11 @@ - - - - - \ No newline at end of file + + + diff --git a/android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_ic_close.xml b/android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_ic_close.xml new file mode 100644 index 000000000000..c3223daab09c --- /dev/null +++ b/android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_ic_close.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_ic_new.xml b/android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_ic_new.xml new file mode 100644 index 000000000000..8050eb5a40e8 --- /dev/null +++ b/android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_ic_new.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_ic_private_browsing.xml b/android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_ic_private_browsing.xml new file mode 100644 index 000000000000..802a5c0dabf2 --- /dev/null +++ b/android-components/components/ui/tabcounter/src/main/res/drawable/mozac_ui_tabcounter_ic_private_browsing.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android-components/components/ui/tabcounter/src/main/res/layout/mozac_ui_tabcounter_layout.xml b/android-components/components/ui/tabcounter/src/main/res/layout/mozac_ui_tabcounter_layout.xml index 47fa944fed7e..7973225ee38b 100644 --- a/android-components/components/ui/tabcounter/src/main/res/layout/mozac_ui_tabcounter_layout.xml +++ b/android-components/components/ui/tabcounter/src/main/res/layout/mozac_ui_tabcounter_layout.xml @@ -5,23 +5,21 @@ + tools:layout_height="wrap_content" + tools:layout_width="wrap_content"> + android:layout_centerVertical="true"> @@ -30,20 +28,10 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" + android:textAlignment="center" android:textColor="@color/mozac_ui_tabcounter_default_tint" android:textSize="12sp" android:textStyle="bold" - tools:text="18" /> + tools:text="16" /> - - - \ No newline at end of file diff --git a/android-components/components/ui/tabcounter/src/main/res/values/colors.xml b/android-components/components/ui/tabcounter/src/main/res/values/colors.xml index 702552f35dc8..f01e0ca31830 100644 --- a/android-components/components/ui/tabcounter/src/main/res/values/colors.xml +++ b/android-components/components/ui/tabcounter/src/main/res/values/colors.xml @@ -4,4 +4,6 @@ - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> #FF272727 + #FFFFFF + #20123A \ No newline at end of file diff --git a/android-components/components/ui/tabcounter/src/main/res/values/dimens.xml b/android-components/components/ui/tabcounter/src/main/res/values/dimens.xml new file mode 100644 index 000000000000..111bc1a7bfdb --- /dev/null +++ b/android-components/components/ui/tabcounter/src/main/res/values/dimens.xml @@ -0,0 +1,4 @@ + + + 24dp + \ No newline at end of file diff --git a/android-components/components/ui/tabcounter/src/main/res/values/strings.xml b/android-components/components/ui/tabcounter/src/main/res/values/strings.xml new file mode 100644 index 000000000000..373502663432 --- /dev/null +++ b/android-components/components/ui/tabcounter/src/main/res/values/strings.xml @@ -0,0 +1,18 @@ + + + + + 1 open tab. Tap to switch tabs. + + %1$s open tabs. Tap to switch tabs. + + New tab + + New private tab + + Close tab + + The tab counter toolbar button. + diff --git a/android-components/components/ui/tabcounter/src/test/java/mozilla/components/ui/tabcounter/TabCounterMenuTest.kt b/android-components/components/ui/tabcounter/src/test/java/mozilla/components/ui/tabcounter/TabCounterMenuTest.kt new file mode 100644 index 000000000000..40ec54066b61 --- /dev/null +++ b/android-components/components/ui/tabcounter/src/test/java/mozilla/components/ui/tabcounter/TabCounterMenuTest.kt @@ -0,0 +1,53 @@ +/* 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 mozilla.components.ui.tabcounter + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify + +@RunWith(AndroidJUnit4::class) +class TabCounterMenuTest { + + @Test + fun `return only the new tab item`() { + val onItemTapped: (TabCounterMenu.Item) -> Unit = spy { Unit } + val menu = TabCounterMenu(testContext, onItemTapped) + + val item = menu.newTabItem + assertEquals("New tab", item.text) + item.onClick() + + verify(onItemTapped).invoke(TabCounterMenu.Item.NewTab) + } + + @Test + fun `return only the new private tab item`() { + val onItemTapped: (TabCounterMenu.Item) -> Unit = spy { Unit } + val menu = TabCounterMenu(testContext, onItemTapped) + + val item = menu.newPrivateTabItem + assertEquals("New private tab", item.text) + item.onClick() + + verify(onItemTapped).invoke(TabCounterMenu.Item.NewPrivateTab) + } + + @Test + fun `return a close button`() { + val onItemTapped: (TabCounterMenu.Item) -> Unit = spy { Unit } + val menu = TabCounterMenu(testContext, onItemTapped) + + val item = menu.closeTabItem + assertEquals("Close tab", item.text) + item.onClick() + + verify(onItemTapped).invoke(TabCounterMenu.Item.CloseTab) + } +} diff --git a/android-components/components/ui/tabcounter/src/test/java/mozilla/components/ui/tabcounter/TabCounterTest.kt b/android-components/components/ui/tabcounter/src/test/java/mozilla/components/ui/tabcounter/TabCounterTest.kt index cbc6672a5339..7b4f53d2e546 100644 --- a/android-components/components/ui/tabcounter/src/test/java/mozilla/components/ui/tabcounter/TabCounterTest.kt +++ b/android-components/components/ui/tabcounter/src/test/java/mozilla/components/ui/tabcounter/TabCounterTest.kt @@ -5,56 +5,39 @@ package mozilla.components.ui.tabcounter import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.android.synthetic.main.mozac_ui_tabcounter_layout.view.* import mozilla.components.support.test.robolectric.testContext -import mozilla.components.ui.tabcounter.TabCounter.Companion.ONE_DIGIT_SIZE_RATIO import mozilla.components.ui.tabcounter.TabCounter.Companion.SO_MANY_TABS_OPEN -import mozilla.components.ui.tabcounter.TabCounter.Companion.TWO_DIGITS_SIZE_RATIO import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class TabCounterTest { - @Test - fun `Default tab count is 0`() { + fun `Default tab count is set to zero`() { val tabCounter = TabCounter(testContext) - - assertEquals("0", tabCounter.getText()) - - assertEquals(ONE_DIGIT_SIZE_RATIO, tabCounter.currentTextRatio) + assertEquals("0", tabCounter.counter_text.text) } @Test - fun `Set tab count as 1`() { + fun `Set tab count as single digit value shows count`() { val tabCounter = TabCounter(testContext) - tabCounter.setCount(1) - - assertEquals("1", tabCounter.getText()) - - assertEquals(ONE_DIGIT_SIZE_RATIO, tabCounter.currentTextRatio) + assertEquals("1", tabCounter.counter_text.text) } @Test - fun `Set tab count as 99`() { + fun `Set tab count as two digit number shows count`() { val tabCounter = TabCounter(testContext) - tabCounter.setCount(99) - - assertEquals("99", tabCounter.getText()) - - assertEquals(TWO_DIGITS_SIZE_RATIO, tabCounter.currentTextRatio) + assertEquals("99", tabCounter.counter_text.text) } @Test - fun `Set tab count as 100`() { + fun `Setting tab count as three digit value shows correct icon`() { val tabCounter = TabCounter(testContext) - tabCounter.setCount(100) - - assertEquals(SO_MANY_TABS_OPEN, tabCounter.getText()) - - assertEquals(ONE_DIGIT_SIZE_RATIO, tabCounter.currentTextRatio) + assertEquals(SO_MANY_TABS_OPEN, tabCounter.counter_text.text) } } diff --git a/android-components/components/ui/tabcounter/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/android-components/components/ui/tabcounter/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 000000000000..cf1c399ea81e --- /dev/null +++ b/android-components/components/ui/tabcounter/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/android-components/samples/browser/build.gradle b/android-components/samples/browser/build.gradle index 96d9c44ddeaf..00e4a347c62e 100644 --- a/android-components/samples/browser/build.gradle +++ b/android-components/samples/browser/build.gradle @@ -133,6 +133,7 @@ dependencies { implementation project(':support-ktx') implementation project(':support-webextensions') implementation project(':support-rustlog') + implementation project(':ui-tabcounter') geckoNightlyImplementation project(':browser-engine-gecko-nightly') geckoBetaImplementation project(':browser-engine-gecko-beta') diff --git a/android-components/samples/browser/src/main/java/org/mozilla/samples/browser/BrowserFragment.kt b/android-components/samples/browser/src/main/java/org/mozilla/samples/browser/BrowserFragment.kt index f5d6eb95d189..a53d3e01df0d 100644 --- a/android-components/samples/browser/src/main/java/org/mozilla/samples/browser/BrowserFragment.kt +++ b/android-components/samples/browser/src/main/java/org/mozilla/samples/browser/BrowserFragment.kt @@ -9,6 +9,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import kotlinx.android.synthetic.main.fragment_browser.view.* +import kotlinx.coroutines.ExperimentalCoroutinesApi import mozilla.components.browser.thumbnails.BrowserThumbnails import mozilla.components.feature.awesomebar.AwesomeBarFeature import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider @@ -30,6 +31,7 @@ import org.mozilla.samples.browser.integration.ReaderViewIntegration /** * Fragment used for browsing the web within the main app. */ +@ExperimentalCoroutinesApi class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { private val thumbnailsFeature = ViewBoundFeatureWrapper() private val readerViewFeature = ViewBoundFeatureWrapper() @@ -52,7 +54,14 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { addDomainProvider(components.shippedDomainsProvider) } - TabsToolbarFeature(layout.toolbar, components.sessionManager, sessionId, ::showTabs) + TabsToolbarFeature( + toolbar = layout.toolbar, + store = components.store, + sessionId = sessionId, + lifecycleOwner = viewLifecycleOwner, + showTabs = ::showTabs, + countBasedOnSelectedTabType = false + ) AwesomeBarFeature(layout.awesomeBar, layout.toolbar, layout.engineView, components.icons) .addHistoryProvider(