summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/browser/tabstray
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/android-components/components/browser/tabstray')
-rw-r--r--mobile/android/android-components/components/browser/tabstray/README.md19
-rw-r--r--mobile/android/android-components/components/browser/tabstray/build.gradle55
-rw-r--r--mobile/android/android-components/components/browser/tabstray/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/main/AndroidManifest.xml7
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/SelectableTabViewHolder.kt17
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabTouchCallback.kt53
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabViewHolder.kt152
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabsAdapter.kt105
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabsTray.kt35
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabsTrayStyling.kt31
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/thumbnail/TabThumbnailView.kt33
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/main/res/layout/mozac_browser_tabstray_item.xml68
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/main/res/values/ids.xml8
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/main/res/values/mozac_browser_tabstray_strings.xml10
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/DefaultTabViewHolderTest.kt223
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/TabTouchCallbackTest.kt73
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/TabViewHolderTest.kt52
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/TabsAdapterTest.kt161
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/thumbnail/TabThumbnailViewTest.kt81
-rw-r--r--mobile/android/android-components/components/browser/tabstray/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
20 files changed, 1206 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/browser/tabstray/README.md b/mobile/android/android-components/components/browser/tabstray/README.md
new file mode 100644
index 0000000000..dc291eaf9f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/README.md
@@ -0,0 +1,19 @@
+# [Android Components](../../../README.md) > Browser > Tabstray
+
+A customizable tabs tray for browsers.
+
+## Usage
+
+### Setting up the dependency
+
+Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)):
+
+```Groovy
+implementation "org.mozilla.components:browser-tabstray:{latest-version}"
+```
+
+## License
+
+ 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/
diff --git a/mobile/android/android-components/components/browser/tabstray/build.gradle b/mobile/android/android-components/components/browser/tabstray/build.gradle
new file mode 100644
index 0000000000..72e76233b4
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/build.gradle
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ lint {
+ warningsAsErrors true
+ abortOnError true
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ namespace 'mozilla.components.browser.tabstray'
+}
+
+dependencies {
+ api project(':concept-tabstray')
+
+ implementation project(':ui-icons')
+ implementation project(':ui-colors')
+ implementation project(':concept-base')
+ implementation project(':browser-state')
+ implementation project(':support-images')
+ implementation project(':support-ktx')
+
+ implementation ComponentsDependencies.androidx_appcompat
+ implementation ComponentsDependencies.androidx_cardview
+ api ComponentsDependencies.androidx_recyclerview
+
+ implementation ComponentsDependencies.kotlin_coroutines
+
+ testImplementation project(':support-test')
+
+ testImplementation ComponentsDependencies.androidx_test_core
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/browser/tabstray/proguard-rules.pro b/mobile/android/android-components/components/browser/tabstray/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/mobile/android/android-components/components/browser/tabstray/src/main/AndroidManifest.xml b/mobile/android/android-components/components/browser/tabstray/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..1eccdee26a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/main/AndroidManifest.xml
@@ -0,0 +1,7 @@
+<!-- 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/. -->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <application android:supportsRtl="true" />
+</manifest>
diff --git a/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/SelectableTabViewHolder.kt b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/SelectableTabViewHolder.kt
new file mode 100644
index 0000000000..8a9aa7b952
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/SelectableTabViewHolder.kt
@@ -0,0 +1,17 @@
+/* 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.browser.tabstray
+
+import android.view.View
+
+/**
+ * A contract for selectable ViewHolders for "tab" items.
+ */
+abstract class SelectableTabViewHolder(view: View) : TabViewHolder(view) {
+ /**
+ * Indicates the multi select state of tab item has changed based on [isSelected] .
+ */
+ abstract fun showTabIsMultiSelectEnabled(selectedMaskView: View?, isSelected: Boolean)
+}
diff --git a/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabTouchCallback.kt b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabTouchCallback.kt
new file mode 100644
index 0000000000..3c9ac7c0ef
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabTouchCallback.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.browser.tabstray
+
+import android.graphics.Canvas
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.RecyclerView
+import mozilla.components.browser.state.state.TabSessionState
+
+/**
+ * An [ItemTouchHelper.Callback] for support gestures on tabs in the tray.
+ *
+ * @param onRemoveTab A callback invoked when a tab is removed.
+ */
+open class TabTouchCallback(
+ private val onRemoveTab: (TabSessionState) -> Unit,
+) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) {
+
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+ with(viewHolder as TabViewHolder) {
+ tab?.let { onRemoveTab(it) }
+ }
+ }
+
+ override fun onChildDraw(
+ c: Canvas,
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ dX: Float,
+ dY: Float,
+ actionState: Int,
+ isCurrentlyActive: Boolean,
+ ) {
+ if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
+ // Alpha on an itemView being swiped should decrease to a min over a distance equal to
+ // the width of the item being swiped.
+ viewHolder.itemView.alpha = alphaForItemSwipe(dX, viewHolder.itemView.width)
+ }
+
+ super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
+ }
+
+ /**
+ * Sets the alpha value for a swipe gesture. This is useful for inherited classes to provide their own values.
+ */
+ open fun alphaForItemSwipe(dX: Float, distanceToAlphaMin: Int): Float {
+ return 1f
+ }
+
+ override fun onMove(p0: RecyclerView, p1: RecyclerView.ViewHolder, p2: RecyclerView.ViewHolder): Boolean = false
+}
diff --git a/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabViewHolder.kt b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabViewHolder.kt
new file mode 100644
index 0000000000..6c9a537af6
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabViewHolder.kt
@@ -0,0 +1,152 @@
+/* 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.browser.tabstray
+
+import android.content.res.ColorStateList
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.Dimension
+import androidx.annotation.Dimension.Companion.DP
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.widget.AppCompatImageButton
+import androidx.recyclerview.widget.RecyclerView
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.tabstray.thumbnail.TabThumbnailView
+import mozilla.components.concept.base.images.ImageLoadRequest
+import mozilla.components.concept.base.images.ImageLoader
+import mozilla.components.support.ktx.android.util.dpToPx
+import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
+
+/**
+ * An abstract ViewHolder implementation for "tab" items.
+ */
+abstract class TabViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+ abstract var tab: TabSessionState?
+
+ /**
+ * Binds the ViewHolder to the `Tab`.
+ * @param tab the `Tab` used to bind the viewHolder.
+ * @param isSelected boolean to describe whether or not the `Tab` is selected.
+ * @param observable message bus to pass events to Observers of the TabsTray.
+ * // TODO fix comment
+ */
+ abstract fun bind(
+ tab: TabSessionState,
+ isSelected: Boolean,
+ styling: TabsTrayStyling,
+ delegate: TabsTray.Delegate,
+ )
+
+ /**
+ * Ask for a partial update of the current tab.
+ * Allows for overriding the current behavior and add or remove the 'selected tab' UI decorator.
+ *
+ * When implementing this do not call super.
+ */
+ open fun updateSelectedTabIndicator(showAsSelected: Boolean) {
+ // Not an abstract fun since not all clients of this library might be interested in this functionality.
+ // But throwing an exception if this is called without an actual implementation in clients.
+ throw UnsupportedOperationException("Method not yet implemented")
+ }
+}
+
+/**
+ * The default implementation of `TabViewHolder`
+ */
+class DefaultTabViewHolder(
+ itemView: View,
+ private val thumbnailLoader: ImageLoader? = null,
+) : TabViewHolder(itemView) {
+ @VisibleForTesting
+ internal val iconView: ImageView? = itemView.findViewById(R.id.mozac_browser_tabstray_icon)
+
+ @VisibleForTesting
+ internal val titleView: TextView = itemView.findViewById(R.id.mozac_browser_tabstray_title)
+
+ @VisibleForTesting
+ internal val closeView: AppCompatImageButton = itemView.findViewById(R.id.mozac_browser_tabstray_close)
+ private val thumbnailView: TabThumbnailView = itemView.findViewById(R.id.mozac_browser_tabstray_thumbnail)
+ private val urlView: TextView? = itemView.findViewById(R.id.mozac_browser_tabstray_url)
+
+ override var tab: TabSessionState? = null
+
+ @VisibleForTesting
+ internal var styling: TabsTrayStyling? = null
+
+ /**
+ * Displays the data of the given session and notifies the given observable about events.
+ */
+ override fun bind(
+ tab: TabSessionState,
+ isSelected: Boolean,
+ styling: TabsTrayStyling,
+ delegate: TabsTray.Delegate,
+ ) {
+ this.tab = tab
+ this.styling = styling
+
+ val title = if (tab.content.title.isNotEmpty()) {
+ tab.content.title
+ } else {
+ tab.content.url
+ }
+
+ titleView.text = title
+ urlView?.text = tab.content.url.tryGetHostFromUrl()
+
+ itemView.setOnClickListener {
+ delegate.onTabSelected(tab)
+ }
+
+ closeView.setOnClickListener {
+ delegate.onTabClosed(tab)
+ }
+
+ updateSelectedTabIndicator(isSelected)
+
+ // In the final else case, we have no cache or fresh screenshot; do nothing instead of clearing the image.
+ if (thumbnailLoader != null) {
+ val thumbnailSize = THUMBNAIL_SIZE.dpToPx(thumbnailView.context.resources.displayMetrics)
+ thumbnailLoader.loadIntoView(
+ thumbnailView,
+ ImageLoadRequest(id = tab.id, size = thumbnailSize, isPrivate = tab.content.private),
+ )
+ }
+
+ iconView?.setImageBitmap(tab.content.icon)
+ }
+
+ override fun updateSelectedTabIndicator(showAsSelected: Boolean) {
+ if (showAsSelected) {
+ showItemAsSelected()
+ } else {
+ showItemAsNotSelected()
+ }
+ }
+
+ @VisibleForTesting
+ internal fun showItemAsSelected() {
+ styling?.let { styling ->
+ titleView.setTextColor(styling.selectedItemTextColor)
+ itemView.setBackgroundColor(styling.selectedItemBackgroundColor)
+ closeView.imageTintList = ColorStateList.valueOf(styling.selectedItemTextColor)
+ }
+ }
+
+ @VisibleForTesting
+ internal fun showItemAsNotSelected() {
+ styling?.let { styling ->
+ titleView.setTextColor(styling.itemTextColor)
+ itemView.setBackgroundColor(styling.itemBackgroundColor)
+ closeView.imageTintList = ColorStateList.valueOf(styling.itemTextColor)
+ }
+ }
+
+ companion object {
+ @Dimension(unit = DP)
+ private const val THUMBNAIL_SIZE = 100
+ }
+}
diff --git a/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabsAdapter.kt b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabsAdapter.kt
new file mode 100644
index 0000000000..4df5bd934f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabsAdapter.kt
@@ -0,0 +1,105 @@
+/* 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.browser.tabstray
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import mozilla.components.browser.state.state.TabPartition
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.concept.base.images.ImageLoader
+
+/**
+ * Function responsible for creating a `TabViewHolder` in the `TabsAdapter`.
+ */
+typealias ViewHolderProvider = (ViewGroup) -> TabViewHolder
+
+/**
+ * RecyclerView adapter implementation to display a list of tabs.
+ *
+ * @param thumbnailLoader an implementation of an [ImageLoader] for loading thumbnail images in the tabs tray.
+ * @param viewHolderProvider a function that creates a [TabViewHolder].
+ * @param styling the default styling for the [TabsTrayStyling].
+ * @param delegate a delegate to handle interactions in the tabs tray.
+ */
+open class TabsAdapter(
+ thumbnailLoader: ImageLoader? = null,
+ private val viewHolderProvider: ViewHolderProvider = { parent ->
+ DefaultTabViewHolder(
+ LayoutInflater.from(parent.context).inflate(R.layout.mozac_browser_tabstray_item, parent, false),
+ thumbnailLoader,
+ )
+ },
+ private val styling: TabsTrayStyling = TabsTrayStyling(),
+ private val delegate: TabsTray.Delegate,
+) : ListAdapter<TabSessionState, TabViewHolder>(DiffCallback), TabsTray {
+
+ private var selectedTabId: String? = null
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabViewHolder {
+ return viewHolderProvider.invoke(parent)
+ }
+
+ override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
+ val tab = getItem(position)
+
+ holder.bind(tab, tab.id == selectedTabId, styling, delegate)
+ }
+
+ override fun onBindViewHolder(
+ holder: TabViewHolder,
+ position: Int,
+ payloads: List<Any>,
+ ) {
+ val tabs = currentList
+ if (tabs.isEmpty()) return
+
+ if (payloads.isEmpty()) {
+ onBindViewHolder(holder, position)
+ return
+ }
+
+ val tab = getItem(position)
+
+ if (payloads.contains(PAYLOAD_HIGHLIGHT_SELECTED_ITEM) && tab.id == selectedTabId) {
+ holder.updateSelectedTabIndicator(true)
+ } else if (payloads.contains(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM) && tab.id == selectedTabId) {
+ holder.updateSelectedTabIndicator(false)
+ }
+ }
+
+ override fun updateTabs(tabs: List<TabSessionState>, tabPartition: TabPartition?, selectedTabId: String?) {
+ this.selectedTabId = selectedTabId
+
+ submitList(tabs)
+ }
+
+ companion object {
+ /**
+ * Payload used in onBindViewHolder for a partial update of the current view.
+ *
+ * Signals that the currently selected tab should be highlighted. This is the default behavior.
+ */
+ val PAYLOAD_HIGHLIGHT_SELECTED_ITEM: Int = R.id.payload_highlight_selected_item
+
+ /**
+ * Payload used in onBindViewHolder for a partial update of the current view.
+ *
+ * Signals that the currently selected tab should NOT be highlighted. No tabs would appear as highlighted.
+ */
+ val PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM: Int = R.id.payload_dont_highlight_selected_item
+ }
+
+ private object DiffCallback : DiffUtil.ItemCallback<TabSessionState>() {
+ override fun areItemsTheSame(oldItem: TabSessionState, newItem: TabSessionState): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(oldItem: TabSessionState, newItem: TabSessionState): Boolean {
+ return oldItem == newItem
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabsTray.kt b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabsTray.kt
new file mode 100644
index 0000000000..99a9f6b552
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabsTray.kt
@@ -0,0 +1,35 @@
+/* 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.browser.tabstray
+
+import mozilla.components.browser.state.state.TabPartition
+import mozilla.components.browser.state.state.TabSessionState
+
+/**
+ * An interface to display a list of tabs.
+ */
+interface TabsTray {
+
+ /**
+ * Interface to be implemented by classes that want to observe or react to the interactions on the tabs list.
+ */
+ interface Delegate {
+
+ /**
+ * A new tab has been selected.
+ */
+ fun onTabSelected(tab: TabSessionState, source: String? = null)
+
+ /**
+ * A tab has been closed.
+ */
+ fun onTabClosed(tab: TabSessionState, source: String? = null)
+ }
+
+ /**
+ * Called when the list of tabs are updated.
+ */
+ fun updateTabs(tabs: List<TabSessionState>, tabPartition: TabPartition?, selectedTabId: String?)
+}
diff --git a/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabsTrayStyling.kt b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabsTrayStyling.kt
new file mode 100644
index 0000000000..75808f77f3
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/TabsTrayStyling.kt
@@ -0,0 +1,31 @@
+/* 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.browser.tabstray
+
+const val DEFAULT_ITEM_BACKGROUND_COLOR = 0xFFFFFFFF.toInt()
+const val DEFAULT_ITEM_BACKGROUND_SELECTED_COLOR = 0xFFFF45A1FF.toInt()
+const val DEFAULT_ITEM_TEXT_COLOR = 0xFF111111.toInt()
+const val DEFAULT_ITEM_TEXT_SELECTED_COLOR = 0xFFFFFFFF.toInt()
+
+/**
+ * Tabs tray styling for items in the [TabsAdapter]. If a custom [TabViewHolder]
+ * is used with [TabsAdapter.viewHolderProvider], the styling can be applied
+ * when [TabViewHolder.bind] is invoked.
+ *
+ * @property itemBackgroundColor the background color for all non-selected tabs.
+ * @property selectedItemBackgroundColor the background color for the selected tab.
+ * @property itemTextColor the text color for all non-selected tabs.
+ * @property selectedItemTextColor the text color for the selected tabs.
+ * @property itemUrlTextColor the URL text color for all non-selected tabs.
+ * @property selectedItemUrlTextColor the URL text color for the selected tab.
+ */
+data class TabsTrayStyling(
+ val itemBackgroundColor: Int = DEFAULT_ITEM_BACKGROUND_COLOR,
+ val selectedItemBackgroundColor: Int = DEFAULT_ITEM_BACKGROUND_SELECTED_COLOR,
+ val itemTextColor: Int = DEFAULT_ITEM_TEXT_COLOR,
+ val selectedItemTextColor: Int = DEFAULT_ITEM_TEXT_SELECTED_COLOR,
+ val itemUrlTextColor: Int = DEFAULT_ITEM_TEXT_COLOR,
+ val selectedItemUrlTextColor: Int = DEFAULT_ITEM_TEXT_SELECTED_COLOR,
+)
diff --git a/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/thumbnail/TabThumbnailView.kt b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/thumbnail/TabThumbnailView.kt
new file mode 100644
index 0000000000..bbb2db2d22
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/main/java/mozilla/components/browser/tabstray/thumbnail/TabThumbnailView.kt
@@ -0,0 +1,33 @@
+/* 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.browser.tabstray.thumbnail
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.widget.AppCompatImageView
+
+class TabThumbnailView(context: Context, attrs: AttributeSet) : AppCompatImageView(context, attrs) {
+
+ init {
+ scaleType = ScaleType.MATRIX
+ }
+
+ @VisibleForTesting
+ public override fun setFrame(l: Int, t: Int, r: Int, b: Int): Boolean {
+ val result = super.setFrame(l, t, r, b)
+
+ val matrix = imageMatrix
+ val scaleFactor = if (drawable != null) {
+ width / drawable.intrinsicWidth.toFloat()
+ } else {
+ 1F
+ }
+ matrix.setScale(scaleFactor, scaleFactor, 0f, 0f)
+ imageMatrix = matrix
+
+ return result
+ }
+}
diff --git a/mobile/android/android-components/components/browser/tabstray/src/main/res/layout/mozac_browser_tabstray_item.xml b/mobile/android/android-components/components/browser/tabstray/src/main/res/layout/mozac_browser_tabstray_item.xml
new file mode 100644
index 0000000000..fe701265b3
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/main/res/layout/mozac_browser_tabstray_item.xml
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="150dp"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_margin="4dp"
+ android:clickable="true"
+ android:focusable="true">
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <ImageView
+ android:id="@+id/mozac_browser_tabstray_icon"
+ android:layout_width="36dp"
+ android:layout_height="36dp"
+ android:importantForAccessibility="no"
+ android:padding="4dp" />
+
+ <TextView
+ android:id="@+id/mozac_browser_tabstray_title"
+ android:layout_width="match_parent"
+ android:layout_height="36dp"
+ android:layout_alignParentTop="true"
+ android:layout_marginStart="42dp"
+ android:layout_toStartOf="@id/mozac_browser_tabstray_close"
+ android:ellipsize="end"
+ android:lines="1"
+ android:padding="8dp"
+ tools:text="Mozilla" />
+
+ <TextView
+ android:id="@+id/mozac_browser_tabstray_url"
+ android:layout_width="match_parent"
+ android:layout_height="36dp"
+ android:layout_alignParentTop="true"
+ android:layout_marginStart="42dp"
+ android:layout_toStartOf="@id/mozac_browser_tabstray_close"
+ android:ellipsize="end"
+ android:lines="1"
+ android:padding="8dp"
+ android:visibility="gone"
+ tools:text="www.mozilla.org" />
+
+ <androidx.appcompat.widget.AppCompatImageButton
+ android:id="@+id/mozac_browser_tabstray_close"
+ android:layout_width="36dp"
+ android:layout_height="36dp"
+ android:layout_alignParentEnd="true"
+ android:layout_alignParentTop="true"
+ android:contentDescription="@string/mozac_browser_tabstray_close_tab"
+ android:background="?android:attr/selectableItemBackgroundBorderless"
+ app:srcCompat="@drawable/mozac_ic_cross_20" />
+
+ <mozilla.components.browser.tabstray.thumbnail.TabThumbnailView
+ android:id="@+id/mozac_browser_tabstray_thumbnail"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_below="@id/mozac_browser_tabstray_title"
+ android:contentDescription="@string/mozac_browser_tabstray_open_tab" />
+
+ </RelativeLayout>
+</androidx.cardview.widget.CardView>
diff --git a/mobile/android/android-components/components/browser/tabstray/src/main/res/values/ids.xml b/mobile/android/android-components/components/browser/tabstray/src/main/res/values/ids.xml
new file mode 100644
index 0000000000..5926a7437d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/main/res/values/ids.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+<resources>
+ <item name="payload_highlight_selected_item" type="id"/>
+ <item name="payload_dont_highlight_selected_item" type="id"/>
+</resources>
diff --git a/mobile/android/android-components/components/browser/tabstray/src/main/res/values/mozac_browser_tabstray_strings.xml b/mobile/android/android-components/components/browser/tabstray/src/main/res/values/mozac_browser_tabstray_strings.xml
new file mode 100644
index 0000000000..acd1b1256d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/main/res/values/mozac_browser_tabstray_strings.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+<resources>
+
+ <!-- Content description (not visible, for screen readers etc.): Description for button closing a tab. -->
+ <string name="mozac_browser_tabstray_close_tab">Close Tab</string>
+ <string name="mozac_browser_tabstray_open_tab">Open Tab</string>
+</resources>
diff --git a/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/DefaultTabViewHolderTest.kt b/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/DefaultTabViewHolderTest.kt
new file mode 100644
index 0000000000..833a3f1358
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/DefaultTabViewHolderTest.kt
@@ -0,0 +1,223 @@
+/* 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.browser.tabstray
+
+import android.content.res.ColorStateList
+import android.graphics.drawable.ColorDrawable
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.concept.base.images.ImageLoadRequest
+import mozilla.components.concept.base.images.ImageLoader
+import mozilla.components.support.test.any
+import mozilla.components.support.test.eq
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.nullable
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class DefaultTabViewHolderTest {
+
+ @Test
+ fun `URL from session is assigned to view`() {
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val titleView = view.findViewById<TextView>(R.id.mozac_browser_tabstray_title)
+ val urlView = view.findViewById<TextView>(R.id.mozac_browser_tabstray_url)
+
+ val holder = DefaultTabViewHolder(view)
+
+ assertEquals("", titleView.text)
+
+ val session = createTab(id = "a", url = "https://www.mozilla.org")
+
+ holder.bind(session, isSelected = false, styling = mock(), mock())
+
+ assertEquals("https://www.mozilla.org", titleView.text)
+ assertEquals("www.mozilla.org", urlView.text)
+ }
+
+ @Test
+ fun `URL text is set to tab URL when exception is thrown`() {
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val urlView = view.findViewById<TextView>(R.id.mozac_browser_tabstray_url)
+ val holder = DefaultTabViewHolder(view)
+ val session = createTab(id = "a", url = "about:home")
+
+ holder.bind(session, isSelected = false, styling = mock(), mock())
+
+ assertEquals("about:home", urlView.text)
+ }
+
+ @Test
+ fun `observer gets notified if item is clicked`() {
+ val delegate: TabsTray.Delegate = mock()
+
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val holder = DefaultTabViewHolder(view)
+
+ val session = createTab(url = "https://www.mozilla.org", id = "a")
+ holder.bind(session, isSelected = false, styling = mock(), delegate)
+
+ view.performClick()
+
+ verify(delegate).onTabSelected(session)
+ }
+
+ @Test
+ fun `observer gets notified if tab gets closed`() {
+ val delegate: TabsTray.Delegate = mock()
+
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val holder = DefaultTabViewHolder(view)
+
+ val session = createTab(url = "https://www.mozilla.org", id = "a")
+ holder.bind(session, isSelected = true, styling = mock(), delegate)
+
+ view.findViewById<View>(R.id.mozac_browser_tabstray_close).performClick()
+
+ verify(delegate).onTabClosed(session)
+ }
+
+ @Test
+ fun `url from session is displayed by default`() {
+ val delegate: TabsTray.Delegate = mock()
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val holder = DefaultTabViewHolder(view)
+
+ val session = createTab(url = "https://www.mozilla.org", id = "a")
+ val titleView = holder.itemView.findViewById<TextView>(R.id.mozac_browser_tabstray_title)
+ val urlView = view.findViewById<TextView>(R.id.mozac_browser_tabstray_url)
+
+ holder.bind(session, isSelected = true, styling = mock(), delegate)
+
+ assertEquals(session.content.url, titleView.text)
+ assertEquals("www.mozilla.org", urlView.text)
+ }
+
+ @Test
+ fun `title from session is displayed if available`() {
+ val delegate: TabsTray.Delegate = mock()
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val holder = DefaultTabViewHolder(view)
+
+ val session = createTab(url = "https://www.mozilla.org", title = "Mozilla Firefox", id = "a")
+ val titleView = holder.itemView.findViewById<TextView>(R.id.mozac_browser_tabstray_title)
+ val urlView = view.findViewById<TextView>(R.id.mozac_browser_tabstray_url)
+
+ holder.bind(session, isSelected = true, styling = mock(), delegate)
+ assertEquals("Mozilla Firefox", titleView.text)
+ assertEquals("www.mozilla.org", urlView.text)
+ }
+
+ @Test
+ fun `thumbnail is set from loader`() {
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val loader: ImageLoader = mock()
+ val viewHolder = DefaultTabViewHolder(view, loader)
+ val tab = createTab(id = "123", url = "https://example.com")
+
+ viewHolder.bind(tab, false, mock(), mock())
+
+ verify(loader).loadIntoView(any(), eq(ImageLoadRequest("123", 100, false)), nullable(), nullable())
+ }
+
+ @Test
+ fun `thumbnailView does not change when there is no cache or new thumbnail`() {
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val viewHolder = DefaultTabViewHolder(view)
+ val tab = createTab(id = "123", url = "https://example.com")
+ val thumbnailView = view.findViewById<ImageView>(R.id.mozac_browser_tabstray_thumbnail)
+
+ thumbnailView.setImageBitmap(mock())
+ val drawable = thumbnailView.drawable
+
+ viewHolder.bind(tab, false, mock(), mock())
+
+ assertEquals(drawable, thumbnailView.drawable)
+ }
+
+ @Test
+ fun `bind sets the values for this instance's Tab and TabsTrayStyling properties`() {
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val viewHolder = DefaultTabViewHolder(view)
+ val tab = createTab(id = "123", url = "https://example.com")
+ val styling: TabsTrayStyling = mock()
+
+ assertNull(viewHolder.tab)
+ assertNull(viewHolder.styling)
+
+ viewHolder.bind(tab, false, styling, mock())
+
+ assertSame(tab, viewHolder.tab)
+ assertSame(styling, viewHolder.styling)
+ }
+
+ @Test
+ fun `bind shows an item as selected or not`() {
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val tab = createTab(id = "123", url = "https://example.com")
+ val viewHolder = spy(DefaultTabViewHolder(view))
+
+ viewHolder.bind(tab, false, mock(), mock())
+ verify(viewHolder).updateSelectedTabIndicator(false)
+
+ viewHolder.bind(tab, true, mock(), mock())
+ verify(viewHolder).updateSelectedTabIndicator(true)
+ }
+
+ @Test
+ fun `updateSelectedTabIndicator should further delegate to the appropriate method`() {
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val viewHolder = spy(DefaultTabViewHolder(view))
+
+ viewHolder.updateSelectedTabIndicator(showAsSelected = true)
+ verify(viewHolder).showItemAsSelected()
+
+ viewHolder.updateSelectedTabIndicator(showAsSelected = false)
+ verify(viewHolder).showItemAsNotSelected()
+ }
+
+ @Test
+ fun `showItemAsSelected should use TabsTrayStyling for indicating that an item is currently selected`() {
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val viewHolder = spy(DefaultTabViewHolder(view))
+ val tab = createTab(id = "123", url = "https://example.com")
+ val styling = TabsTrayStyling()
+
+ // Need to be called first to set the styling for this holder
+ viewHolder.bind(tab, false, TabsTrayStyling(), mock())
+ viewHolder.updateSelectedTabIndicator(true)
+
+ assertEquals(styling.selectedItemTextColor, viewHolder.titleView.textColors.defaultColor)
+ assertEquals(styling.selectedItemBackgroundColor, (viewHolder.itemView.background as ColorDrawable).color)
+ assertEquals(ColorStateList.valueOf(styling.selectedItemTextColor), viewHolder.closeView.imageTintList)
+ }
+
+ @Test
+ fun `showItemAsSelected should use TabsTrayStyling for indicating that an item is not currently selected`() {
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val viewHolder = spy(DefaultTabViewHolder(view))
+ val tab = createTab(id = "123", url = "https://example.com")
+ val styling = TabsTrayStyling()
+
+ // Need to be called first to set the styling for this holder
+ viewHolder.bind(tab, true, TabsTrayStyling(), mock())
+ viewHolder.updateSelectedTabIndicator(false)
+
+ assertEquals(styling.itemTextColor, viewHolder.titleView.textColors.defaultColor)
+ assertEquals(styling.itemBackgroundColor, (viewHolder.itemView.background as ColorDrawable).color)
+ assertEquals(ColorStateList.valueOf(styling.itemTextColor), viewHolder.closeView.imageTintList)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/TabTouchCallbackTest.kt b/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/TabTouchCallbackTest.kt
new file mode 100644
index 0000000000..3df5abdead
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/TabTouchCallbackTest.kt
@@ -0,0 +1,73 @@
+/* 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.browser.tabstray
+
+import android.view.LayoutInflater
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.`when`
+
+@RunWith(AndroidJUnit4::class)
+class TabTouchCallbackTest {
+
+ @Test
+ fun `onSwiped notifies observers`() {
+ var onTabClosedWasCalled = false
+
+ val onTabClosed: (TabSessionState) -> Unit = {
+ onTabClosedWasCalled = true
+ }
+ val touchCallback = TabTouchCallback(onTabClosed)
+ val viewHolder: TabViewHolder = mock()
+
+ touchCallback.onSwiped(viewHolder, 0)
+
+ assertFalse(onTabClosedWasCalled)
+
+ // With a session available.
+ `when`(viewHolder.tab).thenReturn(mock())
+
+ touchCallback.onSwiped(viewHolder, 0)
+
+ assertTrue(onTabClosedWasCalled)
+ }
+
+ @Test
+ fun `onChildDraw alters alpha of ViewHolder on swipe gesture`() {
+ val view = LayoutInflater.from(testContext).inflate(R.layout.mozac_browser_tabstray_item, null)
+ val holder = DefaultTabViewHolder(view)
+ val callback = TabTouchCallback(mock())
+
+ holder.itemView.alpha = 0f
+
+ callback.onChildDraw(mock(), mock(), holder, 0f, 0f, ItemTouchHelper.ACTION_STATE_DRAG, true)
+
+ assertEquals(0f, holder.itemView.alpha)
+
+ callback.onChildDraw(mock(), mock(), holder, 0f, 0f, ItemTouchHelper.ACTION_STATE_SWIPE, true)
+
+ assertEquals(1f, holder.itemView.alpha)
+ }
+
+ @Test
+ fun `alpha default is full`() {
+ val touchCallback = TabTouchCallback(mock())
+ assertEquals(1f, touchCallback.alphaForItemSwipe(0f, 0))
+ }
+
+ @Test
+ fun `onMove is not implemented`() {
+ val touchCallback = TabTouchCallback(mock())
+ assertFalse(touchCallback.onMove(mock(), mock(), mock()))
+ }
+}
diff --git a/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/TabViewHolderTest.kt b/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/TabViewHolderTest.kt
new file mode 100644
index 0000000000..4a69343cb1
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/TabViewHolderTest.kt
@@ -0,0 +1,52 @@
+/* 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.browser.tabstray
+
+import android.view.View
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import junit.framework.TestCase
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.support.test.expectException
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class TabViewHolderTest : TestCase() {
+
+ @Test
+ fun `updateSelectedTabIndicator needs to have a provided implementation`() {
+ val simpleTabViewHolder = object : TabViewHolder(View(testContext)) {
+ override var tab: TabSessionState? = null
+ override fun bind(
+ tab: TabSessionState,
+ isSelected: Boolean,
+ styling: TabsTrayStyling,
+ delegate: TabsTray.Delegate,
+ ) { /* noop */ }
+ }
+
+ expectException(UnsupportedOperationException::class) {
+ simpleTabViewHolder.updateSelectedTabIndicator(true)
+ }
+ }
+
+ @Test
+ fun `updateSelectedTabIndicator with a provided implementation just works`() {
+ val tabViewHolder = object : TabViewHolder(View(testContext)) {
+ override var tab: TabSessionState? = null
+ override fun bind(
+ tab: TabSessionState,
+ isSelected: Boolean,
+ styling: TabsTrayStyling,
+ delegate: TabsTray.Delegate,
+ ) { /* noop */ }
+ override fun updateSelectedTabIndicator(showAsSelected: Boolean) { /* noop */ }
+ }
+
+ // Simply test that this would not fail the test like it would happen above.
+ tabViewHolder.updateSelectedTabIndicator(true)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/TabsAdapterTest.kt b/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/TabsAdapterTest.kt
new file mode 100644
index 0000000000..20fa366b52
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/TabsAdapterTest.kt
@@ -0,0 +1,161 @@
+/* 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.browser.tabstray
+
+import android.view.View
+import android.widget.FrameLayout
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM
+import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_HIGHLIGHT_SELECTED_ITEM
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+private class TestTabViewHolder(view: View) : TabViewHolder(view) {
+ override var tab: TabSessionState? = null
+ override fun bind(
+ tab: TabSessionState,
+ isSelected: Boolean,
+ styling: TabsTrayStyling,
+ delegate: TabsTray.Delegate,
+ ) { // noop
+ }
+
+ override fun updateSelectedTabIndicator(showAsSelected: Boolean) { // noop
+ }
+}
+
+@RunWith(AndroidJUnit4::class)
+class TabsAdapterTest {
+
+ @Test
+ fun `onCreateViewHolder will create a DefaultTabViewHolder`() {
+ val adapter = TabsAdapter(delegate = mock())
+
+ val type = adapter.onCreateViewHolder(FrameLayout(testContext), 0)
+
+ assertTrue(type is DefaultTabViewHolder)
+ }
+
+ @Test
+ fun `onCreateViewHolder will create whatever TabViewHolder is provided`() {
+ val adapter = TabsAdapter(
+ viewHolderProvider = { _ -> TestTabViewHolder(View(testContext)) },
+ delegate = mock(),
+ )
+
+ val type = adapter.onCreateViewHolder(FrameLayout(testContext), 0)
+
+ assertTrue(type is TestTabViewHolder)
+ }
+
+ @Test
+ fun `itemCount will reflect number of sessions`() {
+ val adapter = TabsAdapter(delegate = mock())
+ assertEquals(0, adapter.itemCount)
+
+ adapter.updateTabs(
+ listOf(
+ createTab(id = "A", url = "https://www.mozilla.org"),
+ createTab(id = "B", url = "https://www.firefox.com"),
+ ),
+ tabPartition = null,
+ selectedTabId = "A",
+ )
+ assertEquals(2, adapter.itemCount)
+ }
+
+ @Test
+ fun `onBindViewHolder calls bind on matching holder`() {
+ val styling = TabsTrayStyling()
+ val delegate = mock<TabsTray.Delegate>()
+ val adapter = TabsAdapter(delegate = delegate, styling = styling)
+
+ val holder: TabViewHolder = mock()
+
+ val tab = createTab(id = "A", url = "https://www.mozilla.org")
+
+ adapter.updateTabs(
+ listOf(tab),
+ null,
+ "A",
+ )
+
+ adapter.onBindViewHolder(holder, 0)
+
+ verify(holder).bind(tab, true, styling, delegate)
+ }
+
+ @Test
+ fun `onBindViewHolder will use payloads to indicate if this item is selected`() {
+ val adapter = TabsAdapter(delegate = mock())
+ val holder = spy(TestTabViewHolder(View(testContext)))
+ val tab = createTab(id = "A", url = "https://www.mozilla.org")
+
+ adapter.updateTabs(listOf(mock(), tab), tabPartition = null, selectedTabId = "A")
+
+ adapter.onBindViewHolder(holder, 0, listOf(PAYLOAD_HIGHLIGHT_SELECTED_ITEM))
+ verify(holder, never()).updateSelectedTabIndicator(ArgumentMatchers.anyBoolean())
+
+ adapter.onBindViewHolder(holder, 1, listOf(PAYLOAD_HIGHLIGHT_SELECTED_ITEM))
+ verify(holder).updateSelectedTabIndicator(true)
+ }
+
+ @Test
+ fun `onBindViewHolder will use payloads to indicate if this item is not selected`() {
+ val adapter = TabsAdapter(delegate = mock())
+ val holder = spy(TestTabViewHolder(View(testContext)))
+ val tab = createTab(id = "A", url = "https://www.mozilla.org")
+ adapter.updateTabs(listOf(mock(), tab), tabPartition = null, selectedTabId = "A")
+
+ adapter.onBindViewHolder(holder, 0, listOf(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM))
+ verify(holder, never()).updateSelectedTabIndicator(ArgumentMatchers.anyBoolean())
+
+ adapter.onBindViewHolder(holder, 1, listOf(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM))
+ verify(holder).updateSelectedTabIndicator(false)
+ }
+
+ @Test
+ fun `onBindViewHolder with payloads will return early if there are currently no open tabs`() {
+ val adapter = TabsAdapter(delegate = mock())
+ val holder = TestTabViewHolder(View(testContext))
+ val payloads = spy(arrayListOf(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM))
+
+ adapter.onBindViewHolder(holder, 0, payloads)
+ // verify that calls we expect further down are not happening after the null check
+ verify(payloads, never()).isEmpty()
+ verify(payloads, never()).contains(ArgumentMatchers.anyInt())
+
+ adapter.updateTabs(emptyList(), tabPartition = null, selectedTabId = null)
+ adapter.onBindViewHolder(holder, 0, payloads)
+ // verify that calls we expect further down are not happening after the null check
+ verify(payloads, never()).isEmpty()
+ verify(payloads, never()).contains(ArgumentMatchers.anyInt())
+ }
+
+ @Test
+ fun `onBindViewHolder with empty payloads will call onBindViewHolder and return early for a full bind`() {
+ val adapter = TabsAdapter(delegate = mock())
+ val holder = TestTabViewHolder(View(testContext))
+ val emptyPayloads = spy(arrayListOf<String>())
+
+ adapter.updateTabs(listOf(mock()), tabPartition = null, selectedTabId = null)
+
+ adapter.onBindViewHolder(holder, 0, emptyPayloads)
+
+ verify(emptyPayloads).isEmpty()
+ // verify that calls we expect further down are not happening after the isEmpty check
+ verify(emptyPayloads, never()).contains(ArgumentMatchers.anyString())
+ }
+}
diff --git a/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/thumbnail/TabThumbnailViewTest.kt b/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/thumbnail/TabThumbnailViewTest.kt
new file mode 100644
index 0000000000..9842186874
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/src/test/java/mozilla/components/browser/tabstray/thumbnail/TabThumbnailViewTest.kt
@@ -0,0 +1,81 @@
+/* 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.browser.tabstray.thumbnail
+
+import android.graphics.Matrix
+import android.graphics.drawable.Drawable
+import android.widget.ImageView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+import org.robolectric.Robolectric.buildAttributeSet
+
+@RunWith(AndroidJUnit4::class)
+class TabThumbnailViewTest {
+
+ @Test
+ fun `view should always use Matrix ScaleType`() {
+ val view = TabThumbnailView(testContext, emptyAttributeSet())
+ assertEquals(ImageView.ScaleType.MATRIX, view.scaleType)
+ }
+
+ @Test
+ fun `view updates matrix when changed`() {
+ val view = TabThumbnailView(testContext, emptyAttributeSet())
+ val matrix = view.imageMatrix
+ val drawable: Drawable = mock()
+
+ `when`(drawable.intrinsicWidth).thenReturn(5)
+ `when`(drawable.intrinsicHeight).thenReturn(5)
+
+ view.setImageDrawable(drawable)
+ view.setFrame(5, 5, 5, 5)
+
+ val matrix2 = view.imageMatrix
+
+ assertNotEquals(matrix, matrix2)
+ }
+
+ @Test
+ fun `view updates don't change matrix if no changes to frame`() {
+ val view = TabThumbnailView(testContext, emptyAttributeSet())
+ val drawable: Drawable = mock()
+
+ `when`(drawable.intrinsicWidth).thenReturn(5)
+ `when`(drawable.intrinsicHeight).thenReturn(5)
+
+ view.setImageDrawable(drawable)
+ view.setFrame(5, 5, 5, 5)
+
+ val matrix = view.imageMatrix
+
+ view.setFrame(5, 5, 5, 5)
+
+ val matrix2 = view.imageMatrix
+
+ assertEquals(matrix, matrix2)
+ }
+
+ @Test
+ fun `view scaleFactor does not change if there is no drawable`() {
+ val view = spy(TabThumbnailView(testContext, emptyAttributeSet()))
+ val matrix: Matrix = spy(Matrix())
+
+ `when`(view.imageMatrix).thenReturn(matrix)
+
+ view.setFrame(5, 5, 5, 5)
+
+ verify(matrix).setScale(1f, 1f, 0f, 0f)
+ }
+}
+
+private fun emptyAttributeSet() = buildAttributeSet().build()
diff --git a/mobile/android/android-components/components/browser/tabstray/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/browser/tabstray/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/components/browser/tabstray/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)