diff --git a/app/src/main/java/org/mozilla/fenix/browser/ToolbarGestureHandler.kt b/app/src/main/java/org/mozilla/fenix/browser/ToolbarGestureHandler.kt index 11fe97a65..747c5cb40 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/ToolbarGestureHandler.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/ToolbarGestureHandler.kt @@ -9,7 +9,6 @@ import android.animation.AnimatorListenerAdapter import android.app.Activity import android.graphics.PointF import android.graphics.Rect -import android.os.Build import android.util.TypedValue import android.view.View import android.view.ViewConfiguration @@ -17,7 +16,6 @@ import androidx.annotation.Dimension import androidx.annotation.Dimension.DP import androidx.core.graphics.contains import androidx.core.graphics.toPoint -import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.dynamicanimation.animation.DynamicAnimation import androidx.dynamicanimation.animation.FlingAnimation @@ -25,6 +23,8 @@ import mozilla.components.browser.session.Session import mozilla.components.browser.session.SessionManager import mozilla.components.support.ktx.android.util.dpToPx import mozilla.components.support.ktx.android.view.getRectWithViewLocation +import org.mozilla.fenix.ext.getWindowInsets +import org.mozilla.fenix.ext.isKeyboardVisible import org.mozilla.fenix.ext.sessionsOfType import org.mozilla.fenix.ext.settings import kotlin.math.abs @@ -35,7 +35,6 @@ import kotlin.math.min * Handles intercepting touch events on the toolbar for swipe gestures and executes the * necessary animations. */ -@Suppress("LargeClass", "TooManyFunctions") class ToolbarGestureHandler( private val activity: Activity, private val contentLayout: View, @@ -56,18 +55,6 @@ class ToolbarGestureHandler( private val windowWidth: Int get() = activity.resources.displayMetrics.widthPixels - private val windowInsets: WindowInsetsCompat? - get() = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - // In theory, the rootWindowInsets should exist at this point but if the decorView is - // not attached for some reason we'll get a NullPointerException without the check. - activity.window.decorView.rootWindowInsets?.let { - WindowInsetsCompat.toWindowInsetsCompat(it) - } - } else { - null - } - private val previewOffset = PREVIEW_OFFSET.dpToPx(activity.resources.displayMetrics) private val touchSlop = ViewConfiguration.get(activity).scaledTouchSlop @@ -89,7 +76,12 @@ class ToolbarGestureHandler( GestureDirection.LEFT_TO_RIGHT } - return if (start.isInToolbar() && abs(dx) > touchSlop && abs(dy) < abs(dx)) { + return if ( + !activity.window.decorView.isKeyboardVisible() && + start.isInToolbar() && + abs(dx) > touchSlop && + abs(dy) < abs(dx) + ) { preparePreview(getDestination()) true } else { @@ -313,7 +305,7 @@ class ToolbarGestureHandler( val toolbarLocation = toolbarLayout.getRectWithViewLocation() // In Android 10, the system gesture touch area overlaps the bottom of the toolbar, so // lets make our swipe area taller by that amount - windowInsets?.let { insets -> + activity.window.decorView.getWindowInsets()?.let { insets -> if (activity.settings().shouldUseBottomToolbar) { toolbarLocation.top -= (insets.mandatorySystemGestureInsets.bottom - insets.stableInsetBottom) } diff --git a/app/src/main/java/org/mozilla/fenix/ext/View.kt b/app/src/main/java/org/mozilla/fenix/ext/View.kt index 3e414323c..05c2e8de7 100644 --- a/app/src/main/java/org/mozilla/fenix/ext/View.kt +++ b/app/src/main/java/org/mozilla/fenix/ext/View.kt @@ -5,8 +5,12 @@ package org.mozilla.fenix.ext import android.graphics.Rect +import android.os.Build import android.view.TouchDelegate import android.view.View +import androidx.annotation.Dimension +import androidx.annotation.VisibleForTesting +import androidx.core.view.WindowInsetsCompat import mozilla.components.support.ktx.android.util.dpToPx fun View.increaseTapArea(extraDps: Int) { @@ -26,3 +30,61 @@ fun View.removeTouchDelegate() { parent.touchDelegate = null } } + +/** + * A safer version of [ViewCompat.getRootWindowInsets] that does not throw a NullPointerException + * if the view is not attached. + */ +fun View.getWindowInsets(): WindowInsetsCompat? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + rootWindowInsets?.let { + WindowInsetsCompat.toWindowInsetsCompat(it) + } + } else { + null + } +} + +/** + * Checks if the keyboard is visible + * + * Inspired by https://stackoverflow.com/questions/2150078/how-to-check-visibility-of-software-keyboard-in-android + * API 30 adds a native method for this. We should use it (and a compat method if one + * is added) when it becomes available + */ +fun View.isKeyboardVisible(): Boolean { + // Since we have insets in M and above, we don't need to guess what the keyboard height is. + // Otherwise, we make a guess at the minimum height of the keyboard to account for the + // navigation bar. + val minimumKeyboardHeight = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + 0 + } else { + MINIMUM_KEYBOARD_HEIGHT.dpToPx(resources.displayMetrics) + } + return getKeyboardHeight() > minimumKeyboardHeight +} + +@VisibleForTesting +internal fun View.getWindowVisibleDisplayFrame(): Rect = with(Rect()) { + getWindowVisibleDisplayFrame(this) + this +} + +@VisibleForTesting +internal fun View.getKeyboardHeight(): Int { + val windowRect = getWindowVisibleDisplayFrame() + val statusBarHeight = windowRect.top + var keyboardHeight = rootView.height - (windowRect.height() + statusBarHeight) + getWindowInsets()?.let { + keyboardHeight -= it.stableInsetBottom + } + + return keyboardHeight +} + +/** + * The assumed minimum height of the keyboard. + */ +@VisibleForTesting +@Dimension(unit = Dimension.DP) +internal const val MINIMUM_KEYBOARD_HEIGHT = 100 diff --git a/app/src/test/java/org/mozilla/fenix/ext/ViewTest.kt b/app/src/test/java/org/mozilla/fenix/ext/ViewTest.kt index a70ec0abd..c9da170fb 100644 --- a/app/src/test/java/org/mozilla/fenix/ext/ViewTest.kt +++ b/app/src/test/java/org/mozilla/fenix/ext/ViewTest.kt @@ -5,14 +5,18 @@ package org.mozilla.fenix.ext import android.graphics.Rect +import android.os.Build import android.util.DisplayMetrics import android.view.View +import android.view.WindowInsets import android.widget.FrameLayout +import androidx.core.view.WindowInsetsCompat import io.mockk.MockKAnnotations import io.mockk.Runs import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just +import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.slot import io.mockk.verify @@ -20,7 +24,11 @@ import mozilla.components.support.ktx.android.util.dpToPx import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.robolectric.annotation.Config +@RunWith(FenixRobolectricTestRunner::class) class ViewTest { @MockK private lateinit var view: View @@ -31,6 +39,7 @@ class ViewTest { fun setup() { MockKAnnotations.init(this) mockkStatic("mozilla.components.support.ktx.android.util.DisplayMetricsKt") + mockkStatic("org.mozilla.fenix.ext.ViewKt") every { view.resources.displayMetrics } returns displayMetrics every { view.parent } returns parent @@ -66,4 +75,77 @@ class ViewTest { view.removeTouchDelegate() verify { parent.touchDelegate = null } } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.LOLLIPOP_MR1]) + @Test + fun `getWindowInsets returns null below API 23`() { + assertEquals(null, view.getWindowInsets()) + } + + @Test + fun `getWindowInsets returns null when the system insets don't exist`() { + every { view.rootWindowInsets } returns null + assertEquals(null, view.getWindowInsets()) + } + + @Test + fun `getWindowInsets returns the compat insets when the system insets exist`() { + val rootInsets: WindowInsets = mockk(relaxed = true) + every { view.rootWindowInsets } returns rootInsets + + assertEquals(WindowInsetsCompat.toWindowInsetsCompat(rootInsets), view.getWindowInsets()) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.LOLLIPOP_MR1]) + @Test + fun `getKeyboardHeight accounts for status bar below API 23`() { + every { view.getWindowVisibleDisplayFrame() } returns Rect(0, 50, 1000, 500) + every { view.rootView.height } returns 1000 + + assertEquals(500, view.getKeyboardHeight()) + } + + @Test + fun `getKeyboardHeight accounts for status bar and navigation bar`() { + every { view.getWindowVisibleDisplayFrame() } returns Rect(0, 50, 1000, 500) + every { view.rootView.height } returns 1000 + every { view.getWindowInsets() } returns mockk(relaxed = true) { + every { stableInsetBottom } returns 50 + } + + assertEquals(450, view.getKeyboardHeight()) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.LOLLIPOP_MR1]) + @Test + fun `isKeyboardVisible returns false when the keyboard height is less than or equal to the minimum threshold`() { + val threshold = MINIMUM_KEYBOARD_HEIGHT.dpToPx(displayMetrics) + + every { view.getKeyboardHeight() } returns threshold - 1 + assertEquals(false, view.isKeyboardVisible()) + + every { view.getKeyboardHeight() } returns threshold + assertEquals(false, view.isKeyboardVisible()) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.LOLLIPOP_MR1]) + @Test + fun `isKeyboardVisible returns true when the keyboard height is greater than the minimum threshold`() { + val threshold = MINIMUM_KEYBOARD_HEIGHT.dpToPx(displayMetrics) + every { view.getKeyboardHeight() } returns threshold + 1 + + assertEquals(true, view.isKeyboardVisible()) + } + + @Test + fun `isKeyboardVisible returns false when the keyboard height is 0`() { + every { view.getKeyboardHeight() } returns 0 + assertEquals(false, view.isKeyboardVisible()) + } + + @Test + fun `isKeyboardVisible returns true when the keyboard height is greater than 0`() { + every { view.getKeyboardHeight() } returns 100 + assertEquals(true, view.isKeyboardVisible()) + } }