Merge "Have bubble bar unstash gesture track finger" into main
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
index 084067c..a59445b 100644
--- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
+++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
@@ -108,6 +108,7 @@
 import com.android.launcher3.taskbar.allapps.TaskbarAllAppsController;
 import com.android.launcher3.taskbar.bubbles.BubbleBarController;
 import com.android.launcher3.taskbar.bubbles.BubbleBarPinController;
+import com.android.launcher3.taskbar.bubbles.BubbleBarSwipeController;
 import com.android.launcher3.taskbar.bubbles.BubbleBarView;
 import com.android.launcher3.taskbar.bubbles.BubbleBarViewController;
 import com.android.launcher3.taskbar.bubbles.BubbleControllers;
@@ -279,9 +280,11 @@
         BubbleBarController.onTaskbarRecreated();
         if (BubbleBarController.isBubbleBarEnabled() && bubbleBarView != null) {
             Optional<BubbleStashedHandleViewController> bubbleHandleController = Optional.empty();
+            Optional<BubbleBarSwipeController> bubbleBarSwipeController = Optional.empty();
             if (isTransientTaskbar) {
                 bubbleHandleController = Optional.of(
                         new BubbleStashedHandleViewController(this, bubbleHandleView));
+                bubbleBarSwipeController = Optional.of(new BubbleBarSwipeController(this));
             }
             TaskbarHotseatDimensionsProvider dimensionsProvider =
                     new DeviceProfileDimensionsProviderAdapter(this);
@@ -299,6 +302,7 @@
                             () -> DisplayController.INSTANCE.get(this).getInfo().currentSize),
                     new BubblePinController(this, mDragLayer,
                             () -> DisplayController.INSTANCE.get(this).getInfo().currentSize),
