diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:42 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:42 +0000 |
commit | da4c7e7ed675c3bf405668739c3012d140856109 (patch) | |
tree | cdd868dba063fecba609a1d819de271f0d51b23e /mobile/android/android-components/components/ui/widgets/src | |
parent | Adding upstream version 125.0.3. (diff) | |
download | firefox-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')
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 |