summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/ui/widgets/src
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
commitda4c7e7ed675c3bf405668739c3012d140856109 (patch)
treecdd868dba063fecba609a1d819de271f0d51b23e /mobile/android/android-components/components/ui/widgets/src
parentAdding upstream version 125.0.3. (diff)
downloadfirefox-da4c7e7ed675c3bf405668739c3012d140856109.tar.xz
firefox-da4c7e7ed675c3bf405668739c3012d140856109.zip
Adding upstream version 126.0.upstream/126.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/ui/widgets/src')
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/Extentions.kt37
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/SnackbarDelegate.kt55
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/VerticalSwipeRefreshLayout.kt216
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/WidgetSiteItemView.kt106
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/BrowserGestureDetector.kt192
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/EngineViewClippingBehavior.kt90
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehavior.kt237
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/ViewYTranslationStrategy.kt189
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/ViewYTranslator.kt81
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/drawable/mozac_widget_favicon_background.xml17
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/drawable/rounded_button_background.xml18
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/layout/mozac_widget_site_item.xml77
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-am/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-ar/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-ast/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-azb/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-be/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-bg/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-br/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-bs/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-ca/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-cak/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-co/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-cs/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-cy/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-da/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-de/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-dsb/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-el/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-en-rCA/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-en-rGB/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-eo/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rAR/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rCL/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rES/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rMX/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-es/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-et/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-eu/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-fa/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-fi/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-fr/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-fur/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-fy-rNL/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-gd/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-gl/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-gn/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-hr/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-hsb/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-hu/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-hy-rAM/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-ia/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-in/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-is/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-it/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-iw/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-ja/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-ka/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-kaa/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-kab/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-kk/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-kmr/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-ko/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-lo/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-nb-rNO/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-nl/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-nn-rNO/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-oc/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-pa-rIN/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-pa-rPK/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-pl/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-pt-rBR/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-pt-rPT/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-rm/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-ro/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-ru/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-sat/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-sc/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-si/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-sk/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-skr/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-sl/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-sq/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-sr/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-su/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-sv-rSE/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-tg/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-th/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-tr/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-trs/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-tt/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-ug/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-uk/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-vi/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-zh-rCN/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values-zh-rTW/strings.xml5
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values/attrs.xml19
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values/colors.xml10
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values/dimens.xml14
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values/strings.xml8
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/main/res/values/styles.xml63
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/TestUtils.kt73
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/VerticalSwipeRefreshLayoutTest.kt430
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/WidgetSiteItemViewTest.kt93
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/BrowserGestureDetectorTest.kt231
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewClippingBehaviorTest.kt221
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehaviorTest.kt575
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/TestUtils.kt62
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/ViewYTranslationStrategyTest.kt712
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/ViewYTranslatorTest.kt113
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/ui/widgets/src/test/resources/robolectric.properties1
113 files changed, 4366 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/AndroidManifest.xml b/mobile/android/android-components/components/ui/widgets/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+<!-- 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 />
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/Extentions.kt b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/Extentions.kt
new file mode 100644
index 0000000000..fb17b7eb5a
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/Extentions.kt
@@ -0,0 +1,37 @@
+/* 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.widgets
+
+import android.widget.TextView
+import android.view.View.TEXT_ALIGNMENT_CENTER as CENTER
+
+/**
+ * A shortcut to align buttons' text to center inside AlertDialog.
+ */
+fun androidx.appcompat.app.AlertDialog.withCenterAlignedButtons(): androidx.appcompat.app.AlertDialog {
+ findViewById<TextView>(android.R.id.button1)?.let { it.textAlignment = CENTER }
+ findViewById<TextView>(android.R.id.button2)?.let { it.textAlignment = CENTER }
+ findViewById<TextView>(android.R.id.button3)?.let { it.textAlignment = CENTER }
+ return this
+}
+
+/**
+ * A shortcut to align buttons' text to center inside AlertDialog.
+ *
+ * Important: On Android API levels lower than 24, this method must be called only AFTER the dialog
+ * has been shown. Calling this method prior to displaying the dialog on those API levels will cause
+ * partial initialization of the view, leading to a crash.
+ *
+ * Usage example:
+ * dialog.setOnShowListener {
+ * dialog.withCenterAlignedButtons()
+ * }
+ */
+fun android.app.AlertDialog.withCenterAlignedButtons(): android.app.AlertDialog {
+ findViewById<TextView>(android.R.id.button1)?.let { it.textAlignment = CENTER }
+ findViewById<TextView>(android.R.id.button2)?.let { it.textAlignment = CENTER }
+ findViewById<TextView>(android.R.id.button3)?.let { it.textAlignment = CENTER }
+ return this
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/SnackbarDelegate.kt b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/SnackbarDelegate.kt
new file mode 100644
index 0000000000..8e2dd2fad7
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/SnackbarDelegate.kt
@@ -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/. */
+
+package mozilla.components.ui.widgets
+
+import android.view.View
+import com.google.android.material.snackbar.Snackbar
+
+/**
+ * Delegate to display a snackbar.
+ */
+interface SnackbarDelegate {
+ /**
+ * Displays a snackbar.
+ *
+ * @param snackBarParentView The view to find a parent from for displaying the Snackbar.
+ * @param text The text to show. Can be formatted text.
+ * @param duration How long to display the message.
+ * @param action String resource to display for the action.
+ * @param listener callback to be invoked when the action is clicked.
+ */
+ fun show(
+ snackBarParentView: View,
+ text: Int,
+ duration: Int,
+ action: Int = 0,
+ listener: ((v: View) -> Unit)? = null,
+ )
+}
+
+/**
+ * Default implementation for [SnackbarDelegate]. Will display a standard default Snackbar.
+ */
+class DefaultSnackbarDelegate : SnackbarDelegate {
+ override fun show(
+ snackBarParentView: View,
+ text: Int,
+ duration: Int,
+ action: Int,
+ listener: ((v: View) -> Unit)?,
+ ) {
+ val snackbar = Snackbar.make(
+ snackBarParentView,
+ text,
+ duration,
+ )
+
+ if (action != 0 && listener != null) {
+ snackbar.setAction(action, listener)
+ }
+
+ snackbar.show()
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/VerticalSwipeRefreshLayout.kt b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/VerticalSwipeRefreshLayout.kt
new file mode 100644
index 0000000000..aa8bc10b67
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/VerticalSwipeRefreshLayout.kt
@@ -0,0 +1,216 @@
+/* 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.widgets
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.util.AttributeSet
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewConfiguration
+import androidx.annotation.VisibleForTesting
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+import kotlin.math.abs
+
+/**
+ * [SwipeRefreshLayout] that filters only vertical scrolls for triggering pull to refresh.
+ *
+ * Following situations will not trigger pull to refresh:
+ * - a scroll happening more on the horizontal axis
+ * - a scale in/out gesture
+ * - a quick scale gesture
+ *
+ * To control responding to scrolls and showing the pull to refresh throbber or not
+ * use the [View.isEnabled] property.
+ */
+class VerticalSwipeRefreshLayout @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+) : SwipeRefreshLayout(context, attrs) {
+ @VisibleForTesting
+ internal var isQuickScaleInProgress = false
+
+ @VisibleForTesting
+ internal var quickScaleEvents = QuickScaleEvents()
+ private var previousX = 0f
+ private var previousY = 0f
+ private val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout()
+ private val doubleTapSlop = ViewConfiguration.get(context).scaledDoubleTapSlop
+ private val doubleTapSlopSquare = doubleTapSlop * doubleTapSlop
+
+ @VisibleForTesting
+ internal var hadMultiTouch: Boolean = false
+
+ @VisibleForTesting
+ internal var disallowInterceptTouchEvent = false
+
+ @Suppress("ComplexMethod", "ReturnCount")
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
+ // Setting "isEnabled = false" is recommended for users of this ViewGroup
+ // who who are not interested in the pull to refresh functionality
+ // Setting this easily avoids executing code unneededsly before the check for "canChildScrollUp".
+ if (!isEnabled || disallowInterceptTouchEvent) {
+ return false
+ }
+
+ if (MotionEvent.ACTION_DOWN == event.action) {
+ hadMultiTouch = false
+ }
+
+ // Layman's scale gesture (with two fingers) detector.
+ // Allows for quick, serial inference as opposed to using ScaleGestureDetector
+ // which uses callbacks and would be hard to synchronize in the little time we have.
+ if (event.pointerCount > 1 || hadMultiTouch) {
+ hadMultiTouch = true
+ return false
+ }
+
+ val eventAction = event.action
+
+ // Cleanup if the gesture has been aborted or quick scale just ended/
+ if (MotionEvent.ACTION_CANCEL == eventAction ||
+ (MotionEvent.ACTION_UP == eventAction && isQuickScaleInProgress)
+ ) {
+ forgetQuickScaleEvents()
+ return callSuperOnInterceptTouchEvent(event)
+ }
+
+ // Disable pull to refresh if quick scale is in progress.
+ maybeAddDoubleTapEvent(event)
+ if (isQuickScaleInProgress(quickScaleEvents)) {
+ isQuickScaleInProgress = true
+ return false
+ }
+
+ // Disable pull to refresh if the move was more on the X axis.
+ if (MotionEvent.ACTION_DOWN == eventAction) {
+ previousX = event.x
+ previousY = event.y
+ } else if (MotionEvent.ACTION_MOVE == eventAction) {
+ val xDistance = abs(event.x - previousX)
+ val yDistance = abs(event.y - previousY)
+ previousX = event.x
+ previousY = event.y
+ if (xDistance > yDistance) {
+ return false
+ }
+ }
+
+ return callSuperOnInterceptTouchEvent(event)
+ }
+
+ override fun onStartNestedScroll(child: View, target: View, nestedScrollAxes: Int): Boolean {
+ // Ignoring nested scrolls from descendants.
+ // Allowing descendants to trigger nested scrolls would defeat the purpose of this class
+ // and result in pull to refresh to happen for all movements on the Y axis
+ // (even as part of scale/quick scale gestures) while also doubling the throbber with the overscroll shadow.
+ return if (isEnabled) {
+ return false
+ } else {
+ callSuperOnStartNestedScroll(child, target, nestedScrollAxes)
+ }
+ }
+
+ @SuppressLint("Recycle") // we do recycle the events in forgetQuickScaleEvents()
+ @VisibleForTesting
+ internal fun maybeAddDoubleTapEvent(event: MotionEvent) {
+ val currentEventAction = event.action
+
+ // A double tap event must follow the order:
+ // ACTION_DOWN - ACTION_UP - ACTION_DOWN
+ // all these events happening in an interval defined by a system constant - DOUBLE_TAP_TIMEOUT
+
+ if (MotionEvent.ACTION_DOWN == currentEventAction) {
+ if (quickScaleEvents.upEvent != null) {
+ if (event.eventTime - quickScaleEvents.upEvent!!.eventTime > doubleTapTimeout) {
+ // Too much time passed for the MotionEvents sequence to be considered
+ // a quick scale gesture. Restart counting.
+ forgetQuickScaleEvents()
+ quickScaleEvents.firstDownEvent = MotionEvent.obtain(event)
+ } else {
+ quickScaleEvents.secondDownEvent = MotionEvent.obtain(event)
+ }
+ } else {
+ // This may be the first time the user touches the screen or
+ // the gesture was not finished with ACTION_UP.
+ forgetQuickScaleEvents()
+ quickScaleEvents.firstDownEvent = MotionEvent.obtain(event)
+ }
+ }
+ // For the double tap events series we need ACTION_DOWN first
+ // and then ACTION_UP second.
+ else if (MotionEvent.ACTION_UP == currentEventAction && quickScaleEvents.firstDownEvent != null) {
+ quickScaleEvents.upEvent = MotionEvent.obtain(event)
+ }
+ }
+
+ override fun requestDisallowInterceptTouchEvent(b: Boolean) {
+ // We need to disable Pull to Refresh on this layout be we don't want to propagate the
+ // request to the parent, because they may use the gesture for other purpose, like
+ // propagating it to ToolbarBehavior
+ this.disallowInterceptTouchEvent = b
+ }
+
+ @VisibleForTesting
+ internal fun forgetQuickScaleEvents() {
+ quickScaleEvents.firstDownEvent?.recycle()
+ quickScaleEvents.upEvent?.recycle()
+ quickScaleEvents.secondDownEvent?.recycle()
+ quickScaleEvents.firstDownEvent = null
+ quickScaleEvents.upEvent = null
+ quickScaleEvents.secondDownEvent = null
+
+ isQuickScaleInProgress = false
+ }
+
+ @VisibleForTesting
+ internal fun isQuickScaleInProgress(events: QuickScaleEvents): Boolean {
+ return if (events.isNotNull()) {
+ isQuickScaleInProgress(events.firstDownEvent!!, events.upEvent!!, events.secondDownEvent!!)
+ } else {
+ false
+ }
+ }
+
+ // Method closely following GestureDetectorCompat#isConsideredDoubleTap.
+ // Allows for serial inference of double taps as opposed to using callbacks.
+ @VisibleForTesting
+ internal fun isQuickScaleInProgress(
+ firstDown: MotionEvent,
+ firstUp: MotionEvent,
+ secondDown: MotionEvent,
+ ): Boolean {
+ if (secondDown.eventTime - firstUp.eventTime > doubleTapTimeout) {
+ return false
+ }
+
+ val deltaX = firstDown.x.toInt() - secondDown.x.toInt()
+ val deltaY = firstDown.y.toInt() - secondDown.y.toInt()
+
+ return deltaX * deltaX + deltaY * deltaY < doubleTapSlopSquare
+ }
+
+ @VisibleForTesting
+ internal fun callSuperOnInterceptTouchEvent(event: MotionEvent) =
+ super.onInterceptTouchEvent(event)
+
+ @VisibleForTesting
+ internal fun callSuperOnStartNestedScroll(child: View, target: View, nestedScrollAxes: Int) =
+ super.onStartNestedScroll(child, target, nestedScrollAxes)
+
+ private fun QuickScaleEvents.isNotNull(): Boolean {
+ return firstDownEvent != null && upEvent != null && secondDownEvent != null
+ }
+
+ /**
+ * Wrapper over the MotionEvents that compose a quickScale gesture.
+ */
+ @VisibleForTesting
+ internal data class QuickScaleEvents(
+ var firstDownEvent: MotionEvent? = null,
+ var upEvent: MotionEvent? = null,
+ var secondDownEvent: MotionEvent? = null,
+ )
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/WidgetSiteItemView.kt b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/WidgetSiteItemView.kt
new file mode 100644
index 0000000000..0eca198957
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/WidgetSiteItemView.kt
@@ -0,0 +1,106 @@
+/* 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.widgets
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.FrameLayout
+import android.widget.ImageButton
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.appcompat.content.res.AppCompatResources.getDrawable
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.view.isVisible
+
+/**
+ * Shared UI widget for showing a website in a list of websites,
+ * such as in bookmarks, history, site exceptions, or collections.
+ */
+class WidgetSiteItemView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : ConstraintLayout(context, attrs, defStyleAttr) {
+
+ private val labelView: TextView by lazy { findViewById<TextView>(R.id.label) }
+ private val captionView: TextView by lazy { findViewById<TextView>(R.id.caption) }
+ private val iconWrapper: FrameLayout by lazy { findViewById<FrameLayout>(R.id.favicon_wrapper) }
+ private val secondaryButton: ImageButton by lazy { findViewById<ImageButton>(R.id.secondary_button) }
+
+ /**
+ * ImageView that should display favicons.
+ */
+ val iconView: ImageView by lazy { findViewById<ImageView>(R.id.favicon) }
+
+ init {
+ LayoutInflater.from(context).inflate(R.layout.mozac_widget_site_item, this, true)
+ }
+
+ /**
+ * Sets the text displayed inside of the site item view.
+ *
+ * @param label Main label text, such as a site title.
+ * @param caption Sub caption text, such as a URL. If null, the caption is hidden.
+ */
+ fun setText(label: CharSequence, caption: CharSequence?) {
+ labelView.text = label
+ captionView.text = caption
+ captionView.isVisible = caption != null
+ }
+
+ /**
+ * Add a view that will overlay the favicon, such as a checkmark.
+ */
+ fun addIconOverlay(overlay: View) {
+ iconWrapper.addView(overlay)
+ }
+
+ /**
+ * Add a secondary button, such as an overflow menu.
+ *
+ * @param icon Drawable to display in the button.
+ * @param contentDescription Accessible description of the button's purpose.
+ * @param onClickListener Listener called when the button is clicked.
+ */
+ fun setSecondaryButton(
+ icon: Drawable?,
+ contentDescription: CharSequence,
+ onClickListener: (View) -> Unit,
+ ) {
+ secondaryButton.isVisible = true
+ secondaryButton.setImageDrawable(icon)
+ secondaryButton.contentDescription = contentDescription
+ secondaryButton.setOnClickListener(onClickListener)
+ }
+
+ /**
+ * Add a secondary button, such as an overflow menu.
+ *
+ * @param icon Drawable to display in the button.
+ * @param contentDescription Accessible description of the button's purpose.
+ * @param onClickListener Listener called when the button is clicked.
+ */
+ fun setSecondaryButton(
+ @DrawableRes icon: Int,
+ @StringRes contentDescription: Int,
+ onClickListener: (View) -> Unit,
+ ) = setSecondaryButton(
+ icon = getDrawable(context, icon),
+ contentDescription = context.getString(contentDescription),
+ onClickListener = onClickListener,
+ )
+
+ /**
+ * Removes the secondary button if it was previously set in [setSecondaryButton].
+ */
+ fun removeSecondaryButton() {
+ secondaryButton.isVisible = false
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/BrowserGestureDetector.kt b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/BrowserGestureDetector.kt
new file mode 100644
index 0000000000..b15df65078
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/BrowserGestureDetector.kt
@@ -0,0 +1,192 @@
+/* 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.widgets.behavior
+
+import android.content.Context
+import android.view.GestureDetector
+import android.view.MotionEvent
+import android.view.ScaleGestureDetector
+import androidx.annotation.VisibleForTesting
+import mozilla.components.concept.base.crash.CrashReporting
+import kotlin.math.abs
+
+/**
+ * Wraps exceptions that are caught by [BrowserGestureDetector].
+ * Instances of this class are submitted via [CrashReporting]. This wrapping helps easily identify
+ * exceptions related to [BrowserGestureDetector].
+ */
+internal class BrowserGestureDetectorException(e: Throwable) : Throwable(e)
+
+/**
+ * Custom [MotionEvent] gestures detector with scroll / zoom callbacks.
+ *
+ * Favors zoom gestures in detriment of the scroll gestures with:
+ * - higher sensitivity for multi-finger zoom gestures
+ * - ignoring scrolls if zoom is in progress
+ *
+ * @param applicationContext context used for registering internal gesture listeners.
+ * @param listener client interested in zoom / scroll events.
+ */
+internal class BrowserGestureDetector(
+ applicationContext: Context,
+ listener: GesturesListener,
+ private val crashReporting: CrashReporting? = null,
+) {
+ @VisibleForTesting
+ internal var gestureDetector = GestureDetector(
+ applicationContext,
+ CustomScrollDetectorListener { previousEvent: MotionEvent?, currentEvent: MotionEvent, distanceX, distanceY ->
+ run {
+ listener.onScroll?.invoke(distanceX, distanceY)
+
+ // We got many crashes because of the initial event - ACTION_DOWN being null.
+ // Investigations to be continued in android-components/issues/8552.
+ // In the meantime we'll protect against this with a simple null check.
+ if (previousEvent != null) {
+ if (abs(currentEvent.y - previousEvent.y) >= abs(currentEvent.x - previousEvent.x)) {
+ listener.onVerticalScroll?.invoke(distanceY)
+ } else {
+ listener.onHorizontalScroll?.invoke(distanceX)
+ }
+ }
+ }
+ },
+ )
+
+ @VisibleForTesting
+ internal var scaleGestureDetector = ScaleGestureDetector(
+ applicationContext,
+ CustomScaleDetectorListener(
+ listener.onScaleBegin ?: {},
+ listener.onScale ?: {},
+ listener.onScaleEnd ?: {},
+ ),
+ )
+
+ /**
+ * Accepts MotionEvents and dispatches zoom / scroll events to the registered listener when appropriate.
+ *
+ * Applications should pass a complete and consistent event stream to this method.
+ * A complete and consistent event stream involves all MotionEvents from the initial ACTION_DOWN
+ * to the final ACTION_UP or ACTION_CANCEL.
+ *
+ * @return if the event was handled by any of the registered detectors
+ */
+ @Suppress("ComplexCondition")
+ internal fun handleTouchEvent(event: MotionEvent): Boolean {
+ val eventAction = event.actionMasked
+
+ // A double tap for a quick scale gesture (quick double tap followed by a drag)
+ // would trigger a ACTION_CANCEL event before the MOVE_EVENT.
+ // This would prevent the scale detector from properly inferring the movement.
+ // We'll want to ignore ACTION_CANCEL but process the next stream of events.
+ if (eventAction != MotionEvent.ACTION_CANCEL) {
+ scaleGestureDetector.onTouchEvent(event)
+ }
+
+ // Ignore scrolling if zooming is already in progress.
+ // Always pass motion begin / end events just to have the detector ready
+ // to infer scrolls when the scale gesture ended.
+ return if (!scaleGestureDetector.isInProgress ||
+ eventAction == MotionEvent.ACTION_DOWN ||
+ eventAction == MotionEvent.ACTION_UP ||
+ eventAction == MotionEvent.ACTION_CANCEL
+ ) {
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ gestureDetector.onTouchEvent(event)
+ } catch (e: Exception) {
+ crashReporting?.submitCaughtException(BrowserGestureDetectorException(e))
+ false
+ }
+ } else {
+ false
+ }
+ }
+
+ /**
+ * A convenience containing listeners for zoom / scroll events
+ *
+ * Provide implementation for the events you are interested in.
+ * The others will be no-op.
+ */
+ internal class GesturesListener(
+ /**
+ * Responds to scroll events for a gesture in progress.
+ * The distance in x and y is also supplied for convenience.
+ */
+ val onScroll: ((distanceX: Float, distanceY: Float) -> Unit)? = { _, _ -> run {} },
+
+ /**
+ * Responds to an in progress scroll occuring more on the vertical axis.
+ * The scroll distance is also supplied for convenience.
+ */
+ val onVerticalScroll: ((distance: Float) -> Unit)? = {},
+
+ /**
+ * Responds to an in progress scroll occurring more on the horizontal axis.
+ * The scroll distance is also supplied for convenience.
+ */
+ val onHorizontalScroll: ((distance: Float) -> Unit)? = {},
+
+ /**
+ * Responds to the the beginning of a new scale gesture.
+ * Reported by new pointers going down.
+ */
+ val onScaleBegin: ((scaleFactor: Float) -> Unit)? = {},
+
+ /**
+ * Responds to scaling events for a gesture in progress.
+ * The scaling factor is also supplied for convenience.
+ * This value is represents the difference from the previous scale event to the current event.
+ */
+ val onScale: ((scaleFactor: Float) -> Unit)? = {},
+
+ /**
+ * Responds to the end of a scale gesture.
+ * Reported by existing pointers going up.
+ */
+ val onScaleEnd: ((scaleFactor: Float) -> Unit)? = {},
+ )
+
+ private class CustomScrollDetectorListener(
+ val onScrolling: (
+ previousEvent: MotionEvent?,
+ currentEvent: MotionEvent,
+ distanceX: Float,
+ distanceY: Float,
+ ) -> Unit,
+ ) : GestureDetector.SimpleOnGestureListener() {
+ override fun onScroll(
+ e1: MotionEvent?,
+ e2: MotionEvent,
+ distanceX: Float,
+ distanceY: Float,
+ ): Boolean {
+ onScrolling(e1, e2, distanceX, distanceY)
+ return true
+ }
+ }
+
+ private class CustomScaleDetectorListener(
+ val onScaleBegin: (scaleFactor: Float) -> Unit = {},
+ val onScale: (scaleFactor: Float) -> Unit = {},
+ val onScaleEnd: (scaleFactor: Float) -> Unit = {},
+ ) : ScaleGestureDetector.SimpleOnScaleGestureListener() {
+ override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
+ onScaleBegin(detector.scaleFactor)
+ return true
+ }
+
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
+ onScale(detector.scaleFactor)
+ return true
+ }
+
+ override fun onScaleEnd(detector: ScaleGestureDetector) {
+ onScaleEnd(detector.scaleFactor)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/EngineViewClippingBehavior.kt b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/EngineViewClippingBehavior.kt
new file mode 100644
index 0000000000..d0f532bfbc
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/EngineViewClippingBehavior.kt
@@ -0,0 +1,90 @@
+/* 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.widgets.behavior
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import androidx.annotation.VisibleForTesting
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.concept.toolbar.ScrollableToolbar
+import mozilla.components.support.ktx.android.view.findViewInHierarchy
+import kotlin.math.roundToInt
+
+/**
+ * A [CoordinatorLayout.Behavior] implementation that allows the [EngineView] to automatically
+ * size itself in relation to the Y translation of the [ScrollableToolbar].
+ *
+ * This is useful for dynamic [ScrollableToolbar]s ensuring the web content is displayed immediately
+ * below / above the toolbar even when that is animated.
+ *
+ * @param context [Context] used for various Android interactions
+ * @param attrs XML set attributes configuring this
+ * @param engineViewParent NestedScrollingChild parent of the [EngineView]
+ * @param toolbarHeight size of [ScrollableToolbar] when it is placed above the [EngineView]
+ * @param toolbarPosition whether the [ScrollableToolbar] is placed above or below the [EngineView]
+ */
+class EngineViewClippingBehavior(
+ context: Context?,
+ attrs: AttributeSet?,
+ engineViewParent: View,
+ toolbarHeight: Int,
+ toolbarPosition: ToolbarPosition,
+) : CoordinatorLayout.Behavior<View>(context, attrs) {
+
+ @VisibleForTesting
+ internal val engineView = engineViewParent.findViewInHierarchy { it is EngineView } as EngineView?
+
+ @VisibleForTesting
+ internal var toolbarChangedAction: (Float) -> Unit?
+ private val bottomToolbarChangedAction = { newToolbarTranslationY: Float ->
+ if (!newToolbarTranslationY.isNaN()) {
+ engineView?.setVerticalClipping(-newToolbarTranslationY.roundToInt())
+ }
+ }
+ private val topToolbarChangedAction = { newToolbarTranslationY: Float ->
+ // the top toolbar is translated upwards when collapsing-> all values received are 0 or negative
+ engineView?.let {
+ it.setVerticalClipping(newToolbarTranslationY.roundToInt())
+ // Need to add the toolbarHeight to effectively place the engineView below the toolbar.
+ engineViewParent.translationY = newToolbarTranslationY + toolbarHeight
+ }
+ }
+
+ init {
+ toolbarChangedAction = if (toolbarPosition == ToolbarPosition.TOP) {
+ topToolbarChangedAction
+ } else {
+ bottomToolbarChangedAction
+ }
+ }
+
+ override fun layoutDependsOn(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
+ if (dependency is ScrollableToolbar) {
+ return true
+ }
+
+ return super.layoutDependsOn(parent, child, dependency)
+ }
+
+ /**
+ * Apply vertical clipping to [EngineView]. This requires [EngineViewClippingBehavior] to be set
+ * in/on the [EngineView] or its parent. Must be a direct descending child of [CoordinatorLayout].
+ */
+ override fun onDependentViewChanged(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
+ toolbarChangedAction.invoke(dependency.translationY)
+
+ return true
+ }
+}
+
+/**
+ * Where the toolbar is placed on the screen.
+ */
+enum class ToolbarPosition {
+ TOP,
+ BOTTOM,
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehavior.kt b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehavior.kt
new file mode 100644
index 0000000000..08da7e5064
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehavior.kt
@@ -0,0 +1,237 @@
+/* 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.widgets.behavior
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.MotionEvent
+import android.view.View
+import androidx.annotation.VisibleForTesting
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.ViewCompat
+import mozilla.components.concept.base.crash.CrashReporting
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.support.ktx.android.view.findViewInHierarchy
+
+/**
+ * Where the view is placed on the screen.
+ */
+enum class ViewPosition {
+ TOP,
+ BOTTOM,
+}
+
+/**
+ * A [CoordinatorLayout.Behavior] implementation to be used when placing [View] at the bottom of the screen.
+ *
+ * This is safe to use even if the [View] may be added / removed from a parent layout later
+ * or if it could have Visibility.GONE set.
+ *
+ * This implementation will:
+ * - Show/Hide the [View] automatically when scrolling vertically.
+ * - Snap the [View] to be hidden or visible when the user stops scrolling.
+ */
+class EngineViewScrollingBehavior(
+ val context: Context?,
+ attrs: AttributeSet?,
+ private val viewPosition: ViewPosition,
+ private val crashReporting: CrashReporting? = null,
+) : CoordinatorLayout.Behavior<View>(context, attrs) {
+ // This implementation is heavily based on this blog article:
+ // https://android.jlelse.eu/scroll-your-bottom-navigation-view-away-with-10-lines-of-code-346f1ed40e9e
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal var shouldSnapAfterScroll: Boolean = false
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal var startedScroll = false
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal var isScrollEnabled = false
+
+ /**
+ * Reference to [EngineView] used to check user's [android.view.MotionEvent]s.
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal var engineView: EngineView? = null
+
+ /**
+ * Reference to the actual [View] that we'll animate.
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal var dynamicScrollView: View? = null
+
+ /**
+ * Depending on how user's touch was consumed by EngineView / current website,
+ *
+ * we will animate the dynamic navigation bar if:
+ * - touches were used for zooming / panning operations in the website.
+ *
+ * We will do nothing if:
+ * - the website is not scrollable
+ * - the website handles the touch events itself through it's own touch event listeners.
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal val shouldScroll: Boolean
+ get() = engineView?.getInputResultDetail()?.let {
+ (it.canScrollToBottom() || it.canScrollToTop()) && isScrollEnabled
+ } ?: false
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal var gesturesDetector: BrowserGestureDetector = createGestureDetector()
+
+ @VisibleForTesting
+ internal var yTranslator: ViewYTranslator = createYTranslationStrategy()
+
+ private fun createYTranslationStrategy() = ViewYTranslator(viewPosition)
+
+ override fun onStartNestedScroll(
+ coordinatorLayout: CoordinatorLayout,
+ child: View,
+ directTargetChild: View,
+ target: View,
+ axes: Int,
+ type: Int,
+ ): Boolean {
+ return if (dynamicScrollView != null) {
+ startNestedScroll(axes, type, child)
+ } else {
+ return false // not interested in subsequent scroll events
+ }
+ }
+
+ override fun onStopNestedScroll(
+ coordinatorLayout: CoordinatorLayout,
+ child: View,
+ target: View,
+ type: Int,
+ ) {
+ if (dynamicScrollView != null) {
+ stopNestedScroll(type, child)
+ }
+ }
+
+ override fun onInterceptTouchEvent(
+ parent: CoordinatorLayout,
+ child: View,
+ ev: MotionEvent,
+ ): Boolean {
+ if (dynamicScrollView != null) {
+ gesturesDetector.handleTouchEvent(ev)
+ }
+ return false // allow events to be passed to below listeners
+ }
+
+ override fun onLayoutChild(
+ parent: CoordinatorLayout,
+ child: View,
+ layoutDirection: Int,
+ ): Boolean {
+ dynamicScrollView = child
+ engineView = parent.findViewInHierarchy { it is EngineView } as? EngineView
+
+ return super.onLayoutChild(parent, child, layoutDirection)
+ }
+
+ /**
+ * Used to expand the [View]
+ */
+ fun forceExpand(view: View) {
+ yTranslator.expandWithAnimation(view)
+ }
+
+ /**
+ * Used to collapse the [View]
+ */
+ fun forceCollapse(view: View) {
+ yTranslator.collapseWithAnimation(view)
+ }
+
+ /**
+ * Allow this view to be animated.
+ *
+ * @see disableScrolling
+ */
+ fun enableScrolling() {
+ isScrollEnabled = true
+ }
+
+ /**
+ * Disable scrolling of the view irrespective of the intrinsic checks.
+ *
+ * @see enableScrolling
+ */
+ fun disableScrolling() {
+ isScrollEnabled = false
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun tryToScrollVertically(distance: Float) {
+ dynamicScrollView?.let { view ->
+ if (shouldScroll && startedScroll) {
+ yTranslator.translate(view, distance)
+ } else if (engineView?.getInputResultDetail()?.isTouchHandlingUnknown() == false) {
+ // Force expand the view if the user scrolled up, it is not already expanded and
+ // an animation to expand it is not already in progress,
+ // otherwise the user could get stuck in a state where they cannot show the view
+ // See https://github.com/mozilla-mobile/android-components/issues/7101
+ yTranslator.forceExpandIfNotAlready(view, distance)
+ }
+ }
+ }
+
+ /**
+ * Helper function to ease testing.
+ * (Re)Initializes the [BrowserGestureDetector] in a new context.
+ *
+ * Useful in spied behaviors, to ensure callbacks are of the spy and not of the initially created object
+ * if the passed in argument is the result of [createGestureDetector].
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun initGesturesDetector(detector: BrowserGestureDetector) {
+ gesturesDetector = detector
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun createGestureDetector() =
+ BrowserGestureDetector(
+ context!!,
+ BrowserGestureDetector.GesturesListener(
+ onVerticalScroll = ::tryToScrollVertically,
+ onScaleBegin = {
+ // Scale shouldn't animate the view but a small y translation is still possible
+ // because of a previous scroll. Try to be swift about such an in progress animation.
+ yTranslator.snapImmediately(dynamicScrollView)
+ },
+ ),
+ crashReporting = crashReporting,
+ )
+
+ @VisibleForTesting
+ internal fun startNestedScroll(axes: Int, type: Int, view: View): Boolean {
+ return if (shouldScroll && axes == ViewCompat.SCROLL_AXIS_VERTICAL) {
+ startedScroll = true
+ shouldSnapAfterScroll = type == ViewCompat.TYPE_TOUCH
+ yTranslator.cancelInProgressTranslation()
+ true
+ } else if (engineView?.getInputResultDetail()?.isTouchUnhandled() == true) {
+ // Force expand the view if event is unhandled, otherwise user could get stuck in a
+ // state where they cannot show the view
+ yTranslator.cancelInProgressTranslation()
+ yTranslator.expandWithAnimation(view)
+ false
+ } else {
+ false
+ }
+ }
+
+ @VisibleForTesting
+ internal fun stopNestedScroll(type: Int, view: View) {
+ startedScroll = false
+ if (shouldSnapAfterScroll || type == ViewCompat.TYPE_NON_TOUCH) {
+ yTranslator.snapWithAnimation(view)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/ViewYTranslationStrategy.kt b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/ViewYTranslationStrategy.kt
new file mode 100644
index 0000000000..8311f2d21a
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/ViewYTranslationStrategy.kt
@@ -0,0 +1,189 @@
+/* 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.widgets.behavior
+
+import android.animation.ValueAnimator
+import android.view.View
+import android.view.animation.DecelerateInterpolator
+import androidx.annotation.VisibleForTesting
+import kotlin.math.max
+import kotlin.math.min
+
+@VisibleForTesting
+internal const val SNAP_ANIMATION_DURATION = 150L
+
+/**
+ * Helper class with methods for different behaviors for when translating a [View] on the Y axis.
+ */
+internal abstract class ViewYTranslationStrategy {
+ @VisibleForTesting
+ var animator = ValueAnimator().apply {
+ interpolator = DecelerateInterpolator()
+ duration = SNAP_ANIMATION_DURATION
+ }
+
+ /**
+ * Snap the [View] to be collapsed or expanded, depending on whatever state is closer
+ * over a short amount of time.
+ */
+ abstract fun snapWithAnimation(view: View)
+
+ /**
+ * Snap the [View] to be collapsed or expanded, depending on whatever state is closer immediately.
+ */
+ abstract fun snapImmediately(view: View?)
+
+ /**
+ * Translate the [View] to it's full visible height.
+ */
+ abstract fun expandWithAnimation(view: View)
+
+ /**
+ * Force expanding the [View] depending on the [distance] value that should be translated
+ * cancelling any other translation already in progress.
+ */
+ abstract fun forceExpandWithAnimation(view: View, distance: Float)
+
+ /**
+ * Translate the [View] to it's full 0 visible height.
+ */
+ abstract fun collapseWithAnimation(view: View)
+
+ /**
+ * Translate [view] immediately to the specified [distance] amount (positive or negative).
+ */
+ abstract fun translate(view: View, distance: Float)
+
+ /**
+ * Translate [view] to the indicated [targetTranslationY] vaue over a short amount of time.
+ */
+ open fun animateToTranslationY(view: View, targetTranslationY: Float) = with(animator) {
+ addUpdateListener { view.translationY = it.animatedValue as Float }
+ setFloatValues(view.translationY, targetTranslationY)
+ start()
+ }
+
+ /**
+ * Cancel any translation animations currently in progress.
+ */
+ fun cancelInProgressTranslation() = animator.cancel()
+}
+
+/**
+ * Helper class containing methods for translating a [View] on the Y axis
+ * between 0 and [View.getHeight]
+ */
+internal class BottomViewBehaviorStrategy : ViewYTranslationStrategy() {
+ @VisibleForTesting
+ internal var wasLastExpanding = false
+
+ override fun snapWithAnimation(view: View) {
+ if (view.translationY >= (view.height / 2f)) {
+ collapseWithAnimation(view)
+ } else {
+ expandWithAnimation(view)
+ }
+ }
+
+ override fun snapImmediately(view: View?) {
+ if (animator.isStarted) {
+ animator.end()
+ } else {
+ view?.apply {
+ translationY = if (translationY >= height / 2) {
+ height.toFloat()
+ } else {
+ 0f
+ }
+ }
+ }
+ }
+
+ override fun expandWithAnimation(view: View) {
+ animateToTranslationY(view, 0f)
+ }
+
+ override fun forceExpandWithAnimation(view: View, distance: Float) {
+ val shouldExpandToolbar = distance < 0
+ val isToolbarExpanded = view.translationY == 0f
+ if (shouldExpandToolbar && !isToolbarExpanded && !wasLastExpanding) {
+ animator.cancel()
+ expandWithAnimation(view)
+ }
+ }
+
+ override fun collapseWithAnimation(view: View) {
+ animateToTranslationY(view, view.height.toFloat())
+ }
+
+ override fun translate(view: View, distance: Float) {
+ view.translationY =
+ max(0f, min(view.height.toFloat(), view.translationY + distance))
+ }
+
+ override fun animateToTranslationY(view: View, targetTranslationY: Float) {
+ wasLastExpanding = targetTranslationY <= view.translationY
+ super.animateToTranslationY(view, targetTranslationY)
+ }
+}
+
+/**
+ * Helper class containing methods for translating a [View] on the Y axis
+ * between -[View.getHeight] and 0.
+ */
+internal class TopViewBehaviorStrategy : ViewYTranslationStrategy() {
+ @VisibleForTesting
+ internal var wasLastExpanding = false
+
+ override fun snapWithAnimation(view: View) {
+ if (view.translationY >= -(view.height / 2f)) {
+ expandWithAnimation(view)
+ } else {
+ collapseWithAnimation(view)
+ }
+ }
+
+ override fun snapImmediately(view: View?) {
+ if (animator.isStarted) {
+ animator.end()
+ } else {
+ view?.apply {
+ translationY = if (translationY >= -height / 2) {
+ 0f
+ } else {
+ -height.toFloat()
+ }
+ }
+ }
+ }
+
+ override fun expandWithAnimation(view: View) {
+ animateToTranslationY(view, 0f)
+ }
+
+ override fun forceExpandWithAnimation(view: View, distance: Float) {
+ val isExpandingInProgress = animator.isStarted && wasLastExpanding
+ val shouldExpandToolbar = distance < 0
+ val isToolbarExpanded = view.translationY == 0f
+ if (shouldExpandToolbar && !isToolbarExpanded && !isExpandingInProgress) {
+ animator.cancel()
+ expandWithAnimation(view)
+ }
+ }
+
+ override fun collapseWithAnimation(view: View) {
+ animateToTranslationY(view, -view.height.toFloat())
+ }
+
+ override fun translate(view: View, distance: Float) {
+ view.translationY =
+ min(0f, max(-view.height.toFloat(), view.translationY - distance))
+ }
+
+ override fun animateToTranslationY(view: View, targetTranslationY: Float) {
+ wasLastExpanding = targetTranslationY >= view.translationY
+ super.animateToTranslationY(view, targetTranslationY)
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/ViewYTranslator.kt b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/ViewYTranslator.kt
new file mode 100644
index 0000000000..042b810b40
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/ViewYTranslator.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.ui.widgets.behavior
+
+import android.view.View
+import androidx.annotation.VisibleForTesting
+
+/**
+ * Helper class with methods for translating on the Y axis a top / bottom [View].
+ *
+ * @param viewPosition whether the view is displayed immediately at the top of the screen or
+ * immediately at the bottom. This affects how it will be translated:
+ * - if place at the bottom it will be Y translated between 0 and [View.getHeight]
+ * - if place at the top it will be Y translated between -[View.getHeight] and 0
+ */
+class ViewYTranslator(viewPosition: ViewPosition) {
+ @VisibleForTesting
+ internal var strategy = getTranslationStrategy(viewPosition)
+
+ /**
+ * Snap the [View] to be collapsed or expanded, depending on whatever state is closer
+ * over a short amount of time.
+ */
+ internal fun snapWithAnimation(view: View) {
+ strategy.snapWithAnimation(view)
+ }
+
+ /**
+ * Snap the [View] to be collapsed or expanded, depending on whatever state is closer immediately.
+ */
+ fun snapImmediately(view: View?) {
+ strategy.snapImmediately(view)
+ }
+
+ /**
+ * Translate the [View] to it's full visible height over a short amount of time.
+ */
+ internal fun expandWithAnimation(view: View) {
+ strategy.expandWithAnimation(view)
+ }
+
+ /**
+ * Translate the [View] to be hidden from view over a short amount of time.
+ */
+ internal fun collapseWithAnimation(view: View) {
+ strategy.collapseWithAnimation(view)
+ }
+
+ /**
+ * Force expanding the [View] depending on the [distance] value that should be translated
+ * cancelling any other translation already in progress.
+ */
+ fun forceExpandIfNotAlready(view: View, distance: Float) {
+ strategy.forceExpandWithAnimation(view, distance)
+ }
+
+ /**
+ * Translate [view] immediately to the specified [distance] amount (positive or negative).
+ */
+ fun translate(view: View, distance: Float) {
+ strategy.translate(view, distance)
+ }
+
+ /**
+ * Cancel any translation animations currently in progress.
+ */
+ fun cancelInProgressTranslation() {
+ strategy.cancelInProgressTranslation()
+ }
+
+ @VisibleForTesting
+ internal fun getTranslationStrategy(viewPosition: ViewPosition): ViewYTranslationStrategy {
+ return if (viewPosition == ViewPosition.TOP) {
+ TopViewBehaviorStrategy()
+ } else {
+ BottomViewBehaviorStrategy()
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/drawable/mozac_widget_favicon_background.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/drawable/mozac_widget_favicon_background.xml
new file mode 100644
index 0000000000..93e6f01141
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/drawable/mozac_widget_favicon_background.xml
@@ -0,0 +1,17 @@
+<?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/. -->
+<shape
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:shape="rectangle">
+ <corners android:radius="4dp" />
+ <solid
+ android:color="?mozac_widget_favicon_background_color"
+ tools:color="@color/photonWhite" />
+ <stroke
+ android:width="1dp"
+ android:color="?mozac_widget_favicon_border_color"
+ tools:color="@color/photonLightGrey30" />
+</shape>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/drawable/rounded_button_background.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/drawable/rounded_button_background.xml
new file mode 100644
index 0000000000..260a02530d
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/drawable/rounded_button_background.xml
@@ -0,0 +1,18 @@
+<?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/. -->
+<ripple xmlns:android="http://schemas.android.com/apk/res/android"
+ android:color="?android:attr/colorControlHighlight">
+ <item android:id="@android:id/mask">
+ <shape>
+ <solid android:color="#000000" />
+ <corners android:radius="4dp" />
+ </shape>
+ </item>
+ <item>
+ <shape android:shape="rectangle">
+ <corners android:radius="4dp" />
+ </shape>
+ </item>
+</ripple>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/layout/mozac_widget_site_item.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/layout/mozac_widget_site_item.xml
new file mode 100644
index 0000000000..3ddfd7a2c3
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/layout/mozac_widget_site_item.xml
@@ -0,0 +1,77 @@
+<?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/. -->
+<merge
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:minHeight="@dimen/mozac_widget_site_item_height"
+ android:background="?android:attr/selectableItemBackground">
+
+ <FrameLayout
+ android:id="@+id/favicon_wrapper"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginEnd="16dp"
+ app:layout_constraintHorizontal_chainStyle="spread_inside"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintEnd_toStartOf="@id/label">
+ <ImageView
+ android:id="@+id/favicon"
+ style="@style/Mozac.Widgets.Favicon"
+ android:importantForAccessibility="no"
+ tools:src="@android:drawable/ic_secure" />
+ </FrameLayout>
+
+ <TextView
+ android:id="@+id/label"
+ style="@style/Mozac.Widgets.SiteItem.Label"
+ android:layout_width="0dp"
+ tools:textColor="#20123A"
+ tools:text="Example site"
+ app:layout_goneMarginEnd="16dp"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintBottom_toTopOf="@id/caption"
+ app:layout_constraintStart_toEndOf="@id/favicon_wrapper"
+ app:layout_constraintEnd_toStartOf="@id/secondary_button"
+ app:layout_constraintVertical_chainStyle="packed" />
+
+ <TextView
+ android:id="@+id/caption"
+ style="@style/Mozac.Widgets.SiteItem.Caption"
+ android:layout_width="0dp"
+ android:layout_marginTop="2dp"
+ tools:text="https://example.com/"
+ tools:textColor="@color/photonLightGrey90"
+ app:layout_constraintEnd_toEndOf="@id/label"
+ app:layout_constraintStart_toStartOf="@id/label"
+ app:layout_constraintTop_toBottomOf="@id/label"
+ app:layout_constraintBottom_toBottomOf="parent" />
+
+ <ImageButton
+ android:id="@+id/secondary_button"
+ android:layout_width="@dimen/mozac_widget_site_item_secondary_button_size"
+ android:layout_height="@dimen/mozac_widget_site_item_secondary_button_size"
+ android:padding="@dimen/mozac_widget_site_item_secondary_button_padding"
+ android:layout_marginStart="12dp"
+ android:layout_marginEnd="12dp"
+ android:background="?android:attr/selectableItemBackgroundBorderless"
+ android:visibility="gone"
+ tools:visibility="visible"
+ tools:src="@drawable/mozac_ic_ellipsis_vertical_24"
+ tools:ignore="ContentDescription"
+ tools:tint="#20123A"
+ app:tint="?attr/mozac_primary_text_color"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toEndOf="@id/label"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+</merge>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-am/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-am/strings.xml
new file mode 100644
index 0000000000..cdde258cef
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-am/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">ምስል ወደ ቅንጥብ ሰሌዳ ተቀድቷል</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-ar/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ar/strings.xml
new file mode 100644
index 0000000000..eb2045ed67
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ar/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">نُسخت الصورة إلى الحافظة</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-ast/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ast/strings.xml
new file mode 100644
index 0000000000..013fbec0b8
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ast/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">La imaxe copióse al cartafueyu</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-azb/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-azb/strings.xml
new file mode 100644
index 0000000000..706d647459
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-azb/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">عکس کلیپ‌بوردا کوپی اولدو</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-be/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-be/strings.xml
new file mode 100644
index 0000000000..7096e05e05
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-be/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Відарыс скапіяваны ў буфер абмену</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-bg/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-bg/strings.xml
new file mode 100644
index 0000000000..9c2defef43
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-bg/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Изображението е копирано</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-br/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-br/strings.xml
new file mode 100644
index 0000000000..3ae6d8340a
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-br/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Skeudenn eilet er golver</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-bs/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-bs/strings.xml
new file mode 100644
index 0000000000..b0e64d0437
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-bs/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Slika je kopirana u privremenu memoriju</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-ca/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ca/strings.xml
new file mode 100644
index 0000000000..75a4596b48
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ca/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">S’ha copiat la imatge al porta-retalls</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-cak/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-cak/strings.xml
new file mode 100644
index 0000000000..dee19cb2a1
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-cak/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Xwachib\'ëx ri wachib\'äl pa molwuj</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-co/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-co/strings.xml
new file mode 100644
index 0000000000..dc5ae642c1
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-co/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Fiura cupiata in u preme’papei</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-cs/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-cs/strings.xml
new file mode 100644
index 0000000000..039f1848dc
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-cs/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Obrázek zkopírován do schránky</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-cy/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-cy/strings.xml
new file mode 100644
index 0000000000..274e4c8a92
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-cy/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Copïwyd delwedd i’r clipfwrdd</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-da/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-da/strings.xml
new file mode 100644
index 0000000000..1e8162dcf1
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-da/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Billede kopieret til udklipsholder</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-de/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000000..a6f23ccb4a
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-de/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Grafik in Zwischenablage kopiert</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-dsb/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-dsb/strings.xml
new file mode 100644
index 0000000000..5119090d13
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-dsb/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Wobraz jo se kopěrował do mjazywótkłada</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-el/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-el/strings.xml
new file mode 100644
index 0000000000..57da3ec96d
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-el/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Η εικόνα αντιγράφτηκε στο πρόχειρο</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-en-rCA/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-en-rCA/strings.xml
new file mode 100644
index 0000000000..638ee8543e
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-en-rCA/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Image copied to clipboard</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-en-rGB/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-en-rGB/strings.xml
new file mode 100644
index 0000000000..638ee8543e
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-en-rGB/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Image copied to clipboard</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-eo/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-eo/strings.xml
new file mode 100644
index 0000000000..9cc052f510
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-eo/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Bildo kopiita al la tondujo</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rAR/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rAR/strings.xml
new file mode 100644
index 0000000000..06f644e903
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rAR/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Imagen copiada al portapapeles</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rCL/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rCL/strings.xml
new file mode 100644
index 0000000000..06f644e903
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rCL/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Imagen copiada al portapapeles</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rES/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rES/strings.xml
new file mode 100644
index 0000000000..06f644e903
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rES/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Imagen copiada al portapapeles</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rMX/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rMX/strings.xml
new file mode 100644
index 0000000000..06f644e903
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-es-rMX/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Imagen copiada al portapapeles</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-es/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-es/strings.xml
new file mode 100644
index 0000000000..06f644e903
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-es/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Imagen copiada al portapapeles</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-et/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-et/strings.xml
new file mode 100644
index 0000000000..b77f8fe2fc
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-et/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Pilt kopeeriti vahemällu</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-eu/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-eu/strings.xml
new file mode 100644
index 0000000000..18039c3b03
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-eu/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Irudia arbelean kopiatu da</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-fa/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-fa/strings.xml
new file mode 100644
index 0000000000..4d9c243f3c
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-fa/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">تصویر به تخته‌گیره رونوشت شد</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-fi/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-fi/strings.xml
new file mode 100644
index 0000000000..426de77fe1
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-fi/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Kuva kopioitu leikepöydälle</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-fr/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-fr/strings.xml
new file mode 100644
index 0000000000..c41cec7a23
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-fr/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Image copiée dans le presse-papiers</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-fur/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-fur/strings.xml
new file mode 100644
index 0000000000..5fb71c5b51
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-fur/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Imagjin copiade intes notis</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-fy-rNL/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-fy-rNL/strings.xml
new file mode 100644
index 0000000000..db7c6fa54d
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-fy-rNL/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Ofbylding nei klamboerd kopiearre</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-gd/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-gd/strings.xml
new file mode 100644
index 0000000000..a2c04bd312
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-gd/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Chaidh lethbhreac dhen dealbh a chur air an stòr-bhòrd</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-gl/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-gl/strings.xml
new file mode 100644
index 0000000000..3e7252778d
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-gl/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Copiouse a imaxe ao portapapeis</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-gn/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-gn/strings.xml
new file mode 100644
index 0000000000..af5e3ba7cb
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-gn/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Embohasa ta’ãnga kuatiajokohápe</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-hr/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-hr/strings.xml
new file mode 100644
index 0000000000..dbfcb2d809
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-hr/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Slika je kopirana u međuspremnik</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-hsb/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-hsb/strings.xml
new file mode 100644
index 0000000000..473527a6cd
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-hsb/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Wobraz je so do mjezyskłada kopěrował</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-hu/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-hu/strings.xml
new file mode 100644
index 0000000000..0ca081817d
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-hu/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Kép vágólapra másolva</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-hy-rAM/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-hy-rAM/strings.xml
new file mode 100644
index 0000000000..4567efaed4
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-hy-rAM/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Պատկերը պատճենվել է սեղմատախտակին</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-ia/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ia/strings.xml
new file mode 100644
index 0000000000..6b8f02500b
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ia/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Imagine copiate al area de transferentia</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-in/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-in/strings.xml
new file mode 100644
index 0000000000..4b6807e51e
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-in/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Gambar disalin ke papan klip</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-is/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-is/strings.xml
new file mode 100644
index 0000000000..c2e2bb5714
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-is/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Mynd afrituð á klippispjald</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-it/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-it/strings.xml
new file mode 100644
index 0000000000..79c58ec52e
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-it/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Immagine copiata negli appunti</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-iw/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-iw/strings.xml
new file mode 100644
index 0000000000..85bf7a942a
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-iw/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">התמונה הועתקה ללוח</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-ja/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ja/strings.xml
new file mode 100644
index 0000000000..805eacfc87
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ja/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">画像をクリップボードにコピーしました</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-ka/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ka/strings.xml
new file mode 100644
index 0000000000..8de04f6263
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ka/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">სურათის ასლი აღებულია</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-kaa/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-kaa/strings.xml
new file mode 100644
index 0000000000..cb2c90ba5b
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-kaa/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Súwret almasıw buferine kóshirip alındı</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-kab/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-kab/strings.xml
new file mode 100644
index 0000000000..093f1d2616
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-kab/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Tugna tettwanɣel ɣef wafus</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-kk/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-kk/strings.xml
new file mode 100644
index 0000000000..80b99ef483
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-kk/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Сурет алмасу буферіне көшірілді</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-kmr/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-kmr/strings.xml
new file mode 100644
index 0000000000..b299c8f6b7
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-kmr/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Wêne li panoyê hate kopîkirin</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-ko/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ko/strings.xml
new file mode 100644
index 0000000000..5d6ebf83e7
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ko/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">클립보드에 이미지 복사됨</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-lo/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-lo/strings.xml
new file mode 100644
index 0000000000..ab066dba6f
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-lo/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">ສຳເນົາຮູບໃສ່ຄລິບບອດແລ້ວ</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-nb-rNO/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-nb-rNO/strings.xml
new file mode 100644
index 0000000000..bef36125f1
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-nb-rNO/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Bilde kopiert til utklippstavlen</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-nl/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-nl/strings.xml
new file mode 100644
index 0000000000..06070807bb
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-nl/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Afbeelding naar klembord gekopieerd</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-nn-rNO/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-nn-rNO/strings.xml
new file mode 100644
index 0000000000..16c41f8d38
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-nn-rNO/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Bilde kopiert til utklippstavla</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-oc/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-oc/strings.xml
new file mode 100644
index 0000000000..131c779121
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-oc/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Imatge copiat al quichapapièrs</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-pa-rIN/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-pa-rIN/strings.xml
new file mode 100644
index 0000000000..03300db934
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-pa-rIN/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">ਚਿੱਤਰ ਨੂੰ ਕਲਿੱਪਬੋਰਡ ਲਈ ਕਾਪੀ ਕੀਤਾ</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-pa-rPK/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-pa-rPK/strings.xml
new file mode 100644
index 0000000000..cb4e8c879c
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-pa-rPK/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">کاپی کیتی گئی</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-pl/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-pl/strings.xml
new file mode 100644
index 0000000000..8a79fb5c5e
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-pl/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Skopiowano obraz do schowka</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-pt-rBR/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-pt-rBR/strings.xml
new file mode 100644
index 0000000000..223e60bc6a
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-pt-rBR/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Imagem copiada para área de transferência</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-pt-rPT/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-pt-rPT/strings.xml
new file mode 100644
index 0000000000..48dc017c65
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-pt-rPT/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Imagem copiada para a área de transferência</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-rm/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-rm/strings.xml
new file mode 100644
index 0000000000..8b428f02b1
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-rm/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Copià il maletg en l\'archiv provisoric</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-ro/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ro/strings.xml
new file mode 100644
index 0000000000..be7fc12e65
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ro/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Imaginea a fost copiată în clipboard</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-ru/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ru/strings.xml
new file mode 100644
index 0000000000..35ac3be80a
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ru/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Изображение скопировано в буфер обмена</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-sat/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sat/strings.xml
new file mode 100644
index 0000000000..d945ea02c5
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sat/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">ᱨᱮᱴᱚᱯᱵᱚᱰ ᱨᱮ ᱪᱤᱛᱟᱹᱨ ᱱᱚᱠᱚᱞ ᱮᱱᱟ</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-sc/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sc/strings.xml
new file mode 100644
index 0000000000..6d122f7e8b
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sc/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Immàgine copiada in punta de billete</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-si/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-si/strings.xml
new file mode 100644
index 0000000000..95d46d5ddd
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-si/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">රූපය පසුරුපුවරුවට පිටපත් විය</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-sk/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sk/strings.xml
new file mode 100644
index 0000000000..02dc6628cb
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sk/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Obrázok bol skopírovaný do schránky</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-skr/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-skr/strings.xml
new file mode 100644
index 0000000000..da370ffcb7
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-skr/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">تصویر کلپ بورڈ تے نقل تھی ڳئی</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-sl/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sl/strings.xml
new file mode 100644
index 0000000000..b3a3418289
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sl/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Slika kopirana v odložišče</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-sq/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sq/strings.xml
new file mode 100644
index 0000000000..b76b67a352
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sq/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Figura u kopjua në të papastër</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-sr/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sr/strings.xml
new file mode 100644
index 0000000000..f0fbe5c485
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sr/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Слика је копирана у привремену меморију</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-su/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-su/strings.xml
new file mode 100644
index 0000000000..91a37e77b6
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-su/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Gambar ditiron kana papan klip</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-sv-rSE/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sv-rSE/strings.xml
new file mode 100644
index 0000000000..51210de628
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-sv-rSE/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Bilden har kopierats till urklipp</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-tg/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-tg/strings.xml
new file mode 100644
index 0000000000..b304da28c1
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-tg/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Тасвир ба ҳофизаи муваққатӣ нусха бардошта шуд</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-th/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-th/strings.xml
new file mode 100644
index 0000000000..52a77b64dc
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-th/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">คัดลอกภาพไปยังคลิปบอร์ดแล้ว</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-tr/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-tr/strings.xml
new file mode 100644
index 0000000000..b0ef721f12
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-tr/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Resim panoya kopyalandı</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-trs/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-trs/strings.xml
new file mode 100644
index 0000000000..d905bed3df
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-trs/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Ñadū’hua ngà nanun riña portapapel</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-tt/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-tt/strings.xml
new file mode 100644
index 0000000000..ad4402d83c
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-tt/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Рәсем алмашу буферына копияләнде</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-ug/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ug/strings.xml
new file mode 100644
index 0000000000..2719590251
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-ug/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">سۈرەت چاپلاش تاختىسىغا كۆچۈرۈلدى</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-uk/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-uk/strings.xml
new file mode 100644
index 0000000000..49cffe6ba2
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-uk/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Зображення скопійовано в буфер обміну</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-vi/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-vi/strings.xml
new file mode 100644
index 0000000000..8127c7855b
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-vi/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Đã sao chép ảnh vào khay nhớ tạm</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-zh-rCN/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000000..98d08c5c6a
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">图像已复制到剪贴板</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values-zh-rTW/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values-zh-rTW/strings.xml
new file mode 100644
index 0000000000..39326e4ac0
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">已將圖片複製至剪貼簿</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values/attrs.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values/attrs.xml
new file mode 100644
index 0000000000..60a3b3a35f
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values/attrs.xml
@@ -0,0 +1,19 @@
+<?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>
+ <attr name="mozac_font_semibold" format="reference" />
+ <attr name="mozac_accent" format="reference" />
+ <attr name="mozac_contrast_text" format="reference" />
+
+ <!-- Background color for favicon widget box -->
+ <attr name="mozac_widget_favicon_background_color" format="reference" />
+ <!-- Border color for favicon widget box -->
+ <attr name="mozac_widget_favicon_border_color" format="reference" />
+
+ <!-- Label color and icon button tint for site item widget -->
+ <attr name="mozac_primary_text_color" format="reference" />
+ <!-- Caption color for site item widget -->
+ <attr name="mozac_caption_text_color" format="reference" />
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values/colors.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..105264e605
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values/colors.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>
+ <!-- Button Colors -->
+ <color name="mozac_grey_button_color">#E0E0E6</color>
+ <color name="mozac_destructive_button_text_color">#C50042</color>
+ <color name="mozac_grey_button_text_color">#312A65</color>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values/dimens.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values/dimens.xml
new file mode 100644
index 0000000000..9f6d4587fd
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values/dimens.xml
@@ -0,0 +1,14 @@
+<?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>
+ <dimen name="mozac_widget_favicon_size">40dp</dimen>
+ <dimen name="mozac_widget_favicon_padding">8dp</dimen>
+
+ <dimen name="mozac_widget_site_item_height">56dp</dimen>
+ <dimen name="mozac_widget_site_item_label_text_size">16sp</dimen>
+ <dimen name="mozac_widget_site_item_caption_text_size">12sp</dimen>
+ <dimen name="mozac_widget_site_item_secondary_button_size">32dp</dimen>
+ <dimen name="mozac_widget_site_item_secondary_button_padding">4dp</dimen>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values/strings.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..6a82d7eaef
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values/strings.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>
+ <!-- Text for confirmation "snackbar" shown after copying an image to the clipboard. -->
+ <string name="snackbar_copy_image_to_clipboard_confirmation">Image copied to clipboard</string>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/main/res/values/styles.xml b/mobile/android/android-components/components/ui/widgets/src/main/res/values/styles.xml
new file mode 100644
index 0000000000..515b98e12e
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/main/res/values/styles.xml
@@ -0,0 +1,63 @@
+<?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 xmlns:android="http://schemas.android.com/apk/res/android">
+ <style name="Mozac.Widgets.TestTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
+ <item name="mozac_widget_favicon_background_color">@color/photonWhite</item>
+ <item name="mozac_widget_favicon_border_color">@color/photonLightGrey30</item>
+ <item name="mozac_primary_text_color">@color/photonInk90</item>
+ <item name="mozac_caption_text_color">@color/photonInk50</item>
+ </style>
+
+ <!-- Button styling -->
+ <style name="Mozac.Widgets.NeutralButton" parent="Widget.MaterialComponents.Button.TextButton">
+ <item name="iconTint">@color/mozac_grey_button_text_color</item>
+ <item name="iconPadding">8dp</item>
+ <item name="iconGravity">textStart</item>
+ <item name="android:textAlignment">center</item>
+ <item name="android:background">@drawable/rounded_button_background</item>
+ <item name="android:layout_width">match_parent</item>
+ <item name="android:layout_height">48dp</item>
+ <item name="android:textStyle">bold</item>
+ <item name="android:textAllCaps">false</item>
+ <item name="backgroundTint">@color/mozac_grey_button_color</item>
+ <item name="android:textColor">@color/mozac_grey_button_text_color</item>
+ <item name="android:letterSpacing">0</item>
+ <item name="fontFamily">?mozac_font_semibold</item>
+ </style>
+
+ <style name="Mozac.Widgets.DestructiveButton" parent="Mozac.Widgets.NeutralButton">
+ <item name="iconTint">@color/mozac_destructive_button_text_color</item>
+ <item name="android:textColor">@color/mozac_destructive_button_text_color</item>
+ </style>
+
+ <style name="Mozac.Widgets.PositiveButton" parent="Mozac.Widgets.NeutralButton">
+ <item name="backgroundTint">?mozac_accent</item>
+ <item name="iconTint">?mozac_contrast_text</item>
+ <item name="android:textColor">?mozac_contrast_text</item>
+ </style>
+
+ <!-- Favicon styling -->
+ <style name="Mozac.Widgets.Favicon" parent="">
+ <item name="android:layout_width">@dimen/mozac_widget_favicon_size</item>
+ <item name="android:layout_height">@dimen/mozac_widget_favicon_size</item>
+ <item name="android:scaleType">fitCenter</item>
+ <item name="android:padding">@dimen/mozac_widget_favicon_padding</item>
+ <item name="android:background">@drawable/mozac_widget_favicon_background</item>
+ </style>
+
+ <style name="Mozac.Widgets.SiteItem.Label" parent="">
+ <item name="android:layout_height">wrap_content</item>
+ <item name="android:ellipsize">end</item>
+ <item name="android:singleLine">true</item>
+ <item name="android:textAlignment">viewStart</item>
+ <item name="android:textSize">@dimen/mozac_widget_site_item_label_text_size</item>
+ <item name="android:textColor">?attr/mozac_primary_text_color</item>
+ </style>
+ <style name="Mozac.Widgets.SiteItem.Caption" parent="Mozac.Widgets.SiteItem.Label">
+ <item name="android:textSize">@dimen/mozac_widget_site_item_caption_text_size</item>
+ <item name="android:textColor">?attr/mozac_caption_text_color</item>
+ </style>
+</resources>
diff --git a/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/TestUtils.kt b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/TestUtils.kt
new file mode 100644
index 0000000000..cda854782e
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/TestUtils.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.ui.widgets
+
+import android.view.MotionEvent
+
+object TestUtils {
+ fun getMotionEvent(
+ action: Int,
+ x: Float = 0f,
+ y: Float = 0f,
+ eventTime: Long = System.currentTimeMillis(),
+ previousEvent: MotionEvent? = null,
+ ): MotionEvent {
+ val downTime = previousEvent?.downTime ?: System.currentTimeMillis()
+
+ var pointerCount = previousEvent?.pointerCount ?: 0
+ if (action == MotionEvent.ACTION_POINTER_DOWN) {
+ pointerCount++
+ } else if (action == MotionEvent.ACTION_DOWN) {
+ pointerCount = 1
+ } else if (previousEvent?.action == MotionEvent.ACTION_POINTER_UP) {
+ pointerCount--
+ }
+
+ val properties = Array(
+ pointerCount,
+ TestUtils::getPointerProperties,
+ )
+ val pointerCoords =
+ getPointerCoords(
+ x,
+ y,
+ pointerCount,
+ previousEvent,
+ )
+
+ return MotionEvent.obtain(
+ downTime, eventTime,
+ action, pointerCount, properties,
+ pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0,
+ )
+ }
+
+ private fun getPointerCoords(
+ x: Float,
+ y: Float,
+ pointerCount: Int,
+ previousEvent: MotionEvent? = null,
+ ): Array<MotionEvent.PointerCoords?> {
+ val currentEventCoords = MotionEvent.PointerCoords().apply {
+ this.x = x; this.y = y; pressure = 1f; size = 1f
+ }
+
+ return if (pointerCount > 1 && previousEvent != null) {
+ arrayOf(
+ MotionEvent.PointerCoords().apply {
+ this.x = previousEvent.x; this.y = previousEvent.y; pressure = 1f; size = 1f
+ },
+ currentEventCoords,
+ )
+ } else {
+ arrayOf(currentEventCoords)
+ }
+ }
+
+ private fun getPointerProperties(id: Int): MotionEvent.PointerProperties =
+ MotionEvent.PointerProperties().apply {
+ this.id = id; this.toolType = MotionEvent.TOOL_TYPE_FINGER
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/VerticalSwipeRefreshLayoutTest.kt b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/VerticalSwipeRefreshLayoutTest.kt
new file mode 100644
index 0000000000..b06e67be3f
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/VerticalSwipeRefreshLayoutTest.kt
@@ -0,0 +1,430 @@
+/* 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.widgets
+
+import android.view.MotionEvent
+import android.view.MotionEvent.ACTION_CANCEL
+import android.view.MotionEvent.ACTION_DOWN
+import android.view.MotionEvent.ACTION_MOVE
+import android.view.MotionEvent.ACTION_POINTER_DOWN
+import android.view.MotionEvent.ACTION_POINTER_UP
+import android.view.MotionEvent.ACTION_UP
+import android.view.View
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.ui.widgets.VerticalSwipeRefreshLayout.QuickScaleEvents
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class VerticalSwipeRefreshLayoutTest {
+ private lateinit var swipeLayout: VerticalSwipeRefreshLayout
+
+ @Before
+ fun setup() {
+ swipeLayout = VerticalSwipeRefreshLayout(testContext)
+ }
+
+ @Test
+ fun `onInterceptTouchEvent should abort pull to refresh and return false if the View is disabled`() {
+ swipeLayout = spy(swipeLayout)
+ val secondFingerEvent = TestUtils.getMotionEvent(ACTION_POINTER_DOWN)
+
+ swipeLayout.isEnabled = false
+ assertFalse(swipeLayout.onInterceptTouchEvent(secondFingerEvent))
+ verify(swipeLayout, times(0)).callSuperOnInterceptTouchEvent(secondFingerEvent)
+ }
+
+ @Test
+ fun `onInterceptTouchEvent should abort pull to refresh and return false if the motion was multitouch`() {
+ swipeLayout.isEnabled = true
+ swipeLayout.setOnChildScrollUpCallback { _, _ -> false }
+
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+ val moveEvent = TestUtils.getMotionEvent(ACTION_MOVE, x = 0f, y = 5f, previousEvent = downEvent)
+ val secondFingerEvent = TestUtils.getMotionEvent(ACTION_POINTER_DOWN, previousEvent = moveEvent)
+ val secondFingerNextEvent =
+ TestUtils.getMotionEvent(ACTION_MOVE, x = 0f, y = 5f, previousEvent = secondFingerEvent)
+ val secondFingerUp = TestUtils.getMotionEvent(ACTION_POINTER_UP, previousEvent = secondFingerNextEvent)
+ val upEvent = TestUtils.getMotionEvent(ACTION_UP, previousEvent = secondFingerUp)
+ val newDownEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+
+ swipeLayout.onInterceptTouchEvent(downEvent)
+ assertFalse(swipeLayout.onInterceptTouchEvent(moveEvent))
+ assertFalse(swipeLayout.hadMultiTouch)
+ assertFalse(swipeLayout.onInterceptTouchEvent(secondFingerEvent))
+ assertTrue(swipeLayout.hadMultiTouch)
+ assertFalse(swipeLayout.onInterceptTouchEvent(secondFingerNextEvent))
+ assertTrue(swipeLayout.hadMultiTouch)
+ assertFalse(swipeLayout.onInterceptTouchEvent(secondFingerUp))
+ assertTrue(swipeLayout.hadMultiTouch)
+ assertFalse(swipeLayout.onInterceptTouchEvent(upEvent))
+ assertTrue(swipeLayout.hadMultiTouch)
+ assertFalse(swipeLayout.onInterceptTouchEvent(newDownEvent))
+ assertFalse(swipeLayout.hadMultiTouch)
+ }
+
+ @Test
+ fun `onInterceptTouchEvent should abort pull to refresh and return false if zoom is in progress`() {
+ swipeLayout = spy(swipeLayout)
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN, 0f, 0f)
+ val pointerDownEvent =
+ TestUtils.getMotionEvent(ACTION_POINTER_DOWN, 200f, 200f, previousEvent = downEvent)
+ swipeLayout.isEnabled = true
+ swipeLayout.setOnChildScrollUpCallback { _, _ -> true }
+
+ swipeLayout.onInterceptTouchEvent(downEvent)
+ verify(swipeLayout, times(1)).callSuperOnInterceptTouchEvent(downEvent)
+
+ swipeLayout.onInterceptTouchEvent(pointerDownEvent)
+ assertFalse(swipeLayout.onInterceptTouchEvent(pointerDownEvent))
+ verify(swipeLayout, times(0)).callSuperOnInterceptTouchEvent(pointerDownEvent)
+ }
+
+ @Test
+ fun `onInterceptTouchEvent should cleanup if ACTION_CANCEL`() {
+ swipeLayout = spy(swipeLayout)
+ val cancelEvent = TestUtils.getMotionEvent(
+ ACTION_CANCEL,
+ previousEvent = TestUtils.getMotionEvent(ACTION_DOWN),
+ )
+ swipeLayout.isEnabled = true
+ swipeLayout.setOnChildScrollUpCallback { _, _ -> true }
+
+ swipeLayout.onInterceptTouchEvent(cancelEvent)
+
+ verify(swipeLayout).forgetQuickScaleEvents()
+ verify(swipeLayout).callSuperOnInterceptTouchEvent(cancelEvent)
+ }
+
+ @Test
+ fun `onInterceptTouchEvent should cleanup if quick scale ended`() {
+ swipeLayout = spy(swipeLayout)
+ val upEvent = TestUtils.getMotionEvent(
+ ACTION_CANCEL,
+ previousEvent = TestUtils.getMotionEvent(ACTION_DOWN),
+ )
+ swipeLayout.isEnabled = true
+ swipeLayout.isQuickScaleInProgress = true
+ swipeLayout.setOnChildScrollUpCallback { _, _ -> true }
+
+ swipeLayout.onInterceptTouchEvent(upEvent)
+
+ verify(swipeLayout).forgetQuickScaleEvents()
+ verify(swipeLayout).callSuperOnInterceptTouchEvent(upEvent)
+ }
+
+ @Test
+ fun `onInterceptTouchEvent should disable pull to refresh if quick scale is in progress`() {
+ // default DOUBLE_TAP_TIMEOUT is 300ms
+
+ swipeLayout = spy(swipeLayout)
+ val firstDownEvent = TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 100)
+ val upEvent =
+ TestUtils.getMotionEvent(ACTION_UP, eventTime = 200, previousEvent = firstDownEvent)
+ val newDownEvent =
+ TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 500, previousEvent = upEvent)
+ val previousEvents = QuickScaleEvents(firstDownEvent, upEvent, null)
+ swipeLayout.quickScaleEvents = previousEvents
+ swipeLayout.isQuickScaleInProgress = false
+
+ assertFalse(swipeLayout.onInterceptTouchEvent(newDownEvent))
+ assertTrue(swipeLayout.isQuickScaleInProgress)
+ verify(swipeLayout).maybeAddDoubleTapEvent(newDownEvent)
+ verify(swipeLayout, times(0)).callSuperOnInterceptTouchEvent(newDownEvent)
+ }
+
+ @Test
+ fun `onInterceptTouchEvent should disable pull to refresh if move was more on the x axys`() {
+ // default DOUBLE_TAP_TIMEOUT is 300ms
+
+ swipeLayout = spy(swipeLayout)
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN, x = 0f, y = 0f, eventTime = 0)
+ val moveEvent = TestUtils.getMotionEvent(
+ ACTION_MOVE,
+ x = 1f,
+ y = 0f,
+ eventTime = 100,
+ previousEvent = downEvent,
+ )
+ swipeLayout.isEnabled = true
+ swipeLayout.isQuickScaleInProgress = false
+ swipeLayout.setOnChildScrollUpCallback { _, _ -> false }
+
+ swipeLayout.onInterceptTouchEvent(downEvent)
+ verify(swipeLayout).callSuperOnInterceptTouchEvent(downEvent)
+
+ assertFalse(swipeLayout.onInterceptTouchEvent(moveEvent))
+ verify(swipeLayout, times(0)).callSuperOnInterceptTouchEvent(moveEvent)
+ }
+
+ @Test
+ fun `onInterceptTouchEvent should allow pull to refresh if move was more on the y axys`() {
+ // default DOUBLE_TAP_TIMEOUT is 300ms
+
+ swipeLayout = spy(swipeLayout)
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN, x = 0f, y = 0f, eventTime = 0)
+ val moveEvent = TestUtils.getMotionEvent(
+ ACTION_MOVE,
+ x = 0f,
+ y = 1f,
+ eventTime = 100,
+ previousEvent = downEvent,
+ )
+ swipeLayout.isEnabled = true
+ swipeLayout.isQuickScaleInProgress = false
+ swipeLayout.setOnChildScrollUpCallback { _, _ -> false }
+
+ swipeLayout.onInterceptTouchEvent(downEvent)
+ verify(swipeLayout).callSuperOnInterceptTouchEvent(downEvent)
+
+ swipeLayout.onInterceptTouchEvent(moveEvent)
+ verify(swipeLayout).callSuperOnInterceptTouchEvent(moveEvent)
+ }
+
+ @Test
+ fun `Should not respond descendants initiated scrolls if this View is enabled`() {
+ swipeLayout = spy(swipeLayout)
+ val childView: View = mock()
+ val targetView: View = mock()
+ val scrollAxis = 0
+
+ swipeLayout.isEnabled = true
+
+ assertFalse(swipeLayout.onStartNestedScroll(childView, targetView, scrollAxis))
+ verify(swipeLayout, times(0)).callSuperOnStartNestedScroll(
+ childView,
+ targetView,
+ scrollAxis,
+ )
+ }
+
+ @Test
+ fun `Should delegate super#onStartNestedScroll if this View is not enabled`() {
+ swipeLayout = spy(swipeLayout)
+ val childView: View = mock()
+ val targetView: View = mock()
+ val scrollAxis = 0
+
+ swipeLayout.isEnabled = false
+ swipeLayout.onStartNestedScroll(childView, targetView, scrollAxis)
+
+ verify(swipeLayout).callSuperOnStartNestedScroll(childView, targetView, scrollAxis)
+ }
+
+ @Test
+ fun `maybeAddDoubleTapEvent should not modify quickScaleEvents if not for ACTION_DOWN or ACTION_UP`() {
+ val emptyListOfEvents = QuickScaleEvents()
+ swipeLayout.quickScaleEvents = emptyListOfEvents
+
+ swipeLayout.maybeAddDoubleTapEvent(TestUtils.getMotionEvent(ACTION_POINTER_DOWN))
+
+ assertEquals(emptyListOfEvents, swipeLayout.quickScaleEvents)
+ }
+
+ @Test
+ fun `maybeAddDoubleTapEvent will add ACTION_UP as second event if there is already one event in sequence`() {
+ val firstEvent = spy(TestUtils.getMotionEvent(ACTION_DOWN))
+ val secondEvent =
+ spy(TestUtils.getMotionEvent(ACTION_UP, eventTime = 133, previousEvent = firstEvent))
+ val expectedResult = Triple<MotionEvent?, MotionEvent?, MotionEvent?>(
+ firstEvent,
+ secondEvent,
+ null,
+ )
+ swipeLayout.quickScaleEvents = QuickScaleEvents(firstEvent, null, null)
+
+ swipeLayout.maybeAddDoubleTapEvent(secondEvent)
+
+ // A Triple assert or MotionEvent assert fails probably because of the copies made
+ // Verifying the expected actions and eventTime should be good enough.
+ assertEquals(expectedResult.first, swipeLayout.quickScaleEvents.firstDownEvent)
+ assertEquals(
+ expectedResult.second!!.actionMasked,
+ swipeLayout.quickScaleEvents.upEvent!!.actionMasked,
+ )
+ assertEquals(
+ expectedResult.second!!.eventTime,
+ swipeLayout.quickScaleEvents.upEvent!!.eventTime,
+ )
+ assertEquals(null, swipeLayout.quickScaleEvents.secondDownEvent)
+ }
+
+ @Test
+ fun `maybeAddDoubleTapEvent will not add ACTION_UP if there is not a first event already in sequence`() {
+ val firstEvent = spy(TestUtils.getMotionEvent(ACTION_DOWN))
+ val secondEvent =
+ spy(TestUtils.getMotionEvent(ACTION_UP, eventTime = 133, previousEvent = firstEvent))
+ val expectedResult = QuickScaleEvents()
+ swipeLayout.quickScaleEvents = expectedResult
+
+ swipeLayout.maybeAddDoubleTapEvent(secondEvent)
+
+ assertEquals(null, swipeLayout.quickScaleEvents.firstDownEvent)
+ assertEquals(null, swipeLayout.quickScaleEvents.upEvent)
+ assertEquals(null, swipeLayout.quickScaleEvents.secondDownEvent)
+ }
+
+ @Test
+ fun `maybeAddDoubleTapEvent will add the first ACTION_DOWN if the events list is otherwise empty`() {
+ swipeLayout = spy(swipeLayout)
+ val emptyListOfEvents = QuickScaleEvents()
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 133)
+ swipeLayout.quickScaleEvents = emptyListOfEvents
+
+ swipeLayout.maybeAddDoubleTapEvent(downEvent)
+
+ verify(swipeLayout).forgetQuickScaleEvents()
+ assertEquals(downEvent.actionMasked, swipeLayout.quickScaleEvents.firstDownEvent!!.actionMasked)
+ assertEquals(downEvent.eventTime, swipeLayout.quickScaleEvents.firstDownEvent!!.eventTime)
+ assertEquals(null, swipeLayout.quickScaleEvents.upEvent)
+ assertEquals(null, swipeLayout.quickScaleEvents.secondDownEvent)
+ }
+
+ @Test
+ fun `maybeAddDoubleTapEvent will reset the first ACTION_DOWN if the events list does not contain other events`() {
+ swipeLayout = spy(swipeLayout)
+ val previousDownEvent = TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 111)
+ val previousEvents = QuickScaleEvents(previousDownEvent, null, null)
+ val newDownEvent = TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 222)
+ swipeLayout.quickScaleEvents = previousEvents
+
+ swipeLayout.maybeAddDoubleTapEvent(newDownEvent)
+
+ verify(swipeLayout).forgetQuickScaleEvents()
+ assertEquals(newDownEvent.actionMasked, swipeLayout.quickScaleEvents.firstDownEvent!!.actionMasked)
+ assertEquals(newDownEvent.eventTime, swipeLayout.quickScaleEvents.firstDownEvent!!.eventTime)
+ assertEquals(null, swipeLayout.quickScaleEvents.upEvent)
+ assertEquals(null, swipeLayout.quickScaleEvents.secondDownEvent)
+ }
+
+ @Test
+ fun `maybeAddDoubleTapEvent will reset ACTION_DOWN if timeout was reached`() {
+ // default DOUBLE_TAP_TIMEOUT is 300ms
+
+ swipeLayout = spy(swipeLayout)
+ val firstDownEvent = TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 100)
+ val upEvent =
+ TestUtils.getMotionEvent(ACTION_UP, eventTime = 200, previousEvent = firstDownEvent)
+ val newDownEvent =
+ TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 501, previousEvent = upEvent)
+ val previousEvents = QuickScaleEvents(firstDownEvent, upEvent, null)
+ swipeLayout.quickScaleEvents = previousEvents
+
+ swipeLayout.maybeAddDoubleTapEvent(newDownEvent)
+
+ verify(swipeLayout).forgetQuickScaleEvents()
+ assertEquals(newDownEvent.actionMasked, swipeLayout.quickScaleEvents.firstDownEvent!!.actionMasked)
+ assertEquals(newDownEvent.eventTime, swipeLayout.quickScaleEvents.firstDownEvent!!.eventTime)
+ assertEquals(null, swipeLayout.quickScaleEvents.upEvent)
+ assertEquals(null, swipeLayout.quickScaleEvents.secondDownEvent)
+ }
+
+ @Test
+ fun `maybeAddDoubleTapEvent will add a second ACTION_DOWN already have two events and timeout is not reached`() {
+ // default DOUBLE_TAP_TIMEOUT is 300ms
+
+ swipeLayout = spy(swipeLayout)
+ val firstDownEvent = TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 100)
+ val upEvent =
+ TestUtils.getMotionEvent(ACTION_UP, eventTime = 200, previousEvent = firstDownEvent)
+ val newDownEvent =
+ TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 500, previousEvent = upEvent)
+ val previousEvents = QuickScaleEvents(firstDownEvent, upEvent, null)
+ swipeLayout.quickScaleEvents = previousEvents
+
+ swipeLayout.maybeAddDoubleTapEvent(newDownEvent)
+
+ verify(swipeLayout, times(0)).forgetQuickScaleEvents()
+ assertEquals(firstDownEvent.actionMasked, swipeLayout.quickScaleEvents.firstDownEvent!!.actionMasked)
+ assertEquals(firstDownEvent.eventTime, swipeLayout.quickScaleEvents.firstDownEvent!!.eventTime)
+ assertEquals(upEvent.actionMasked, swipeLayout.quickScaleEvents.upEvent!!.actionMasked)
+ assertEquals(upEvent.eventTime, swipeLayout.quickScaleEvents.upEvent!!.eventTime)
+ assertEquals(newDownEvent.actionMasked, swipeLayout.quickScaleEvents.secondDownEvent!!.actionMasked)
+ assertEquals(newDownEvent.eventTime, swipeLayout.quickScaleEvents.secondDownEvent!!.eventTime)
+ }
+
+ @Test
+ fun `forgetQuickScaleEvents should recycle all events and reset the quickScaleStatus`() {
+ val firstEvent = spy(TestUtils.getMotionEvent(ACTION_DOWN))
+ val secondEvent = spy(TestUtils.getMotionEvent(ACTION_UP, previousEvent = firstEvent))
+ val thirdEvent = spy(TestUtils.getMotionEvent(ACTION_DOWN))
+ swipeLayout.quickScaleEvents = QuickScaleEvents(firstEvent, secondEvent, thirdEvent)
+ swipeLayout.isQuickScaleInProgress = true
+
+ swipeLayout.forgetQuickScaleEvents()
+
+ verify(firstEvent).recycle()
+ verify(secondEvent).recycle()
+ verify(thirdEvent).recycle()
+ assertEquals(QuickScaleEvents(), swipeLayout.quickScaleEvents)
+ assertFalse(swipeLayout.isQuickScaleInProgress)
+ }
+
+ @Test
+ fun `isQuickScaleInProgress should return false if timeout was reached`() {
+ // default DOUBLE_TAP_TIMEOUT is 300ms
+
+ val firstEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+ val secondEvent = TestUtils.getMotionEvent(ACTION_UP, 0f, 0f, 0, firstEvent)
+ val thirdEvent = TestUtils.getMotionEvent(ACTION_DOWN, 0f, 0f, 301L, secondEvent)
+
+ assertFalse(swipeLayout.isQuickScaleInProgress(firstEvent, secondEvent, thirdEvent))
+ }
+
+ @Test
+ fun `isQuickScaleInProgress should return false if taps were too apart`() {
+ val firstEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+ val secondEvent = TestUtils.getMotionEvent(ACTION_UP, 0f, 0f, 0, firstEvent)
+ val thirdEvent = TestUtils.getMotionEvent(ACTION_DOWN, 200f, 20f, 200L, secondEvent)
+
+ assertFalse(swipeLayout.isQuickScaleInProgress(firstEvent, secondEvent, thirdEvent))
+ }
+
+ @Test
+ fun `isQuickScaleInProgress should return true if taps were close`() {
+ val firstEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+ val secondEvent = TestUtils.getMotionEvent(ACTION_UP, 0f, 0f, 0, firstEvent)
+ val thirdEvent = TestUtils.getMotionEvent(ACTION_DOWN, 20f, 20f, 200L, secondEvent)
+
+ assertTrue(swipeLayout.isQuickScaleInProgress(firstEvent, secondEvent, thirdEvent))
+ }
+
+ @Test
+ fun `isQuickScaleInProgress should return false if any event is null`() {
+ // Using the same values as in the above test that asserts true
+ val firstEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+ val secondEvent = TestUtils.getMotionEvent(ACTION_UP, 0f, 0f, 0, firstEvent)
+ val thirdEvent = TestUtils.getMotionEvent(ACTION_DOWN, 20f, 20f, 200L, secondEvent)
+ val oneNullEvent = QuickScaleEvents(firstEvent, secondEvent, null)
+ val twoNullEvents = QuickScaleEvents(null, null, thirdEvent)
+ val allNullEvents = QuickScaleEvents(null, null, null)
+
+ assertFalse(swipeLayout.isQuickScaleInProgress(oneNullEvent))
+ assertFalse(swipeLayout.isQuickScaleInProgress(twoNullEvents))
+ assertFalse(swipeLayout.isQuickScaleInProgress(allNullEvents))
+ }
+
+ @Test
+ fun `isQuickScaleInProgress should return true for valid sequence of non null events`() {
+ // Using the same values as in the above test that asserts true
+ swipeLayout = spy(swipeLayout)
+ val firstEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+ val secondEvent = TestUtils.getMotionEvent(ACTION_UP, 0f, 0f, 0, firstEvent)
+ val thirdEvent = TestUtils.getMotionEvent(ACTION_DOWN, 20f, 20f, 200L, secondEvent)
+ val quickScaleEvents = QuickScaleEvents(firstEvent, secondEvent, thirdEvent)
+
+ assertTrue(swipeLayout.isQuickScaleInProgress(quickScaleEvents))
+ verify(swipeLayout).isQuickScaleInProgress(firstEvent, secondEvent, thirdEvent)
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/WidgetSiteItemViewTest.kt b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/WidgetSiteItemViewTest.kt
new file mode 100644
index 0000000000..babc038dc0
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/WidgetSiteItemViewTest.kt
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.ui.widgets
+
+import android.graphics.drawable.Drawable
+import android.widget.ImageButton
+import android.widget.TextView
+import androidx.appcompat.view.ContextThemeWrapper
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+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.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import mozilla.components.ui.icons.R as iconsR
+
+@RunWith(AndroidJUnit4::class)
+class WidgetSiteItemViewTest {
+
+ private lateinit var view: WidgetSiteItemView
+
+ @Before
+ fun setup() {
+ val context = ContextThemeWrapper(testContext, R.style.Mozac_Widgets_TestTheme)
+ view = WidgetSiteItemView(context)
+ }
+
+ @Test
+ fun `setText hides the caption`() {
+ val labelView = view.findViewById<TextView>(R.id.label)
+ val captionView = view.findViewById<TextView>(R.id.caption)
+
+ view.setText(label = "label", caption = null)
+ assertEquals("label", labelView.text)
+ assertTrue(captionView.isGone)
+
+ view.setText(label = "Label", caption = "")
+ assertEquals("Label", labelView.text)
+ assertEquals("", captionView.text)
+ assertTrue(captionView.isVisible)
+ }
+
+ @Test
+ fun `setSecondaryButton shows the button`() {
+ val secondaryButton = view.findViewById<ImageButton>(R.id.secondary_button)
+ val drawable = mock<Drawable>()
+ var clicked = false
+ view.setSecondaryButton(
+ icon = drawable,
+ contentDescription = "Menu",
+ onClickListener = { clicked = true },
+ )
+ assertTrue(secondaryButton.isVisible)
+ assertEquals(drawable, secondaryButton.drawable)
+ assertEquals("Menu", secondaryButton.contentDescription)
+
+ secondaryButton.performClick()
+ assertTrue(clicked)
+ }
+
+ @Test
+ fun `setSecondaryButton with resource IDs shows the button`() {
+ val secondaryButton = view.findViewById<ImageButton>(R.id.secondary_button)
+ var clicked = false
+ view.setSecondaryButton(
+ icon = iconsR.drawable.mozac_ic_lock_24,
+ contentDescription = iconsR.string.mozac_error_lock,
+ onClickListener = { clicked = true },
+ )
+ assertTrue(secondaryButton.isVisible)
+ assertNotNull(secondaryButton.drawable)
+ assertEquals("mozac_error_lock", secondaryButton.contentDescription)
+
+ secondaryButton.performClick()
+ assertTrue(clicked)
+ }
+
+ @Test
+ fun `removeSecondaryButton does nothing if set was not called`() {
+ val secondaryButton = view.findViewById<ImageButton>(R.id.secondary_button)
+ assertTrue(secondaryButton.isGone)
+
+ view.removeSecondaryButton()
+ assertTrue(secondaryButton.isGone)
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/BrowserGestureDetectorTest.kt b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/BrowserGestureDetectorTest.kt
new file mode 100644
index 0000000000..66c4b826ff
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/BrowserGestureDetectorTest.kt
@@ -0,0 +1,231 @@
+/* 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.widgets.behavior
+
+import android.view.GestureDetector
+import android.view.MotionEvent
+import android.view.MotionEvent.ACTION_CANCEL
+import android.view.MotionEvent.ACTION_DOWN
+import android.view.MotionEvent.ACTION_MOVE
+import android.view.MotionEvent.ACTION_POINTER_DOWN
+import android.view.MotionEvent.ACTION_UP
+import android.view.ScaleGestureDetector
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.concept.base.crash.CrashReporting
+import mozilla.components.support.test.any
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyFloat
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+
+@RunWith(AndroidJUnit4::class)
+class BrowserGestureDetectorTest {
+ // Robolectric currently (April 17th 2020) only offer a stub in it's `ShadowScaleGestureDetector`
+ // so unit tests based on the actual implementation of `ScaleGestureListener` are not possible.
+
+ // Used spies and not mocks as it was observed that verifying more of the below as mocks
+ // will fail the tests because of "UnfinishedVerificationException"
+ private val scrollListener = spy { _: Float, _: Float -> run {} }
+ private val verticalScrollListener = spy { _: Float -> run {} }
+ private val horizontalScrollListener = spy { _: Float -> run {} }
+ private val scaleBeginListener = spy { _: Float -> run {} }
+ private val scaleInProgressListener = spy { _: Float -> run {} }
+ private val scaleEndListener = spy { _: Float -> run {} }
+ private val gesturesListener = BrowserGestureDetector.GesturesListener(
+ onScroll = scrollListener,
+ onVerticalScroll = verticalScrollListener,
+ onHorizontalScroll = horizontalScrollListener,
+ onScaleBegin = scaleBeginListener,
+ onScale = scaleInProgressListener,
+ onScaleEnd = scaleEndListener,
+ )
+
+ @Test
+ fun `Detector should not attempt to detect zoom if MotionEvent's action is ACTION_CANCEL`() {
+ val detector = spy(BrowserGestureDetector(testContext, mock()))
+ val scaleDetector: ScaleGestureDetector = mock()
+ detector.scaleGestureDetector = scaleDetector
+
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+ val cancelEvent = TestUtils.getMotionEvent(ACTION_CANCEL, previousEvent = downEvent)
+ val moveEvent = TestUtils.getMotionEvent(ACTION_MOVE, previousEvent = downEvent)
+ detector.handleTouchEvent(downEvent)
+ detector.handleTouchEvent(cancelEvent)
+ detector.handleTouchEvent(downEvent)
+ detector.handleTouchEvent(moveEvent)
+
+ verify(scaleDetector, times(3)).onTouchEvent(any<MotionEvent>())
+ }
+
+ @Test
+ fun `Detector should not attempt to detect scrolls if a zoom gesture is in progress`() {
+ val detector = spy(BrowserGestureDetector(testContext, mock()))
+ val scrollDetector: GestureDetector = mock()
+ val scaleDetector: ScaleGestureDetector = mock()
+ detector.gestureDetector = scrollDetector
+ detector.scaleGestureDetector = scaleDetector
+ `when`(scaleDetector.isInProgress).thenReturn(true)
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+ val moveEvent = TestUtils.getMotionEvent(ACTION_MOVE, previousEvent = downEvent)
+
+ detector.handleTouchEvent(downEvent)
+ detector.handleTouchEvent(moveEvent)
+
+ // In this case we let ACTION_DOWN, ACTION_UP, ACTION_CANCEL be handled but not others.
+ verify(scrollDetector, times(1)).onTouchEvent(downEvent)
+ verify(scrollDetector, never()).onTouchEvent(moveEvent)
+ }
+
+ @Test
+ fun `Detector's handleTouchEvent returns false if the event was not handled`() {
+ val detector = spy(BrowserGestureDetector(testContext, mock()))
+ val unhandledEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+
+ // Neither the scale detector, nor the scroll detector should be interested
+ // in a one of a time ACTION_CANCEL MotionEvent
+ val wasEventHandled = detector.handleTouchEvent(
+ TestUtils.getMotionEvent(ACTION_CANCEL, previousEvent = unhandledEvent),
+ )
+
+ assertFalse(wasEventHandled)
+ }
+
+ @Test
+ fun `Detector's handleTouchEvent returns true if the event was handled`() {
+ val detector = spy(BrowserGestureDetector(testContext, mock()))
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+ val moveEvent = TestUtils.getMotionEvent(ACTION_MOVE, 0f, 0f, previousEvent = downEvent)
+ val moveEvent2 = TestUtils.getMotionEvent(ACTION_MOVE, 100f, 100f, previousEvent = moveEvent)
+
+ detector.handleTouchEvent(downEvent)
+ detector.handleTouchEvent(moveEvent)
+ val wasEventHandled = detector.handleTouchEvent(moveEvent2)
+
+ assertTrue(wasEventHandled)
+ }
+
+ @Test
+ fun `Detector should inform about scroll and vertical scrolls events`() {
+ val detector = spy(BrowserGestureDetector(testContext, gesturesListener))
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+ val moveEvent = TestUtils.getMotionEvent(ACTION_MOVE, 0f, 0f, previousEvent = downEvent)
+ val moveEvent2 = TestUtils.getMotionEvent(ACTION_MOVE, 100f, 200f, previousEvent = moveEvent)
+
+ detector.handleTouchEvent(downEvent)
+ detector.handleTouchEvent(moveEvent)
+ detector.handleTouchEvent(moveEvent2)
+
+ // If the movement was more on the Y axis both "onScroll" and "onVerticalScroll" callbacks
+ // should be called but no others.
+ verify(scrollListener).invoke(-100f, -200f)
+ verify(verticalScrollListener).invoke(-200f)
+ verify(horizontalScrollListener, never()).invoke(anyFloat())
+ verify(scaleBeginListener, never()).invoke(anyFloat())
+ verify(scaleInProgressListener, never()).invoke(anyFloat())
+ verify(scaleEndListener, never()).invoke(anyFloat())
+ }
+
+ @Test
+ fun `Detector should prioritize vertical scrolls over horizontal scrolls`() {
+ val detector = spy(BrowserGestureDetector(testContext, gesturesListener))
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+ val moveEvent = TestUtils.getMotionEvent(ACTION_MOVE, 0f, 0f, previousEvent = downEvent)
+ val moveEvent2 = TestUtils.getMotionEvent(ACTION_MOVE, 100f, 100f, previousEvent = moveEvent)
+
+ detector.handleTouchEvent(downEvent)
+ detector.handleTouchEvent(moveEvent)
+ detector.handleTouchEvent(moveEvent2)
+
+ // If the movement was for the same amount on both the Y axis and the X axis
+ // both "onScroll" and "onVerticalScroll" callbacks should be called but no others.
+ verify(scrollListener).invoke(-100f, -100f)
+ verify(verticalScrollListener).invoke(-100f)
+ verify(horizontalScrollListener, never()).invoke(anyFloat())
+ verify(scaleBeginListener, never()).invoke(anyFloat())
+ verify(scaleInProgressListener, never()).invoke(anyFloat())
+ verify(scaleEndListener, never()).invoke(anyFloat())
+ }
+
+ @Test
+ fun `Detector should inform about scroll and horizontal scrolls events`() {
+ val detector = spy(BrowserGestureDetector(testContext, gesturesListener))
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+ val moveEvent = TestUtils.getMotionEvent(ACTION_MOVE, 0f, 0f, previousEvent = downEvent)
+ val moveEvent2 = TestUtils.getMotionEvent(ACTION_MOVE, 101f, 100f, previousEvent = moveEvent)
+
+ detector.handleTouchEvent(downEvent)
+ detector.handleTouchEvent(moveEvent)
+ detector.handleTouchEvent(moveEvent2)
+
+ // If the movement was for the same amount on both the Y axis and the X axis
+ // both "onScroll" and "onVerticalScroll" callbacks should be called but no others.
+ verify(scrollListener).invoke(-101f, -100f)
+ verify(horizontalScrollListener).invoke(-101f)
+ verify(verticalScrollListener, never()).invoke(anyFloat())
+ verify(scaleBeginListener, never()).invoke(anyFloat())
+ verify(scaleInProgressListener, never()).invoke(anyFloat())
+ verify(scaleEndListener, never()).invoke(anyFloat())
+ }
+
+ @Test
+ fun `Detector should always pass the ACTION_DOWN, ACTION_UP, ACTION_CANCEL events to the scroll detector`() {
+ val detector = spy(BrowserGestureDetector(testContext, mock()))
+ val scrollDetector: GestureDetector = mock()
+ val scaleDetector: ScaleGestureDetector = mock()
+ detector.gestureDetector = scrollDetector
+ detector.scaleGestureDetector = scaleDetector
+ // The aforementioned events should always be passed to the scroll detector,
+ // even if scaling is in progress.
+ `when`(scaleDetector.isInProgress).thenReturn(true)
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+ val moveEvent = TestUtils.getMotionEvent(ACTION_MOVE, previousEvent = downEvent)
+ val upEvent = TestUtils.getMotionEvent(ACTION_UP, previousEvent = moveEvent)
+ val cancelEvent = TestUtils.getMotionEvent(ACTION_CANCEL, previousEvent = upEvent)
+
+ listOf(downEvent, moveEvent, upEvent, cancelEvent).forEach {
+ detector.handleTouchEvent(it)
+ }
+
+ // With scaling in progress we let ACTION_DOWN, ACTION_UP, ACTION_CANCEL be handled but not others.
+ verify(scrollDetector, times(1)).onTouchEvent(downEvent)
+ verify(scrollDetector, times(1)).onTouchEvent(upEvent)
+ verify(scrollDetector, times(1)).onTouchEvent(cancelEvent)
+ verify(scrollDetector, never()).onTouchEvent(moveEvent)
+ }
+
+ @Test
+ fun `Detector should not crash when the scroll detector receives a null first MotionEvent`() {
+ val crashReporting: CrashReporting = mock()
+ val detector = BrowserGestureDetector(testContext, gesturesListener, crashReporting)
+ // We need a previous event for ACTION_MOVE.
+ // Don't use ACTION_DOWN (first pointer on the screen) but ACTION_POINTER_DOWN (other later pointer)
+ // just to artificially be able to recreate the bug from 8552. This should not happen IRL.
+ val downEvent = TestUtils.getMotionEvent(ACTION_POINTER_DOWN)
+ val moveEvent = TestUtils.getMotionEvent(ACTION_MOVE, 0f, 0f, previousEvent = downEvent)
+ val moveEvent2 = TestUtils.getMotionEvent(ACTION_MOVE, 100f, 200f, previousEvent = moveEvent)
+
+ detector.handleTouchEvent(downEvent)
+ detector.handleTouchEvent(moveEvent)
+ detector.handleTouchEvent(moveEvent2)
+
+ verify(scrollListener).invoke(-100f, -200f)
+
+ // We don't crash but neither can identify vertical / horizontal scrolls.
+
+ verify(verticalScrollListener, never()).invoke(anyFloat())
+ verify(horizontalScrollListener, never()).invoke(anyFloat())
+ verify(scaleBeginListener, never()).invoke(anyFloat())
+ verify(scaleInProgressListener, never()).invoke(anyFloat())
+ verify(scaleEndListener, never()).invoke(anyFloat())
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewClippingBehaviorTest.kt b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewClippingBehaviorTest.kt
new file mode 100644
index 0000000000..13538bc591
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewClippingBehaviorTest.kt
@@ -0,0 +1,221 @@
+/* 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.widgets.behavior
+
+import android.content.Context
+import android.view.View
+import android.widget.EditText
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.concept.toolbar.ScrollableToolbar
+import mozilla.components.support.test.fakes.engine.FakeEngineView
+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.doReturn
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class EngineViewClippingBehaviorTest {
+
+ @Test
+ fun `EngineView clipping and bottom toolbar offset are kept in sync`() {
+ val engineView: EngineView = spy(FakeEngineView(testContext))
+ val toolbar: View = mock()
+ doReturn(100).`when`(toolbar).height
+ doReturn(42f).`when`(toolbar).translationY
+
+ val behavior = EngineViewClippingBehavior(
+ mock(),
+ null,
+ engineView.asView(),
+ toolbar.height,
+ ToolbarPosition.BOTTOM,
+ )
+
+ behavior.onDependentViewChanged(mock(), mock(), toolbar)
+
+ verify(engineView).setVerticalClipping(-42)
+ assertEquals(0f, engineView.asView().translationY)
+ }
+
+ @Test
+ fun `EngineView clipping and top toolbar offset are kept in sync`() {
+ val engineView: EngineView = spy(FakeEngineView(testContext))
+ val toolbar: View = mock()
+ doReturn(100).`when`(toolbar).height
+ doReturn(42f).`when`(toolbar).translationY
+
+ val behavior = EngineViewClippingBehavior(
+ mock(),
+ null,
+ engineView.asView(),
+ toolbar.height,
+ ToolbarPosition.TOP,
+ )
+
+ behavior.onDependentViewChanged(mock(), mock(), toolbar)
+
+ verify(engineView).setVerticalClipping(42)
+ assertEquals(142f, engineView.asView().translationY)
+ }
+
+ @Test
+ fun `Behavior does not depend on normal views`() {
+ val behavior = EngineViewClippingBehavior(
+ mock(),
+ null,
+ mock(),
+ 0,
+ ToolbarPosition.BOTTOM,
+ )
+
+ assertFalse(behavior.layoutDependsOn(mock(), mock(), TextView(testContext)))
+ assertFalse(behavior.layoutDependsOn(mock(), mock(), EditText(testContext)))
+ assertFalse(behavior.layoutDependsOn(mock(), mock(), ImageView(testContext)))
+ }
+
+ @Test
+ fun `Behavior depends on BrowserToolbar`() {
+ val behavior = EngineViewClippingBehavior(
+ mock(),
+ null,
+ mock(),
+ 0,
+ ToolbarPosition.BOTTOM,
+ )
+
+ assertTrue(behavior.layoutDependsOn(mock(), mock(), BrowserToolbar(testContext)))
+ }
+
+ @Test
+ fun `GIVEN a bottom toolbar WHEN translation has below a half decimal THEN set vertical clipping with the floor value`() {
+ val engineView: FakeEngineView = mock()
+ val behavior = EngineViewClippingBehavior(
+ mock(),
+ null,
+ engineView,
+ 100,
+ ToolbarPosition.BOTTOM,
+ )
+
+ behavior.toolbarChangedAction(40.4f)
+
+ verify(engineView).setVerticalClipping(-40)
+ }
+
+ @Test
+ fun `GIVEN a bottom toolbar WHEN translation has exactly half of a decimal THEN set vertical clipping with the ceiling value`() {
+ val engineView: FakeEngineView = mock()
+ val behavior = EngineViewClippingBehavior(
+ mock(),
+ null,
+ engineView,
+ 100,
+ ToolbarPosition.BOTTOM,
+ )
+
+ behavior.toolbarChangedAction(40.5f)
+
+ verify(engineView).setVerticalClipping(-41)
+ }
+
+ @Test
+ fun `GIVEN a bottom toolbar WHEN translation has more than a half decimal THEN set vertical clipping with the ceiling value`() {
+ val engineView: FakeEngineView = mock()
+ val behavior = EngineViewClippingBehavior(
+ mock(),
+ null,
+ engineView,
+ 100,
+ ToolbarPosition.BOTTOM,
+ )
+
+ behavior.toolbarChangedAction(40.6f)
+
+ verify(engineView).setVerticalClipping(-41)
+ }
+
+ @Test
+ fun `GIVEN a top toolbar WHEN translation has below a half decimal THEN set vertical clipping with the floor value`() {
+ val engineView: FakeEngineView = mock()
+ val behavior = EngineViewClippingBehavior(
+ mock(),
+ null,
+ engineView,
+ 100,
+ ToolbarPosition.TOP,
+ )
+
+ behavior.toolbarChangedAction(40.4f)
+
+ verify(engineView).setVerticalClipping(40)
+ }
+
+ @Test
+ fun `GIVEN a top toolbar WHEN translation has exactly half of a decimal THEN set vertical clipping with the ceiling value`() {
+ val engineView: FakeEngineView = mock()
+ val behavior = EngineViewClippingBehavior(
+ mock(),
+ null,
+ engineView,
+ 100,
+ ToolbarPosition.TOP,
+ )
+
+ behavior.toolbarChangedAction(40.5f)
+
+ verify(engineView).setVerticalClipping(41)
+ }
+
+ @Test
+ fun `GIVEN a top toolbar WHEN translation has more than a half decimal THEN set vertical clipping with the ceiling value`() {
+ val engineView: FakeEngineView = mock()
+ val behavior = EngineViewClippingBehavior(
+ mock(),
+ null,
+ engineView,
+ 100,
+ ToolbarPosition.TOP,
+ )
+
+ behavior.toolbarChangedAction(40.6f)
+
+ verify(engineView).setVerticalClipping(41)
+ }
+
+ @Test
+ fun `GIVEN a bottom toolbar WHEN translation returns NaN THEN no exception thrown`() {
+ val engineView: EngineView = spy(FakeEngineView(testContext))
+ val toolbar: View = mock()
+ doReturn(100).`when`(toolbar).height
+ doReturn(Float.NaN).`when`(toolbar).translationY
+
+ val behavior = EngineViewClippingBehavior(
+ mock(),
+ null,
+ engineView.asView(),
+ toolbar.height,
+ ToolbarPosition.BOTTOM,
+ )
+
+ behavior.onDependentViewChanged(mock(), mock(), toolbar)
+ assertEquals(0f, engineView.asView().translationY)
+ }
+}
+
+private class BrowserToolbar(context: Context) : TextView(context), ScrollableToolbar {
+ override fun enableScrolling() {}
+ override fun disableScrolling() {}
+ override fun expand() {}
+ override fun collapse() {}
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehaviorTest.kt b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehaviorTest.kt
new file mode 100644
index 0000000000..0f0c10b71a
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehaviorTest.kt
@@ -0,0 +1,575 @@
+/* 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.widgets.behavior
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.view.MotionEvent.ACTION_DOWN
+import android.view.MotionEvent.ACTION_MOVE
+import android.view.View
+import android.widget.FrameLayout
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.ViewCompat
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.EngineView
+import mozilla.components.concept.engine.INPUT_UNHANDLED
+import mozilla.components.concept.engine.InputResultDetail
+import mozilla.components.concept.engine.selection.SelectionActionDelegate
+import mozilla.components.support.test.any
+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.ArgumentMatchers.anyFloat
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+
+@RunWith(AndroidJUnit4::class)
+class EngineViewScrollingBehaviorTest {
+ @Test
+ fun `onStartNestedScroll should attempt scrolling only if browserToolbar is valid`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ doReturn(true).`when`(behavior).shouldScroll
+
+ behavior.dynamicScrollView = null
+ var acceptsNestedScroll = behavior.onStartNestedScroll(
+ coordinatorLayout = mock(),
+ child = mock(),
+ directTargetChild = mock(),
+ target = mock(),
+ axes = ViewCompat.SCROLL_AXIS_VERTICAL,
+ type = ViewCompat.TYPE_TOUCH,
+ )
+ assertFalse(acceptsNestedScroll)
+ verify(behavior, never()).startNestedScroll(anyInt(), anyInt(), any())
+
+ behavior.dynamicScrollView = mock()
+ acceptsNestedScroll = behavior.onStartNestedScroll(
+ coordinatorLayout = mock(),
+ child = mock(),
+ directTargetChild = mock(),
+ target = mock(),
+ axes = ViewCompat.SCROLL_AXIS_VERTICAL,
+ type = ViewCompat.TYPE_TOUCH,
+ )
+ assertTrue(acceptsNestedScroll)
+ verify(behavior).startNestedScroll(anyInt(), anyInt(), any())
+ }
+
+ @Test
+ fun `startNestedScroll should cancel an ongoing snap animation`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val yTranslator: ViewYTranslator = mock()
+ behavior.yTranslator = yTranslator
+ doReturn(true).`when`(behavior).shouldScroll
+
+ val acceptsNestedScroll = behavior.startNestedScroll(
+ axes = ViewCompat.SCROLL_AXIS_VERTICAL,
+ type = ViewCompat.TYPE_TOUCH,
+ view = mock(),
+ )
+
+ assertTrue(acceptsNestedScroll)
+ verify(yTranslator).cancelInProgressTranslation()
+ }
+
+ @Test
+ fun `startNestedScroll should not accept nested scrolls on the horizontal axis`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ doReturn(true).`when`(behavior).shouldScroll
+
+ var acceptsNestedScroll = behavior.startNestedScroll(
+ axes = ViewCompat.SCROLL_AXIS_VERTICAL,
+ type = ViewCompat.TYPE_TOUCH,
+ view = mock(),
+ )
+ assertTrue(acceptsNestedScroll)
+
+ acceptsNestedScroll = behavior.startNestedScroll(
+ axes = ViewCompat.SCROLL_AXIS_HORIZONTAL,
+ type = ViewCompat.TYPE_TOUCH,
+ view = mock(),
+ )
+ assertFalse(acceptsNestedScroll)
+ }
+
+ @Test
+ fun `GIVEN a gesture that doesn't scroll the toolbar WHEN startNestedScroll THEN toolbar is expanded and nested scroll not accepted`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val engineView: EngineView = mock()
+ val inputResultDetail: InputResultDetail = mock()
+ val yTranslator: ViewYTranslator = mock()
+ behavior.yTranslator = yTranslator
+ doReturn(false).`when`(behavior).shouldScroll
+ doReturn(true).`when`(inputResultDetail).isTouchUnhandled()
+ behavior.engineView = engineView
+ doReturn(inputResultDetail).`when`(engineView).getInputResultDetail()
+
+ val acceptsNestedScroll = behavior.startNestedScroll(
+ axes = ViewCompat.SCROLL_AXIS_VERTICAL,
+ type = ViewCompat.TYPE_TOUCH,
+ view = mock(),
+ )
+
+ verify(yTranslator).cancelInProgressTranslation()
+ verify(yTranslator).expandWithAnimation(any())
+ assertFalse(acceptsNestedScroll)
+ }
+
+ @Test
+ fun `Behavior should not accept nested scrolls on the horizontal axis`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ behavior.dynamicScrollView = mock()
+ doReturn(true).`when`(behavior).shouldScroll
+
+ var acceptsNestedScroll = behavior.onStartNestedScroll(
+ coordinatorLayout = mock(),
+ child = mock(),
+ directTargetChild = mock(),
+ target = mock(),
+ axes = ViewCompat.SCROLL_AXIS_VERTICAL,
+ type = ViewCompat.TYPE_TOUCH,
+ )
+ assertTrue(acceptsNestedScroll)
+
+ acceptsNestedScroll = behavior.onStartNestedScroll(
+ coordinatorLayout = mock(),
+ child = mock(),
+ directTargetChild = mock(),
+ target = mock(),
+ axes = ViewCompat.SCROLL_AXIS_HORIZONTAL,
+ type = ViewCompat.TYPE_TOUCH,
+ )
+ assertFalse(acceptsNestedScroll)
+ }
+
+ @Test
+ fun `Behavior should delegate the onStartNestedScroll logic`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val view: View = mock()
+ behavior.dynamicScrollView = view
+ val inputType = ViewCompat.TYPE_TOUCH
+ val axes = ViewCompat.SCROLL_AXIS_VERTICAL
+
+ behavior.onStartNestedScroll(
+ coordinatorLayout = mock(),
+ child = view,
+ directTargetChild = mock(),
+ target = mock(),
+ axes = axes,
+ type = inputType,
+ )
+
+ verify(behavior).startNestedScroll(axes, inputType, view)
+ }
+
+ @Test
+ fun `onStopNestedScroll should attempt stopping nested scrolling only if browserToolbar is valid`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+
+ behavior.dynamicScrollView = null
+ behavior.onStopNestedScroll(
+ coordinatorLayout = mock(),
+ child = mock(),
+ target = mock(),
+ type = 0,
+ )
+ verify(behavior, never()).stopNestedScroll(anyInt(), any())
+
+ behavior.dynamicScrollView = mock()
+ behavior.onStopNestedScroll(
+ coordinatorLayout = mock(),
+ child = mock(),
+ target = mock(),
+ type = 0,
+ )
+ verify(behavior).stopNestedScroll(anyInt(), any())
+ }
+
+ @Test
+ fun `Behavior should delegate the onStopNestedScroll logic`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val inputType = ViewCompat.TYPE_TOUCH
+ val view: View = mock()
+
+ behavior.dynamicScrollView = null
+ behavior.onStopNestedScroll(
+ coordinatorLayout = mock(),
+ child = view,
+ target = mock(),
+ type = inputType,
+ )
+ verify(behavior, never()).stopNestedScroll(inputType, view)
+ }
+
+ @Test
+ fun `stopNestedScroll will snap toolbar up if toolbar is more than 50 percent visible`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val yTranslator: ViewYTranslator = mock()
+ behavior.yTranslator = yTranslator
+ behavior.dynamicScrollView = mock()
+ doReturn(true).`when`(behavior).shouldScroll
+
+ val child = mock<View>()
+ doReturn(100).`when`(child).height
+ doReturn(10f).`when`(child).translationY
+
+ behavior.onStartNestedScroll(
+ coordinatorLayout = mock(),
+ child = child,
+ directTargetChild = mock(),
+ target = mock(),
+ axes = ViewCompat.SCROLL_AXIS_VERTICAL,
+ type = ViewCompat.TYPE_TOUCH,
+ )
+
+ assertTrue(behavior.shouldSnapAfterScroll)
+ verify(yTranslator).cancelInProgressTranslation()
+ verify(yTranslator, never()).expandWithAnimation(any())
+ verify(yTranslator, never()).collapseWithAnimation(any())
+
+ behavior.stopNestedScroll(0, child)
+
+ verify(yTranslator).snapWithAnimation(child)
+ }
+
+ @Test
+ fun `stopNestedScroll will snap toolbar down if toolbar is less than 50 percent visible`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ doReturn(true).`when`(behavior).shouldScroll
+ val yTranslator: ViewYTranslator = mock()
+ behavior.yTranslator = yTranslator
+
+ val child = mock<View>()
+ behavior.dynamicScrollView = child
+ doReturn(100).`when`(child).height
+ doReturn(90f).`when`(child).translationY
+
+ behavior.onStartNestedScroll(
+ coordinatorLayout = mock(),
+ child = child,
+ directTargetChild = mock(),
+ target = mock(),
+ axes = ViewCompat.SCROLL_AXIS_VERTICAL,
+ type = ViewCompat.TYPE_TOUCH,
+ )
+
+ assertTrue(behavior.shouldSnapAfterScroll)
+ verify(yTranslator).cancelInProgressTranslation()
+ verify(yTranslator, never()).expandWithAnimation(any())
+ verify(yTranslator, never()).collapseWithAnimation(any())
+
+ behavior.stopNestedScroll(0, child)
+
+ verify(yTranslator).snapWithAnimation(child)
+ }
+
+ @Test
+ fun `onStopNestedScroll should snap the toolbar only if browserToolbar is valid`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ behavior.dynamicScrollView = null
+
+ behavior.onStopNestedScroll(
+ coordinatorLayout = mock(),
+ child = mock(),
+ target = mock(),
+ type = ViewCompat.TYPE_TOUCH,
+ )
+
+ verify(behavior, never()).stopNestedScroll(anyInt(), any())
+ }
+
+ @Test
+ fun `Behavior will intercept MotionEvents and pass them to the custom gesture detector`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val gestureDetector: BrowserGestureDetector = mock()
+ behavior.initGesturesDetector(gestureDetector)
+ behavior.dynamicScrollView = mock()
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+
+ behavior.onInterceptTouchEvent(mock(), mock(), downEvent)
+
+ verify(gestureDetector).handleTouchEvent(downEvent)
+ }
+
+ @Test
+ fun `Behavior should only dispatch MotionEvents to the gesture detector only if browserToolbar is valid`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val gestureDetector: BrowserGestureDetector = mock()
+ behavior.initGesturesDetector(gestureDetector)
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN)
+
+ behavior.onInterceptTouchEvent(mock(), mock(), downEvent)
+
+ verify(gestureDetector, never()).handleTouchEvent(downEvent)
+ }
+
+ @Test
+ fun `Behavior will apply translation to toolbar only for vertical scrolls`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ behavior.initGesturesDetector(behavior.createGestureDetector())
+ val child = spy(View(testContext, null, 0))
+ behavior.dynamicScrollView = child
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN, 0f, 0f)
+ val moveEvent = TestUtils.getMotionEvent(ACTION_MOVE, 0f, 100f, downEvent)
+
+ behavior.onInterceptTouchEvent(mock(), mock(), downEvent)
+ behavior.onInterceptTouchEvent(mock(), mock(), moveEvent)
+
+ verify(behavior).tryToScrollVertically(-100f)
+ }
+
+ @Test
+ fun `GIVEN a null InputResultDetail from the EngineView WHEN shouldScroll is called THEN it returns false`() {
+ val behavior = EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM)
+ behavior.engineView = null
+ assertFalse(behavior.shouldScroll)
+ behavior.engineView = mock()
+ `when`(behavior.engineView!!.getInputResultDetail()).thenReturn(null)
+
+ assertFalse(behavior.shouldScroll)
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail with the right values and scroll enabled WHEN shouldScroll is called THEN it returns true`() {
+ val behavior = EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM)
+ val engineView: EngineView = mock()
+ behavior.engineView = engineView
+ behavior.isScrollEnabled = true
+ val validInputResultDetail: InputResultDetail = mock()
+ doReturn(validInputResultDetail).`when`(engineView).getInputResultDetail()
+
+ doReturn(true).`when`(validInputResultDetail).canScrollToBottom()
+ doReturn(false).`when`(validInputResultDetail).canScrollToTop()
+ assertTrue(behavior.shouldScroll)
+
+ doReturn(false).`when`(validInputResultDetail).canScrollToBottom()
+ doReturn(true).`when`(validInputResultDetail).canScrollToTop()
+ assertTrue(behavior.shouldScroll)
+
+ doReturn(true).`when`(validInputResultDetail).canScrollToBottom()
+ doReturn(true).`when`(validInputResultDetail).canScrollToTop()
+ assertTrue(behavior.shouldScroll)
+ }
+
+ @Test
+ fun `GIVEN an InputResultDetail with the right values but with scroll disabled WHEN shouldScroll is called THEN it returns false`() {
+ val behavior = EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM)
+ behavior.engineView = mock()
+ behavior.isScrollEnabled = false
+ val validInputResultDetail: InputResultDetail = mock()
+ doReturn(true).`when`(validInputResultDetail).canScrollToBottom()
+ doReturn(true).`when`(validInputResultDetail).canScrollToTop()
+
+ assertFalse(behavior.shouldScroll)
+ }
+
+ @Test
+ fun `GIVEN scroll enabled but EngineView cannot scroll to bottom WHEN shouldScroll is called THEN it returns false`() {
+ val behavior = EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM)
+ behavior.engineView = mock()
+ behavior.isScrollEnabled = true
+ val validInputResultDetail: InputResultDetail = mock()
+ doReturn(false).`when`(validInputResultDetail).canScrollToBottom()
+ doReturn(true).`when`(validInputResultDetail).canScrollToTop()
+
+ assertFalse(behavior.shouldScroll)
+ }
+
+ @Test
+ fun `GIVEN scroll enabled but EngineView cannot scroll to top WHEN shouldScroll is called THEN it returns false`() {
+ val behavior = EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM)
+ behavior.engineView = mock()
+ behavior.isScrollEnabled = true
+ val validInputResultDetail: InputResultDetail = mock()
+ doReturn(true).`when`(validInputResultDetail).canScrollToBottom()
+ doReturn(false).`when`(validInputResultDetail).canScrollToTop()
+
+ assertFalse(behavior.shouldScroll)
+ }
+
+ @Test
+ fun `Behavior will vertically scroll nested scroll started and EngineView handled the event`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val yTranslator: ViewYTranslator = mock()
+ behavior.yTranslator = yTranslator
+ doReturn(true).`when`(behavior).shouldScroll
+ val child = spy(View(testContext, null, 0))
+ behavior.dynamicScrollView = child
+ doReturn(100).`when`(child).height
+ doReturn(0f).`when`(child).translationY
+ behavior.startedScroll = true
+
+ behavior.tryToScrollVertically(25f)
+
+ verify(yTranslator).translate(child, 25f)
+ }
+
+ @Test
+ fun `Behavior will not scroll vertically if startedScroll is false`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val yTranslator: ViewYTranslator = mock()
+ behavior.yTranslator = yTranslator
+ doReturn(true).`when`(behavior).shouldScroll
+ val child = spy(View(testContext, null, 0))
+ behavior.dynamicScrollView = child
+ doReturn(100).`when`(child).height
+ doReturn(0f).`when`(child).translationY
+ behavior.startedScroll = false
+
+ behavior.tryToScrollVertically(25f)
+
+ verify(yTranslator, never()).translate(any(), anyFloat())
+ }
+
+ @Test
+ fun `Behavior will not scroll vertically if EngineView did not handled the event`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val yTranslator: ViewYTranslator = mock()
+ behavior.yTranslator = yTranslator
+ doReturn(false).`when`(behavior).shouldScroll
+ val child = spy(View(testContext, null, 0))
+ behavior.dynamicScrollView = child
+ doReturn(100).`when`(child).height
+ doReturn(0f).`when`(child).translationY
+
+ behavior.tryToScrollVertically(25f)
+
+ verify(yTranslator, never()).translate(any(), anyFloat())
+ }
+
+ @Test
+ fun `forceExpand should delegate the translator`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val yTranslator: ViewYTranslator = mock()
+ behavior.yTranslator = yTranslator
+ val view: View = mock()
+
+ behavior.forceExpand(view)
+
+ verify(yTranslator).expandWithAnimation(view)
+ }
+
+ @Test
+ fun `forceCollapse should delegate the translator`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val yTranslator: ViewYTranslator = mock()
+ behavior.yTranslator = yTranslator
+ val view: View = mock()
+
+ behavior.forceCollapse(view)
+
+ verify(yTranslator).collapseWithAnimation(view)
+ }
+
+ @Test
+ fun `Behavior will forceExpand when scrolling up and !shouldScroll if the touch was handled in the browser`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val yTranslator: ViewYTranslator = mock()
+ behavior.yTranslator = yTranslator
+ behavior.initGesturesDetector(behavior.createGestureDetector())
+ val view: View = spy(View(testContext, null, 0))
+ behavior.dynamicScrollView = view
+ val engineView: EngineView = mock()
+ behavior.engineView = engineView
+ val handledTouchInput = InputResultDetail.newInstance().copy(INPUT_UNHANDLED)
+ doReturn(handledTouchInput).`when`(engineView).getInputResultDetail()
+
+ doReturn(100).`when`(view).height
+ doReturn(100f).`when`(view).translationY
+
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN, 0f, 0f)
+ val moveEvent = TestUtils.getMotionEvent(ACTION_MOVE, 0f, 30f, downEvent)
+
+ behavior.onInterceptTouchEvent(mock(), mock(), downEvent)
+ behavior.onInterceptTouchEvent(mock(), mock(), moveEvent)
+
+ verify(behavior).tryToScrollVertically(-30f)
+ verify(yTranslator).forceExpandIfNotAlready(view, -30f)
+ }
+
+ @Test
+ fun `Behavior will not forceExpand when scrolling up and !shouldScroll if the touch was not yet handled in the browser`() {
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val yTranslator: ViewYTranslator = mock()
+ behavior.yTranslator = yTranslator
+ behavior.initGesturesDetector(behavior.createGestureDetector())
+ val view: View = spy(View(testContext, null, 0))
+ behavior.dynamicScrollView = view
+ val engineView: EngineView = mock()
+ behavior.engineView = engineView
+ val handledTouchInput = InputResultDetail.newInstance()
+ doReturn(handledTouchInput).`when`(engineView).getInputResultDetail()
+
+ doReturn(100).`when`(view).height
+ doReturn(100f).`when`(view).translationY
+
+ val downEvent = TestUtils.getMotionEvent(ACTION_DOWN, 0f, 0f)
+ val moveEvent = TestUtils.getMotionEvent(ACTION_MOVE, 0f, 30f, downEvent)
+
+ behavior.onInterceptTouchEvent(mock(), mock(), downEvent)
+ behavior.onInterceptTouchEvent(mock(), mock(), moveEvent)
+
+ verify(behavior).tryToScrollVertically(-30f)
+ verify(yTranslator, never()).forceExpandIfNotAlready(view, -30f)
+ }
+
+ @Test
+ fun `onLayoutChild initializes browserToolbar and engineView`() {
+ val view = View(testContext)
+ val engineView = createDummyEngineView(testContext).asView()
+ val container = CoordinatorLayout(testContext).apply {
+ addView(View(testContext))
+ addView(engineView)
+ }
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+
+ behavior.onLayoutChild(container, view, ViewCompat.LAYOUT_DIRECTION_LTR)
+
+ assertEquals(view, behavior.dynamicScrollView)
+ assertEquals(engineView, behavior.engineView)
+ }
+
+ @Test
+ fun `enableScrolling sets isScrollEnabled to true`() {
+ val behavior = EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM)
+
+ assertFalse(behavior.isScrollEnabled)
+ behavior.enableScrolling()
+
+ assertTrue(behavior.isScrollEnabled)
+ }
+
+ @Test
+ fun `disableScrolling sets isScrollEnabled to false`() {
+ val behavior = EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM)
+ behavior.isScrollEnabled = true
+
+ assertTrue(behavior.isScrollEnabled)
+ behavior.disableScrolling()
+
+ assertFalse(behavior.isScrollEnabled)
+ }
+
+ private fun createDummyEngineView(context: Context): EngineView = DummyEngineView(context)
+
+ open class DummyEngineView(context: Context) : FrameLayout(context), EngineView {
+ override fun setVerticalClipping(clippingHeight: Int) {}
+ override fun setDynamicToolbarMaxHeight(height: Int) {}
+ override fun setActivityContext(context: Context?) {}
+ override fun captureThumbnail(onFinish: (Bitmap?) -> Unit) = Unit
+ override fun render(session: EngineSession) {}
+ override fun release() {}
+ override var selectionActionDelegate: SelectionActionDelegate? = null
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/TestUtils.kt b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/TestUtils.kt
new file mode 100644
index 0000000000..2e5bbaa9df
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/TestUtils.kt
@@ -0,0 +1,62 @@
+/* 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.widgets.behavior
+
+import android.view.MotionEvent
+
+object TestUtils {
+ fun getMotionEvent(
+ action: Int,
+ x: Float = 0f,
+ y: Float = 0f,
+ previousEvent: MotionEvent? = null,
+ ): MotionEvent {
+ val currentTime = System.currentTimeMillis()
+ val downTime = previousEvent?.downTime ?: System.currentTimeMillis()
+
+ var pointerCount = previousEvent?.pointerCount ?: 0
+ if (action == MotionEvent.ACTION_POINTER_DOWN) {
+ pointerCount++
+ } else if (action == MotionEvent.ACTION_DOWN) {
+ pointerCount = 1
+ }
+
+ val properties = Array(pointerCount, TestUtils::getPointerProperties)
+ val pointerCoords = getPointerCoords(x, y, pointerCount)
+
+ return MotionEvent.obtain(
+ downTime, currentTime,
+ action, pointerCount, properties,
+ pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0,
+ )
+ }
+
+ private fun getPointerCoords(
+ x: Float,
+ y: Float,
+ pointerCount: Int,
+ previousEvent: MotionEvent? = null,
+ ): Array<MotionEvent.PointerCoords?> {
+ val currentEventCoords = MotionEvent.PointerCoords().apply {
+ this.x = x; this.y = y; pressure = 1f; size = 1f
+ }
+
+ return if (pointerCount > 1 && previousEvent != null) {
+ arrayOf(
+ MotionEvent.PointerCoords().apply {
+ this.x = previousEvent.x; this.y = previousEvent.y; pressure = 1f; size = 1f
+ },
+ currentEventCoords,
+ )
+ } else {
+ arrayOf(currentEventCoords)
+ }
+ }
+
+ private fun getPointerProperties(id: Int): MotionEvent.PointerProperties =
+ MotionEvent.PointerProperties().apply {
+ this.id = id; this.toolType = MotionEvent.TOOL_TYPE_FINGER
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/ViewYTranslationStrategyTest.kt b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/ViewYTranslationStrategyTest.kt
new file mode 100644
index 0000000000..62d152f7f6
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/ViewYTranslationStrategyTest.kt
@@ -0,0 +1,712 @@
+/* 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.widgets.behavior
+
+import android.animation.ValueAnimator
+import android.view.View
+import android.view.animation.DecelerateInterpolator
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.test.any
+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.doReturn
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class ViewYTranslationStrategyTest {
+ @Test
+ fun `snapAnimator should use a DecelerateInterpolator with SNAP_ANIMATION_DURATION for bottom toolbar translations`() {
+ val strategy = BottomViewBehaviorStrategy()
+
+ assertTrue(strategy.animator.interpolator is DecelerateInterpolator)
+ assertEquals(SNAP_ANIMATION_DURATION, strategy.animator.duration)
+ }
+
+ @Test
+ fun `snapAnimator should use a DecelerateInterpolator with SNAP_ANIMATION_DURATION for top toolbar translations`() {
+ val strategy = TopViewBehaviorStrategy()
+
+ assertTrue(strategy.animator.interpolator is DecelerateInterpolator)
+ assertEquals(SNAP_ANIMATION_DURATION, strategy.animator.duration)
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy should start with isToolbarExpanding = false`() {
+ val strategy = BottomViewBehaviorStrategy()
+
+ assertFalse(strategy.wasLastExpanding)
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy should start with isToolbarExpanding = false`() {
+ val strategy = TopViewBehaviorStrategy()
+
+ assertFalse(strategy.wasLastExpanding)
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - snapWithAnimation should collapse toolbar if more than half hidden`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(50f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ doReturn(51f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ doReturn(100f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ doReturn(333f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ verify(strategy, times(4)).collapseWithAnimation(view)
+ verify(strategy, never()).expandWithAnimation(view)
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - snapWithAnimation should expand toolbar if more than half visible`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(49f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ doReturn(0f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ doReturn(-50f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ verify(strategy, times(3)).expandWithAnimation(view)
+ verify(strategy, never()).collapseWithAnimation(view)
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - snapWithAnimation should collapse toolbar if more than half hidden`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(-51f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ doReturn(-100f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ doReturn(-333f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ verify(strategy, times(3)).collapseWithAnimation(view)
+ verify(strategy, never()).expandWithAnimation(view)
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - snapWithAnimation should expand toolbar if more than half visible`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(-50f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ doReturn(-49f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ doReturn(0f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ doReturn(50f).`when`(view).translationY
+ strategy.snapWithAnimation(view)
+
+ verify(strategy, times(4)).expandWithAnimation(view)
+ verify(strategy, never()).collapseWithAnimation(view)
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - snapImmediately should end translations animations if in progress`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(true).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+
+ strategy.snapImmediately(view)
+
+ verify(animator).end()
+ verify(view, never()).translationY
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - snapImmediately should translate away the toolbar if half translated`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(50f).`when`(view).translationY
+ strategy.snapImmediately(view)
+ verify(view).translationY = 100f
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - snapImmediately should translate away the toolbar if more than half`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(55f).`when`(view).translationY
+ strategy.snapImmediately(view)
+ verify(view).translationY = 100f
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - snapImmediately should translate away the toolbar if translated off screen`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(555f).`when`(view).translationY
+ strategy.snapImmediately(view)
+ verify(view).translationY = 100f
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - snapImmediately should translate to 0 the toolbar if translated less than half`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(49f).`when`(view).translationY
+ strategy.snapImmediately(view)
+ verify(view).translationY = 0f
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - snapImmediately should translate to 0 the toolbar if translated 0`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(0f).`when`(view).translationY
+ strategy.snapImmediately(view)
+ verify(view).translationY = 0f
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - snapImmediately should translate to 0 the toolbar if translated inside the screen`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(-1f).`when`(view).translationY
+ strategy.snapImmediately(view)
+ verify(view).translationY = 0f
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - snapImmediately should end translations animations if in progress`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(true).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+
+ strategy.snapImmediately(view)
+
+ verify(animator).end()
+ verify(view, never()).translationY
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - snapImmediately should translate translate to 0 the toolbar if translated less than half`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(-49f).`when`(view).translationY
+ strategy.snapImmediately(view)
+ verify(view).translationY = 0f
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - snapImmediately should translate to 0 the toolbar if translated 0`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(0f).`when`(view).translationY
+ strategy.snapImmediately(view)
+ verify(view).translationY = 0f
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - snapImmediately should translate to 0 the toolbar if translated inside the screen`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(1f).`when`(view).translationY
+ strategy.snapImmediately(view)
+ verify(view).translationY = 0f
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - snapImmediately should translate to 0 the toolbar if half translated`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(-50f).`when`(view).translationY
+ strategy.snapImmediately(view)
+ verify(view).translationY = 0f
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - snapImmediately should translate away the toolbar if more than half translated`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(-55f).`when`(view).translationY
+ strategy.snapImmediately(view)
+ verify(view).translationY = -100f
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - snapImmediately should translate to 0 the toolbar if translated offscreen`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ doReturn(-111f).`when`(view).translationY
+ strategy.snapImmediately(view)
+ verify(view).translationY = -100f
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - expandWithAnimation should translate the toolbar to to y 0`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ val view: View = mock()
+
+ strategy.expandWithAnimation(view)
+
+ verify(strategy).animateToTranslationY(view, 0f)
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - expandWithAnimation should translate the toolbar to to y 0`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ val view: View = mock()
+
+ strategy.expandWithAnimation(view)
+
+ verify(strategy).animateToTranslationY(view, 0f)
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - forceExpandWithAnimation should expand toolbar`() {
+ // Setting the scenario in which forceExpandWithAnimation will actually do what the name says.
+ // Below this test we can change each variable one at a time to test them in isolation.
+
+ val strategy = spy(BottomViewBehaviorStrategy())
+ strategy.wasLastExpanding = false
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100f).`when`(view).translationY
+
+ strategy.forceExpandWithAnimation(view, -100f)
+
+ verify(strategy.animator).cancel()
+ verify(strategy).expandWithAnimation(any())
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - forceExpandWithAnimation should not force expand the toolbar if not currently collapsing`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ strategy.wasLastExpanding = true
+ val animator: ValueAnimator = mock()
+ doReturn(true).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100f).`when`(view).translationY
+
+ strategy.forceExpandWithAnimation(view, -100f)
+
+ verify(strategy.animator, never()).cancel()
+ verify(strategy, never()).expandWithAnimation(any())
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - forceExpandWithAnimation should not expand if user swipes down`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ strategy.wasLastExpanding = false
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(100f).`when`(view).translationY
+
+ strategy.forceExpandWithAnimation(view, 100f)
+
+ verify(strategy.animator, never()).cancel()
+ verify(strategy, never()).expandWithAnimation(any())
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - forceExpandWithAnimation should not expand the toolbar if it is already expanded`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ strategy.wasLastExpanding = false
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(0f).`when`(view).translationY
+
+ strategy.forceExpandWithAnimation(view, -100f)
+
+ verify(strategy.animator, never()).cancel()
+ verify(strategy, never()).expandWithAnimation(any())
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - forceExpandWithAnimation should expand toolbar`() {
+ // Setting the scenario in which forceExpandWithAnimation will actually do what the name says.
+ // Below this test we can change each variable one at a time to test them in isolation.
+
+ val strategy = spy(TopViewBehaviorStrategy())
+ strategy.wasLastExpanding = false
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(-100f).`when`(view).translationY
+
+ strategy.forceExpandWithAnimation(view, -100f)
+
+ verify(strategy.animator).cancel()
+ verify(strategy).expandWithAnimation(any())
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - forceExpandWithAnimation should not force expand the toolbar if not currently collapsing`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ strategy.wasLastExpanding = true
+ val animator: ValueAnimator = mock()
+ doReturn(true).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(-100f).`when`(view).translationY
+
+ strategy.forceExpandWithAnimation(view, -100f)
+
+ verify(strategy.animator, never()).cancel()
+ verify(strategy, never()).expandWithAnimation(any())
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - forceExpandWithAnimation should not expand if user swipes up`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ strategy.wasLastExpanding = false
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(-100f).`when`(view).translationY
+
+ strategy.forceExpandWithAnimation(view, 10f)
+
+ verify(strategy.animator, never()).cancel()
+ verify(strategy, never()).expandWithAnimation(any())
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - forceExpandWithAnimation should not expand the toolbar if it is already expanded`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ strategy.wasLastExpanding = false
+ val animator: ValueAnimator = mock()
+ doReturn(false).`when`(animator).isStarted
+ strategy.animator = animator
+ val view: View = mock()
+ doReturn(0f).`when`(view).translationY
+
+ strategy.forceExpandWithAnimation(view, -100f)
+
+ verify(strategy.animator, never()).cancel()
+ verify(strategy, never()).expandWithAnimation(any())
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - collapseWithAnimation should animate translating the toolbar down, off-screen`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ strategy.collapseWithAnimation(view)
+
+ verify(strategy).animateToTranslationY(view, 100f)
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - collapseWithAnimation should animate translating the toolbar up, off-screen`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+
+ strategy.collapseWithAnimation(view)
+
+ verify(strategy).animateToTranslationY(view, -100f)
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - translate should translate up the toolbar with the distance parameter`() {
+ val strategy = BottomViewBehaviorStrategy()
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+ doReturn(50f).`when`(view).translationY
+
+ strategy.translate(view, -25f)
+
+ verify(view).translationY = 25f
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - translate should translate down the toolbar with the distance parameter`() {
+ val strategy = BottomViewBehaviorStrategy()
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+ doReturn(50f).`when`(view).translationY
+
+ strategy.translate(view, 25f)
+
+ verify(view).translationY = 75f
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - translate should not translate up the toolbar if already expanded`() {
+ val strategy = BottomViewBehaviorStrategy()
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+ doReturn(0f).`when`(view).translationY
+
+ strategy.translate(view, -1f)
+
+ verify(view).translationY = 0f
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - translate should not translate up the toolbar more than to 0`() {
+ val strategy = BottomViewBehaviorStrategy()
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+ doReturn(50f).`when`(view).translationY
+
+ strategy.translate(view, -51f)
+
+ verify(view).translationY = 0f
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - translate should not translate down the toolbar if already collapsed`() {
+ val strategy = BottomViewBehaviorStrategy()
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+ doReturn(100f).`when`(view).translationY
+
+ strategy.translate(view, 1f)
+
+ verify(view).translationY = 100f
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - translate should not translate down the toolbar more than it's height`() {
+ val strategy = BottomViewBehaviorStrategy()
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+ doReturn(50f).`when`(view).translationY
+
+ strategy.translate(view, 51f)
+
+ verify(view).translationY = 100f
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - translate should translate down the toolbar with the distance parameter`() {
+ val strategy = TopViewBehaviorStrategy()
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+ doReturn(-50f).`when`(view).translationY
+
+ strategy.translate(view, 25f)
+
+ verify(view).translationY = -75f
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - translate should translate up the toolbar with the distance parameter`() {
+ val strategy = TopViewBehaviorStrategy()
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+ doReturn(-50f).`when`(view).translationY
+
+ strategy.translate(view, 25f)
+
+ verify(view).translationY = -75f
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - translate should not translate down the toolbar if already expanded`() {
+ val strategy = TopViewBehaviorStrategy()
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+ doReturn(0f).`when`(view).translationY
+
+ strategy.translate(view, -1f)
+
+ verify(view).translationY = 0f
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - translate should not translate down the toolbar more than to 0`() {
+ val strategy = TopViewBehaviorStrategy()
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+ doReturn(-50f).`when`(view).translationY
+
+ strategy.translate(view, -51f)
+
+ verify(view).translationY = 0f
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - translate should not translate up the toolbar if already collapsed`() {
+ val strategy = TopViewBehaviorStrategy()
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+ doReturn(-100f).`when`(view).translationY
+
+ strategy.translate(view, 1f)
+
+ verify(view).translationY = -100f
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - translate should not translate up the toolbar more than it's height`() {
+ val strategy = TopViewBehaviorStrategy()
+ val view: View = mock()
+ doReturn(100).`when`(view).height
+ doReturn(-50f).`when`(view).translationY
+
+ strategy.translate(view, 51f)
+
+ verify(view).translationY = -100f
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - animateToTranslationY should set wasLastExpanding if expanding`() {
+ val strategy = BottomViewBehaviorStrategy()
+ strategy.wasLastExpanding = false
+ val view: View = mock()
+ doReturn(50f).`when`(view).translationY
+
+ strategy.animateToTranslationY(view, 10f)
+ assertTrue(strategy.wasLastExpanding)
+
+ strategy.animateToTranslationY(view, 60f)
+ assertFalse(strategy.wasLastExpanding)
+ }
+
+ @Test
+ fun `BottomToolbarBehaviorStrategy - animateToTranslationY should animate to the indicated y translation`() {
+ val strategy = spy(BottomViewBehaviorStrategy())
+ strategy.wasLastExpanding = false
+ val view = View(testContext)
+ val animator: ValueAnimator = spy(strategy.animator)
+ strategy.animator = animator
+
+ strategy.animateToTranslationY(view, 10f)
+
+ verify(animator).start()
+ animator.end()
+ assertEquals(10f, view.translationY)
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - animateToTranslationY should set wasLastExpanding if expanding`() {
+ val strategy = TopViewBehaviorStrategy()
+ strategy.wasLastExpanding = false
+ val view: View = mock()
+ doReturn(-50f).`when`(view).translationY
+
+ strategy.animateToTranslationY(view, -10f)
+ assertTrue(strategy.wasLastExpanding)
+
+ strategy.animateToTranslationY(view, -60f)
+ assertFalse(strategy.wasLastExpanding)
+ }
+
+ @Test
+ fun `TopToolbarBehaviorStrategy - animateToTranslationY should animate to the indicated y translation`() {
+ val strategy = spy(TopViewBehaviorStrategy())
+ strategy.wasLastExpanding = false
+ val view = View(testContext)
+ val animator: ValueAnimator = spy(strategy.animator)
+ strategy.animator = animator
+
+ strategy.animateToTranslationY(view, -10f)
+
+ verify(animator).start()
+ animator.end()
+ assertEquals(-10f, view.translationY)
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/ViewYTranslatorTest.kt b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/ViewYTranslatorTest.kt
new file mode 100644
index 0000000000..6a3a908adc
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/ViewYTranslatorTest.kt
@@ -0,0 +1,113 @@
+/* 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.widgets.behavior
+
+import android.view.View
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class ViewYTranslatorTest {
+ @Test
+ fun `yTranslator should use BottomToolbarBehaviorStrategy for bottom placed toolbars`() {
+ val yTranslator = ViewYTranslator(ViewPosition.BOTTOM)
+
+ assertTrue(yTranslator.strategy is BottomViewBehaviorStrategy)
+ }
+
+ @Test
+ fun `yTranslator should use TopToolbarBehaviorStrategy for top placed toolbars`() {
+ val yTranslator = ViewYTranslator(ViewPosition.TOP)
+
+ assertTrue(yTranslator.strategy is TopViewBehaviorStrategy)
+ }
+
+ @Test
+ fun `yTranslator should delegate it's strategy for snapWithAnimation`() {
+ val yTranslator = ViewYTranslator(ViewPosition.BOTTOM)
+ val strategy: ViewYTranslationStrategy = mock()
+ yTranslator.strategy = strategy
+ val view: View = mock()
+
+ yTranslator.snapWithAnimation(view)
+
+ verify(strategy).snapWithAnimation(view)
+ }
+
+ @Test
+ fun `yTranslator should delegate it's strategy for expandWithAnimation`() {
+ val yTranslator = ViewYTranslator(ViewPosition.BOTTOM)
+ val strategy: ViewYTranslationStrategy = mock()
+ yTranslator.strategy = strategy
+ val view: View = mock()
+
+ yTranslator.expandWithAnimation(view)
+
+ verify(strategy).expandWithAnimation(view)
+ }
+
+ @Test
+ fun `yTranslator should delegate it's strategy for collapseWithAnimation`() {
+ val yTranslator = ViewYTranslator(ViewPosition.BOTTOM)
+ val strategy: ViewYTranslationStrategy = mock()
+ yTranslator.strategy = strategy
+ val view: View = mock()
+
+ yTranslator.collapseWithAnimation(view)
+
+ verify(strategy).collapseWithAnimation(view)
+ }
+
+ @Test
+ fun `yTranslator should delegate it's strategy for forceExpandIfNotAlready`() {
+ val yTranslator = ViewYTranslator(ViewPosition.BOTTOM)
+ val strategy: ViewYTranslationStrategy = mock()
+ yTranslator.strategy = strategy
+ val view: View = mock()
+
+ yTranslator.forceExpandIfNotAlready(view, 14f)
+
+ verify(strategy).forceExpandWithAnimation(view, 14f)
+ }
+
+ @Test
+ fun `yTranslator should delegate it's strategy for translate`() {
+ val yTranslator = ViewYTranslator(ViewPosition.BOTTOM)
+ val strategy: ViewYTranslationStrategy = mock()
+ yTranslator.strategy = strategy
+ val view: View = mock()
+
+ yTranslator.translate(view, 23f)
+
+ verify(strategy).translate(view, 23f)
+ }
+
+ @Test
+ fun `yTranslator should delegate it's strategy for cancelInProgressTranslation`() {
+ val yTranslator = ViewYTranslator(ViewPosition.BOTTOM)
+ val strategy: ViewYTranslationStrategy = mock()
+ yTranslator.strategy = strategy
+
+ yTranslator.cancelInProgressTranslation()
+
+ verify(strategy).cancelInProgressTranslation()
+ }
+
+ @Test
+ fun `yTranslator should delegate it's strategy for snapImmediately`() {
+ val yTranslator = ViewYTranslator(ViewPosition.BOTTOM)
+ val strategy: ViewYTranslationStrategy = mock()
+ yTranslator.strategy = strategy
+ val view: View = mock()
+
+ yTranslator.snapImmediately(view)
+
+ verify(strategy).snapImmediately(view)
+ }
+}
diff --git a/mobile/android/android-components/components/ui/widgets/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/ui/widgets/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/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/mobile/android/android-components/components/ui/widgets/src/test/resources/robolectric.properties b/mobile/android/android-components/components/ui/widgets/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/ui/widgets/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28