+                    bubbleBarSwipeController,
                     new BubbleCreator(this)
             ));
         }
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarSwipeController.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarSwipeController.kt
new file mode 100644
index 0000000..a831fd7
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarSwipeController.kt
@@ -0,0 +1,203 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.taskbar.bubbles
+
+import android.animation.ValueAnimator
+import android.content.Context
+import androidx.annotation.VisibleForTesting
+import androidx.core.animation.doOnEnd
+import androidx.dynamicanimation.animation.SpringForce
+import com.android.launcher3.anim.AnimatedFloat
+import com.android.launcher3.anim.SpringAnimationBuilder
+import com.android.launcher3.taskbar.TaskbarActivityContext
+import com.android.launcher3.taskbar.TaskbarThresholdUtils
+import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController
+import com.android.launcher3.touch.OverScroll
+
+/** Handle swipe events on the bubble bar and handle */
+class BubbleBarSwipeController {
+
+    private val context: Context
+
+    private var bubbleStashedHandleViewController: BubbleStashedHandleViewController? = null
+    private var bubbleBarViewController: BubbleBarViewController? = null
+    private var bubbleStashController: BubbleStashController? = null
+
+    private var springAnimation: ValueAnimator? = null
+    private val animatedSwipeTranslation = AnimatedFloat(this::onSwipeUpdate)
+
+    private val unstashThreshold: Int
+    private val expandThreshold: Int
+    private val maxOverscroll: Int
+
+    private var swipeState: SwipeState = SwipeState()
+
+    constructor(tac: TaskbarActivityContext) : this(tac, DefaultDimensionProvider(tac))
+
+    @VisibleForTesting
+    constructor(context: Context, dimensionProvider: DimensionProvider) {
+        this.context = context
+        unstashThreshold = dimensionProvider.unstashThreshold
+        expandThreshold = dimensionProvider.expandThreshold
+        maxOverscroll = dimensionProvider.maxOverscroll
+    }
+
+    fun init(bubbleControllers: BubbleControllers) {
+        bubbleStashedHandleViewController =
+            bubbleControllers.bubbleStashedHandleViewController.orElse(null)
+        bubbleBarViewController = bubbleControllers.bubbleBarViewController
+        bubbleStashController = bubbleControllers.bubbleStashController
+    }
+
+    /** Start tracking a new swipe gesture */
+    fun start() {
+        if (springAnimation != null) reset()
+        val stashed = bubbleStashController?.isStashed ?: false
+        val barVisible = bubbleStashController?.isBubbleBarVisible() ?: false
+        val expanded = bubbleBarViewController?.isExpanded ?: false
+
+        swipeState =
+            SwipeState(
+                stashedOnStart = stashed,
+                collapsedOnStart = !stashed && barVisible && !expanded,
+                expandedOnStart = expanded,
+            )
+    }
+
+    /** Update swipe distance to [dy] */
+    fun swipeTo(dy: Float) {
+        // Only handle swipe up and stashed or collapsed bar
+        if (dy > 0 || swipeState.expandedOnStart) return
+
+        animatedSwipeTranslation.updateValue(dy)
+
+        val prevState = swipeState
+        // We can pass unstash threshold once per gesture, keep it true if it happened once
+        val passedUnstashThreshold = isUnstash(dy) || prevState.passedUnstashThreshold
+        // Expand happens at the end of the gesture, always keep the current value
+        val passedExpandThreshold = isExpand(dy)
+
+        if (
+            passedUnstashThreshold != prevState.passedUnstashThreshold ||
+                passedExpandThreshold != prevState.passedExpandThreshold
+        ) {
+            swipeState =
+                swipeState.copy(
+                    passedUnstashThreshold = passedUnstashThreshold,
+                    passedExpandThreshold = passedExpandThreshold,
+                )
+        }
+
+        if (
+            swipeState.stashedOnStart &&
+                swipeState.passedUnstashThreshold &&
+                !prevState.passedUnstashThreshold
+        ) {
+            bubbleStashController?.showBubbleBar(expandBubbles = false)
+        }
+    }
+
+    /** Finish tracking swipe gesture. Animate views back to resting state */
+    fun finish() {
+        if (swipeState.passedExpandThreshold) {
+            bubbleStashController?.showBubbleBar(expandBubbles = true)
+        }
+        springToRest()
+    }
+
+    /** Returns `true` if we are tracking a swipe gesture */
+    fun isSwipeGesture(): Boolean {
+        return swipeState.passedUnstashThreshold || swipeState.passedExpandThreshold
+    }
+
+    private fun isUnstash(dy: Float): Boolean {
+        return dy < -unstashThreshold
+    }
+
+    private fun isExpand(dy: Float): Boolean {
+        return dy < -expandThreshold
+    }
+
+    private fun reset() {
+        springAnimation?.let {
+            if (it.isRunning) {
+                it.removeAllListeners()
+                it.cancel()
+                animatedSwipeTranslation.updateValue(0f)
+            }
+        }
+        springAnimation = null
+        swipeState = SwipeState()
+    }
+
+    private fun onSwipeUpdate(value: Float) {
+        val dampedSwipe = -OverScroll.dampedScroll(-value, maxOverscroll).toFloat()
+        bubbleStashedHandleViewController?.setTranslationYForSwipe(dampedSwipe)
+        bubbleBarViewController?.setTranslationYForSwipe(dampedSwipe)
+    }
+
+    private fun springToRest() {
+        springAnimation =
+            SpringAnimationBuilder(context)
+                .setStartValue(animatedSwipeTranslation.value)
+                .setEndValue(0f)
+                .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
+                .setStiffness(SpringForce.STIFFNESS_LOW)
+                .build(animatedSwipeTranslation, AnimatedFloat.VALUE)
+                .also { it.doOnEnd { reset() } }
+        springAnimation?.start()
+    }
+
+    internal data class SwipeState(
+        val stashedOnStart: Boolean = false,
+        val collapsedOnStart: Boolean = false,
+        val expandedOnStart: Boolean = false,
+        val passedUnstashThreshold: Boolean = false,
+        val passedExpandThreshold: Boolean = false,
+    )
+
+    /** Allows overriding the dimension provider for testing */
+    @VisibleForTesting
+    interface DimensionProvider {
+        val unstashThreshold: Int
+        val expandThreshold: Int
+        val maxOverscroll: Int
+    }
+
+    private class DefaultDimensionProvider(taskbarActivityContext: TaskbarActivityContext) :
+        DimensionProvider {
+        override val unstashThreshold: Int
+        override val expandThreshold: Int
+        override val maxOverscroll: Int
+
+        init {
+            val resources = taskbarActivityContext.resources
+            unstashThreshold =
+                TaskbarThresholdUtils.getFromNavThreshold(
+                    resources,
+                    taskbarActivityContext.deviceProfile,
+                )
+            // TODO(325673340): review threshold with ux
+            expandThreshold =
+                TaskbarThresholdUtils.getAppWindowThreshold(
+                    resources,
+                    taskbarActivityContext.deviceProfile,
+                )
+            maxOverscroll = taskbarActivityContext.deviceProfile.heightPx - unstashThreshold
+        }
+    }
+}
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleControllers.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleControllers.java
index a66df4c..8230f42 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleControllers.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleControllers.java
@@ -40,6 +40,7 @@
     public final BubbleDismissController bubbleDismissController;
     public final BubbleBarPinController bubbleBarPinController;
     public final BubblePinController bubblePinController;
+    public final Optional<BubbleBarSwipeController> bubbleBarSwipeController;
     public final BubbleCreator bubbleCreator;
 
     private final RunnableList mPostInitRunnables = new RunnableList();
@@ -58,6 +59,7 @@
             BubbleDismissController bubbleDismissController,
             BubbleBarPinController bubbleBarPinController,
             BubblePinController bubblePinController,
+            Optional<BubbleBarSwipeController> bubbleBarSwipeController,
             BubbleCreator bubbleCreator) {
         this.bubbleBarController = bubbleBarController;
         this.bubbleBarViewController = bubbleBarViewController;
@@ -67,6 +69,7 @@
         this.bubbleDismissController = bubbleDismissController;
         this.bubbleBarPinController = bubbleBarPinController;
         this.bubblePinController = bubblePinController;
+        this.bubbleBarSwipeController = bubbleBarSwipeController;
         this.bubbleCreator = bubbleCreator;
     }
 
@@ -111,6 +114,7 @@
         bubbleDismissController.init(/* bubbleControllers = */ this);
         bubbleBarPinController.init(this, bubbleBarLocationListeners);
         bubblePinController.init(this);
+        bubbleBarSwipeController.ifPresent(c -> c.init(this));
 
         mPostInitRunnables.executeAllAndDestroy();
     }
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleCreator.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleCreator.java
index 12b1487..340a120 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleCreator.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleCreator.java
@@ -44,8 +44,6 @@
 import android.view.LayoutInflater;
 import android.view.ViewGroup;
 
-import androidx.appcompat.content.res.AppCompatResources;
-
 import com.android.internal.graphics.ColorUtils;
 import com.android.launcher3.R;
 import com.android.launcher3.icons.BitmapInfo;
@@ -196,8 +194,7 @@
     }
 
     private Bitmap createOverflowBitmap() {
-        Drawable iconDrawable = AppCompatResources.getDrawable(mContext,
-                R.drawable.bubble_ic_overflow_button);
+        Drawable iconDrawable = mContext.getDrawable(R.drawable.bubble_ic_overflow_button);
 
         final TypedArray ta = mContext.obtainStyledAttributes(
                 new int[]{
diff --git a/quickstep/src/com/android/quickstep/inputconsumers/BubbleBarInputConsumer.java b/quickstep/src/com/android/quickstep/inputconsumers/BubbleBarInputConsumer.java
index 92031c5..778c231 100644
--- a/quickstep/src/com/android/quickstep/inputconsumers/BubbleBarInputConsumer.java
+++ b/quickstep/src/com/android/quickstep/inputconsumers/BubbleBarInputConsumer.java
@@ -23,10 +23,12 @@
 import android.view.MotionEvent;
 import android.view.ViewConfiguration;
 
+import androidx.annotation.Nullable;
+
 import com.android.launcher3.taskbar.TaskbarActivityContext;
+import com.android.launcher3.taskbar.bubbles.BubbleBarSwipeController;
 import com.android.launcher3.taskbar.bubbles.BubbleBarViewController;
 import com.android.launcher3.taskbar.bubbles.BubbleControllers;
-import com.android.launcher3.taskbar.bubbles.BubbleDragController;
 import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController;
 import com.android.launcher3.testing.TestLogging;
 import com.android.launcher3.testing.shared.TestProtocol;
@@ -40,10 +42,11 @@
 
     private final BubbleStashController mBubbleStashController;
     private final BubbleBarViewController mBubbleBarViewController;
-    private final BubbleDragController mBubbleDragController;
+    @Nullable
+    private final BubbleBarSwipeController mBubbleBarSwipeController;
     private final InputMonitorCompat mInputMonitorCompat;
 
-    private boolean mSwipeUpOnBubbleHandle;
+    private boolean mPilfered;
     private boolean mPassedTouchSlop;
     private boolean mStashedOrCollapsedOnDown;
 
@@ -57,7 +60,8 @@
             InputMonitorCompat inputMonitorCompat) {
         mBubbleStashController = bubbleControllers.bubbleStashController;
         mBubbleBarViewController = bubbleControllers.bubbleBarViewController;
-        mBubbleDragController = bubbleControllers.bubbleDragController;
+        mBubbleBarSwipeController = bubbleControllers.bubbleBarSwipeController.orElse(null);
+
         mInputMonitorCompat = inputMonitorCompat;
         mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
         mTimeForTap = ViewConfiguration.getTapTimeout();
@@ -77,6 +81,9 @@
                 mDownPos.set(ev.getX(), ev.getY());
                 mLastPos.set(mDownPos);
                 mStashedOrCollapsedOnDown = mBubbleStashController.isStashed() || isCollapsed();
+                if (mBubbleBarSwipeController != null) {
+                    mBubbleBarSwipeController.start();
+                }
                 break;
             case MotionEvent.ACTION_MOVE:
                 int pointerIndex = ev.findPointerIndex(mActivePointerId);
@@ -90,11 +97,10 @@
                 if (!mPassedTouchSlop) {
                     mPassedTouchSlop = Math.abs(dY) > mTouchSlop || Math.abs(dX) > mTouchSlop;
                 }
-                if (mStashedOrCollapsedOnDown && !mSwipeUpOnBubbleHandle && mPassedTouchSlop) {
-                    boolean verticalGesture = Math.abs(dY) > Math.abs(dX);
-                    if (verticalGesture && !mBubbleDragController.isDragging()) {
-                        mSwipeUpOnBubbleHandle = true;
-                        mBubbleStashController.showBubbleBar(/* expandBubbles= */ true);
+                if (mBubbleBarSwipeController != null) {
+                    mBubbleBarSwipeController.swipeTo(dY);
+                    if (!mPilfered && mBubbleBarSwipeController.isSwipeGesture()) {
+                        mPilfered = true;
                         // Bubbles is handling the swipe so make sure no one else gets it.
                         TestLogging.recordEvent(TestProtocol.SEQUENCE_PILFER, "pilferPointers");
                         mInputMonitorCompat.pilferPointers();
@@ -102,8 +108,10 @@
                 }
                 break;
             case MotionEvent.ACTION_UP:
+                boolean swipeUpOnBubbleHandle = mBubbleBarSwipeController != null
+                        && mBubbleBarSwipeController.isSwipeGesture();
                 boolean isWithinTapTime = ev.getEventTime() - ev.getDownTime() <= mTimeForTap;
-                if (isWithinTapTime && !mSwipeUpOnBubbleHandle && !mPassedTouchSlop
+                if (isWithinTapTime && !swipeUpOnBubbleHandle && !mPassedTouchSlop
                         && mStashedOrCollapsedOnDown) {
                     // Taps on the handle / collapsed state should open the bar
                     mBubbleStashController.showBubbleBar(/* expandBubbles= */ true);
@@ -116,8 +124,11 @@
     }
 
     private void cleanupAfterMotionEvent() {
+        if (mBubbleBarSwipeController != null) {
+            mBubbleBarSwipeController.finish();
+        }
         mPassedTouchSlop = false;
-        mSwipeUpOnBubbleHandle = false;
+        mPilfered = false;
     }
 
     private boolean isCollapsed() {
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/BubbleBarInputConsumerTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/BubbleBarInputConsumerTest.kt
index 785ec66..c8f50f7 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/BubbleBarInputConsumerTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/BubbleBarInputConsumerTest.kt
@@ -49,6 +49,7 @@
     @Mock private lateinit var bubbleDismissController: BubbleDismissController
     @Mock private lateinit var bubbleBarPinController: BubbleBarPinController
     @Mock private lateinit var bubblePinController: BubblePinController
+    @Mock private lateinit var bubbleBarSwipeController: BubbleBarSwipeController
     @Mock private lateinit var bubbleCreator: BubbleCreator
 
     @Mock private lateinit var motionEvent: MotionEvent
@@ -67,7 +68,8 @@
                 bubbleDismissController,
                 bubbleBarPinController,
                 bubblePinController,
-                bubbleCreator
+                Optional.of(bubbleBarSwipeController),
+                bubbleCreator,
             )
     }
 
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/BubbleBarSwipeControllerTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/BubbleBarSwipeControllerTest.kt
new file mode 100644
index 0000000..97847be
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/BubbleBarSwipeControllerTest.kt
@@ -0,0 +1,327 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.taskbar.bubbles
+
+import android.animation.AnimatorTestRule
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController
+import com.android.launcher3.touch.OverScroll
+import com.google.common.truth.Truth.assertThat
+import java.util.Optional
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.atLeastOnce
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@RunWith(AndroidJUnit4::class)
+class BubbleBarSwipeControllerTest {
+
+    companion object {
+        const val UNSTASH_THRESHOLD = 100
+        const val EXPAND_THRESHOLD = 200
+        const val MAX_OVERSCROLL = 300
+
+        const val UP_BELOW_UNSTASH = -UNSTASH_THRESHOLD + 10f
+        const val UP_ABOVE_UNSTASH = -UNSTASH_THRESHOLD - 10f
+        const val UP_ABOVE_EXPAND = -EXPAND_THRESHOLD - 10f
+        const val DOWN_BELOW_UNSTASH = UNSTASH_THRESHOLD + 10f
+    }
+
+    private val context = ApplicationProvider.getApplicationContext<Context>()
+
+    @get:Rule(order = 0) val mockitoRule: MockitoRule = MockitoJUnit.rule()
+    @get:Rule(order = 1) val animatorTestRule: AnimatorTestRule = AnimatorTestRule(this)
+
+    private lateinit var bubbleBarSwipeController: BubbleBarSwipeController
+
+    @Mock private lateinit var bubbleBarController: BubbleBarController
+    @Mock private lateinit var bubbleBarViewController: BubbleBarViewController
+    @Mock private lateinit var bubbleStashController: BubbleStashController
+    @Mock private lateinit var bubbleStashedHandleViewController: BubbleStashedHandleViewController
+    @Mock private lateinit var bubbleDragController: BubbleDragController
+    @Mock private lateinit var bubbleDismissController: BubbleDismissController
+    @Mock private lateinit var bubbleBarPinController: BubbleBarPinController
+    @Mock private lateinit var bubblePinController: BubblePinController
+    @Mock private lateinit var bubbleCreator: BubbleCreator
+
+    @Before
+    fun setUp() {
+        val dimensionProvider =
+            object : BubbleBarSwipeController.DimensionProvider {
+                override val unstashThreshold: Int
+                    get() = UNSTASH_THRESHOLD
+
+                override val expandThreshold: Int
+                    get() = EXPAND_THRESHOLD
+
+                override val maxOverscroll: Int
+                    get() = MAX_OVERSCROLL
+            }
+        bubbleBarSwipeController = BubbleBarSwipeController(context, dimensionProvider)
+
+        val bubbleControllers =
+            BubbleControllers(
+                bubbleBarController,
+                bubbleBarViewController,
+                bubbleStashController,
+                Optional.of(bubbleStashedHandleViewController),
+                bubbleDragController,
+                bubbleDismissController,
+                bubbleBarPinController,
+                bubblePinController,
+                Optional.of(bubbleBarSwipeController),
+                bubbleCreator,
+            )
+
+        bubbleBarSwipeController.init(bubbleControllers)
+    }
+
+    private fun testViewsHaveDampedTranslationOnSwipe(swipe: Float) {
+        val dampedTranslation = -OverScroll.dampedScroll(-swipe, MAX_OVERSCROLL).toFloat()
+        getInstrumentation().runOnMainSync {
+            bubbleBarSwipeController.start()
+            bubbleBarSwipeController.swipeTo(swipe)
+        }
+        verify(bubbleStashedHandleViewController).setTranslationYForSwipe(dampedTranslation)
+        verify(bubbleBarViewController).setTranslationYForSwipe(dampedTranslation)
+    }
+
+    @Test
+    fun swipeUp_stashedBar_belowUnstashThreshold_viewsHaveDampedTranslation() {
+        setUpStashedBar()
+        testViewsHaveDampedTranslationOnSwipe(UP_BELOW_UNSTASH)
+    }
+
+    @Test
+    fun swipeUp_stashedBar_aboveUnstashThreshold_viewsHaveDampedTranslation() {
+        setUpStashedBar()
+        testViewsHaveDampedTranslationOnSwipe(UP_ABOVE_UNSTASH)
+    }
+
+    @Test
+    fun swipeUp_stashedBar_aboveExpandThreshold_viewsHaveDampedTranslation() {
+        setUpStashedBar()
+        testViewsHaveDampedTranslationOnSwipe(UP_ABOVE_EXPAND)
+    }
+
+    @Test
+    fun swipeUp_collapsedBar_aboveUnstashThreshold_viewsHaveDampedTranslation() {
+        setUpCollapsedBar()
+        testViewsHaveDampedTranslationOnSwipe(UP_ABOVE_UNSTASH)
+    }
+
+    @Test
+    fun swipeUp_collapsedBar_aboveExpandThreshold_viewsHaveDampedTranslation() {
+        setUpCollapsedBar()
+        testViewsHaveDampedTranslationOnSwipe(UP_ABOVE_EXPAND)
+    }
+
+    private fun testViewsTranslationResetOnFinish(swipe: Float) {
+        getInstrumentation().runOnMainSync {
+            bubbleBarSwipeController.start()
+            bubbleBarSwipeController.swipeTo(swipe)
+            bubbleBarSwipeController.finish()
+            // We use a spring animation. Advance by 5 seconds to give it time to finish
+            animatorTestRule.advanceTimeBy(5000)
+        }
+        val handleSwipeTranslation = argumentCaptor<Float>()
+        val barSwipeTranslation = argumentCaptor<Float>()
+        verify(bubbleStashedHandleViewController, atLeastOnce())
+            .setTranslationYForSwipe(handleSwipeTranslation.capture())
+        verify(bubbleBarViewController, atLeastOnce())
+            .setTranslationYForSwipe(barSwipeTranslation.capture())
+
+        assertThat(handleSwipeTranslation.firstValue).isNonZero()
+        assertThat(handleSwipeTranslation.lastValue).isZero()
+
+        assertThat(barSwipeTranslation.firstValue).isNonZero()
+        assertThat(barSwipeTranslation.lastValue).isZero()
+    }
+
+    @Test
+    fun swipeUp_stashedBar_belowUnstashThreshold_animateTranslationToZeroOnFinish() {
+        setUpStashedBar()
+        testViewsTranslationResetOnFinish(UP_BELOW_UNSTASH)
+    }
+
+    @Test
+    fun swipeUp_stashedBar_aboveUnstashThreshold_animateTranslationToZeroOnFinish() {
+        setUpStashedBar()
+        testViewsTranslationResetOnFinish(UP_ABOVE_UNSTASH)
+    }
+
+    @Test
+    fun swipeUp_stashedBar_aboveExpandThreshold_animateTranslationToZeroOnFinish() {
+        setUpStashedBar()
+        testViewsTranslationResetOnFinish(UP_ABOVE_EXPAND)
+    }
+
+    @Test
+    fun swipeUp_collapsedBar_aboveUnstashThreshold_animateTranslationToZeroOnFinish() {
+        setUpCollapsedBar()
+        testViewsTranslationResetOnFinish(UP_ABOVE_UNSTASH)
+    }
+
+    @Test
+    fun swipeUp_collapsedBar_aboveExpandThreshold_animateTranslationToZeroOnFinish() {
+        setUpCollapsedBar()
+        testViewsTranslationResetOnFinish(UP_ABOVE_EXPAND)
+    }
+
+    @Test
+    fun swipeUp_stashedBar_belowUnstashThreshold_doesNotShowBar() {
+        setUpStashedBar()
+        getInstrumentation().runOnMainSync {
+            bubbleBarSwipeController.start()
+            bubbleBarSwipeController.swipeTo(UP_BELOW_UNSTASH)
+        }
+        verify(bubbleStashController, never()).showBubbleBar(any())
+    }
+
+    @Test
+    fun swipeUp_stashedBar_belowUnstashThreshold_isSwipeGestureFalse() {
+        setUpStashedBar()
+        getInstrumentation().runOnMainSync {
+            bubbleBarSwipeController.start()
+            bubbleBarSwipeController.swipeTo(UP_BELOW_UNSTASH)
+        }
+        assertThat(bubbleBarSwipeController.isSwipeGesture()).isFalse()
+    }
+
+    @Test
+    fun swipeUp_stashedBar_aboveUnstashThreshold_unstashBubbleBar() {
+        setUpStashedBar()
+        getInstrumentation().runOnMainSync {
+            bubbleBarSwipeController.start()
+            bubbleBarSwipeController.swipeTo(UP_ABOVE_UNSTASH)
+        }
+        verify(bubbleStashController).showBubbleBar(expandBubbles = false)
+    }
+
+    @Test
+    fun swipeUp_stashedBar_overUnstashThreshold_isSwipeGestureTrue() {
+        setUpStashedBar()
+        getInstrumentation().runOnMainSync {
+            bubbleBarSwipeController.start()
+            bubbleBarSwipeController.swipeTo(UP_ABOVE_UNSTASH)
+        }
+        assertThat(bubbleBarSwipeController.isSwipeGesture()).isTrue()
+    }
+
+    @Test
+    fun swipeUp_stashedBar_overUnstashThresholdMultipleTimes_unstashBubbleBarOnce() {
+        setUpStashedBar()
+        getInstrumentation().runOnMainSync {
+            bubbleBarSwipeController.start()
+            bubbleBarSwipeController.swipeTo(UP_ABOVE_UNSTASH)
+            bubbleBarSwipeController.swipeTo(UP_BELOW_UNSTASH)
+            bubbleBarSwipeController.swipeTo(UP_ABOVE_UNSTASH)
+        }
+        verify(bubbleStashController).showBubbleBar(expandBubbles = false)
+    }
+
+    @Test
+    fun swipeUp_stashedBar_overExpandThreshold_doesNotExpandBeforeFinish() {
+        setUpStashedBar()
+        getInstrumentation().runOnMainSync {
+            bubbleBarSwipeController.start()
+            bubbleBarSwipeController.swipeTo(UP_ABOVE_EXPAND)
+        }
+        verify(bubbleStashController).showBubbleBar(expandBubbles = false)
+        getInstrumentation().runOnMainSync { bubbleBarSwipeController.finish() }
+        verify(bubbleStashController).showBubbleBar(expandBubbles = true)
+    }
+
+    @Test
+    fun swipeUp_stashedBar_overExpandThreshold_isSwipeGestureTrue() {
+        setUpStashedBar()
+        getInstrumentation().runOnMainSync {
+            bubbleBarSwipeController.start()
+            bubbleBarSwipeController.swipeTo(UP_ABOVE_EXPAND)
+        }
+        assertThat(bubbleBarSwipeController.isSwipeGesture()).isTrue()
+    }
+
+    @Test
+    fun swipeUp_stashedBar_overExpandThresholdAndBackDown_doesNotExpandAfterFinish() {
+        setUpStashedBar()
+        getInstrumentation().runOnMainSync {
+            bubbleBarSwipeController.start()
+            bubbleBarSwipeController.swipeTo(UP_ABOVE_EXPAND)
+            bubbleBarSwipeController.swipeTo(UP_ABOVE_UNSTASH)
+        }
+        verify(bubbleStashController).showBubbleBar(expandBubbles = false)
+        getInstrumentation().runOnMainSync { bubbleBarSwipeController.finish() }
+        verify(bubbleStashController).showBubbleBar(expandBubbles = false)
+    }
+
+    @Test
+    fun swipeUp_expandedBar_swipeIgnored() {
+        setUpExpandedBar()
+        getInstrumentation().runOnMainSync {
+            bubbleBarSwipeController.start()
+            bubbleBarSwipeController.swipeTo(UP_ABOVE_EXPAND)
+            bubbleBarSwipeController.swipeTo(DOWN_BELOW_UNSTASH)
+            bubbleBarSwipeController.finish()
+        }
+        verify(bubbleStashedHandleViewController, never()).setTranslationYForSwipe(any())
+        verify(bubbleBarViewController, never()).setTranslationYForSwipe(any())
+        verify(bubbleStashController, never()).showBubbleBar(any())
+    }
+
+    @Test
+    fun swipeDown_stashedBar_swipeIgnored() {
+        setUpStashedBar()
+        getInstrumentation().runOnMainSync {
+            bubbleBarSwipeController.start()
+            bubbleBarSwipeController.swipeTo(DOWN_BELOW_UNSTASH)
+        }
+        verify(bubbleStashedHandleViewController, never()).setTranslationYForSwipe(any())
+        verify(bubbleBarViewController, never()).setTranslationYForSwipe(any())
+        verify(bubbleStashController, never()).showBubbleBar(any())
+    }
+
+    private fun setUpStashedBar() {
+        whenever(bubbleStashController.isStashed).thenReturn(true)
+        whenever(bubbleStashController.isBubbleBarVisible()).thenReturn(false)
+        whenever(bubbleBarViewController.isExpanded).thenReturn(false)
+    }
+
+    private fun setUpCollapsedBar() {
+        whenever(bubbleStashController.isStashed).thenReturn(false)
+        whenever(bubbleStashController.isBubbleBarVisible()).thenReturn(true)
+        whenever(bubbleBarViewController.isExpanded).thenReturn(false)
+    }
+
+    private fun setUpExpandedBar() {
+        whenever(bubbleStashController.isStashed).thenReturn(false)
+        whenever(bubbleStashController.isBubbleBarVisible()).thenReturn(true)
+        whenever(bubbleBarViewController.isExpanded).thenReturn(true)
+    }
+}