Move Pip codes from SysUI to WMShell lib (9/N)

Bug: 161118569

Test: make SystemUI
Test: make ArcSystemUI
Test: make WMShellUnitTests
Test: lunch cf_x86_tv-userdebug & make
Test: atest CtsSystemUiTestCases:BasicPipTests
Test: atest CtsSystemUiTestCases:CustomPipActionsTests
Test: atest CtsSystemUiTestCases:FlickerPipTests
Test: atest CtsSystemUiTestCases:PipNotificationTests
Test: atest SystemUITests
Test: atest WMShellUnitTests
Test: manual test PIP demo AP
Test: manual test TV PIP function
Test: manual test disabled PIP
Test: adb shell input keyevent 171(KEYCODE_WINDOW)
Test: adb shell am start -n android.systemui.cts.tv.pip/.PipTestActivity
    -a android.systemui.cts.tv.pip.PipTestActivity.enter_pip
Test: adb root;adb shell am start \
    -n com.android.systemui/com.android.wm.shell.pip.tv.PipMenuActivity
Test: adb shell dumpsys activity service com.android.systemui
Change-Id: I0ec0e9b9bfc4795a10483acd225f14bde9c72407
diff --git a/libs/WindowManager/Shell/Android.bp b/libs/WindowManager/Shell/Android.bp
index 1591b06..0defbd6 100644
--- a/libs/WindowManager/Shell/Android.bp
+++ b/libs/WindowManager/Shell/Android.bp
@@ -23,7 +23,18 @@
 
 filegroup {
     name: "wm_shell-sources",
-    srcs: ["src/**/*.java"],
+    srcs: [
+        "src/**/*.java",
+    ],
+    path: "src",
+}
+
+// TODO(b/168581922) protologtool do not support kotlin(*.kt)
+filegroup {
+    name: "wm_shell-sources-kt",
+    srcs: [
+        "src/**/*.kt",
+    ],
     path: "src",
 }
 
@@ -97,15 +108,23 @@
     name: "WindowManager-Shell",
     srcs: [
         ":wm_shell_protolog_src",
+        // TODO(b/168581922) protologtool do not support kotlin(*.kt)
+        ":wm_shell-sources-kt",
         "src/**/I*.aidl",
     ],
     resource_dirs: [
         "res",
     ],
     static_libs: [
+        "androidx.dynamicanimation_dynamicanimation",
+        "kotlinx-coroutines-android",
+        "kotlinx-coroutines-core",
         "protolog-lib",
         "WindowManager-Shell-proto",
         "androidx.appcompat_appcompat",
     ],
+    kotlincflags: ["-Xjvm-default=enable"],
     manifest: "AndroidManifest.xml",
+
+    min_sdk_version: "26",
 }
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/drawable/dismiss_circle_background.xml b/libs/WindowManager/Shell/res/drawable/dismiss_circle_background.xml
new file mode 100644
index 0000000..7809c83
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/dismiss_circle_background.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 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.
+  -->
+
+<shape
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="oval">
+
+    <stroke
+        android:width="1dp"
+        android:color="#AAFFFFFF" />
+
+    <solid android:color="#77000000" />
+
+</shape>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/drawable/pip_ic_skip_next_white.xml b/libs/WindowManager/Shell/res/drawable/pip_ic_skip_next_white.xml
new file mode 100644
index 0000000..040c7e6
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/pip_ic_skip_next_white.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Copyright (C) 2017 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.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+
+    <path
+        android:fillColor="#FFFFFF"
+        android:pathData="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
+    <path
+        android:pathData="M0 0h24v24H0z" />
+</vector>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/drawable/pip_ic_skip_previous_white.xml b/libs/WindowManager/Shell/res/drawable/pip_ic_skip_previous_white.xml
new file mode 100644
index 0000000..b9b94b7
--- /dev/null
+++ b/libs/WindowManager/Shell/res/drawable/pip_ic_skip_previous_white.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Copyright (C) 2017 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.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+
+    <path
+        android:fillColor="#FFFFFF"
+        android:pathData="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
+    <path
+        android:pathData="M0 0h24v24H0z" />
+</vector>
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/res/layout/tv_pip_control_button.xml b/libs/WindowManager/Shell/res/layout/tv_pip_control_button.xml
index 72287c1..727ac34 100644
--- a/libs/WindowManager/Shell/res/layout/tv_pip_control_button.xml
+++ b/libs/WindowManager/Shell/res/layout/tv_pip_control_button.xml
@@ -14,7 +14,7 @@
     See the License for the specific language governing permissions and
     limitations under the License.
 -->
-<!-- Layout for {@link com.android.systemui.pip.tv.PipControlButtonView}. -->
+<!-- Layout for {@link com.android.wm.shell.pip.tv.PipControlButtonView}. -->
 <merge xmlns:android="http://schemas.android.com/apk/res/android">
 
     <ImageView android:id="@+id/button"
diff --git a/libs/WindowManager/Shell/res/layout/tv_pip_controls.xml b/libs/WindowManager/Shell/res/layout/tv_pip_controls.xml
index 22e0452..d2f235e 100644
--- a/libs/WindowManager/Shell/res/layout/tv_pip_controls.xml
+++ b/libs/WindowManager/Shell/res/layout/tv_pip_controls.xml
@@ -14,17 +14,17 @@
     See the License for the specific language governing permissions and
     limitations under the License.
 -->
-<!-- Layout for {@link com.android.systemui.pip.tv.PipControlsView}. -->
+<!-- Layout for {@link com.android.wm.shell.pip.tv.PipControlsView}. -->
 <merge xmlns:android="http://schemas.android.com/apk/res/android">
 
-    <com.android.systemui.pip.tv.PipControlButtonView
+    <com.android.wm.shell.pip.tv.PipControlButtonView
         android:id="@+id/full_button"
         android:layout_width="@dimen/picture_in_picture_button_width"
         android:layout_height="wrap_content"
         android:src="@drawable/pip_ic_fullscreen_white"
         android:text="@string/pip_fullscreen" />
 
-    <com.android.systemui.pip.tv.PipControlButtonView
+    <com.android.wm.shell.pip.tv.PipControlButtonView
         android:id="@+id/close_button"
         android:layout_width="@dimen/picture_in_picture_button_width"
         android:layout_height="wrap_content"
@@ -32,7 +32,7 @@
         android:src="@drawable/pip_ic_close_white"
         android:text="@string/pip_close" />
 
-    <com.android.systemui.pip.tv.PipControlButtonView
+    <com.android.wm.shell.pip.tv.PipControlButtonView
         android:id="@+id/play_pause_button"
         android:layout_width="@dimen/picture_in_picture_button_width"
         android:layout_height="wrap_content"
diff --git a/libs/WindowManager/Shell/res/layout/tv_pip_custom_control.xml b/libs/WindowManager/Shell/res/layout/tv_pip_custom_control.xml
index e6cd112..452f2cd 100644
--- a/libs/WindowManager/Shell/res/layout/tv_pip_custom_control.xml
+++ b/libs/WindowManager/Shell/res/layout/tv_pip_custom_control.xml
@@ -14,7 +14,7 @@
     See the License for the specific language governing permissions and
     limitations under the License.
 -->
-<com.android.systemui.pip.tv.PipControlButtonView
+<com.android.wm.shell.pip.tv.PipControlButtonView
     xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="@dimen/picture_in_picture_button_width"
     android:layout_height="wrap_content"
diff --git a/libs/WindowManager/Shell/res/layout/tv_pip_menu.xml b/libs/WindowManager/Shell/res/layout/tv_pip_menu.xml
index a049787..d8474b8 100644
--- a/libs/WindowManager/Shell/res/layout/tv_pip_menu.xml
+++ b/libs/WindowManager/Shell/res/layout/tv_pip_menu.xml
@@ -15,15 +15,15 @@
     limitations under the License.
 -->
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="horizontal"
-    android:paddingTop="350dp"
-    android:background="#CC000000"
-    android:gravity="top|center_horizontal"
-    android:clipChildren="false">
+              android:layout_width="match_parent"
+              android:layout_height="match_parent"
+              android:orientation="horizontal"
+              android:paddingTop="350dp"
+              android:background="#CC000000"
+              android:gravity="top|center_horizontal"
+              android:clipChildren="false">
 
-    <com.android.systemui.pip.tv.PipControlsView
+    <com.android.wm.shell.pip.tv.PipControlsView
         android:id="@+id/pip_controls"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
diff --git a/libs/WindowManager/Shell/res/values/config.xml b/libs/WindowManager/Shell/res/values/config.xml
index 63b0f6f..e99350b 100644
--- a/libs/WindowManager/Shell/res/values/config.xml
+++ b/libs/WindowManager/Shell/res/values/config.xml
@@ -32,4 +32,8 @@
 
     <!-- Allow one handed to enable round corner -->
     <bool name="config_one_handed_enable_round_corner">true</bool>
+
+    <!-- Bounds [left top right bottom] on screen for picture-in-picture (PIP) windows,
+     when the PIP menu is shown in center. -->
+    <string translatable="false" name="pip_menu_bounds">"596 280 1324 690"</string>
 </resources>
diff --git a/libs/WindowManager/Shell/res/values/dimen.xml b/libs/WindowManager/Shell/res/values/dimen.xml
index 7fb641a..a9917a6 100644
--- a/libs/WindowManager/Shell/res/values/dimen.xml
+++ b/libs/WindowManager/Shell/res/values/dimen.xml
@@ -57,6 +57,9 @@
     <dimen name="pip_resize_handle_margin">4dp</dimen>
     <dimen name="pip_resize_handle_padding">0dp</dimen>
 
+    <dimen name="dismiss_target_x_size">24dp</dimen>
+    <dimen name="floating_dismiss_bottom_margin">50dp</dimen>
+
     <!-- How high we lift the divider when touching -->
     <dimen name="docked_stack_divider_lift_elevation">4dp</dimen>
 
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/WindowManagerShellWrapper.java b/libs/WindowManager/Shell/src/com/android/wm/shell/WindowManagerShellWrapper.java
new file mode 100644
index 0000000..acb9a5da
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/WindowManagerShellWrapper.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (C) 2020 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.wm.shell;
+
+import static android.view.Display.DEFAULT_DISPLAY;
+
+import android.app.WindowConfiguration;
+import android.os.RemoteException;
+import android.view.WindowManagerGlobal;
+
+import com.android.wm.shell.pip.PinnedStackListenerForwarder;
+
+/**
+ * The singleton wrapper to communicate between WindowManagerService and WMShell features
+ * (e.g: PIP, SplitScreen, Bubble, OneHandedMode...etc)
+ */
+public class WindowManagerShellWrapper {
+    private static final String TAG = WindowManagerShellWrapper.class.getSimpleName();
+
+    public static final int WINDOWING_MODE_PINNED = WindowConfiguration.WINDOWING_MODE_PINNED;
+
+    /**
+     * Forwarder to which we can add multiple pinned stack listeners. Each listener will receive
+     * updates from the window manager service.
+     */
+    private PinnedStackListenerForwarder mPinnedStackListenerForwarder =
+            new PinnedStackListenerForwarder();
+
+    /**
+     * Adds a pinned stack listener, which will receive updates from the window manager service
+     * along with any other pinned stack listeners that were added via this method.
+     */
+    public void addPinnedStackListener(PinnedStackListenerForwarder.PinnedStackListener listener)
+            throws
+            RemoteException {
+        mPinnedStackListenerForwarder.addListener(listener);
+        WindowManagerGlobal.getWindowManagerService().registerPinnedStackListener(
+                DEFAULT_DISPLAY, mPinnedStackListenerForwarder);
+    }
+
+    /**
+     * Removes a pinned stack listener.
+     */
+    public void removePinnedStackListener(
+            PinnedStackListenerForwarder.PinnedStackListener listener) {
+        mPinnedStackListenerForwarder.removeListener(listener);
+    }
+
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/animation/FloatProperties.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/animation/FloatProperties.kt
new file mode 100644
index 0000000..d4f8282
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/animation/FloatProperties.kt
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.animation
+
+import android.graphics.Rect
+import android.graphics.RectF
+import androidx.dynamicanimation.animation.FloatPropertyCompat
+
+/**
+ * Helpful extra properties to use with the [PhysicsAnimator]. These allow you to animate objects
+ * such as [Rect] and [RectF].
+ *
+ * There are additional, more basic properties available in [DynamicAnimation].
+ */
+class FloatProperties {
+    companion object {
+        /**
+         * Represents the x-coordinate of a [Rect]. Typically used to animate moving a Rect
+         * horizontally.
+         *
+         * This property's getter returns [Rect.left], and its setter uses [Rect.offsetTo], which
+         * sets [Rect.left] to the new value and offsets [Rect.right] so that the width of the Rect
+         * does not change.
+         */
+        @JvmField
+        val RECT_X = object : FloatPropertyCompat<Rect>("RectX") {
+            override fun setValue(rect: Rect?, value: Float) {
+                rect?.offsetTo(value.toInt(), rect.top)
+            }
+
+            override fun getValue(rect: Rect?): Float {
+                return rect?.left?.toFloat() ?: -Float.MAX_VALUE
+            }
+        }
+
+        /**
+         * Represents the y-coordinate of a [Rect]. Typically used to animate moving a Rect
+         * vertically.
+         *
+         * This property's getter returns [Rect.top], and its setter uses [Rect.offsetTo], which
+         * sets [Rect.top] to the new value and offsets [Rect.bottom] so that the height of the Rect
+         * does not change.
+         */
+        @JvmField
+        val RECT_Y = object : FloatPropertyCompat<Rect>("RectY") {
+            override fun setValue(rect: Rect?, value: Float) {
+                rect?.offsetTo(rect.left, value.toInt())
+            }
+
+            override fun getValue(rect: Rect?): Float {
+                return rect?.top?.toFloat() ?: -Float.MAX_VALUE
+            }
+        }
+
+        /**
+         * Represents the width of a [Rect]. Typically used to animate resizing a Rect horizontally.
+         *
+         * This property's getter returns [Rect.width], and its setter changes the value of
+         * [Rect.right] by adding the animated width value to [Rect.left].
+         */
+        @JvmField
+        val RECT_WIDTH = object : FloatPropertyCompat<Rect>("RectWidth") {
+            override fun getValue(rect: Rect): Float {
+                return rect.width().toFloat()
+            }
+
+            override fun setValue(rect: Rect, value: Float) {
+                rect.right = rect.left + value.toInt()
+            }
+        }
+
+        /**
+         * Represents the height of a [Rect]. Typically used to animate resizing a Rect vertically.
+         *
+         * This property's getter returns [Rect.height], and its setter changes the value of
+         * [Rect.bottom] by adding the animated height value to [Rect.top].
+         */
+        @JvmField
+        val RECT_HEIGHT = object : FloatPropertyCompat<Rect>("RectHeight") {
+            override fun getValue(rect: Rect): Float {
+                return rect.height().toFloat()
+            }
+
+            override fun setValue(rect: Rect, value: Float) {
+                rect.bottom = rect.top + value.toInt()
+            }
+        }
+
+        /**
+         * Represents the x-coordinate of a [RectF]. Typically used to animate moving a RectF
+         * horizontally.
+         *
+         * This property's getter returns [RectF.left], and its setter uses [RectF.offsetTo], which
+         * sets [RectF.left] to the new value and offsets [RectF.right] so that the width of the
+         * RectF does not change.
+         */
+        @JvmField
+        val RECTF_X = object : FloatPropertyCompat<RectF>("RectFX") {
+            override fun setValue(rect: RectF?, value: Float) {
+                rect?.offsetTo(value, rect.top)
+            }
+
+            override fun getValue(rect: RectF?): Float {
+                return rect?.left ?: -Float.MAX_VALUE
+            }
+        }
+
+        /**
+         * Represents the y-coordinate of a [RectF]. Typically used to animate moving a RectF
+         * vertically.
+         *
+         * This property's getter returns [RectF.top], and its setter uses [RectF.offsetTo], which
+         * sets [RectF.top] to the new value and offsets [RectF.bottom] so that the height of the
+         * RectF does not change.
+         */
+        @JvmField
+        val RECTF_Y = object : FloatPropertyCompat<RectF>("RectFY") {
+            override fun setValue(rect: RectF?, value: Float) {
+                rect?.offsetTo(rect.left, value)
+            }
+
+            override fun getValue(rect: RectF?): Float {
+                return rect?.top ?: -Float.MAX_VALUE
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/animation/Interpolators.java b/libs/WindowManager/Shell/src/com/android/wm/shell/animation/Interpolators.java
index b794b91..416ada7 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/animation/Interpolators.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/animation/Interpolators.java
@@ -24,6 +24,16 @@
  */
 public class Interpolators {
     /**
+     * Interpolator for alpha in animation.
+     */
+    public static final Interpolator ALPHA_IN = new PathInterpolator(0.4f, 0f, 1f, 1f);
+
+    /**
+     * Interpolator for alpha out animation.
+     */
+    public static final Interpolator ALPHA_OUT = new PathInterpolator(0f, 0f, 0.8f, 1f);
+
+    /**
      * Interpolator for fast out linear in animation.
      */
     public static final Interpolator FAST_OUT_LINEAR_IN = new PathInterpolator(0.4f, 0f, 1f, 1f);
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/animation/PhysicsAnimator.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/animation/PhysicsAnimator.kt
new file mode 100644
index 0000000..5cd660a
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/animation/PhysicsAnimator.kt
@@ -0,0 +1,1071 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.animation
+
+import android.os.Looper
+import android.util.ArrayMap
+import android.util.Log
+import android.view.View
+import androidx.dynamicanimation.animation.AnimationHandler
+import androidx.dynamicanimation.animation.DynamicAnimation
+import androidx.dynamicanimation.animation.FlingAnimation
+import androidx.dynamicanimation.animation.FloatPropertyCompat
+import androidx.dynamicanimation.animation.SpringAnimation
+import androidx.dynamicanimation.animation.SpringForce
+import com.android.wm.shell.animation.PhysicsAnimator.Companion.getInstance
+import java.lang.ref.WeakReference
+import java.util.WeakHashMap
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Extension function for all objects which will return a PhysicsAnimator instance for that object.
+ */
+val <T : View> T.physicsAnimator: PhysicsAnimator<T> get() { return getInstance(this) }
+
+private const val TAG = "PhysicsAnimator"
+
+private val UNSET = -Float.MAX_VALUE
+
+/**
+ * [FlingAnimation] multiplies the friction set via [FlingAnimation.setFriction] by 4.2f, which is
+ * where this number comes from. We use it in [PhysicsAnimator.flingThenSpring] to calculate the
+ * minimum velocity for a fling to reach a certain value, given the fling's friction.
+ */
+private const val FLING_FRICTION_SCALAR_MULTIPLIER = 4.2f
+
+typealias EndAction = () -> Unit
+
+/** A map of Property -> AnimationUpdate, which is provided to update listeners on each frame. */
+typealias UpdateMap<T> =
+        ArrayMap<FloatPropertyCompat<in T>, PhysicsAnimator.AnimationUpdate>
+
+/**
+ * Map of the animators associated with a given object. This ensures that only one animator
+ * per object exists.
+ */
+internal val animators = WeakHashMap<Any, PhysicsAnimator<*>>()
+
+/**
+ * Default spring configuration to use for animations where stiffness and/or damping ratio
+ * were not provided, and a default spring was not set via [PhysicsAnimator.setDefaultSpringConfig].
+ */
+private val globalDefaultSpring = PhysicsAnimator.SpringConfig(
+        SpringForce.STIFFNESS_MEDIUM,
+        SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
+
+/**
+ * Default fling configuration to use for animations where friction was not provided, and a default
+ * fling config was not set via [PhysicsAnimator.setDefaultFlingConfig].
+ */
+private val globalDefaultFling = PhysicsAnimator.FlingConfig(
+        friction = 1f, min = -Float.MAX_VALUE, max = Float.MAX_VALUE)
+
+/** Whether to log helpful debug information about animations. */
+private var verboseLogging = false
+
+/**
+ * Animator that uses physics-based animations to animate properties on views and objects. Physics
+ * animations use real-world physical concepts, such as momentum and mass, to realistically simulate
+ * motion. PhysicsAnimator is heavily inspired by [android.view.ViewPropertyAnimator], and
+ * also uses the builder pattern to configure and start animations.
+ *
+ * The physics animations are backed by [DynamicAnimation].
+ *
+ * @param T The type of the object being animated.
+ */
+class PhysicsAnimator<T> private constructor (target: T) {
+    /** Weak reference to the animation target. */
+    val weakTarget = WeakReference(target)
+
+    /** Data class for representing animation frame updates. */
+    data class AnimationUpdate(val value: Float, val velocity: Float)
+
+    /** [DynamicAnimation] instances for the given properties. */
+    private val springAnimations = ArrayMap<FloatPropertyCompat<in T>, SpringAnimation>()
+    private val flingAnimations = ArrayMap<FloatPropertyCompat<in T>, FlingAnimation>()
+
+    /**
+     * Spring and fling configurations for the properties to be animated on the target. We'll
+     * configure and start the DynamicAnimations for these properties according to the provided
+     * configurations.
+     */
+    private val springConfigs = ArrayMap<FloatPropertyCompat<in T>, SpringConfig>()
+    private val flingConfigs = ArrayMap<FloatPropertyCompat<in T>, FlingConfig>()
+
+    /**
+     * Animation listeners for the animation. These will be notified when each property animation
+     * updates or ends.
+     */
+    private val updateListeners = ArrayList<UpdateListener<T>>()
+    private val endListeners = ArrayList<EndListener<T>>()
+
+    /** End actions to run when all animations have completed.  */
+    private val endActions = ArrayList<EndAction>()
+
+    /** SpringConfig to use by default for properties whose springs were not provided. */
+    private var defaultSpring: SpringConfig = globalDefaultSpring
+
+    /** FlingConfig to use by default for properties whose fling configs were not provided. */
+    private var defaultFling: FlingConfig = globalDefaultFling
+
+    /**
+     * AnimationHandler to use if it need custom AnimationHandler, if this is null, it will use
+     * the default AnimationHandler in the DynamicAnimation.
+     */
+    private var customAnimationHandler: AnimationHandler? = null
+
+    /**
+     * Internal listeners that respond to DynamicAnimations updating and ending, and dispatch to
+     * the listeners provided via [addUpdateListener] and [addEndListener]. This allows us to add
+     * just one permanent update and end listener to the DynamicAnimations.
+     */
+    internal var internalListeners = ArrayList<InternalListener>()
+
+    /**
+     * Action to run when [start] is called. This can be changed by
+     * [PhysicsAnimatorTestUtils.prepareForTest] to enable animators to run under test and provide
+     * helpful test utilities.
+     */
+    internal var startAction: () -> Unit = ::startInternal
+
+    /**
+     * Action to run when [cancel] is called. This can be changed by
+     * [PhysicsAnimatorTestUtils.prepareForTest] to cancel animations from the main thread, which
+     * is required.
+     */
+    internal var cancelAction: (Set<FloatPropertyCompat<in T>>) -> Unit = ::cancelInternal
+
+    /**
+     * Springs a property to the given value, using the provided configuration settings.
+     *
+     * Springs are used when you know the exact value to which you want to animate. They can be
+     * configured with a start velocity (typically used when the spring is initiated by a touch
+     * event), but this velocity will be realistically attenuated as forces are applied to move the
+     * property towards the end value.
+     *
+     * If you find yourself repeating the same stiffness and damping ratios many times, consider
+     * storing a single [SpringConfig] instance and passing that in instead of individual values.
+     *
+     * @param property The property to spring to the given value. The property must be an instance
+     * of FloatPropertyCompat&lt;? super T&gt;. For example, if this is a
+     * PhysicsAnimator&lt;FrameLayout&gt;, you can use a FloatPropertyCompat&lt;FrameLayout&gt;, as
+     * well as a FloatPropertyCompat&lt;ViewGroup&gt;, and so on.
+     * @param toPosition The value to spring the given property to.
+     * @param startVelocity The initial velocity to use for the animation.
+     * @param stiffness The stiffness to use for the spring. Higher stiffness values result in
+     * faster animations, while lower stiffness means a slower animation. Reasonable values for
+     * low, medium, and high stiffness can be found as constants in [SpringForce].
+     * @param dampingRatio The damping ratio (bounciness) to use for the spring. Higher values
+     * result in a less 'springy' animation, while lower values allow the animation to bounce
+     * back and forth for a longer time after reaching the final position. Reasonable values for
+     * low, medium, and high damping can be found in [SpringForce].
+     */
+    fun spring(
+        property: FloatPropertyCompat<in T>,
+        toPosition: Float,
+        startVelocity: Float = 0f,
+        stiffness: Float = defaultSpring.stiffness,
+        dampingRatio: Float = defaultSpring.dampingRatio
+    ): PhysicsAnimator<T> {
+        if (verboseLogging) {
+            Log.d(TAG, "Springing ${getReadablePropertyName(property)} to $toPosition.")
+        }
+
+        springConfigs[property] =
+                SpringConfig(stiffness, dampingRatio, startVelocity, toPosition)
+        return this
+    }
+
+    /**
+     * Springs a property to a given value using the provided start velocity and configuration
+     * options.
+     *
+     * @see spring
+     */
+    fun spring(
+        property: FloatPropertyCompat<in T>,
+        toPosition: Float,
+        startVelocity: Float,
+        config: SpringConfig = defaultSpring
+    ): PhysicsAnimator<T> {
+        return spring(
+                property, toPosition, startVelocity, config.stiffness, config.dampingRatio)
+    }
+
+    /**
+     * Springs a property to a given value using the provided configuration options, and a start
+     * velocity of 0f.
+     *
+     * @see spring
+     */
+    fun spring(
+        property: FloatPropertyCompat<in T>,
+        toPosition: Float,
+        config: SpringConfig = defaultSpring
+    ): PhysicsAnimator<T> {
+        return spring(property, toPosition, 0f, config)
+    }
+
+    /**
+     * Springs a property to a given value using the provided configuration options, and a start
+     * velocity of 0f.
+     *
+     * @see spring
+     */
+    fun spring(
+        property: FloatPropertyCompat<in T>,
+        toPosition: Float
+    ): PhysicsAnimator<T> {
+        return spring(property, toPosition, 0f)
+    }
+
+    /**
+     * Flings a property using the given start velocity, using a [FlingAnimation] configured using
+     * the provided configuration settings.
+     *
+     * Flings are used when you have a start velocity, and want the property value to realistically
+     * decrease as friction is applied until the velocity reaches zero. Flings do not have a
+     * deterministic end value. If you are attempting to animate to a specific end value, use
+     * [spring].
+     *
+     * If you find yourself repeating the same friction/min/max values, consider storing a single
+     * [FlingConfig] and passing that in instead.
+     *
+     * @param property The property to fling using the given start velocity.
+     * @param startVelocity The start velocity (in pixels per second) with which to start the fling.
+     * @param friction Friction value applied to slow down the animation over time. Higher values
+     * will more quickly slow the animation. Typical friction values range from 1f to 10f.
+     * @param min The minimum value allowed for the animation. If this value is reached, the
+     * animation will end abruptly.
+     * @param max The maximum value allowed for the animation. If this value is reached, the
+     * animation will end abruptly.
+     */
+    fun fling(
+        property: FloatPropertyCompat<in T>,
+        startVelocity: Float,
+        friction: Float = defaultFling.friction,
+        min: Float = defaultFling.min,
+        max: Float = defaultFling.max
+    ): PhysicsAnimator<T> {
+        if (verboseLogging) {
+            Log.d(TAG, "Flinging ${getReadablePropertyName(property)} " +
+                    "with velocity $startVelocity.")
+        }
+
+        flingConfigs[property] = FlingConfig(friction, min, max, startVelocity)
+        return this
+    }
+
+    /**
+     * Flings a property using the given start velocity, using a [FlingAnimation] configured using
+     * the provided configuration settings.
+     *
+     * @see fling
+     */
+    fun fling(
+        property: FloatPropertyCompat<in T>,
+        startVelocity: Float,
+        config: FlingConfig = defaultFling
+    ): PhysicsAnimator<T> {
+        return fling(property, startVelocity, config.friction, config.min, config.max)
+    }
+
+    /**
+     * Flings a property using the given start velocity. If the fling animation reaches the min/max
+     * bounds (from the [flingConfig]) with velocity remaining, it'll overshoot it and spring back.
+     *
+     * If the object is already out of the fling bounds, it will immediately spring back within
+     * bounds.
+     *
+     * This is useful for animating objects that are bounded by constraints such as screen edges,
+     * since otherwise the fling animation would end abruptly upon reaching the min/max bounds.
+     *
+     * @param property The property to animate.
+     * @param startVelocity The velocity, in pixels/second, with which to start the fling. If the
+     * object is already outside the fling bounds, this velocity will be used as the start velocity
+     * of the spring that will spring it back within bounds.
+     * @param flingMustReachMinOrMax If true, the fling animation is guaranteed to reach either its
+     * minimum bound (if [startVelocity] is negative) or maximum bound (if it's positive). The
+     * animator will use startVelocity if it's sufficient, or add more velocity if necessary. This
+     * is useful when fling's deceleration-based physics are preferable to the acceleration-based
+     * forces used by springs - typically, when you're allowing the user to move an object somewhere
+     * on the screen, but it needs to be along an edge.
+     * @param flingConfig The configuration to use for the fling portion of the animation.
+     * @param springConfig The configuration to use for the spring portion of the animation.
+     */
+    @JvmOverloads
+    fun flingThenSpring(
+        property: FloatPropertyCompat<in T>,
+        startVelocity: Float,
+        flingConfig: FlingConfig,
+        springConfig: SpringConfig,
+        flingMustReachMinOrMax: Boolean = false
+    ): PhysicsAnimator<T> {
+        val target = weakTarget.get()
+        if (target == null) {
+            Log.w(TAG, "Trying to animate a GC-ed target.")
+            return this
+        }
+        val flingConfigCopy = flingConfig.copy()
+        val springConfigCopy = springConfig.copy()
+        val toAtLeast = if (startVelocity < 0) flingConfig.min else flingConfig.max
+
+        if (flingMustReachMinOrMax && isValidValue(toAtLeast)) {
+            val currentValue = property.getValue(target)
+            val flingTravelDistance =
+                    startVelocity / (flingConfig.friction * FLING_FRICTION_SCALAR_MULTIPLIER)
+            val projectedFlingEndValue = currentValue + flingTravelDistance
+            val midpoint = (flingConfig.min + flingConfig.max) / 2
+
+            // If fling velocity is too low to push the target past the midpoint between min and
+            // max, then spring back towards the nearest edge, starting with the current velocity.
+            if ((startVelocity < 0 && projectedFlingEndValue > midpoint) ||
+                    (startVelocity > 0 && projectedFlingEndValue < midpoint)) {
+                val toPosition =
+                        if (projectedFlingEndValue < midpoint) flingConfig.min else flingConfig.max
+                if (isValidValue(toPosition)) {
+                    return spring(property, toPosition, startVelocity, springConfig)
+                }
+            }
+
+            // Projected fling end value is past the midpoint, so fling forward.
+            val distanceToDestination = toAtLeast - property.getValue(target)
+
+            // The minimum velocity required for the fling to end up at the given destination,
+            // taking the provided fling friction value.
+            val velocityToReachDestination = distanceToDestination *
+                    (flingConfig.friction * FLING_FRICTION_SCALAR_MULTIPLIER)
+
+            // If there's distance to cover, and the provided velocity is moving in the correct
+            // direction, ensure that the velocity is high enough to reach the destination.
+            // Otherwise, just use startVelocity - this means that the fling is at or out of bounds.
+            // The fling will immediately end and a spring will bring the object back into bounds
+            // with this startVelocity.
+            flingConfigCopy.startVelocity = when {
+                distanceToDestination > 0f && startVelocity >= 0f ->
+                    max(velocityToReachDestination, startVelocity)
+                distanceToDestination < 0f && startVelocity <= 0f ->
+                    min(velocityToReachDestination, startVelocity)
+                else -> startVelocity
+            }
+
+            springConfigCopy.finalPosition = toAtLeast
+        } else {
+            flingConfigCopy.startVelocity = startVelocity
+        }
+
+        flingConfigs[property] = flingConfigCopy
+        springConfigs[property] = springConfigCopy
+        return this
+    }
+
+    private fun isValidValue(value: Float) = value < Float.MAX_VALUE && value > -Float.MAX_VALUE
+
+    /**
+     * Adds a listener that will be called whenever any property on the animated object is updated.
+     * This will be called on every animation frame, with the current value of the animated object
+     * and the new property values.
+     */
+    fun addUpdateListener(listener: UpdateListener<T>): PhysicsAnimator<T> {
+        updateListeners.add(listener)
+        return this
+    }
+
+    /**
+     * Adds a listener that will be called when a property stops animating. This is useful if
+     * you care about a specific property ending, or want to use the end value/end velocity from a
+     * particular property's animation. If you just want to run an action when all property
+     * animations have ended, use [withEndActions].
+     */
+    fun addEndListener(listener: EndListener<T>): PhysicsAnimator<T> {
+        endListeners.add(listener)
+        return this
+    }
+
+    /**
+     * Adds end actions that will be run sequentially when animations for every property involved in
+     * this specific animation have ended (unless they were explicitly canceled). For example, if
+     * you call:
+     *
+     * animator
+     *   .spring(TRANSLATION_X, ...)
+     *   .spring(TRANSLATION_Y, ...)
+     *   .withEndAction(action)
+     *   .start()
+     *
+     * 'action' will be run when both TRANSLATION_X and TRANSLATION_Y end.
+     *
+     * Other properties may still be animating, if those animations were not started in the same
+     * call. For example:
+     *
+     * animator
+     *   .spring(ALPHA, ...)
+     *   .start()
+     *
+     * animator
+     *   .spring(TRANSLATION_X, ...)
+     *   .spring(TRANSLATION_Y, ...)
+     *   .withEndAction(action)
+     *   .start()
+     *
+     * 'action' will still be run as soon as TRANSLATION_X and TRANSLATION_Y end, even if ALPHA is
+     * still animating.
+     *
+     * If you want to run actions as soon as a subset of property animations have ended, you want
+     * access to the animation's end value/velocity, or you want to run these actions even if the
+     * animation is explicitly canceled, use [addEndListener]. End listeners have an allEnded param,
+     * which indicates that all relevant animations have ended.
+     */
+    fun withEndActions(vararg endActions: EndAction?): PhysicsAnimator<T> {
+        this.endActions.addAll(endActions.filterNotNull())
+        return this
+    }
+
+    /**
+     * Helper overload so that callers from Java can use Runnables or method references as end
+     * actions without having to explicitly return Unit.
+     */
+    fun withEndActions(vararg endActions: Runnable?): PhysicsAnimator<T> {
+        this.endActions.addAll(endActions.filterNotNull().map { it::run })
+        return this
+    }
+
+    fun setDefaultSpringConfig(defaultSpring: SpringConfig) {
+        this.defaultSpring = defaultSpring
+    }
+
+    fun setDefaultFlingConfig(defaultFling: FlingConfig) {
+        this.defaultFling = defaultFling
+    }
+
+    /**
+     * Set the custom AnimationHandler for all aniatmion in this animator. Set this with null for
+     * restoring to default AnimationHandler.
+     */
+    fun setCustomAnimationHandler(handler: AnimationHandler) {
+        this.customAnimationHandler = handler
+    }
+
+    /** Starts the animations! */
+    fun start() {
+        startAction()
+    }
+
+    /**
+     * Starts the animations for real! This is typically called immediately by [start] unless this
+     * animator is under test.
+     */
+    internal fun startInternal() {
+        if (!Looper.getMainLooper().isCurrentThread) {
+            Log.e(TAG, "Animations can only be started on the main thread. If you are seeing " +
+                    "this message in a test, call PhysicsAnimatorTestUtils#prepareForTest in " +
+                    "your test setup.")
+        }
+        val target = weakTarget.get()
+        if (target == null) {
+            Log.w(TAG, "Trying to animate a GC-ed object.")
+            return
+        }
+
+        // Functions that will actually start the animations. These are run after we build and add
+        // the InternalListener, since some animations might update/end immediately and we don't
+        // want to miss those updates.
+        val animationStartActions = ArrayList<() -> Unit>()
+
+        for (animatedProperty in getAnimatedProperties()) {
+            val flingConfig = flingConfigs[animatedProperty]
+            val springConfig = springConfigs[animatedProperty]
+
+            // The property's current value on the object.
+            val currentValue = animatedProperty.getValue(target)
+
+            // Start by checking for a fling configuration. If one is present, we're either flinging
+            // or flinging-then-springing. Either way, we'll want to start the fling first.
+            if (flingConfig != null) {
+                animationStartActions.add {
+                    // When the animation is starting, adjust the min/max bounds to include the
+                    // current value of the property, if necessary. This is required to allow a
+                    // fling to bring an out-of-bounds object back into bounds. For example, if an
+                    // object was dragged halfway off the left side of the screen, but then flung to
+                    // the right, we don't want the animation to end instantly just because the
+                    // object started out of bounds. If the fling is in the direction that would
+                    // take it farther out of bounds, it will end instantly as expected.
+                    flingConfig.apply {
+                        min = min(currentValue, this.min)
+                        max = max(currentValue, this.max)
+                    }
+
+                    // Flings can't be updated to a new position while maintaining velocity, because
+                    // we're using the explicitly provided start velocity. Cancel any flings (or
+                    // springs) on this property before flinging.
+                    cancel(animatedProperty)
+
+                    // Apply the custom animation handler if it not null
+                    val flingAnim = getFlingAnimation(animatedProperty, target)
+                    flingAnim.animationHandler =
+                            customAnimationHandler ?: flingAnim.animationHandler
+
+                    // Apply the configuration and start the animation.
+                    flingAnim.also { flingConfig.applyToAnimation(it) }.start()
+                }
+            }
+
+            // Check for a spring configuration. If one is present, we're either springing, or
+            // flinging-then-springing.
+            if (springConfig != null) {
+
+                // If there is no corresponding fling config, we're only springing.
+                if (flingConfig == null) {
+                    // Apply the configuration and start the animation.
+                    val springAnim = getSpringAnimation(animatedProperty, target)
+
+                    // If customAnimationHander is exist and has not been set to the animation,
+                    // it should set here.
+                    if (customAnimationHandler != null &&
+                            springAnim.animationHandler != customAnimationHandler) {
+                        // Cancel the animation before set animation handler
+                        if (springAnim.isRunning) {
+                            cancel(animatedProperty)
+                        }
+                        // Apply the custom animation handler if it not null
+                        springAnim.animationHandler =
+                                customAnimationHandler ?: springAnim.animationHandler
+                    }
+
+                    // Apply the configuration and start the animation.
+                    springConfig.applyToAnimation(springAnim)
+                    animationStartActions.add(springAnim::start)
+                } else {
+                    // If there's a corresponding fling config, we're flinging-then-springing. Save
+                    // the fling's original bounds so we can spring to them when the fling ends.
+                    val flingMin = flingConfig.min
+                    val flingMax = flingConfig.max
+
+                    // Add an end listener that will start the spring when the fling ends.
+                    endListeners.add(0, object : EndListener<T> {
+                        override fun onAnimationEnd(
+                            target: T,
+                            property: FloatPropertyCompat<in T>,
+                            wasFling: Boolean,
+                            canceled: Boolean,
+                            finalValue: Float,
+                            finalVelocity: Float,
+                            allRelevantPropertyAnimsEnded: Boolean
+                        ) {
+                            // If this isn't the relevant property, it wasn't a fling, or the fling
+                            // was explicitly cancelled, don't spring.
+                            if (property != animatedProperty || !wasFling || canceled) {
+                                return
+                            }
+
+                            val endedWithVelocity = abs(finalVelocity) > 0
+
+                            // If the object was out of bounds when the fling animation started, it
+                            // will immediately end. In that case, we'll spring it back in bounds.
+                            val endedOutOfBounds = finalValue !in flingMin..flingMax
+
+                            // If the fling ended either out of bounds or with remaining velocity,
+                            // it's time to spring.
+                            if (endedWithVelocity || endedOutOfBounds) {
+                                springConfig.startVelocity = finalVelocity
+
+                                // If the spring's final position isn't set, this is a
+                                // flingThenSpring where flingMustReachMinOrMax was false. We'll
+                                // need to set the spring's final position here.
+                                if (springConfig.finalPosition == UNSET) {
+                                    if (endedWithVelocity) {
+                                        // If the fling ended with negative velocity, that means it
+                                        // hit the min bound, so spring to that bound (and vice
+                                        // versa).
+                                        springConfig.finalPosition =
+                                                if (finalVelocity < 0) flingMin else flingMax
+                                    } else if (endedOutOfBounds) {
+                                        // If the fling ended out of bounds, spring it to the
+                                        // nearest bound.
+                                        springConfig.finalPosition =
+                                                if (finalValue < flingMin) flingMin else flingMax
+                                    }
+                                }
+
+                                // Apply the custom animation handler if it not null
+                                val springAnim = getSpringAnimation(animatedProperty, target)
+                                springAnim.animationHandler =
+                                        customAnimationHandler ?: springAnim.animationHandler
+
+                                // Apply the configuration and start the spring animation.
+                                springAnim.also { springConfig.applyToAnimation(it) }.start()
+                            }
+                        }
+                    })
+                }
+            }
+        }
+
+        // Add an internal listener that will dispatch animation events to the provided listeners.
+        internalListeners.add(InternalListener(
+                target,
+                getAnimatedProperties(),
+                ArrayList(updateListeners),
+                ArrayList(endListeners),
+                ArrayList(endActions)))
+
+        // Actually start the DynamicAnimations. This is delayed until after the InternalListener is
+        // constructed and added so that we don't miss the end listener firing for any animations
+        // that immediately end.
+        animationStartActions.forEach { it.invoke() }
+
+        clearAnimator()
+    }
+
+    /** Clear the animator's builder variables. */
+    private fun clearAnimator() {
+        springConfigs.clear()
+        flingConfigs.clear()
+
+        updateListeners.clear()
+        endListeners.clear()
+        endActions.clear()
+    }
+
+    /** Retrieves a spring animation for the given property, building one if needed. */
+    private fun getSpringAnimation(
+        property: FloatPropertyCompat<in T>,
+        target: T
+    ): SpringAnimation {
+        return springAnimations.getOrPut(
+                property,
+                { configureDynamicAnimation(SpringAnimation(target, property), property)
+                        as SpringAnimation })
+    }
+
+    /** Retrieves a fling animation for the given property, building one if needed. */
+    private fun getFlingAnimation(property: FloatPropertyCompat<in T>, target: T): FlingAnimation {
+        return flingAnimations.getOrPut(
+                property,
+                { configureDynamicAnimation(FlingAnimation(target, property), property)
+                        as FlingAnimation })
+    }
+
+    /**
+     * Adds update and end listeners to the DynamicAnimation which will dispatch to the internal
+     * listeners.
+     */
+    private fun configureDynamicAnimation(
+        anim: DynamicAnimation<*>,
+        property: FloatPropertyCompat<in T>
+    ): DynamicAnimation<*> {
+        anim.addUpdateListener { _, value, velocity ->
+            for (i in 0 until internalListeners.size) {
+                internalListeners[i].onInternalAnimationUpdate(property, value, velocity)
+            }
+        }
+        anim.addEndListener { _, canceled, value, velocity ->
+            internalListeners.removeAll {
+                it.onInternalAnimationEnd(
+                        property, canceled, value, velocity, anim is FlingAnimation)
+            }
+            if (springAnimations[property] == anim) {
+                springAnimations.remove(property)
+            }
+            if (flingAnimations[property] == anim) {
+                flingAnimations.remove(property)
+            }
+        }
+        return anim
+    }
+
+    /**
+     * Internal listener class that receives updates from DynamicAnimation listeners, and dispatches
+     * them to the appropriate update/end listeners. This class is also aware of which properties
+     * were being animated when the end listeners were passed in, so that we can provide the
+     * appropriate value for allEnded to [EndListener.onAnimationEnd].
+     */
+    internal inner class InternalListener constructor(
+        private val target: T,
+        private var properties: Set<FloatPropertyCompat<in T>>,
+        private var updateListeners: List<UpdateListener<T>>,
+        private var endListeners: List<EndListener<T>>,
+        private var endActions: List<EndAction>
+    ) {
+
+        /** The number of properties whose animations haven't ended. */
+        private var numPropertiesAnimating = properties.size
+
+        /**
+         * Update values that haven't yet been dispatched because not all property animations have
+         * updated yet.
+         */
+        private val undispatchedUpdates =
+                ArrayMap<FloatPropertyCompat<in T>, AnimationUpdate>()
+
+        /** Called when a DynamicAnimation updates.  */
+        internal fun onInternalAnimationUpdate(
+            property: FloatPropertyCompat<in T>,
+            value: Float,
+            velocity: Float
+        ) {
+
+            // If this property animation isn't relevant to this listener, ignore it.
+            if (!properties.contains(property)) {
+                return
+            }
+
+            undispatchedUpdates[property] = AnimationUpdate(value, velocity)
+            maybeDispatchUpdates()
+        }
+
+        /**
+         * Called when a DynamicAnimation ends.
+         *
+         * @return True if this listener should be removed from the list of internal listeners, so
+         * it no longer receives updates from DynamicAnimations.
+         */
+        internal fun onInternalAnimationEnd(
+            property: FloatPropertyCompat<in T>,
+            canceled: Boolean,
+            finalValue: Float,
+            finalVelocity: Float,
+            isFling: Boolean
+        ): Boolean {
+
+            // If this property animation isn't relevant to this listener, ignore it.
+            if (!properties.contains(property)) {
+                return false
+            }
+
+            // Dispatch updates if we have one for each property.
+            numPropertiesAnimating--
+            maybeDispatchUpdates()
+
+            // If we didn't have an update for each property, dispatch the update for the ending
+            // property. This guarantees that an update isn't sent for this property *after* we call
+            // onAnimationEnd for that property.
+            if (undispatchedUpdates.contains(property)) {
+                updateListeners.forEach { updateListener ->
+                    updateListener.onAnimationUpdateForProperty(
+                            target,
+                            UpdateMap<T>().also { it[property] = undispatchedUpdates[property] })
+                }
+
+                undispatchedUpdates.remove(property)
+            }
+
+            val allEnded = !arePropertiesAnimating(properties)
+            endListeners.forEach {
+                it.onAnimationEnd(
+                        target, property, isFling, canceled, finalValue, finalVelocity,
+                        allEnded)
+
+                // Check that the end listener didn't restart this property's animation.
+                if (isPropertyAnimating(property)) {
+                    return false
+                }
+            }
+
+            // If all of the animations that this listener cares about have ended, run the end
+            // actions unless the animation was canceled.
+            if (allEnded && !canceled) {
+                endActions.forEach { it() }
+            }
+
+            return allEnded
+        }
+
+        /**
+         * Dispatch undispatched values if we've received an update from each of the animating
+         * properties.
+         */
+        private fun maybeDispatchUpdates() {
+            if (undispatchedUpdates.size >= numPropertiesAnimating &&
+                    undispatchedUpdates.size > 0) {
+                updateListeners.forEach {
+                    it.onAnimationUpdateForProperty(target, ArrayMap(undispatchedUpdates))
+                }
+
+                undispatchedUpdates.clear()
+            }
+        }
+    }
+
+    /** Return true if any animations are running on the object.  */
+    fun isRunning(): Boolean {
+        return arePropertiesAnimating(springAnimations.keys.union(flingAnimations.keys))
+    }
+
+    /** Returns whether the given property is animating.  */
+    fun isPropertyAnimating(property: FloatPropertyCompat<in T>): Boolean {
+        return springAnimations[property]?.isRunning ?: false ||
+                flingAnimations[property]?.isRunning ?: false
+    }
+
+    /** Returns whether any of the given properties are animating.  */
+    fun arePropertiesAnimating(properties: Set<FloatPropertyCompat<in T>>): Boolean {
+        return properties.any { isPropertyAnimating(it) }
+    }
+
+    /** Return the set of properties that will begin animating upon calling [start]. */
+    internal fun getAnimatedProperties(): Set<FloatPropertyCompat<in T>> {
+        return springConfigs.keys.union(flingConfigs.keys)
+    }
+
+    /**
+     * Cancels the given properties. This is typically called immediately by [cancel], unless this
+     * animator is under test.
+     */
+    internal fun cancelInternal(properties: Set<FloatPropertyCompat<in T>>) {
+        for (property in properties) {
+            flingAnimations[property]?.cancel()
+            springAnimations[property]?.cancel()
+        }
+    }
+
+    /** Cancels all in progress animations on all properties. */
+    fun cancel() {
+        cancelAction(flingAnimations.keys)
+        cancelAction(springAnimations.keys)
+    }
+
+    /** Cancels in progress animations on the provided properties only. */
+    fun cancel(vararg properties: FloatPropertyCompat<in T>) {
+        cancelAction(properties.toSet())
+    }
+
+    /**
+     * Container object for spring animation configuration settings. This allows you to store
+     * default stiffness and damping ratio values in a single configuration object, which you can
+     * pass to [spring].
+     */
+    data class SpringConfig internal constructor(
+        internal var stiffness: Float,
+        internal var dampingRatio: Float,
+        internal var startVelocity: Float = 0f,
+        internal var finalPosition: Float = UNSET
+    ) {
+
+        constructor() :
+                this(globalDefaultSpring.stiffness, globalDefaultSpring.dampingRatio)
+
+        constructor(stiffness: Float, dampingRatio: Float) :
+                this(stiffness = stiffness, dampingRatio = dampingRatio, startVelocity = 0f)
+
+        /** Apply these configuration settings to the given SpringAnimation. */
+        internal fun applyToAnimation(anim: SpringAnimation) {
+            val springForce = anim.spring ?: SpringForce()
+            anim.spring = springForce.apply {
+                stiffness = this@SpringConfig.stiffness
+                dampingRatio = this@SpringConfig.dampingRatio
+                finalPosition = this@SpringConfig.finalPosition
+            }
+
+            if (startVelocity != 0f) anim.setStartVelocity(startVelocity)
+        }
+    }
+
+    /**
+     * Container object for fling animation configuration settings. This allows you to store default
+     * friction values (as well as optional min/max values) in a single configuration object, which
+     * you can pass to [fling] and related methods.
+     */
+    data class FlingConfig internal constructor(
+        internal var friction: Float,
+        internal var min: Float,
+        internal var max: Float,
+        internal var startVelocity: Float
+    ) {
+
+        constructor() : this(globalDefaultFling.friction)
+
+        constructor(friction: Float) :
+                this(friction, globalDefaultFling.min, globalDefaultFling.max)
+
+        constructor(friction: Float, min: Float, max: Float) :
+                this(friction, min, max, startVelocity = 0f)
+
+        /** Apply these configuration settings to the given FlingAnimation. */
+        internal fun applyToAnimation(anim: FlingAnimation) {
+            anim.apply {
+                friction = this@FlingConfig.friction
+                setMinValue(min)
+                setMaxValue(max)
+                setStartVelocity(startVelocity)
+            }
+        }
+    }
+
+    /**
+     * Listener for receiving values from in progress animations. Used with
+     * [PhysicsAnimator.addUpdateListener].
+     *
+     * @param <T> The type of the object being animated.
+    </T> */
+    interface UpdateListener<T> {
+
+        /**
+         * Called on each animation frame with the target object, and a map of FloatPropertyCompat
+         * -> AnimationUpdate, containing the latest value and velocity for that property. When
+         * multiple properties are animating together, the map will typically contain one entry for
+         * each property. However, you should never assume that this is the case - when a property
+         * animation ends earlier than the others, you'll receive an UpdateMap containing only that
+         * property's final update. Subsequently, you'll only receive updates for the properties
+         * that are still animating.
+         *
+         * Always check that the map contains an update for the property you're interested in before
+         * accessing it.
+         *
+         * @param target The animated object itself.
+         * @param values Map of property to AnimationUpdate, which contains that property
+         * animation's latest value and velocity. You should never assume that a particular property
+         * is present in this map.
+         */
+        fun onAnimationUpdateForProperty(
+            target: T,
+            values: UpdateMap<T>
+        )
+    }
+
+    /**
+     * Listener for receiving callbacks when animations end.
+     *
+     * @param <T> The type of the object being animated.
+    </T> */
+    interface EndListener<T> {
+
+        /**
+         * Called with the final animation values as each property animation ends. This can be used
+         * to respond to specific property animations concluding (such as hiding a view when ALPHA
+         * ends, even if the corresponding TRANSLATION animations have not ended).
+         *
+         * If you just want to run an action when all of the property animations have ended, you can
+         * use [PhysicsAnimator.withEndActions].
+         *
+         * @param target The animated object itself.
+         * @param property The property whose animation has just ended.
+         * @param wasFling Whether this property ended after a fling animation (as opposed to a
+         * spring animation). If this property was animated via [flingThenSpring], this will be true
+         * if the fling animation did not reach the min/max bounds, decelerating to a stop
+         * naturally. It will be false if it hit the bounds and was sprung back.
+         * @param canceled Whether the animation was explicitly canceled before it naturally ended.
+         * @param finalValue The final value of the animated property.
+         * @param finalVelocity The final velocity (in pixels per second) of the ended animation.
+         * This is typically zero, unless this was a fling animation which ended abruptly due to
+         * reaching its configured min/max values.
+         * @param allRelevantPropertyAnimsEnded Whether all properties relevant to this end listener
+         * have ended. Relevant properties are those which were animated alongside the
+         * [addEndListener] call where this animator was passed in. For example:
+         *
+         * animator
+         *    .spring(TRANSLATION_X, 100f)
+         *    .spring(TRANSLATION_Y, 200f)
+         *    .withEndListener(firstEndListener)
+         *    .start()
+         *
+         * firstEndListener will be called first for TRANSLATION_X, with allEnded = false,
+         * because TRANSLATION_Y is still running. When TRANSLATION_Y ends, it'll be called with
+         * allEnded = true.
+         *
+         * If a subsequent call to start() is made with other properties, those properties are not
+         * considered relevant and allEnded will still equal true when only TRANSLATION_X and
+         * TRANSLATION_Y end. For example, if immediately after the prior example, while
+         * TRANSLATION_X and TRANSLATION_Y are still animating, we called:
+         *
+         * animator.
+         *    .spring(SCALE_X, 2f, stiffness = 10f) // That will take awhile...
+         *    .withEndListener(secondEndListener)
+         *    .start()
+         *
+         * firstEndListener will still be called with allEnded = true when TRANSLATION_X/Y end, even
+         * though SCALE_X is still animating. Similarly, secondEndListener will be called with
+         * allEnded = true as soon as SCALE_X ends, even if the translation animations are still
+         * running.
+         */
+        fun onAnimationEnd(
+            target: T,
+            property: FloatPropertyCompat<in T>,
+            wasFling: Boolean,
+            canceled: Boolean,
+            finalValue: Float,
+            finalVelocity: Float,
+            allRelevantPropertyAnimsEnded: Boolean
+        )
+    }
+
+    companion object {
+
+        /**
+         * Constructor to use to for new physics animator instances in [getInstance]. This is
+         * typically the default constructor, but [PhysicsAnimatorTestUtils] can change it so that
+         * all code using the physics animator is given testable instances instead.
+         */
+        internal var instanceConstructor: (Any) -> PhysicsAnimator<*> = ::PhysicsAnimator
+
+        @JvmStatic
+        @Suppress("UNCHECKED_CAST")
+        fun <T : Any> getInstance(target: T): PhysicsAnimator<T> {
+            if (!animators.containsKey(target)) {
+                animators[target] = instanceConstructor(target)
+            }
+
+            return animators[target] as PhysicsAnimator<T>
+        }
+
+        /**
+         * Set whether all physics animators should log a lot of information about animations.
+         * Useful for debugging!
+         */
+        @JvmStatic
+        fun setVerboseLogging(debug: Boolean) {
+            verboseLogging = debug
+        }
+
+        /**
+         * Estimates the end value of a fling that starts at the given value using the provided
+         * start velocity and fling configuration.
+         *
+         * This is only an estimate. Fling animations use a timing-based physics simulation that is
+         * non-deterministic, so this exact value may not be reached.
+         */
+        @JvmStatic
+        fun estimateFlingEndValue(
+            startValue: Float,
+            startVelocity: Float,
+            flingConfig: FlingConfig
+        ): Float {
+            val distance = startVelocity / (flingConfig.friction * FLING_FRICTION_SCALAR_MULTIPLIER)
+            return Math.min(flingConfig.max, Math.max(flingConfig.min, startValue + distance))
+        }
+
+        @JvmStatic
+        fun getReadablePropertyName(property: FloatPropertyCompat<*>): String {
+            return when (property) {
+                DynamicAnimation.TRANSLATION_X -> "translationX"
+                DynamicAnimation.TRANSLATION_Y -> "translationY"
+                DynamicAnimation.TRANSLATION_Z -> "translationZ"
+                DynamicAnimation.SCALE_X -> "scaleX"
+                DynamicAnimation.SCALE_Y -> "scaleY"
+                DynamicAnimation.ROTATION -> "rotation"
+                DynamicAnimation.ROTATION_X -> "rotationX"
+                DynamicAnimation.ROTATION_Y -> "rotationY"
+                DynamicAnimation.SCROLL_X -> "scrollX"
+                DynamicAnimation.SCROLL_Y -> "scrollY"
+                DynamicAnimation.ALPHA -> "alpha"
+                else -> "Custom FloatPropertyCompat instance"
+            }
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/animation/PhysicsAnimatorTestUtils.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/animation/PhysicsAnimatorTestUtils.kt
new file mode 100644
index 0000000..86eb8da
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/animation/PhysicsAnimatorTestUtils.kt
@@ -0,0 +1,485 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.animation
+
+import android.os.Handler
+import android.os.Looper
+import android.util.ArrayMap
+import androidx.dynamicanimation.animation.FloatPropertyCompat
+import com.android.wm.shell.animation.PhysicsAnimatorTestUtils.prepareForTest
+import java.util.*
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.collections.ArrayList
+import kotlin.collections.HashMap
+import kotlin.collections.HashSet
+import kotlin.collections.Set
+import kotlin.collections.component1
+import kotlin.collections.component2
+import kotlin.collections.drop
+import kotlin.collections.forEach
+import kotlin.collections.getOrPut
+import kotlin.collections.set
+import kotlin.collections.toList
+import kotlin.collections.toTypedArray
+
+typealias UpdateMatcher = (PhysicsAnimator.AnimationUpdate) -> Boolean
+typealias UpdateFramesPerProperty<T> =
+        ArrayMap<FloatPropertyCompat<in T>, ArrayList<PhysicsAnimator.AnimationUpdate>>
+
+/**
+ * Utilities for testing code that uses [PhysicsAnimator].
+ *
+ * Start by calling [prepareForTest] at the beginning of each test - this will modify the behavior
+ * of all PhysicsAnimator instances so that they post animations to the main thread (so they don't
+ * crash). It'll also enable the use of the other static helper methods in this class, which you can
+ * use to do things like block the test until animations complete (so you can test end states), or
+ * verify keyframes.
+ */
+object PhysicsAnimatorTestUtils {
+    var timeoutMs: Long = 2000
+    private var startBlocksUntilAnimationsEnd = false
+    private val animationThreadHandler = Handler(Looper.getMainLooper())
+    private val allAnimatedObjects = HashSet<Any>()
+    private val animatorTestHelpers = HashMap<PhysicsAnimator<*>, AnimatorTestHelper<*>>()
+
+    /**
+     * Modifies the behavior of all [PhysicsAnimator] instances so that they post animations to the
+     * main thread, and report all of their
+     */
+    @JvmStatic
+    fun prepareForTest() {
+        val defaultConstructor = PhysicsAnimator.instanceConstructor
+        PhysicsAnimator.instanceConstructor = fun(target: Any): PhysicsAnimator<*> {
+            val animator = defaultConstructor(target)
+            allAnimatedObjects.add(target)
+            animatorTestHelpers[animator] = AnimatorTestHelper(animator)
+            return animator
+        }
+
+        timeoutMs = 2000
+        startBlocksUntilAnimationsEnd = false
+        allAnimatedObjects.clear()
+    }
+
+    @JvmStatic
+    fun tearDown() {
+        val latch = CountDownLatch(1)
+        animationThreadHandler.post {
+            animatorTestHelpers.keys.forEach { it.cancel() }
+            latch.countDown()
+        }
+
+        latch.await()
+
+        animatorTestHelpers.clear()
+        animators.clear()
+        allAnimatedObjects.clear()
+    }
+
+    /**
+     * Sets the maximum time (in milliseconds) to block the test thread while waiting for animations
+     * before throwing an exception.
+     */
+    @JvmStatic
+    fun setBlockTimeout(timeoutMs: Long) {
+        PhysicsAnimatorTestUtils.timeoutMs = timeoutMs
+    }
+
+    /**
+     * Sets whether all animations should block the test thread until they end. This is typically
+     * the desired behavior, since you can invoke code that runs an animation and then assert things
+     * about its end state.
+     */
+    @JvmStatic
+    fun setAllAnimationsBlock(block: Boolean) {
+        startBlocksUntilAnimationsEnd = block
+    }
+
+    /**
+     * Blocks the calling thread until animations of the given property on the target object end.
+     */
+    @JvmStatic
+    @Throws(InterruptedException::class)
+    fun <T : Any> blockUntilAnimationsEnd(
+        animator: PhysicsAnimator<T>,
+        vararg properties: FloatPropertyCompat<in T>
+    ) {
+        val animatingProperties = HashSet<FloatPropertyCompat<in T>>()
+        for (property in properties) {
+            if (animator.isPropertyAnimating(property)) {
+                animatingProperties.add(property)
+            }
+        }
+
+        if (animatingProperties.size > 0) {
+            val latch = CountDownLatch(animatingProperties.size)
+            getAnimationTestHelper(animator).addTestEndListener(
+                    object : PhysicsAnimator.EndListener<T> {
+                override fun onAnimationEnd(
+                    target: T,
+                    property: FloatPropertyCompat<in T>,
+                    wasFling: Boolean,
+                    canceled: Boolean,
+                    finalValue: Float,
+                    finalVelocity: Float,
+                    allRelevantPropertyAnimsEnded: Boolean
+                ) {
+                    if (animatingProperties.contains(property)) {
+                        latch.countDown()
+                    }
+                }
+            })
+
+            latch.await(timeoutMs, TimeUnit.MILLISECONDS)
+        }
+    }
+
+    /**
+     * Blocks the calling thread until all animations of the given property (on all target objects)
+     * have ended. Useful when you don't have access to the objects being animated, but still need
+     * to wait for them to end so that other testable side effects occur (such as update/end
+     * listeners).
+     */
+    @JvmStatic
+    @Throws(InterruptedException::class)
+    @Suppress("UNCHECKED_CAST")
+    fun <T : Any> blockUntilAnimationsEnd(
+        properties: FloatPropertyCompat<in T>
+    ) {
+        for (target in allAnimatedObjects) {
+            try {
+                blockUntilAnimationsEnd(
+                        PhysicsAnimator.getInstance(target) as PhysicsAnimator<T>, properties)
+            } catch (e: ClassCastException) {
+                // Keep checking the other objects for ones whose types match the provided
+                // properties.
+            }
+        }
+    }
+
+    /**
+     * Blocks the calling thread until the first animation frame in which predicate returns true. If
+     * the given object isn't animating, returns without blocking.
+     */
+    @JvmStatic
+    @Throws(InterruptedException::class)
+    fun <T : Any> blockUntilFirstAnimationFrameWhereTrue(
+        animator: PhysicsAnimator<T>,
+        predicate: (T) -> Boolean
+    ) {
+        if (animator.isRunning()) {
+            val latch = CountDownLatch(1)
+            getAnimationTestHelper(animator).addTestUpdateListener(object : PhysicsAnimator
+            .UpdateListener<T> {
+                override fun onAnimationUpdateForProperty(
+                    target: T,
+                    values: UpdateMap<T>
+                ) {
+                    if (predicate(target)) {
+                        latch.countDown()
+                    }
+                }
+            })
+
+            latch.await(timeoutMs, TimeUnit.MILLISECONDS)
+        }
+    }
+
+    /**
+     * Verifies that the animator reported animation frame values to update listeners that satisfy
+     * the given matchers, in order. Not all frames need to satisfy a matcher - we'll run through
+     * all animation frames, and check them against the current predicate. If it returns false, we
+     * continue through the frames until it returns true, and then move on to the next matcher.
+     * Verification fails if we run out of frames while unsatisfied matchers remain.
+     *
+     * If verification is successful, all frames to this point are considered 'verified' and will be
+     * cleared. Subsequent calls to this method will start verification at the next animation frame.
+     *
+     * Example: Verify that an animation surpassed x = 50f before going negative.
+     * verifyAnimationUpdateFrames(
+     *    animator, TRANSLATION_X,
+     *    { u -> u.value > 50f },
+     *    { u -> u.value < 0f })
+     *
+     * Example: verify that an animation went backwards at some point while still being on-screen.
+     * verifyAnimationUpdateFrames(
+     *    animator, TRANSLATION_X,
+     *    { u -> u.velocity < 0f && u.value >= 0f })
+     *
+     * This method is intended to help you test longer, more complicated animations where it's
+     * critical that certain values were reached. Using this method to test short animations can
+     * fail due to the animation having fewer frames than provided matchers. For example, an
+     * animation from x = 1f to x = 5f might only have two frames, at x = 3f and x = 5f. The
+     * following would then fail despite it seeming logically sound:
+     *
+     * verifyAnimationUpdateFrames(
+     *    animator, TRANSLATION_X,
+     *    { u -> u.value > 1f },
+     *    { u -> u.value > 2f },
+     *    { u -> u.value > 3f })
+     *
+     * Tests might also fail if your matchers are too granular, such as this example test after an
+     * animation from x = 0f to x = 100f. It's unlikely there was a frame specifically between 2f
+     * and 3f.
+     *
+     * verifyAnimationUpdateFrames(
+     *    animator, TRANSLATION_X,
+     *    { u -> u.value > 2f && u.value < 3f },
+     *    { u -> u.value >= 50f })
+     *
+     * Failures will print a helpful log of all animation frames so you can see what caused the test
+     * to fail.
+     */
+    fun <T : Any> verifyAnimationUpdateFrames(
+        animator: PhysicsAnimator<T>,
+        property: FloatPropertyCompat<in T>,
+        firstUpdateMatcher: UpdateMatcher,
+        vararg additionalUpdateMatchers: UpdateMatcher
+    ) {
+        val updateFrames: UpdateFramesPerProperty<T> = getAnimationUpdateFrames(animator)
+
+        if (!updateFrames.containsKey(property)) {
+            error("No frames for given target object and property.")
+        }
+
+        // Copy the frames to avoid a ConcurrentModificationException if the animation update
+        // listeners attempt to add a new frame while we're verifying these.
+        val framesForProperty = ArrayList(updateFrames[property]!!)
+        val matchers = ArrayDeque<UpdateMatcher>(
+                additionalUpdateMatchers.toList())
+        val frameTraceMessage = StringBuilder()
+
+        var curMatcher = firstUpdateMatcher
+
+        // Loop through the updates from the testable animator.
+        for (update in framesForProperty) {
+
+            // Check whether this frame satisfies the current matcher.
+            if (curMatcher(update)) {
+
+                // If that was the last unsatisfied matcher, we're good here. 'Verify' all remaining
+                // frames and return without failing.
+                if (matchers.size == 0) {
+                    getAnimationUpdateFrames(animator).remove(property)
+                    return
+                }
+
+                frameTraceMessage.append("$update\t(satisfied matcher)\n")
+                curMatcher = matchers.pop() // Get the next matcher and keep going.
+            } else {
+                frameTraceMessage.append("${update}\n")
+            }
+        }
+
+        val readablePropertyName = PhysicsAnimator.getReadablePropertyName(property)
+        getAnimationUpdateFrames(animator).remove(property)
+
+        throw RuntimeException(
+                "Failed to verify animation frames for property $readablePropertyName: " +
+                        "Provided ${additionalUpdateMatchers.size + 1} matchers, " +
+                        "however ${matchers.size + 1} remained unsatisfied.\n\n" +
+                        "All frames:\n$frameTraceMessage")
+    }
+
+    /**
+     * Overload of [verifyAnimationUpdateFrames] that builds matchers for you, from given float
+     * values. For example, to verify that an animations passed from 0f to 50f to 100f back to 50f:
+     *
+     * verifyAnimationUpdateFrames(animator, TRANSLATION_X, 0f, 50f, 100f, 50f)
+     *
+     * This verifies that update frames were received with values of >= 0f, >= 50f, >= 100f, and
+     * <= 50f.
+     *
+     * The same caveats apply: short animations might not have enough frames to satisfy all of the
+     * matchers, and overly specific calls (such as 0f, 1f, 2f, 3f, etc. for an animation from
+     * x = 0f to x = 100f) might fail as the animation only had frames at 0f, 25f, 50f, 75f, and
+     * 100f. As with [verifyAnimationUpdateFrames], failures will print a helpful log of all frames
+     * so you can see what caused the test to fail.
+     */
+    fun <T : Any> verifyAnimationUpdateFrames(
+        animator: PhysicsAnimator<T>,
+        property: FloatPropertyCompat<in T>,
+        startValue: Float,
+        firstTargetValue: Float,
+        vararg additionalTargetValues: Float
+    ) {
+        val matchers = ArrayList<UpdateMatcher>()
+
+        val values = ArrayList<Float>().also {
+            it.add(firstTargetValue)
+            it.addAll(additionalTargetValues.toList())
+        }
+
+        var prevVal = startValue
+        for (value in values) {
+            if (value > prevVal) {
+                matchers.add { update -> update.value >= value }
+            } else {
+                matchers.add { update -> update.value <= value }
+            }
+
+            prevVal = value
+        }
+
+        verifyAnimationUpdateFrames(
+                animator, property, matchers[0], *matchers.drop(0).toTypedArray())
+    }
+
+    /**
+     * Returns all of the values that have ever been reported to update listeners, per property.
+     */
+    @Suppress("UNCHECKED_CAST")
+    fun <T : Any> getAnimationUpdateFrames(animator: PhysicsAnimator<T>):
+            UpdateFramesPerProperty<T> {
+        return animatorTestHelpers[animator]?.getUpdates() as UpdateFramesPerProperty<T>
+    }
+
+    /**
+     * Clears animation frame updates from the given animator so they aren't used the next time its
+     * passed to [verifyAnimationUpdateFrames].
+     */
+    fun <T : Any> clearAnimationUpdateFrames(animator: PhysicsAnimator<T>) {
+        animatorTestHelpers[animator]?.clearUpdates()
+    }
+
+    @Suppress("UNCHECKED_CAST")
+    private fun <T> getAnimationTestHelper(animator: PhysicsAnimator<T>): AnimatorTestHelper<T> {
+        return animatorTestHelpers[animator] as AnimatorTestHelper<T>
+    }
+
+    /**
+     * Helper class for testing an animator. This replaces the animator's start action with
+     * [startForTest] and adds test listeners to enable other test utility behaviors. We build one
+     * these for each Animator and keep them around so we can access the updates.
+     */
+    class AnimatorTestHelper<T> (private val animator: PhysicsAnimator<T>) {
+
+        /** All updates received for each property animation. */
+        private val allUpdates =
+                ArrayMap<FloatPropertyCompat<in T>, ArrayList<PhysicsAnimator.AnimationUpdate>>()
+
+        private val testEndListeners = ArrayList<PhysicsAnimator.EndListener<T>>()
+        private val testUpdateListeners = ArrayList<PhysicsAnimator.UpdateListener<T>>()
+
+        /** Whether we're currently in the middle of executing startInternal(). */
+        private var currentlyRunningStartInternal = false
+
+        init {
+            animator.startAction = ::startForTest
+            animator.cancelAction = ::cancelForTest
+        }
+
+        internal fun addTestEndListener(listener: PhysicsAnimator.EndListener<T>) {
+            testEndListeners.add(listener)
+        }
+
+        internal fun addTestUpdateListener(listener: PhysicsAnimator.UpdateListener<T>) {
+            testUpdateListeners.add(listener)
+        }
+
+        internal fun getUpdates(): UpdateFramesPerProperty<T> {
+            return allUpdates
+        }
+
+        internal fun clearUpdates() {
+            allUpdates.clear()
+        }
+
+        private fun startForTest() {
+            // The testable animator needs to block the main thread until super.start() has been
+            // called, since callers expect .start() to be synchronous but we're posting it to a
+            // handler here. We may also continue blocking until all animations end, if
+            // startBlocksUntilAnimationsEnd = true.
+            val unblockLatch = CountDownLatch(if (startBlocksUntilAnimationsEnd) 2 else 1)
+
+            animationThreadHandler.post {
+                // Add an update listener that dispatches to any test update listeners added by
+                // tests.
+                animator.addUpdateListener(object : PhysicsAnimator.UpdateListener<T> {
+                    override fun onAnimationUpdateForProperty(
+                        target: T,
+                        values: ArrayMap<FloatPropertyCompat<in T>, PhysicsAnimator.AnimationUpdate>
+                    ) {
+                        values.forEach { (property, value) ->
+                            allUpdates.getOrPut(property, { ArrayList() }).add(value)
+                        }
+
+                        for (listener in testUpdateListeners) {
+                            listener.onAnimationUpdateForProperty(target, values)
+                        }
+                    }
+                })
+
+                // Add an end listener that dispatches to any test end listeners added by tests, and
+                // unblocks the main thread if required.
+                animator.addEndListener(object : PhysicsAnimator.EndListener<T> {
+                    override fun onAnimationEnd(
+                        target: T,
+                        property: FloatPropertyCompat<in T>,
+                        wasFling: Boolean,
+                        canceled: Boolean,
+                        finalValue: Float,
+                        finalVelocity: Float,
+                        allRelevantPropertyAnimsEnded: Boolean
+                    ) {
+                        for (listener in testEndListeners) {
+                            listener.onAnimationEnd(
+                                    target, property, wasFling, canceled, finalValue, finalVelocity,
+                                    allRelevantPropertyAnimsEnded)
+                        }
+
+                        if (allRelevantPropertyAnimsEnded) {
+                            testEndListeners.clear()
+                            testUpdateListeners.clear()
+
+                            if (startBlocksUntilAnimationsEnd) {
+                                unblockLatch.countDown()
+                            }
+                        }
+                    }
+                })
+
+                currentlyRunningStartInternal = true
+                animator.startInternal()
+                currentlyRunningStartInternal = false
+                unblockLatch.countDown()
+            }
+
+            unblockLatch.await(timeoutMs, TimeUnit.MILLISECONDS)
+        }
+
+        private fun cancelForTest(properties: Set<FloatPropertyCompat<in T>>) {
+            // If this was called from startInternal, we are already on the animation thread, and
+            // should just call cancelInternal rather than posting it. If we post it, the
+            // cancellation will occur after the rest of startInternal() and we'll immediately
+            // cancel the animation we worked so hard to start!
+            if (currentlyRunningStartInternal) {
+                animator.cancelInternal(properties)
+                return
+            }
+
+            val unblockLatch = CountDownLatch(1)
+
+            animationThreadHandler.post {
+                animator.cancelInternal(properties)
+                unblockLatch.countDown()
+            }
+
+            unblockLatch.await(timeoutMs, TimeUnit.MILLISECONDS)
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/DismissCircleView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/common/DismissCircleView.java
new file mode 100644
index 0000000..976fba5
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/DismissCircleView.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.common;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.view.Gravity;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+
+import com.android.wm.shell.R;
+
+/**
+ * Circular view with a semitransparent, circular background with an 'X' inside it.
+ *
+ * This is used by both Bubbles and PIP as the dismiss target.
+ */
+public class DismissCircleView extends FrameLayout {
+
+    private final ImageView mIconView = new ImageView(getContext());
+
+    public DismissCircleView(Context context) {
+        super(context);
+        final Resources res = getResources();
+
+        setBackground(res.getDrawable(R.drawable.dismiss_circle_background));
+
+        mIconView.setImageDrawable(res.getDrawable(R.drawable.pip_ic_close_white));
+        addView(mIconView);
+
+        setViewSizes();
+    }
+
+    @Override
+    protected void onConfigurationChanged(Configuration newConfig) {
+        super.onConfigurationChanged(newConfig);
+        setViewSizes();
+    }
+
+    /** Retrieves the current dimensions for the icon and circle and applies them. */
+    private void setViewSizes() {
+        final Resources res = getResources();
+        final int iconSize = res.getDimensionPixelSize(R.dimen.dismiss_target_x_size);
+        mIconView.setLayoutParams(
+                new FrameLayout.LayoutParams(iconSize, iconSize, Gravity.CENTER));
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/FloatingContentCoordinator.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/common/FloatingContentCoordinator.kt
new file mode 100644
index 0000000..d5d072a
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/FloatingContentCoordinator.kt
@@ -0,0 +1,368 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.common
+
+import android.graphics.Rect
+import android.util.Log
+import com.android.wm.shell.common.FloatingContentCoordinator.FloatingContent
+import java.util.HashMap
+
+/** Tag for debug logging. */
+private const val TAG = "FloatingCoordinator"
+
+/**
+ * Coordinates the positions and movement of floating content, such as PIP and Bubbles, to ensure
+ * that they don't overlap. If content does overlap due to content appearing or moving, the
+ * coordinator will ask content to move to resolve the conflict.
+ *
+ * After implementing [FloatingContent], content should call [onContentAdded] to begin coordination.
+ * Subsequently, call [onContentMoved] whenever the content moves, and the coordinator will move
+ * other content out of the way. [onContentRemoved] should be called when the content is removed or
+ * no longer visible.
+ */
+
+class FloatingContentCoordinator constructor() {
+    /**
+     * Represents a piece of floating content, such as PIP or the Bubbles stack. Provides methods
+     * that allow the [FloatingContentCoordinator] to determine the current location of the content,
+     * as well as the ability to ask it to move out of the way of other content.
+     *
+     * The default implementation of [calculateNewBoundsOnOverlap] moves the content up or down,
+     * depending on the position of the conflicting content. You can override this method if you
+     * want your own custom conflict resolution logic.
+     */
+    interface FloatingContent {
+
+        /**
+         * Return the bounds claimed by this content. This should include the bounds occupied by the
+         * content itself, as well as any padding, if desired. The coordinator will ensure that no
+         * other content is located within these bounds.
+         *
+         * If the content is animating, this method should return the bounds to which the content is
+         * animating. If that animation is cancelled, or updated, be sure that your implementation
+         * of this method returns the appropriate bounds, and call [onContentMoved] so that the
+         * coordinator moves other content out of the way.
+         */
+        fun getFloatingBoundsOnScreen(): Rect
+
+        /**
+         * Return the area within which this floating content is allowed to move. When resolving
+         * conflicts, the coordinator will never ask your content to move to a position where any
+         * part of the content would be out of these bounds.
+         */
+        fun getAllowedFloatingBoundsRegion(): Rect
+
+        /**
+         * Called when the coordinator needs this content to move to the given bounds. It's up to
+         * you how to do that.
+         *
+         * Note that if you start an animation to these bounds, [getFloatingBoundsOnScreen] should
+         * return the destination bounds, not the in-progress animated bounds. This is so the
+         * coordinator knows where floating content is going to be and can resolve conflicts
+         * accordingly.
+         */
+        fun moveToBounds(bounds: Rect)
+
+        /**
+         * Called by the coordinator when it needs to find a new home for this floating content,
+         * because a new or moving piece of content is now overlapping with it.
+         *
+         * [findAreaForContentVertically] and [findAreaForContentAboveOrBelow] are helpful utility
+         * functions that will find new bounds for your content automatically. Unless you require
+         * specific conflict resolution logic, these should be sufficient. By default, this method
+         * delegates to [findAreaForContentVertically].
+         *
+         * @param overlappingContentBounds The bounds of the other piece of content, which
+         * necessitated this content's relocation. Your new position must not overlap with these
+         * bounds.
+         * @param otherContentBounds The bounds of any other pieces of floating content. Your new
+         * position must not overlap with any of these either. These bounds are guaranteed to be
+         * non-overlapping.
+         * @return The new bounds for this content.
+         */
+        @JvmDefault
+        fun calculateNewBoundsOnOverlap(
+            overlappingContentBounds: Rect,
+            otherContentBounds: List<Rect>
+        ): Rect {
+            return findAreaForContentVertically(
+                    getFloatingBoundsOnScreen(),
+                    overlappingContentBounds,
+                    otherContentBounds,
+                    getAllowedFloatingBoundsRegion())
+        }
+    }
+
+    /** The bounds of all pieces of floating content added to the coordinator. */
+    private val allContentBounds: MutableMap<FloatingContent, Rect> = HashMap()
+
+    /**
+     * Whether we are currently resolving conflicts by asking content to move. If we are, we'll
+     * temporarily ignore calls to [onContentMoved] - those calls are from the content that is
+     * moving to new, conflict-free bounds, so we don't need to perform conflict detection
+     * calculations in response.
+     */
+    private var currentlyResolvingConflicts = false
+
+    /**
+     * Makes the coordinator aware of a new piece of floating content, and moves any existing
+     * content out of the way, if necessary.
+     *
+     * If you don't want your new content to move existing content, use [getOccupiedBounds] to find
+     * an unoccupied area, and move the content there before calling this method.
+     */
+    fun onContentAdded(newContent: FloatingContent) {
+        updateContentBounds()
+        allContentBounds[newContent] = newContent.getFloatingBoundsOnScreen()
+        maybeMoveConflictingContent(newContent)
+    }
+
+    /**
+     * Called to notify the coordinator that a piece of floating content has moved (or is animating)
+     * to a new position, and that any conflicting floating content should be moved out of the way.
+     *
+     * The coordinator will call [FloatingContent.getFloatingBoundsOnScreen] to find the new bounds
+     * for the moving content. If you're animating the content, be sure that your implementation of
+     * getFloatingBoundsOnScreen returns the bounds to which it's animating, not the content's
+     * current bounds.
+     *
+     * If the animation moving this content is cancelled or updated, you'll need to call this method
+     * again, to ensure that content is moved out of the way of the latest bounds.
+     *
+     * @param content The content that has moved.
+     */
+    fun onContentMoved(content: FloatingContent) {
+
+        // Ignore calls when we are currently resolving conflicts, since those calls are from
+        // content that is moving to new, conflict-free bounds.
+        if (currentlyResolvingConflicts) {
+            return
+        }
+
+        if (!allContentBounds.containsKey(content)) {
+            Log.wtf(TAG, "Received onContentMoved call before onContentAdded! " +
+                    "This should never happen.")
+            return
+        }
+
+        updateContentBounds()
+        maybeMoveConflictingContent(content)
+    }
+
+    /**
+     * Called to notify the coordinator that a piece of floating content has been removed or is no
+     * longer visible.
+     */
+    fun onContentRemoved(removedContent: FloatingContent) {
+        allContentBounds.remove(removedContent)
+    }
+
+    /**
+     * Returns a set of Rects that represent the bounds of all of the floating content on the
+     * screen.
+     *
+     * [onContentAdded] will move existing content out of the way if the added content intersects
+     * existing content. That's fine - but if your specific starting position is not important, you
+     * can use this function to find unoccupied space for your content before calling
+     * [onContentAdded], so that moving existing content isn't necessary.
+     */
+    fun getOccupiedBounds(): Collection<Rect> {
+        return allContentBounds.values
+    }
+
+    /**
+     * Identifies any pieces of content that are now overlapping with the given content, and asks
+     * them to move out of the way.
+     */
+    private fun maybeMoveConflictingContent(fromContent: FloatingContent) {
+        currentlyResolvingConflicts = true
+
+        val conflictingNewBounds = allContentBounds[fromContent]!!
+        allContentBounds
+                // Filter to content that intersects with the new bounds. That's content that needs
+                // to move.
+                .filter { (content, bounds) ->
+                    content != fromContent && Rect.intersects(conflictingNewBounds, bounds) }
+                // Tell that content to get out of the way, and save the bounds it says it's moving
+                // (or animating) to.
+                .forEach { (content, bounds) ->
+                    val newBounds = content.calculateNewBoundsOnOverlap(
+                            conflictingNewBounds,
+                            // Pass all of the content bounds except the bounds of the
+                            // content we're asking to move, and the conflicting new bounds
+                            // (since those are passed separately).
+                            otherContentBounds = allContentBounds.values
+                                    .minus(bounds)
+                                    .minus(conflictingNewBounds))
+
+                    // If the new bounds are empty, it means there's no non-overlapping position
+                    // that is in bounds. Just leave the content where it is. This should normally
+                    // not happen, but sometimes content like PIP reports incorrect bounds
+                    // temporarily.
+                    if (!newBounds.isEmpty) {
+                        content.moveToBounds(newBounds)
+                        allContentBounds[content] = content.getFloatingBoundsOnScreen()
+                    }
+                }
+
+        currentlyResolvingConflicts = false
+    }
+
+    /**
+     * Update [allContentBounds] by calling [FloatingContent.getFloatingBoundsOnScreen] for all
+     * content and saving the result.
+     */
+    private fun updateContentBounds() {
+        allContentBounds.keys.forEach { allContentBounds[it] = it.getFloatingBoundsOnScreen() }
+    }
+
+    companion object {
+        /**
+         * Finds new bounds for the given content, either above or below its current position. The
+         * new bounds won't intersect with the newly overlapping rect or the exclusion rects, and
+         * will be within the allowed bounds unless no possible position exists.
+         *
+         * You can use this method to help find a new position for your content when the coordinator
+         * calls [FloatingContent.moveToAreaExcluding].
+         *
+         * @param contentRect The bounds of the content for which we're finding a new home.
+         * @param newlyOverlappingRect The bounds of the content that forced this relocation by
+         * intersecting with the content we now need to move. If the overlapping content is
+         * overlapping the top half of this content, we'll try to move this content downward if
+         * possible (since the other content is 'pushing' it down), and vice versa.
+         * @param exclusionRects Any other areas that we need to avoid when finding a new home for
+         * the content. These areas must be non-overlapping with each other.
+         * @param allowedBounds The area within which we're allowed to find new bounds for the
+         * content.
+         * @return New bounds for the content that don't intersect the exclusion rects or the
+         * newly overlapping rect, and that is within bounds - or an empty Rect if no in-bounds
+         * position exists.
+         */
+        @JvmStatic
+        fun findAreaForContentVertically(
+            contentRect: Rect,
+            newlyOverlappingRect: Rect,
+            exclusionRects: Collection<Rect>,
+            allowedBounds: Rect
+        ): Rect {
+            // If the newly overlapping Rect's center is above the content's center, we'll prefer to
+            // find a space for this content that is below the overlapping content, since it's
+            // 'pushing' it down. This may not be possible due to to screen bounds, in which case
+            // we'll find space in the other direction.
+            val overlappingContentPushingDown =
+                    newlyOverlappingRect.centerY() < contentRect.centerY()
+
+            // Filter to exclusion rects that are above or below the content that we're finding a
+            // place for. Then, split into two lists - rects above the content, and rects below it.
+            var (rectsToAvoidAbove, rectsToAvoidBelow) = exclusionRects
+                    .filter { rectToAvoid -> rectsIntersectVertically(rectToAvoid, contentRect) }
+                    .partition { rectToAvoid -> rectToAvoid.top < contentRect.top }
+
+            // Lazily calculate the closest possible new tops for the content, above and below its
+            // current location.
+            val newContentBoundsAbove by lazy {
+                findAreaForContentAboveOrBelow(
+                        contentRect,
+                        exclusionRects = rectsToAvoidAbove.plus(newlyOverlappingRect),
+                        findAbove = true)
+            }
+            val newContentBoundsBelow by lazy {
+                findAreaForContentAboveOrBelow(
+                        contentRect,
+                        exclusionRects = rectsToAvoidBelow.plus(newlyOverlappingRect),
+                        findAbove = false)
+            }
+
+            val positionAboveInBounds by lazy { allowedBounds.contains(newContentBoundsAbove) }
+            val positionBelowInBounds by lazy { allowedBounds.contains(newContentBoundsBelow) }
+
+            // Use the 'below' position if the content is being overlapped from the top, unless it's
+            // out of bounds. Also use it if the content is being overlapped from the bottom, but
+            // the 'above' position is out of bounds. Otherwise, use the 'above' position.
+            val usePositionBelow =
+                    overlappingContentPushingDown && positionBelowInBounds ||
+                            !overlappingContentPushingDown && !positionAboveInBounds
+
+            // Return the content rect, but offset to reflect the new position.
+            val newBounds = if (usePositionBelow) newContentBoundsBelow else newContentBoundsAbove
+
+            // If the new bounds are within the allowed bounds, return them. If not, it means that
+            // there are no legal new bounds. This can happen if the new content's bounds are too
+            // large (for example, full-screen PIP). Since there is no reasonable action to take
+            // here, return an empty Rect and we will just not move the content.
+            return if (allowedBounds.contains(newBounds)) newBounds else Rect()
+        }
+
+        /**
+         * Finds a new position for the given content, either above or below its current position
+         * depending on whether [findAbove] is true or false, respectively. This new position will
+         * not intersect with any of the [exclusionRects].
+         *
+         * This method is useful as a helper method for implementing your own conflict resolution
+         * logic. Otherwise, you'd want to use [findAreaForContentVertically], which takes screen
+         * bounds and conflicting bounds' location into account when deciding whether to move to new
+         * bounds above or below the current bounds.
+         *
+         * @param contentRect The content we're finding an area for.
+         * @param exclusionRects The areas we need to avoid when finding a new area for the content.
+         * These areas must be non-overlapping with each other.
+         * @param findAbove Whether we are finding an area above the content's current position,
+         * rather than an area below it.
+         */
+        fun findAreaForContentAboveOrBelow(
+            contentRect: Rect,
+            exclusionRects: Collection<Rect>,
+            findAbove: Boolean
+        ): Rect {
+            // Sort the rects, since we want to move the content as little as possible. We'll
+            // start with the rects closest to the content and move outward. If we're finding an
+            // area above the content, that means we sort in reverse order to search the rects
+            // from highest to lowest y-value.
+            val sortedExclusionRects =
+                    exclusionRects.sortedBy { if (findAbove) -it.top else it.top }
+
+            val proposedNewBounds = Rect(contentRect)
+            for (exclusionRect in sortedExclusionRects) {
+                // If the proposed new bounds don't intersect with this exclusion rect, that
+                // means there's room for the content here. We know this because the rects are
+                // sorted and non-overlapping, so any subsequent exclusion rects would be higher
+                // (or lower) than this one and can't possibly intersect if this one doesn't.
+                if (!Rect.intersects(proposedNewBounds, exclusionRect)) {
+                    break
+                } else {
+                    // Otherwise, we need to keep searching for new bounds. If we're finding an
+                    // area above, propose new bounds that place the content just above the
+                    // exclusion rect. If we're finding an area below, propose new bounds that
+                    // place the content just below the exclusion rect.
+                    val verticalOffset =
+                            if (findAbove) -contentRect.height() else exclusionRect.height()
+                    proposedNewBounds.offsetTo(
+                            proposedNewBounds.left,
+                            exclusionRect.top + verticalOffset)
+                }
+            }
+
+            return proposedNewBounds
+        }
+
+        /** Returns whether or not the two Rects share any of the same space on the X axis. */
+        private fun rectsIntersectVertically(r1: Rect, r2: Rect): Boolean {
+            return (r1.left >= r2.left && r1.left <= r2.right) ||
+                    (r1.right <= r2.right && r1.right >= r2.left)
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/magnetictarget/MagnetizedObject.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/common/magnetictarget/MagnetizedObject.kt
new file mode 100644
index 0000000..b4d7387
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/magnetictarget/MagnetizedObject.kt
@@ -0,0 +1,699 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.common.magnetictarget
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.database.ContentObserver
+import android.graphics.PointF
+import android.os.Handler
+import android.os.UserHandle
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.provider.Settings
+import android.view.MotionEvent
+import android.view.VelocityTracker
+import android.view.View
+import android.view.ViewConfiguration
+import androidx.dynamicanimation.animation.DynamicAnimation
+import androidx.dynamicanimation.animation.FloatPropertyCompat
+import androidx.dynamicanimation.animation.SpringForce
+import com.android.wm.shell.animation.PhysicsAnimator
+import kotlin.math.abs
+import kotlin.math.hypot
+
+/**
+ * Utility class for creating 'magnetized' objects that are attracted to one or more magnetic
+ * targets. Magnetic targets attract objects that are dragged near them, and hold them there unless
+ * they're moved away or released. Releasing objects inside a magnetic target typically performs an
+ * action on the object.
+ *
+ * MagnetizedObject also supports flinging to targets, which will result in the object being pulled
+ * into the target and released as if it was dragged into it.
+ *
+ * To use this class, either construct an instance with an object of arbitrary type, or use the
+ * [MagnetizedObject.magnetizeView] shortcut method if you're magnetizing a view. Then, set
+ * [magnetListener] to receive event callbacks. In your touch handler, pass all MotionEvents
+ * that move this object to [maybeConsumeMotionEvent]. If that method returns true, consider the
+ * event consumed by the MagnetizedObject and don't move the object unless it begins returning false
+ * again.
+ *
+ * @param context Context, used to retrieve a Vibrator instance for vibration effects.
+ * @param underlyingObject The actual object that we're magnetizing.
+ * @param xProperty Property that sets the x value of the object's position.
+ * @param yProperty Property that sets the y value of the object's position.
+ */
+abstract class MagnetizedObject<T : Any>(
+    val context: Context,
+
+    /** The actual object that is animated. */
+    val underlyingObject: T,
+
+    /** Property that gets/sets the object's X value. */
+    val xProperty: FloatPropertyCompat<in T>,
+
+    /** Property that gets/sets the object's Y value. */
+    val yProperty: FloatPropertyCompat<in T>
+) {
+
+    /** Return the width of the object. */
+    abstract fun getWidth(underlyingObject: T): Float
+
+    /** Return the height of the object. */
+    abstract fun getHeight(underlyingObject: T): Float
+
+    /**
+     * Fill the provided array with the location of the top-left of the object, relative to the
+     * entire screen. Compare to [View.getLocationOnScreen].
+     */
+    abstract fun getLocationOnScreen(underlyingObject: T, loc: IntArray)
+
+    /** Methods for listening to events involving a magnetized object.  */
+    interface MagnetListener {
+
+        /**
+         * Called when touch events move within the magnetic field of a target, causing the
+         * object to animate to the target and become 'stuck' there. The animation happens
+         * automatically here - you should not move the object. You can, however, change its state
+         * to indicate to the user that it's inside the target and releasing it will have an effect.
+         *
+         * [maybeConsumeMotionEvent] is now returning true and will continue to do so until a call
+         * to [onUnstuckFromTarget] or [onReleasedInTarget].
+         *
+         * @param target The target that the object is now stuck to.
+         */
+        fun onStuckToTarget(target: MagneticTarget)
+
+        /**
+         * Called when the object is no longer stuck to a target. This means that either touch
+         * events moved outside of the magnetic field radius, or that a forceful fling out of the
+         * target was detected.
+         *
+         * The object won't be automatically animated out of the target, since you're responsible
+         * for moving the object again. You should move it (or animate it) using your own
+         * movement/animation logic.
+         *
+         * Reverse any effects applied in [onStuckToTarget] here.
+         *
+         * If [wasFlungOut] is true, [maybeConsumeMotionEvent] returned true for the ACTION_UP event
+         * that concluded the fling. If [wasFlungOut] is false, that means a drag gesture is ongoing
+         * and [maybeConsumeMotionEvent] is now returning false.
+         *
+         * @param target The target that this object was just unstuck from.
+         * @param velX The X velocity of the touch gesture when it exited the magnetic field.
+         * @param velY The Y velocity of the touch gesture when it exited the magnetic field.
+         * @param wasFlungOut Whether the object was unstuck via a fling gesture. This means that
+         * an ACTION_UP event was received, and that the gesture velocity was sufficient to conclude
+         * that the user wants to un-stick the object despite no touch events occurring outside of
+         * the magnetic field radius.
+         */
+        fun onUnstuckFromTarget(
+            target: MagneticTarget,
+            velX: Float,
+            velY: Float,
+            wasFlungOut: Boolean
+        )
+
+        /**
+         * Called when the object is released inside a target, or flung towards it with enough
+         * velocity to reach it.
+         *
+         * @param target The target that the object was released in.
+         */
+        fun onReleasedInTarget(target: MagneticTarget)
+    }
+
+    private val animator: PhysicsAnimator<T> = PhysicsAnimator.getInstance(underlyingObject)
+    private val objectLocationOnScreen = IntArray(2)
+
+    /**
+     * Targets that have been added to this object. These will all be considered when determining
+     * magnetic fields and fling trajectories.
+     */
+    private val associatedTargets = ArrayList<MagneticTarget>()
+
+    private val velocityTracker: VelocityTracker = VelocityTracker.obtain()
+    private val vibrator: Vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
+
+    private var touchDown = PointF()
+    private var touchSlop = 0
+    private var movedBeyondSlop = false
+
+    /** Whether touch events are presently occurring within the magnetic field area of a target. */
+    val objectStuckToTarget: Boolean
+        get() = targetObjectIsStuckTo != null
+
+    /** The target the object is stuck to, or null if the object is not stuck to any target. */
+    private var targetObjectIsStuckTo: MagneticTarget? = null
+
+    /**
+     * Sets the listener to receive events. This must be set, or [maybeConsumeMotionEvent]
+     * will always return false and no magnetic effects will occur.
+     */
+    lateinit var magnetListener: MagnetizedObject.MagnetListener
+
+    /**
+     * Optional update listener to provide to the PhysicsAnimator that is used to spring the object
+     * into the target.
+     */
+    var physicsAnimatorUpdateListener: PhysicsAnimator.UpdateListener<T>? = null
+
+    /**
+     * Optional end listener to provide to the PhysicsAnimator that is used to spring the object
+     * into the target.
+     */
+    var physicsAnimatorEndListener: PhysicsAnimator.EndListener<T>? = null
+
+    /**
+     * Method that is called when the object should be animated stuck to the target. The default
+     * implementation uses the object's x and y properties to animate the object centered inside the
+     * target. You can override this if you need custom animation.
+     *
+     * The method is invoked with the MagneticTarget that the object is sticking to, the X and Y
+     * velocities of the gesture that brought the object into the magnetic radius, whether or not it
+     * was flung, and a callback you must call after your animation completes.
+     */
+    var animateStuckToTarget: (MagneticTarget, Float, Float, Boolean, (() -> Unit)?) -> Unit =
+            ::animateStuckToTargetInternal
+
+    /**
+     * Sets whether forcefully flinging the object vertically towards a target causes it to be
+     * attracted to the target and then released immediately, despite never being dragged within the
+     * magnetic field.
+     */
+    var flingToTargetEnabled = true
+
+    /**
+     * If fling to target is enabled, forcefully flinging the object towards a target will cause
+     * it to be attracted to the target and then released immediately, despite never being dragged
+     * within the magnetic field.
+     *
+     * This sets the width of the area considered 'near' enough a target to be considered a fling,
+     * in terms of percent of the target view's width. For example, setting this to 3f means that
+     * flings towards a 100px-wide target will be considered 'near' enough if they're towards the
+     * 300px-wide area around the target.
+     *
+     * Flings whose trajectory intersects the area will be attracted and released - even if the
+     * target view itself isn't intersected:
+     *
+     * |             |
+     * |           0 |
+     * |          /  |
+     * |         /   |
+     * |      X /    |
+     * |.....###.....|
+     *
+     *
+     * Flings towards the target whose trajectories do not intersect the area will be treated as
+     * normal flings and the magnet will leave the object alone:
+     *
+     * |             |
+     * |             |
+     * |   0         |
+     * |  /          |
+     * | /    X      |
+     * |.....###.....|
+     *
+     */
+    var flingToTargetWidthPercent = 3f
+
+    /**
+     * Sets the minimum velocity (in pixels per second) required to fling an object to the target
+     * without dragging it into the magnetic field.
+     */
+    var flingToTargetMinVelocity = 4000f
+
+    /**
+     * Sets the minimum velocity (in pixels per second) required to fling un-stuck an object stuck
+     * to the target. If this velocity is reached, the object will be freed even if it wasn't moved
+     * outside the magnetic field radius.
+     */
+    var flingUnstuckFromTargetMinVelocity = 4000f
+
+    /**
+     * Sets the maximum X velocity above which the object will not stick to the target. Even if the
+     * object is dragged through the magnetic field, it will not stick to the target until the
+     * horizontal velocity is below this value.
+     */
+    var stickToTargetMaxXVelocity = 2000f
+
+    /**
+     * Enable or disable haptic vibration effects when the object interacts with the magnetic field.
+     *
+     * If you're experiencing crashes when the object enters targets, ensure that you have the
+     * android.permission.VIBRATE permission!
+     */
+    var hapticsEnabled = true
+
+    /** Default spring configuration to use for animating the object into a target. */
+    var springConfig = PhysicsAnimator.SpringConfig(
+            SpringForce.STIFFNESS_MEDIUM, SpringForce.DAMPING_RATIO_NO_BOUNCY)
+
+    /**
+     * Spring configuration to use to spring the object into a target specifically when it's flung
+     * towards (rather than dragged near) it.
+     */
+    var flungIntoTargetSpringConfig = springConfig
+
+    init {
+        initHapticSettingObserver(context)
+    }
+
+    /**
+     * Adds the provided MagneticTarget to this object. The object will now be attracted to the
+     * target if it strays within its magnetic field or is flung towards it.
+     *
+     * If this target (or its magnetic field) overlaps another target added to this object, the
+     * prior target will take priority.
+     */
+    fun addTarget(target: MagneticTarget) {
+        associatedTargets.add(target)
+        target.updateLocationOnScreen()
+    }
+
+    /**
+     * Shortcut that accepts a View and a magnetic field radius and adds it as a magnetic target.
+     *
+     * @return The MagneticTarget instance for the given View. This can be used to change the
+     * target's magnetic field radius after it's been added. It can also be added to other
+     * magnetized objects.
+     */
+    fun addTarget(target: View, magneticFieldRadiusPx: Int): MagneticTarget {
+        return MagneticTarget(target, magneticFieldRadiusPx).also { addTarget(it) }
+    }
+
+    /**
+     * Removes the given target from this object. The target will no longer attract the object.
+     */
+    fun removeTarget(target: MagneticTarget) {
+        associatedTargets.remove(target)
+    }
+
+    /**
+     * Provide this method with all motion events that move the magnetized object. If the
+     * location of the motion events moves within the magnetic field of a target, or indicate a
+     * fling-to-target gesture, this method will return true and you should not move the object
+     * yourself until it returns false again.
+     *
+     * Note that even when this method returns true, you should continue to pass along new motion
+     * events so that we know when the events move back outside the magnetic field area.
+     *
+     * This method will always return false if you haven't set a [magnetListener].
+     */
+    fun maybeConsumeMotionEvent(ev: MotionEvent): Boolean {
+        // Short-circuit if we don't have a listener or any targets, since those are required.
+        if (associatedTargets.size == 0) {
+            return false
+        }
+
+        // When a gesture begins, recalculate target views' positions on the screen in case they
+        // have changed. Also, clear state.
+        if (ev.action == MotionEvent.ACTION_DOWN) {
+            updateTargetViews()
+
+            // Clear the velocity tracker and stuck target.
+            velocityTracker.clear()
+            targetObjectIsStuckTo = null
+
+            // Set the touch down coordinates and reset movedBeyondSlop.
+            touchDown.set(ev.rawX, ev.rawY)
+            movedBeyondSlop = false
+        }
+
+        // Always pass events to the VelocityTracker.
+        addMovement(ev)
+
+        // If we haven't yet moved beyond the slop distance, check if we have.
+        if (!movedBeyondSlop) {
+            val dragDistance = hypot(ev.rawX - touchDown.x, ev.rawY - touchDown.y)
+            if (dragDistance > touchSlop) {
+                // If we're beyond the slop distance, save that and continue.
+                movedBeyondSlop = true
+            } else {
+                // Otherwise, don't do anything yet.
+                return false
+            }
+        }
+
+        val targetObjectIsInMagneticFieldOf = associatedTargets.firstOrNull { target ->
+            val distanceFromTargetCenter = hypot(
+                    ev.rawX - target.centerOnScreen.x,
+                    ev.rawY - target.centerOnScreen.y)
+            distanceFromTargetCenter < target.magneticFieldRadiusPx
+        }
+
+        // If we aren't currently stuck to a target, and we're in the magnetic field of a target,
+        // we're newly stuck.
+        val objectNewlyStuckToTarget =
+                !objectStuckToTarget && targetObjectIsInMagneticFieldOf != null
+
+        // If we are currently stuck to a target, we're in the magnetic field of a target, and that
+        // target isn't the one we're currently stuck to, then touch events have moved into a
+        // adjacent target's magnetic field.
+        val objectMovedIntoDifferentTarget =
+                objectStuckToTarget &&
+                        targetObjectIsInMagneticFieldOf != null &&
+                        targetObjectIsStuckTo != targetObjectIsInMagneticFieldOf
+
+        if (objectNewlyStuckToTarget || objectMovedIntoDifferentTarget) {
+            velocityTracker.computeCurrentVelocity(1000)
+            val velX = velocityTracker.xVelocity
+            val velY = velocityTracker.yVelocity
+
+            // If the object is moving too quickly within the magnetic field, do not stick it. This
+            // only applies to objects newly stuck to a target. If the object is moved into a new
+            // target, it wasn't moving at all (since it was stuck to the previous one).
+            if (objectNewlyStuckToTarget && abs(velX) > stickToTargetMaxXVelocity) {
+                return false
+            }
+
+            // This touch event is newly within the magnetic field - let the listener know, and
+            // animate sticking to the magnet.
+            targetObjectIsStuckTo = targetObjectIsInMagneticFieldOf
+            cancelAnimations()
+            magnetListener.onStuckToTarget(targetObjectIsInMagneticFieldOf!!)
+            animateStuckToTarget(targetObjectIsInMagneticFieldOf, velX, velY, false, null)
+
+            vibrateIfEnabled(VibrationEffect.EFFECT_HEAVY_CLICK)
+        } else if (targetObjectIsInMagneticFieldOf == null && objectStuckToTarget) {
+            velocityTracker.computeCurrentVelocity(1000)
+
+            // This touch event is newly outside the magnetic field - let the listener know. It will
+            // move the object out of the target using its own movement logic.
+            cancelAnimations()
+            magnetListener.onUnstuckFromTarget(
+                    targetObjectIsStuckTo!!, velocityTracker.xVelocity, velocityTracker.yVelocity,
+                    wasFlungOut = false)
+            targetObjectIsStuckTo = null
+
+            vibrateIfEnabled(VibrationEffect.EFFECT_TICK)
+        }
+
+        // First, check for relevant gestures concluding with an ACTION_UP.
+        if (ev.action == MotionEvent.ACTION_UP) {
+
+            velocityTracker.computeCurrentVelocity(1000 /* units */)
+            val velX = velocityTracker.xVelocity
+            val velY = velocityTracker.yVelocity
+
+            // Cancel the magnetic animation since we might still be springing into the magnetic
+            // target, but we're about to fling away or release.
+            cancelAnimations()
+
+            if (objectStuckToTarget) {
+                if (-velY > flingUnstuckFromTargetMinVelocity) {
+                    // If the object is stuck, but it was forcefully flung away from the target in
+                    // the upward direction, tell the listener so the object can be animated out of
+                    // the target.
+                    magnetListener.onUnstuckFromTarget(
+                            targetObjectIsStuckTo!!, velX, velY, wasFlungOut = true)
+                } else {
+                    // If the object is stuck and not flung away, it was released inside the target.
+                    magnetListener.onReleasedInTarget(targetObjectIsStuckTo!!)
+                    vibrateIfEnabled(VibrationEffect.EFFECT_HEAVY_CLICK)
+                }
+
+                // Either way, we're no longer stuck.
+                targetObjectIsStuckTo = null
+                return true
+            }
+
+            // The target we're flinging towards, or null if we're not flinging towards any target.
+            val flungToTarget = associatedTargets.firstOrNull { target ->
+                isForcefulFlingTowardsTarget(target, ev.rawX, ev.rawY, velX, velY)
+            }
+
+            if (flungToTarget != null) {
+                // If this is a fling-to-target, animate the object to the magnet and then release
+                // it.
+                magnetListener.onStuckToTarget(flungToTarget)
+                targetObjectIsStuckTo = flungToTarget
+
+                animateStuckToTarget(flungToTarget, velX, velY, true) {
+                    magnetListener.onReleasedInTarget(flungToTarget)
+                    targetObjectIsStuckTo = null
+                    vibrateIfEnabled(VibrationEffect.EFFECT_HEAVY_CLICK)
+                }
+
+                return true
+            }
+
+            // If it's not either of those things, we are not interested.
+            return false
+        }
+
+        return objectStuckToTarget // Always consume touch events if the object is stuck.
+    }
+
+    /** Plays the given vibration effect if haptics are enabled. */
+    @SuppressLint("MissingPermission")
+    private fun vibrateIfEnabled(effect: Int) {
+        if (hapticsEnabled && systemHapticsEnabled) {
+            vibrator.vibrate(effect.toLong())
+        }
+    }
+
+    /** Adds the movement to the velocity tracker using raw coordinates. */
+    private fun addMovement(event: MotionEvent) {
+        // Add movement to velocity tracker using raw screen X and Y coordinates instead
+        // of window coordinates because the window frame may be moving at the same time.
+        val deltaX = event.rawX - event.x
+        val deltaY = event.rawY - event.y
+        event.offsetLocation(deltaX, deltaY)
+        velocityTracker.addMovement(event)
+        event.offsetLocation(-deltaX, -deltaY)
+    }
+
+    /** Animates sticking the object to the provided target with the given start velocities.  */
+    private fun animateStuckToTargetInternal(
+        target: MagneticTarget,
+        velX: Float,
+        velY: Float,
+        flung: Boolean,
+        after: (() -> Unit)? = null
+    ) {
+        target.updateLocationOnScreen()
+        getLocationOnScreen(underlyingObject, objectLocationOnScreen)
+
+        // Calculate the difference between the target's center coordinates and the object's.
+        // Animating the object's x/y properties by these values will center the object on top
+        // of the magnetic target.
+        val xDiff = target.centerOnScreen.x -
+                getWidth(underlyingObject) / 2f - objectLocationOnScreen[0]
+        val yDiff = target.centerOnScreen.y -
+                getHeight(underlyingObject) / 2f - objectLocationOnScreen[1]
+
+        val springConfig = if (flung) flungIntoTargetSpringConfig else springConfig
+
+        cancelAnimations()
+
+        // Animate to the center of the target.
+        animator
+                .spring(xProperty, xProperty.getValue(underlyingObject) + xDiff, velX,
+                        springConfig)
+                .spring(yProperty, yProperty.getValue(underlyingObject) + yDiff, velY,
+                        springConfig)
+
+        if (physicsAnimatorUpdateListener != null) {
+            animator.addUpdateListener(physicsAnimatorUpdateListener!!)
+        }
+
+        if (physicsAnimatorEndListener != null) {
+            animator.addEndListener(physicsAnimatorEndListener!!)
+        }
+
+        if (after != null) {
+            animator.withEndActions(after)
+        }
+
+        animator.start()
+    }
+
+    /**
+     * Whether or not the provided values match a 'fast fling' towards the provided target. If it
+     * does, we consider it a fling-to-target gesture.
+     */
+    private fun isForcefulFlingTowardsTarget(
+        target: MagneticTarget,
+        rawX: Float,
+        rawY: Float,
+        velX: Float,
+        velY: Float
+    ): Boolean {
+        if (!flingToTargetEnabled) {
+            return false
+        }
+
+        // Whether velocity is sufficient, depending on whether we're flinging into a target at the
+        // top or the bottom of the screen.
+        val velocitySufficient =
+                if (rawY < target.centerOnScreen.y) velY > flingToTargetMinVelocity
+                else velY < flingToTargetMinVelocity
+
+        if (!velocitySufficient) {
+            return false
+        }
+
+        // Whether the trajectory of the fling intersects the target area.
+        var targetCenterXIntercept = rawX
+
+        // Only do math if the X velocity is non-zero, otherwise X won't change.
+        if (velX != 0f) {
+            // Rise over run...
+            val slope = velY / velX
+            // ...y = mx + b, b = y / mx...
+            val yIntercept = rawY - slope * rawX
+
+            // ...calculate the x value when y = the target's y-coordinate.
+            targetCenterXIntercept = (target.centerOnScreen.y - yIntercept) / slope
+        }
+
+        // The width of the area we're looking for a fling towards.
+        val targetAreaWidth = target.targetView.width * flingToTargetWidthPercent
+
+        // Velocity was sufficient, so return true if the intercept is within the target area.
+        return targetCenterXIntercept > target.centerOnScreen.x - targetAreaWidth / 2 &&
+                targetCenterXIntercept < target.centerOnScreen.x + targetAreaWidth / 2
+    }
+
+    /** Cancel animations on this object's x/y properties. */
+    internal fun cancelAnimations() {
+        animator.cancel(xProperty, yProperty)
+    }
+
+    /** Updates the locations on screen of all of the [associatedTargets]. */
+    internal fun updateTargetViews() {
+        associatedTargets.forEach { it.updateLocationOnScreen() }
+
+        // Update the touch slop, since the configuration may have changed.
+        if (associatedTargets.size > 0) {
+            touchSlop =
+                    ViewConfiguration.get(associatedTargets[0].targetView.context).scaledTouchSlop
+        }
+    }
+
+    /**
+     * Represents a target view with a magnetic field radius and cached center-on-screen
+     * coordinates.
+     *
+     * Instances of MagneticTarget are passed to a MagnetizedObject's [addTarget], and can then
+     * attract the object if it's dragged near or flung towards it. MagneticTargets can be added to
+     * multiple objects.
+     */
+    class MagneticTarget(
+        val targetView: View,
+        var magneticFieldRadiusPx: Int
+    ) {
+        val centerOnScreen = PointF()
+
+        private val tempLoc = IntArray(2)
+
+        fun updateLocationOnScreen() {
+            targetView.post {
+                targetView.getLocationOnScreen(tempLoc)
+
+                // Add half of the target size to get the center, and subtract translation since the
+                // target could be animating in while we're doing this calculation.
+                centerOnScreen.set(
+                        tempLoc[0] + targetView.width / 2f - targetView.translationX,
+                        tempLoc[1] + targetView.height / 2f - targetView.translationY)
+            }
+        }
+    }
+
+    companion object {
+
+        /**
+         * Whether the HAPTIC_FEEDBACK_ENABLED setting is true.
+         *
+         * We put it in the companion object because we need to register a settings observer and
+         * [MagnetizedObject] doesn't have an obvious lifecycle so we don't have a good time to
+         * remove that observer. Since this settings is shared among all instances we just let all
+         * instances read from this value.
+         */
+        private var systemHapticsEnabled = false
+        private var hapticSettingObserverInitialized = false
+
+        private fun initHapticSettingObserver(context: Context) {
+            if (hapticSettingObserverInitialized) {
+                return
+            }
+
+            val hapticSettingObserver =
+                    object : ContentObserver(Handler.getMain()) {
+                        override fun onChange(selfChange: Boolean) {
+                            systemHapticsEnabled =
+                                    Settings.System.getIntForUser(
+                                            context.contentResolver,
+                                            Settings.System.HAPTIC_FEEDBACK_ENABLED,
+                                            0,
+                                            UserHandle.USER_CURRENT) != 0
+                        }
+                    }
+
+            context.contentResolver.registerContentObserver(
+                    Settings.System.getUriFor(Settings.System.HAPTIC_FEEDBACK_ENABLED),
+                    true /* notifyForDescendants */, hapticSettingObserver)
+
+            // Trigger the observer once to initialize systemHapticsEnabled.
+            hapticSettingObserver.onChange(false /* selfChange */)
+            hapticSettingObserverInitialized = true
+        }
+
+        /**
+         * Magnetizes the given view. Magnetized views are attracted to one or more magnetic
+         * targets. Magnetic targets attract objects that are dragged near them, and hold them there
+         * unless they're moved away or released. Releasing objects inside a magnetic target
+         * typically performs an action on the object.
+         *
+         * Magnetized views can also be flung to targets, which will result in the view being pulled
+         * into the target and released as if it was dragged into it.
+         *
+         * To use the returned MagnetizedObject<View> instance, first set [magnetListener] to
+         * receive event callbacks. In your touch handler, pass all MotionEvents that move this view
+         * to [maybeConsumeMotionEvent]. If that method returns true, consider the event consumed by
+         * MagnetizedObject and don't move the view unless it begins returning false again.
+         *
+         * The view will be moved via translationX/Y properties, and its
+         * width/height will be determined via getWidth()/getHeight(). If you are animating
+         * something other than a view, or want to position your view using properties other than
+         * translationX/Y, implement an instance of [MagnetizedObject].
+         *
+         * Note that the magnetic library can't re-order your view automatically. If the view
+         * renders on top of the target views, it will obscure the target when it sticks to it.
+         * You'll want to bring the view to the front in [MagnetListener.onStuckToTarget].
+         */
+        @JvmStatic
+        fun <T : View> magnetizeView(view: T): MagnetizedObject<T> {
+            return object : MagnetizedObject<T>(
+                    view.context,
+                    view,
+                    DynamicAnimation.TRANSLATION_X,
+                    DynamicAnimation.TRANSLATION_Y) {
+                override fun getWidth(underlyingObject: T): Float {
+                    return underlyingObject.width.toFloat()
+                }
+
+                override fun getHeight(underlyingObject: T): Float {
+                    return underlyingObject.height.toFloat() }
+
+                override fun getLocationOnScreen(underlyingObject: T, loc: IntArray) {
+                    underlyingObject.getLocationOnScreen(loc)
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PinnedStackListenerForwarder.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PinnedStackListenerForwarder.java
new file mode 100644
index 0000000..993e0e7
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PinnedStackListenerForwarder.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2019 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.wm.shell.pip;
+
+import android.app.RemoteAction;
+import android.content.ComponentName;
+import android.content.pm.ParceledListSlice;
+import android.view.DisplayInfo;
+import android.view.IPinnedStackController;
+import android.view.IPinnedStackListener;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * PinnedStackListener that simply forwards all calls to each listener added via
+ * {@link #addListener}. This is necessary since calling
+ * {@link com.android.server.wm.WindowManagerService#registerPinnedStackListener} replaces any
+ * previously set listener.
+ */
+public class PinnedStackListenerForwarder extends IPinnedStackListener.Stub {
+    private List<PinnedStackListener> mListeners = new ArrayList<>();
+
+    /** Adds a listener to receive updates from the WindowManagerService. */
+    public void addListener(PinnedStackListener listener) {
+        mListeners.add(listener);
+    }
+
+    /** Removes a listener so it will no longer receive updates from the WindowManagerService. */
+    public void removeListener(PinnedStackListener listener) {
+        mListeners.remove(listener);
+    }
+
+    @Override
+    public void onListenerRegistered(IPinnedStackController controller) {
+        for (PinnedStackListener listener : mListeners) {
+            listener.onListenerRegistered(controller);
+        }
+    }
+
+    @Override
+    public void onMovementBoundsChanged(boolean fromImeAdjustment) {
+        for (PinnedStackListener listener : mListeners) {
+            listener.onMovementBoundsChanged(fromImeAdjustment);
+        }
+    }
+
+    @Override
+    public void onImeVisibilityChanged(boolean imeVisible, int imeHeight) {
+        for (PinnedStackListener listener : mListeners) {
+            listener.onImeVisibilityChanged(imeVisible, imeHeight);
+        }
+    }
+
+    @Override
+    public void onActionsChanged(ParceledListSlice<RemoteAction> actions) {
+        for (PinnedStackListener listener : mListeners) {
+            listener.onActionsChanged(actions);
+        }
+    }
+
+    @Override
+    public void onActivityHidden(ComponentName componentName) {
+        for (PinnedStackListener listener : mListeners) {
+            listener.onActivityHidden(componentName);
+        }
+    }
+
+    @Override
+    public void onDisplayInfoChanged(DisplayInfo displayInfo) {
+        for (PinnedStackListener listener : mListeners) {
+            listener.onDisplayInfoChanged(displayInfo);
+        }
+    }
+
+    @Override
+    public void onConfigurationChanged() {
+        for (PinnedStackListener listener : mListeners) {
+            listener.onConfigurationChanged();
+        }
+    }
+
+    @Override
+    public void onAspectRatioChanged(float aspectRatio) {
+        for (PinnedStackListener listener : mListeners) {
+            listener.onAspectRatioChanged(aspectRatio);
+        }
+    }
+
+    /**
+     * A counterpart of {@link IPinnedStackListener} with empty implementations.
+     * Subclasses can ignore those methods they do not intend to take action upon.
+     */
+    public static class PinnedStackListener {
+        public void onListenerRegistered(IPinnedStackController controller) {}
+
+        public void onMovementBoundsChanged(boolean fromImeAdjustment) {}
+
+        public void onImeVisibilityChanged(boolean imeVisible, int imeHeight) {}
+
+        public void onActionsChanged(ParceledListSlice<RemoteAction> actions) {}
+
+        public void onActivityHidden(ComponentName componentName) {}
+
+        public void onDisplayInfoChanged(DisplayInfo displayInfo) {}
+
+        public void onConfigurationChanged() {}
+
+        public void onAspectRatioChanged(float aspectRatio) {}
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/Pip.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/Pip.java
new file mode 100644
index 0000000..7c26251
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/Pip.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip;
+
+import android.annotation.Nullable;
+import android.app.ActivityManager;
+import android.content.ComponentName;
+import android.media.session.MediaController;
+
+import com.android.wm.shell.pip.phone.PipTouchHandler;
+import com.android.wm.shell.pip.tv.PipController;
+
+import java.io.PrintWriter;
+import java.util.function.Consumer;
+
+/**
+ * Interface to engage picture in picture feature.
+ */
+public interface Pip {
+    /**
+     * Registers {@link com.android.wm.shell.pip.tv.PipController.Listener} that gets called.
+     * whenever receiving notification on changes in PIP.
+     */
+    default void addListener(PipController.Listener listener) {
+    }
+
+    /**
+     * Registers a {@link PipController.MediaListener} to PipController.
+     */
+    default void addMediaListener(PipController.MediaListener listener) {
+    }
+
+    /**
+     * Closes PIP (PIPed activity and PIP system UI).
+     */
+    default void closePip() {
+    }
+
+    /**
+     * Dump the current state and information if need.
+     *
+     * @param pw The stream to dump information to.
+     */
+    default void dump(PrintWriter pw) {
+    }
+
+    /**
+     * Expand PIP, it's possible that specific request to activate the window via Alt-tab.
+     */
+    default void expandPip() {
+    }
+
+    /**
+     * Get current play back state. (e.g: Used in TV)
+     *
+     * @return The state of defined in PipController.
+     */
+    default int getPlaybackState() {
+        return -1;
+    }
+
+    /**
+     * Get the touch handler which manages all the touch handling for PIP on the Phone,
+     * including moving, dismissing and expanding the PIP. (Do not used in TV)
+     *
+     * @return
+     */
+    default @Nullable PipTouchHandler getPipTouchHandler() {
+        return null;
+    }
+
+    /**
+     * Get MediaController.
+     *
+     * @return The MediaController instance.
+     */
+    default MediaController getMediaController() {
+        return null;
+    }
+
+    /**
+     * Hides the PIP menu.
+     */
+    void hidePipMenu(Runnable onStartCallback, Runnable onEndCallback);
+
+    /**
+     * Returns {@code true} if PIP is shown.
+     */
+    default boolean isPipShown() {
+        return false;
+    }
+
+    /**
+     * Moves the PIPed activity to the fullscreen and closes PIP system UI.
+     */
+    default void movePipToFullscreen() {
+    }
+
+    /**
+     * Called whenever an Activity is moved to the pinned stack from another stack.
+     */
+    default void onActivityPinned(String packageName) {
+    }
+
+    /**
+     * Called whenever an Activity is moved from the pinned stack to another stack
+     */
+    default void onActivityUnpinned(ComponentName topActivity) {
+    }
+
+    /**
+     * Called whenever IActivityManager.startActivity is called on an activity that is already
+     * running, but the task is either brought to the front or a new Intent is delivered to it.
+     *
+     * @param task        information about the task the activity was relaunched into
+     * @param clearedTask whether or not the launch activity also cleared the task as a part of
+     *                    starting
+     */
+    default void onActivityRestartAttempt(ActivityManager.RunningTaskInfo task,
+            boolean clearedTask) {
+    }
+
+    /**
+     * Called when display size or font size of settings changed
+     */
+    default void onDensityOrFontScaleChanged() {
+    }
+
+    /**
+     * Called when overlay package change invoked.
+     */
+    default void onOverlayChanged() {
+    }
+
+    /**
+     * Registers the session listener for the current user.
+     */
+    default void registerSessionListenerForCurrentUser() {
+    }
+
+    /**
+     * Called when SysUI state changed.
+     *
+     * @param isSysUiStateValid Is SysUI state valid or not.
+     * @param flag Current SysUI state.
+     */
+    default void onSystemUiStateChanged(boolean isSysUiStateValid, int flag) {
+    }
+
+    /**
+     * Called when task stack changed.
+     */
+    default void onTaskStackChanged() {
+    }
+
+    /**
+     * Removes a {@link PipController.Listener} from PipController.
+     */
+    default void removeListener(PipController.Listener listener) {
+    }
+
+    /**
+     * Removes a {@link PipController.MediaListener} from PipController.
+     */
+    default void removeMediaListener(PipController.MediaListener listener) {
+    }
+
+    /**
+     * Resize the Pip to the appropriate size for the input state.
+     *
+     * @param state In Pip state also used to determine the new size for the Pip.
+     */
+    default void resizePinnedStack(int state) {
+    }
+
+    /**
+     * Resumes resizing operation on the Pip that was previously suspended.
+     *
+     * @param reason The reason resizing operations on the Pip was suspended.
+     */
+    default void resumePipResizing(int reason) {
+    }
+
+    /**
+     * Sets both shelf visibility and its height.
+     *
+     * @param visible visibility of shelf.
+     * @param height  to specify the height for shelf.
+     */
+    default void setShelfHeight(boolean visible, int height) {
+    }
+
+    /**
+     * Registers the pinned stack animation listener.
+     *
+     * @param callback The callback of pinned stack animation.
+     */
+    default void setPinnedStackAnimationListener(Consumer<Boolean> callback) {
+    }
+
+    /**
+     * Set the pinned stack with {@link PipAnimationController.AnimationType}
+     *
+     * @param animationType The pre-defined {@link PipAnimationController.AnimationType}
+     */
+    default void setPinnedStackAnimationType(int animationType) {
+    }
+
+    /**
+     * Called when showing Pip menu.
+     */
+    void showPictureInPictureMenu();
+
+    /**
+     * Suspends resizing operation on the Pip until {@link #resumePipResizing} is called.
+     *
+     * @param reason The reason for suspending resizing operations on the Pip.
+     */
+    default void suspendPipResizing(int reason) {
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipAnimationController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipAnimationController.java
new file mode 100644
index 0000000..d829462
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipAnimationController.java
@@ -0,0 +1,462 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip;
+
+import android.animation.AnimationHandler;
+import android.animation.Animator;
+import android.animation.RectEvaluator;
+import android.animation.ValueAnimator;
+import android.annotation.IntDef;
+import android.graphics.Rect;
+import android.view.SurfaceControl;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.graphics.SfVsyncFrameCallbackProvider;
+import com.android.wm.shell.animation.Interpolators;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Controller class of PiP animations (both from and to PiP mode).
+ */
+public class PipAnimationController {
+    private static final float FRACTION_START = 0f;
+    private static final float FRACTION_END = 1f;
+
+    public static final int ANIM_TYPE_BOUNDS = 0;
+    public static final int ANIM_TYPE_ALPHA = 1;
+
+    @IntDef(prefix = { "ANIM_TYPE_" }, value = {
+            ANIM_TYPE_BOUNDS,
+            ANIM_TYPE_ALPHA
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface AnimationType {}
+
+    public static final int TRANSITION_DIRECTION_NONE = 0;
+    public static final int TRANSITION_DIRECTION_SAME = 1;
+    public static final int TRANSITION_DIRECTION_TO_PIP = 2;
+    public static final int TRANSITION_DIRECTION_LEAVE_PIP = 3;
+    public static final int TRANSITION_DIRECTION_LEAVE_PIP_TO_SPLIT_SCREEN = 4;
+    public static final int TRANSITION_DIRECTION_REMOVE_STACK = 5;
+
+    @IntDef(prefix = { "TRANSITION_DIRECTION_" }, value = {
+            TRANSITION_DIRECTION_NONE,
+            TRANSITION_DIRECTION_SAME,
+            TRANSITION_DIRECTION_TO_PIP,
+            TRANSITION_DIRECTION_LEAVE_PIP,
+            TRANSITION_DIRECTION_LEAVE_PIP_TO_SPLIT_SCREEN,
+            TRANSITION_DIRECTION_REMOVE_STACK
+    })
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface TransitionDirection {}
+
+    public static boolean isInPipDirection(@TransitionDirection int direction) {
+        return direction == TRANSITION_DIRECTION_TO_PIP;
+    }
+
+    public static boolean isOutPipDirection(@TransitionDirection int direction) {
+        return direction == TRANSITION_DIRECTION_LEAVE_PIP
+                || direction == TRANSITION_DIRECTION_LEAVE_PIP_TO_SPLIT_SCREEN;
+    }
+
+    private final PipSurfaceTransactionHelper mSurfaceTransactionHelper;
+
+    private PipTransitionAnimator mCurrentAnimator;
+
+    private ThreadLocal<AnimationHandler> mSfAnimationHandlerThreadLocal =
+            ThreadLocal.withInitial(() -> {
+                AnimationHandler handler = new AnimationHandler();
+                handler.setProvider(new SfVsyncFrameCallbackProvider());
+                return handler;
+            });
+
+    public PipAnimationController(PipSurfaceTransactionHelper helper) {
+        mSurfaceTransactionHelper = helper;
+    }
+
+    @SuppressWarnings("unchecked")
+    @VisibleForTesting
+    public PipTransitionAnimator getAnimator(SurfaceControl leash,
+            Rect destinationBounds, float alphaStart, float alphaEnd) {
+        if (mCurrentAnimator == null) {
+            mCurrentAnimator = setupPipTransitionAnimator(
+                    PipTransitionAnimator.ofAlpha(leash, destinationBounds, alphaStart, alphaEnd));
+        } else if (mCurrentAnimator.getAnimationType() == ANIM_TYPE_ALPHA
+                && mCurrentAnimator.isRunning()) {
+            mCurrentAnimator.updateEndValue(alphaEnd);
+        } else {
+            mCurrentAnimator.cancel();
+            mCurrentAnimator = setupPipTransitionAnimator(
+                    PipTransitionAnimator.ofAlpha(leash, destinationBounds, alphaStart, alphaEnd));
+        }
+        return mCurrentAnimator;
+    }
+
+    @SuppressWarnings("unchecked")
+    @VisibleForTesting
+    public PipTransitionAnimator getAnimator(SurfaceControl leash, Rect startBounds, Rect endBounds,
+            Rect sourceHintRect, @PipAnimationController.TransitionDirection int direction) {
+        if (mCurrentAnimator == null) {
+            mCurrentAnimator = setupPipTransitionAnimator(
+                    PipTransitionAnimator.ofBounds(leash, startBounds, endBounds, sourceHintRect,
+                            direction));
+        } else if (mCurrentAnimator.getAnimationType() == ANIM_TYPE_ALPHA
+                && mCurrentAnimator.isRunning()) {
+            // If we are still animating the fade into pip, then just move the surface and ensure
+            // we update with the new destination bounds, but don't interrupt the existing animation
+            // with a new bounds
+            mCurrentAnimator.setDestinationBounds(endBounds);
+        } else if (mCurrentAnimator.getAnimationType() == ANIM_TYPE_BOUNDS
+                && mCurrentAnimator.isRunning()) {
+            mCurrentAnimator.setDestinationBounds(endBounds);
+            // construct new Rect instances in case they are recycled
+            mCurrentAnimator.updateEndValue(new Rect(endBounds));
+        } else {
+            mCurrentAnimator.cancel();
+            mCurrentAnimator = setupPipTransitionAnimator(
+                    PipTransitionAnimator.ofBounds(leash, startBounds, endBounds, sourceHintRect,
+                            direction));
+        }
+        return mCurrentAnimator;
+    }
+
+    PipTransitionAnimator getCurrentAnimator() {
+        return mCurrentAnimator;
+    }
+
+    private PipTransitionAnimator setupPipTransitionAnimator(PipTransitionAnimator animator) {
+        animator.setSurfaceTransactionHelper(mSurfaceTransactionHelper);
+        animator.setInterpolator(Interpolators.FAST_OUT_SLOW_IN);
+        animator.setFloatValues(FRACTION_START, FRACTION_END);
+        animator.setAnimationHandler(mSfAnimationHandlerThreadLocal.get());
+        return animator;
+    }
+
+    /**
+     * Additional callback interface for PiP animation
+     */
+    public static class PipAnimationCallback {
+        /**
+         * Called when PiP animation is started.
+         */
+        public void onPipAnimationStart(PipTransitionAnimator animator) {}
+
+        /**
+         * Called when PiP animation is ended.
+         */
+        public void onPipAnimationEnd(SurfaceControl.Transaction tx,
+                PipTransitionAnimator animator) {}
+
+        /**
+         * Called when PiP animation is cancelled.
+         */
+        public void onPipAnimationCancel(PipTransitionAnimator animator) {}
+    }
+
+    /**
+     * Animator for PiP transition animation which supports both alpha and bounds animation.
+     * @param <T> Type of property to animate, either alpha (float) or bounds (Rect)
+     */
+    public abstract static class PipTransitionAnimator<T> extends ValueAnimator implements
+            ValueAnimator.AnimatorUpdateListener,
+            ValueAnimator.AnimatorListener {
+        private final SurfaceControl mLeash;
+        private final @AnimationType int mAnimationType;
+        private final Rect mDestinationBounds = new Rect();
+
+        protected T mCurrentValue;
+        protected T mStartValue;
+        private T mEndValue;
+        private PipAnimationCallback mPipAnimationCallback;
+        private PipSurfaceTransactionHelper.SurfaceControlTransactionFactory
+                mSurfaceControlTransactionFactory;
+        private PipSurfaceTransactionHelper mSurfaceTransactionHelper;
+        private @TransitionDirection int mTransitionDirection;
+
+        private PipTransitionAnimator(SurfaceControl leash, @AnimationType int animationType,
+                Rect destinationBounds, T startValue, T endValue) {
+            mLeash = leash;
+            mAnimationType = animationType;
+            mDestinationBounds.set(destinationBounds);
+            mStartValue = startValue;
+            mEndValue = endValue;
+            addListener(this);
+            addUpdateListener(this);
+            mSurfaceControlTransactionFactory = SurfaceControl.Transaction::new;
+            mTransitionDirection = TRANSITION_DIRECTION_NONE;
+        }
+
+        @Override
+        public void onAnimationStart(Animator animation) {
+            mCurrentValue = mStartValue;
+            onStartTransaction(mLeash, newSurfaceControlTransaction());
+            if (mPipAnimationCallback != null) {
+                mPipAnimationCallback.onPipAnimationStart(this);
+            }
+        }
+
+        @Override
+        public void onAnimationUpdate(ValueAnimator animation) {
+            applySurfaceControlTransaction(mLeash, newSurfaceControlTransaction(),
+                    animation.getAnimatedFraction());
+        }
+
+        @Override
+        public void onAnimationEnd(Animator animation) {
+            mCurrentValue = mEndValue;
+            final SurfaceControl.Transaction tx = newSurfaceControlTransaction();
+            onEndTransaction(mLeash, tx, mTransitionDirection);
+            if (mPipAnimationCallback != null) {
+                mPipAnimationCallback.onPipAnimationEnd(tx, this);
+            }
+        }
+
+        @Override
+        public void onAnimationCancel(Animator animation) {
+            if (mPipAnimationCallback != null) {
+                mPipAnimationCallback.onPipAnimationCancel(this);
+            }
+        }
+
+        @Override public void onAnimationRepeat(Animator animation) {}
+
+        @VisibleForTesting
+        @AnimationType public int getAnimationType() {
+            return mAnimationType;
+        }
+
+        @VisibleForTesting
+        public PipTransitionAnimator<T> setPipAnimationCallback(PipAnimationCallback callback) {
+            mPipAnimationCallback = callback;
+            return this;
+        }
+        @VisibleForTesting
+        @TransitionDirection public int getTransitionDirection() {
+            return mTransitionDirection;
+        }
+
+        @VisibleForTesting
+        public PipTransitionAnimator<T> setTransitionDirection(@TransitionDirection int direction) {
+            if (direction != TRANSITION_DIRECTION_SAME) {
+                mTransitionDirection = direction;
+            }
+            return this;
+        }
+
+        T getStartValue() {
+            return mStartValue;
+        }
+
+        @VisibleForTesting
+        public T getEndValue() {
+            return mEndValue;
+        }
+
+        Rect getDestinationBounds() {
+            return mDestinationBounds;
+        }
+
+        void setDestinationBounds(Rect destinationBounds) {
+            mDestinationBounds.set(destinationBounds);
+            if (mAnimationType == ANIM_TYPE_ALPHA) {
+                onStartTransaction(mLeash, newSurfaceControlTransaction());
+            }
+        }
+
+        void setCurrentValue(T value) {
+            mCurrentValue = value;
+        }
+
+        boolean shouldApplyCornerRadius() {
+            return !isOutPipDirection(mTransitionDirection);
+        }
+
+        boolean inScaleTransition() {
+            if (mAnimationType != ANIM_TYPE_BOUNDS) return false;
+            final int direction = getTransitionDirection();
+            return !isInPipDirection(direction) && !isOutPipDirection(direction);
+        }
+
+        /**
+         * Updates the {@link #mEndValue}.
+         *
+         * NOTE: Do not forget to call {@link #setDestinationBounds(Rect)} for bounds animation.
+         * This is typically used when we receive a shelf height adjustment during the bounds
+         * animation. In which case we can update the end bounds and keep the existing animation
+         * running instead of cancelling it.
+         */
+        public void updateEndValue(T endValue) {
+            mEndValue = endValue;
+        }
+
+        SurfaceControl.Transaction newSurfaceControlTransaction() {
+            return mSurfaceControlTransactionFactory.getTransaction();
+        }
+
+        @VisibleForTesting
+        public void setSurfaceControlTransactionFactory(
+                PipSurfaceTransactionHelper.SurfaceControlTransactionFactory factory) {
+            mSurfaceControlTransactionFactory = factory;
+        }
+
+        PipSurfaceTransactionHelper getSurfaceTransactionHelper() {
+            return mSurfaceTransactionHelper;
+        }
+
+        void setSurfaceTransactionHelper(PipSurfaceTransactionHelper helper) {
+            mSurfaceTransactionHelper = helper;
+        }
+
+        void onStartTransaction(SurfaceControl leash, SurfaceControl.Transaction tx) {}
+
+        void onEndTransaction(SurfaceControl leash, SurfaceControl.Transaction tx,
+                @TransitionDirection int transitionDirection) {}
+
+        abstract void applySurfaceControlTransaction(SurfaceControl leash,
+                SurfaceControl.Transaction tx, float fraction);
+
+        static PipTransitionAnimator<Float> ofAlpha(SurfaceControl leash,
+                Rect destinationBounds, float startValue, float endValue) {
+            return new PipTransitionAnimator<Float>(leash, ANIM_TYPE_ALPHA,
+                    destinationBounds, startValue, endValue) {
+                @Override
+                void applySurfaceControlTransaction(SurfaceControl leash,
+                        SurfaceControl.Transaction tx, float fraction) {
+                    final float alpha = getStartValue() * (1 - fraction) + getEndValue() * fraction;
+                    setCurrentValue(alpha);
+                    getSurfaceTransactionHelper().alpha(tx, leash, alpha);
+                    tx.apply();
+                }
+
+                @Override
+                void onStartTransaction(SurfaceControl leash, SurfaceControl.Transaction tx) {
+                    if (getTransitionDirection() == TRANSITION_DIRECTION_REMOVE_STACK) {
+                        // while removing the pip stack, no extra work needs to be done here.
+                        return;
+                    }
+                    getSurfaceTransactionHelper()
+                            .resetScale(tx, leash, getDestinationBounds())
+                            .crop(tx, leash, getDestinationBounds())
+                            .round(tx, leash, shouldApplyCornerRadius());
+                    tx.show(leash);
+                    tx.apply();
+                }
+
+                @Override
+                public void updateEndValue(Float endValue) {
+                    super.updateEndValue(endValue);
+                    mStartValue = mCurrentValue;
+                }
+            };
+        }
+
+        static PipTransitionAnimator<Rect> ofBounds(SurfaceControl leash,
+                Rect startValue, Rect endValue, Rect sourceHintRect,
+                @PipAnimationController.TransitionDirection int direction) {
+            // Just for simplicity we'll interpolate between the source rect hint insets and empty
+            // insets to calculate the window crop
+            final Rect initialSourceValue;
+            if (isOutPipDirection(direction)) {
+                initialSourceValue = new Rect(endValue);
+            } else {
+                initialSourceValue = new Rect(startValue);
+            }
+
+            final Rect sourceHintRectInsets;
+            if (sourceHintRect == null) {
+                sourceHintRectInsets = null;
+            } else {
+                sourceHintRectInsets = new Rect(sourceHintRect.left - initialSourceValue.left,
+                        sourceHintRect.top - initialSourceValue.top,
+                        initialSourceValue.right - sourceHintRect.right,
+                        initialSourceValue.bottom - sourceHintRect.bottom);
+            }
+            final Rect sourceInsets = new Rect(0, 0, 0, 0);
+
+            // construct new Rect instances in case they are recycled
+            return new PipTransitionAnimator<Rect>(leash, ANIM_TYPE_BOUNDS,
+                    endValue, new Rect(startValue), new Rect(endValue)) {
+                private final RectEvaluator mRectEvaluator = new RectEvaluator(new Rect());
+                private final RectEvaluator mInsetsEvaluator = new RectEvaluator(new Rect());
+
+                @Override
+                void applySurfaceControlTransaction(SurfaceControl leash,
+                        SurfaceControl.Transaction tx, float fraction) {
+                    final Rect start = getStartValue();
+                    final Rect end = getEndValue();
+                    Rect bounds = mRectEvaluator.evaluate(fraction, start, end);
+                    setCurrentValue(bounds);
+                    if (inScaleTransition() || sourceHintRect == null) {
+                        if (isOutPipDirection(direction)) {
+                            getSurfaceTransactionHelper().scale(tx, leash, end, bounds);
+                        } else {
+                            getSurfaceTransactionHelper().scale(tx, leash, start, bounds);
+                        }
+                    } else {
+                        final Rect insets;
+                        if (isOutPipDirection(direction)) {
+                            insets = mInsetsEvaluator.evaluate(fraction, sourceHintRectInsets,
+                                    sourceInsets);
+                        } else {
+                            insets = mInsetsEvaluator.evaluate(fraction, sourceInsets,
+                                    sourceHintRectInsets);
+                        }
+                        getSurfaceTransactionHelper().scaleAndCrop(tx, leash,
+                                initialSourceValue, bounds, insets);
+                    }
+                    tx.apply();
+                }
+
+                @Override
+                void onStartTransaction(SurfaceControl leash, SurfaceControl.Transaction tx) {
+                    getSurfaceTransactionHelper()
+                            .alpha(tx, leash, 1f)
+                            .round(tx, leash, shouldApplyCornerRadius());
+                    tx.show(leash);
+                    tx.apply();
+                }
+
+                @Override
+                void onEndTransaction(SurfaceControl leash, SurfaceControl.Transaction tx,
+                        int transitionDirection) {
+                    // NOTE: intentionally does not apply the transaction here.
+                    // this end transaction should get executed synchronously with the final
+                    // WindowContainerTransaction in task organizer
+                    final Rect destBounds = getDestinationBounds();
+                    getSurfaceTransactionHelper().resetScale(tx, leash, destBounds);
+                    if (transitionDirection == TRANSITION_DIRECTION_LEAVE_PIP) {
+                        // Leaving to fullscreen, reset crop to null.
+                        tx.setPosition(leash, destBounds.left, destBounds.top);
+                        tx.setWindowCrop(leash, 0, 0);
+                    } else {
+                        getSurfaceTransactionHelper().crop(tx, leash, destBounds);
+                    }
+                }
+
+                @Override
+                public void updateEndValue(Rect endValue) {
+                    super.updateEndValue(endValue);
+                    if (mStartValue != null && mCurrentValue != null) {
+                        mStartValue.set(mCurrentValue);
+                    }
+                }
+            };
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipBoundsHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipBoundsHandler.java
new file mode 100644
index 0000000..de3261b
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipBoundsHandler.java
@@ -0,0 +1,551 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip;
+
+import static android.app.WindowConfiguration.ACTIVITY_TYPE_UNDEFINED;
+import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED;
+import static android.util.TypedValue.COMPLEX_UNIT_DIP;
+import static android.view.Surface.ROTATION_0;
+import static android.view.Surface.ROTATION_180;
+
+import android.app.ActivityTaskManager;
+import android.app.ActivityTaskManager.RootTaskInfo;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.os.RemoteException;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.util.Size;
+import android.util.TypedValue;
+import android.view.Display;
+import android.view.DisplayInfo;
+import android.view.Gravity;
+import android.window.WindowContainerTransaction;
+
+import com.android.wm.shell.common.DisplayLayout;
+
+import java.io.PrintWriter;
+
+/**
+ * Handles bounds calculation for PIP on Phone and other form factors, it keeps tracking variant
+ * state changes originated from Window Manager and is the source of truth for PiP window bounds.
+ */
+public class PipBoundsHandler {
+
+    private static final String TAG = PipBoundsHandler.class.getSimpleName();
+    private static final float INVALID_SNAP_FRACTION = -1f;
+
+    private final PipSnapAlgorithm mSnapAlgorithm;
+    private final DisplayInfo mDisplayInfo = new DisplayInfo();
+    private DisplayLayout mDisplayLayout;
+
+    private ComponentName mLastPipComponentName;
+    private float mReentrySnapFraction = INVALID_SNAP_FRACTION;
+    private Size mReentrySize;
+
+    private float mDefaultAspectRatio;
+    private float mMinAspectRatio;
+    private float mMaxAspectRatio;
+    private float mAspectRatio;
+    private int mDefaultStackGravity;
+    private int mDefaultMinSize;
+    private Point mScreenEdgeInsets;
+    private int mCurrentMinSize;
+    private Size mOverrideMinimalSize;
+
+    private boolean mIsImeShowing;
+    private int mImeHeight;
+    private boolean mIsShelfShowing;
+    private int mShelfHeight;
+
+    public PipBoundsHandler(Context context) {
+        mSnapAlgorithm = new PipSnapAlgorithm(context);
+        mDisplayLayout = new DisplayLayout();
+        reloadResources(context);
+        // Initialize the aspect ratio to the default aspect ratio.  Don't do this in reload
+        // resources as it would clobber mAspectRatio when entering PiP from fullscreen which
+        // triggers a configuration change and the resources to be reloaded.
+        mAspectRatio = mDefaultAspectRatio;
+    }
+
+    /**
+     * TODO: move the resources to SysUI package.
+     */
+    private void reloadResources(Context context) {
+        final Resources res = context.getResources();
+        mDefaultAspectRatio = res.getFloat(
+                com.android.internal.R.dimen.config_pictureInPictureDefaultAspectRatio);
+        mDefaultStackGravity = res.getInteger(
+                com.android.internal.R.integer.config_defaultPictureInPictureGravity);
+        mDefaultMinSize = res.getDimensionPixelSize(
+                com.android.internal.R.dimen.default_minimal_size_pip_resizable_task);
+        mCurrentMinSize = mDefaultMinSize;
+        final String screenEdgeInsetsDpString = res.getString(
+                com.android.internal.R.string.config_defaultPictureInPictureScreenEdgeInsets);
+        final Size screenEdgeInsetsDp = !screenEdgeInsetsDpString.isEmpty()
+                ? Size.parseSize(screenEdgeInsetsDpString)
+                : null;
+        mScreenEdgeInsets = screenEdgeInsetsDp == null ? new Point()
+                : new Point(dpToPx(screenEdgeInsetsDp.getWidth(), res.getDisplayMetrics()),
+                        dpToPx(screenEdgeInsetsDp.getHeight(), res.getDisplayMetrics()));
+        mMinAspectRatio = res.getFloat(
+                com.android.internal.R.dimen.config_pictureInPictureMinAspectRatio);
+        mMaxAspectRatio = res.getFloat(
+                com.android.internal.R.dimen.config_pictureInPictureMaxAspectRatio);
+    }
+
+    /**
+     * Sets or update latest {@link DisplayLayout} when new display added or rotation callbacks
+     * from {@link DisplayController.OnDisplaysChangedListener}
+     * @param newDisplayLayout latest {@link DisplayLayout}
+     */
+    public void setDisplayLayout(DisplayLayout newDisplayLayout) {
+        mDisplayLayout.set(newDisplayLayout);
+    }
+
+    /**
+     * Update the Min edge size for {@link PipSnapAlgorithm} to calculate corresponding bounds
+     * @param minEdgeSize
+     */
+    public void setMinEdgeSize(int minEdgeSize) {
+        mCurrentMinSize = minEdgeSize;
+    }
+
+    protected float getAspectRatio() {
+        return mAspectRatio;
+    }
+
+    /**
+     * Sets both shelf visibility and its height if applicable.
+     * @return {@code true} if the internal shelf state is changed, {@code false} otherwise.
+     */
+    public boolean setShelfHeight(boolean shelfVisible, int shelfHeight) {
+        final boolean shelfShowing = shelfVisible && shelfHeight > 0;
+        if (shelfShowing == mIsShelfShowing && shelfHeight == mShelfHeight) {
+            return false;
+        }
+
+        mIsShelfShowing = shelfVisible;
+        mShelfHeight = shelfHeight;
+        return true;
+    }
+
+    /**
+     * Responds to IPinnedStackListener on IME visibility change.
+     */
+    public void onImeVisibilityChanged(boolean imeVisible, int imeHeight) {
+        mIsImeShowing = imeVisible;
+        mImeHeight = imeHeight;
+    }
+
+    /**
+     * Responds to IPinnedStackListener on movement bounds change.
+     * Note that both inset and normal bounds will be calculated here rather than in the caller.
+     */
+    public void onMovementBoundsChanged(Rect insetBounds, Rect normalBounds,
+            Rect animatingBounds, DisplayInfo displayInfo) {
+        getInsetBounds(insetBounds);
+        final Rect defaultBounds = getDefaultBounds(INVALID_SNAP_FRACTION, null);
+        normalBounds.set(defaultBounds);
+        if (animatingBounds.isEmpty()) {
+            animatingBounds.set(defaultBounds);
+        }
+        if (isValidPictureInPictureAspectRatio(mAspectRatio)) {
+            transformBoundsToAspectRatio(normalBounds, mAspectRatio,
+                    false /* useCurrentMinEdgeSize */, false /* useCurrentSize */);
+        }
+        displayInfo.copyFrom(mDisplayInfo);
+    }
+
+    /**
+     * Responds to IPinnedStackListener on saving reentry snap fraction and size
+     * for a given {@link ComponentName}.
+     */
+    public void onSaveReentryBounds(ComponentName componentName, Rect bounds) {
+        mReentrySnapFraction = getSnapFraction(bounds);
+        mReentrySize = new Size(bounds.width(), bounds.height());
+        mLastPipComponentName = componentName;
+    }
+
+    /**
+     * Responds to IPinnedStackListener on resetting reentry snap fraction and size
+     * for a given {@link ComponentName}.
+     */
+    public void onResetReentryBounds(ComponentName componentName) {
+        if (componentName.equals(mLastPipComponentName)) {
+            onResetReentryBoundsUnchecked();
+        }
+    }
+
+    private void onResetReentryBoundsUnchecked() {
+        mReentrySnapFraction = INVALID_SNAP_FRACTION;
+        mReentrySize = null;
+        mLastPipComponentName = null;
+    }
+
+    /**
+     * Returns ture if there's a valid snap fraction. This is used with {@link EXTRA_IS_FIRST_ENTRY}
+     * to see if this is the first time user has entered PIP for the component.
+     */
+    public boolean hasSaveReentryBounds() {
+        return mReentrySnapFraction != INVALID_SNAP_FRACTION;
+    }
+
+    /**
+     * The {@link PipSnapAlgorithm} is couple on display bounds
+     * @return {@link PipSnapAlgorithm}.
+     */
+    public PipSnapAlgorithm getSnapAlgorithm() {
+        return mSnapAlgorithm;
+    }
+
+    public Rect getDisplayBounds() {
+        return new Rect(0, 0, mDisplayInfo.logicalWidth, mDisplayInfo.logicalHeight);
+    }
+
+    public int getDisplayRotation() {
+        return mDisplayInfo.rotation;
+    }
+
+    /**
+     * Responds to IPinnedStackListener on {@link DisplayInfo} change.
+     * It will normally follow up with a
+     * {@link #onMovementBoundsChanged(Rect, Rect, Rect, DisplayInfo)} callback.
+     */
+    public void onDisplayInfoChanged(DisplayInfo displayInfo) {
+        mDisplayInfo.copyFrom(displayInfo);
+    }
+
+    /**
+     * Responds to IPinnedStackListener on configuration change.
+     */
+    public void onConfigurationChanged(Context context) {
+        reloadResources(context);
+    }
+
+    /**
+     * Responds to IPinnedStackListener on resetting aspect ratio for the pinned window.
+     * It will normally follow up with a
+     * {@link #onMovementBoundsChanged(Rect, Rect, Rect, DisplayInfo)} callback.
+     */
+    public void onAspectRatioChanged(float aspectRatio) {
+        mAspectRatio = aspectRatio;
+    }
+
+    /**
+     * See {@link #getDestinationBounds(ComponentName, float, Rect, Size, boolean)}
+     */
+    public Rect getDestinationBounds(ComponentName componentName, float aspectRatio, Rect bounds,
+            Size minimalSize) {
+        return getDestinationBounds(componentName, aspectRatio, bounds, minimalSize,
+                false /* useCurrentMinEdgeSize */);
+    }
+
+    /**
+     * @return {@link Rect} of the destination PiP window bounds.
+     */
+    public Rect getDestinationBounds(ComponentName componentName, float aspectRatio, Rect bounds,
+            Size minimalSize, boolean useCurrentMinEdgeSize) {
+        if (!componentName.equals(mLastPipComponentName)) {
+            onResetReentryBoundsUnchecked();
+            mLastPipComponentName = componentName;
+        }
+        final Rect destinationBounds;
+        if (bounds == null) {
+            final Rect defaultBounds = getDefaultBounds(mReentrySnapFraction, mReentrySize);
+            destinationBounds = new Rect(defaultBounds);
+            if (mReentrySnapFraction == INVALID_SNAP_FRACTION && mReentrySize == null) {
+                mOverrideMinimalSize = minimalSize;
+            }
+        } else {
+            destinationBounds = new Rect(bounds);
+        }
+        if (isValidPictureInPictureAspectRatio(aspectRatio)) {
+            boolean useCurrentSize = bounds == null && mReentrySize != null;
+            transformBoundsToAspectRatio(destinationBounds, aspectRatio, useCurrentMinEdgeSize,
+                    useCurrentSize);
+        }
+        mAspectRatio = aspectRatio;
+        return destinationBounds;
+    }
+
+    public float getDefaultAspectRatio() {
+        return mDefaultAspectRatio;
+    }
+
+    public void onOverlayChanged(Context context, Display display) {
+        mDisplayLayout = new DisplayLayout(context, display);
+    }
+
+    /**
+     * Updatest the display info and display layout on rotation change. This is needed even when we
+     * aren't in PIP because the rotation layout is used to calculate the proper insets for the
+     * next enter animation into PIP.
+     */
+    public void onDisplayRotationChangedNotInPip(Context context, int toRotation) {
+        // Update the display layout, note that we have to do this on every rotation even if we
+        // aren't in PIP since we need to update the display layout to get the right resources
+        mDisplayLayout.rotateTo(context.getResources(), toRotation);
+
+        // Populate the new {@link #mDisplayInfo}.
+        // The {@link DisplayInfo} queried from DisplayManager would be the one before rotation,
+        // therefore, the width/height may require a swap first.
+        // Moving forward, we should get the new dimensions after rotation from DisplayLayout.
+        mDisplayInfo.rotation = toRotation;
+        updateDisplayInfoIfNeeded();
+    }
+
+    /**
+     * Updates the display info, calculating and returning the new stack and movement bounds in the
+     * new orientation of the device if necessary.
+     *
+     * @return {@code true} if internal {@link DisplayInfo} is rotated, {@code false} otherwise.
+     */
+    public boolean onDisplayRotationChanged(Context context, Rect outBounds, Rect oldBounds,
+            Rect outInsetBounds,
+            int displayId, int fromRotation, int toRotation, WindowContainerTransaction t) {
+        // Bail early if the event is not sent to current {@link #mDisplayInfo}
+        if ((displayId != mDisplayInfo.displayId) || (fromRotation == toRotation)) {
+            return false;
+        }
+
+        // Bail early if the pinned task is staled.
+        final RootTaskInfo pinnedTaskInfo;
+        try {
+            pinnedTaskInfo = ActivityTaskManager.getService()
+                    .getRootTaskInfo(WINDOWING_MODE_PINNED, ACTIVITY_TYPE_UNDEFINED);
+            if (pinnedTaskInfo == null) return false;
+        } catch (RemoteException e) {
+            Log.e(TAG, "Failed to get RootTaskInfo for pinned task", e);
+            return false;
+        }
+
+        // Calculate the snap fraction of the current stack along the old movement bounds
+        final Rect postChangeStackBounds = new Rect(oldBounds);
+        final float snapFraction = getSnapFraction(postChangeStackBounds);
+
+        // Update the display layout
+        mDisplayLayout.rotateTo(context.getResources(), toRotation);
+
+        // Populate the new {@link #mDisplayInfo}.
+        // The {@link DisplayInfo} queried from DisplayManager would be the one before rotation,
+        // therefore, the width/height may require a swap first.
+        // Moving forward, we should get the new dimensions after rotation from DisplayLayout.
+        mDisplayInfo.rotation = toRotation;
+        updateDisplayInfoIfNeeded();
+
+        // Calculate the stack bounds in the new orientation based on same fraction along the
+        // rotated movement bounds.
+        final Rect postChangeMovementBounds = getMovementBounds(postChangeStackBounds,
+                false /* adjustForIme */);
+        mSnapAlgorithm.applySnapFraction(postChangeStackBounds, postChangeMovementBounds,
+                snapFraction);
+
+        getInsetBounds(outInsetBounds);
+        outBounds.set(postChangeStackBounds);
+        t.setBounds(pinnedTaskInfo.token, outBounds);
+        return true;
+    }
+
+    private void updateDisplayInfoIfNeeded() {
+        final boolean updateNeeded;
+        if ((mDisplayInfo.rotation == ROTATION_0) || (mDisplayInfo.rotation == ROTATION_180)) {
+            updateNeeded = (mDisplayInfo.logicalWidth > mDisplayInfo.logicalHeight);
+        } else {
+            updateNeeded = (mDisplayInfo.logicalWidth < mDisplayInfo.logicalHeight);
+        }
+        if (updateNeeded) {
+            final int newLogicalHeight = mDisplayInfo.logicalWidth;
+            mDisplayInfo.logicalWidth = mDisplayInfo.logicalHeight;
+            mDisplayInfo.logicalHeight = newLogicalHeight;
+        }
+    }
+
+    /**
+     * @return whether the given {@param aspectRatio} is valid.
+     */
+    private boolean isValidPictureInPictureAspectRatio(float aspectRatio) {
+        return Float.compare(mMinAspectRatio, aspectRatio) <= 0
+                && Float.compare(aspectRatio, mMaxAspectRatio) <= 0;
+    }
+
+    /**
+     * Sets the current bound with the currently store aspect ratio.
+     * @param stackBounds
+     */
+    public void transformBoundsToAspectRatio(Rect stackBounds) {
+        transformBoundsToAspectRatio(stackBounds, mAspectRatio, true /* useCurrentMinEdgeSize */,
+                true /* useCurrentSize */);
+    }
+
+    /**
+     * Set the current bounds (or the default bounds if there are no current bounds) with the
+     * specified aspect ratio.
+     */
+    private void transformBoundsToAspectRatio(Rect stackBounds, float aspectRatio,
+            boolean useCurrentMinEdgeSize, boolean useCurrentSize) {
+        // Save the snap fraction and adjust the size based on the new aspect ratio.
+        final float snapFraction = mSnapAlgorithm.getSnapFraction(stackBounds,
+                getMovementBounds(stackBounds));
+        final int minEdgeSize = useCurrentMinEdgeSize ? mCurrentMinSize : mDefaultMinSize;
+        final Size size;
+        if (useCurrentMinEdgeSize || useCurrentSize) {
+            size = mSnapAlgorithm.getSizeForAspectRatio(
+                    new Size(stackBounds.width(), stackBounds.height()), aspectRatio, minEdgeSize);
+        } else {
+            size = mSnapAlgorithm.getSizeForAspectRatio(aspectRatio, minEdgeSize,
+                    mDisplayInfo.logicalWidth, mDisplayInfo.logicalHeight);
+        }
+
+        final int left = (int) (stackBounds.centerX() - size.getWidth() / 2f);
+        final int top = (int) (stackBounds.centerY() - size.getHeight() / 2f);
+        stackBounds.set(left, top, left + size.getWidth(), top + size.getHeight());
+        // apply the override minimal size if applicable, this minimal size is specified by app
+        if (mOverrideMinimalSize != null) {
+            transformBoundsToMinimalSize(stackBounds, aspectRatio, mOverrideMinimalSize);
+        }
+        mSnapAlgorithm.applySnapFraction(stackBounds, getMovementBounds(stackBounds), snapFraction);
+    }
+
+    /**
+     * Transforms a given bounds to meet the minimal size constraints.
+     * This function assumes the given {@param stackBounds} qualifies {@param aspectRatio}.
+     */
+    private void transformBoundsToMinimalSize(Rect stackBounds, float aspectRatio,
+            Size minimalSize) {
+        if (minimalSize == null) return;
+        final Size adjustedMinimalSize;
+        final float minimalSizeAspectRatio =
+                minimalSize.getWidth() / (float) minimalSize.getHeight();
+        if (minimalSizeAspectRatio > aspectRatio) {
+            // minimal size is wider, fixed the width and increase the height
+            adjustedMinimalSize = new Size(
+                    minimalSize.getWidth(), (int) (minimalSize.getWidth() / aspectRatio));
+        } else {
+            adjustedMinimalSize = new Size(
+                    (int) (minimalSize.getHeight() * aspectRatio), minimalSize.getHeight());
+        }
+        final Rect containerBounds = new Rect(stackBounds);
+        Gravity.apply(mDefaultStackGravity,
+                adjustedMinimalSize.getWidth(), adjustedMinimalSize.getHeight(),
+                containerBounds, stackBounds);
+    }
+
+    /**
+     * @return the default bounds to show the PIP, if a {@param snapFraction} and {@param size} are
+     * provided, then it will apply the default bounds to the provided snap fraction and size.
+     */
+    private Rect getDefaultBounds(float snapFraction, Size size) {
+        final Rect defaultBounds = new Rect();
+        if (snapFraction != INVALID_SNAP_FRACTION && size != null) {
+            defaultBounds.set(0, 0, size.getWidth(), size.getHeight());
+            final Rect movementBounds = getMovementBounds(defaultBounds);
+            mSnapAlgorithm.applySnapFraction(defaultBounds, movementBounds, snapFraction);
+        } else {
+            final Rect insetBounds = new Rect();
+            getInsetBounds(insetBounds);
+            size = mSnapAlgorithm.getSizeForAspectRatio(mDefaultAspectRatio,
+                    mDefaultMinSize, mDisplayInfo.logicalWidth, mDisplayInfo.logicalHeight);
+            Gravity.apply(mDefaultStackGravity, size.getWidth(), size.getHeight(), insetBounds,
+                    0, Math.max(mIsImeShowing ? mImeHeight : 0,
+                            mIsShelfShowing ? mShelfHeight : 0),
+                    defaultBounds);
+        }
+        return defaultBounds;
+    }
+
+    /**
+     * Populates the bounds on the screen that the PIP can be visible in.
+     */
+    protected void getInsetBounds(Rect outRect) {
+        Rect insets = mDisplayLayout.stableInsets();
+        outRect.set(insets.left + mScreenEdgeInsets.x,
+                insets.top + mScreenEdgeInsets.y,
+                mDisplayInfo.logicalWidth - insets.right - mScreenEdgeInsets.x,
+                mDisplayInfo.logicalHeight - insets.bottom - mScreenEdgeInsets.y);
+    }
+
+    /**
+     * @return the movement bounds for the given {@param stackBounds} and the current state of the
+     *         controller.
+     */
+    private Rect getMovementBounds(Rect stackBounds) {
+        return getMovementBounds(stackBounds, true /* adjustForIme */);
+    }
+
+    /**
+     * @return the movement bounds for the given {@param stackBounds} and the current state of the
+     *         controller.
+     */
+    private Rect getMovementBounds(Rect stackBounds, boolean adjustForIme) {
+        final Rect movementBounds = new Rect();
+        getInsetBounds(movementBounds);
+
+        // Apply the movement bounds adjustments based on the current state.
+        mSnapAlgorithm.getMovementBounds(stackBounds, movementBounds, movementBounds,
+                (adjustForIme && mIsImeShowing) ? mImeHeight : 0);
+        return movementBounds;
+    }
+
+    /**
+     * @return the default snap fraction to apply instead of the default gravity when calculating
+     *         the default stack bounds when first entering PiP.
+     */
+    public float getSnapFraction(Rect stackBounds) {
+        return mSnapAlgorithm.getSnapFraction(stackBounds, getMovementBounds(stackBounds));
+    }
+
+    /**
+     * Applies the given snap fraction to the given stack bounds.
+     */
+    public void applySnapFraction(Rect stackBounds, float snapFraction) {
+        final Rect movementBounds = getMovementBounds(stackBounds);
+        mSnapAlgorithm.applySnapFraction(stackBounds, movementBounds, snapFraction);
+    }
+
+    /**
+     * @return the pixels for a given dp value.
+     */
+    private int dpToPx(float dpValue, DisplayMetrics dm) {
+        return (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, dpValue, dm);
+    }
+
+    /**
+     * Dumps internal states.
+     */
+    public void dump(PrintWriter pw, String prefix) {
+        final String innerPrefix = prefix + "  ";
+        pw.println(prefix + TAG);
+        pw.println(innerPrefix + "mLastPipComponentName=" + mLastPipComponentName);
+        pw.println(innerPrefix + "mReentrySnapFraction=" + mReentrySnapFraction);
+        pw.println(innerPrefix + "mReentrySize=" + mReentrySize);
+        pw.println(innerPrefix + "mDisplayInfo=" + mDisplayInfo);
+        pw.println(innerPrefix + "mDefaultAspectRatio=" + mDefaultAspectRatio);
+        pw.println(innerPrefix + "mMinAspectRatio=" + mMinAspectRatio);
+        pw.println(innerPrefix + "mMaxAspectRatio=" + mMaxAspectRatio);
+        pw.println(innerPrefix + "mAspectRatio=" + mAspectRatio);
+        pw.println(innerPrefix + "mDefaultStackGravity=" + mDefaultStackGravity);
+        pw.println(innerPrefix + "mIsImeShowing=" + mIsImeShowing);
+        pw.println(innerPrefix + "mImeHeight=" + mImeHeight);
+        pw.println(innerPrefix + "mIsShelfShowing=" + mIsShelfShowing);
+        pw.println(innerPrefix + "mShelfHeight=" + mShelfHeight);
+        pw.println(innerPrefix + "mSnapAlgorithm" + mSnapAlgorithm);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipSnapAlgorithm.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipSnapAlgorithm.java
new file mode 100644
index 0000000..820930c
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipSnapAlgorithm.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.util.Size;
+
+/**
+ * Calculates the snap targets and the snap position for the PIP given a position and a velocity.
+ * All bounds are relative to the display top/left.
+ */
+public class PipSnapAlgorithm {
+
+    private final float mDefaultSizePercent;
+    private final float mMinAspectRatioForMinSize;
+    private final float mMaxAspectRatioForMinSize;
+
+    public PipSnapAlgorithm(Context context) {
+        Resources res = context.getResources();
+        mDefaultSizePercent = res.getFloat(
+                com.android.internal.R.dimen.config_pictureInPictureDefaultSizePercent);
+        mMaxAspectRatioForMinSize = res.getFloat(
+                com.android.internal.R.dimen.config_pictureInPictureAspectRatioLimitForMinSize);
+        mMinAspectRatioForMinSize = 1f / mMaxAspectRatioForMinSize;
+    }
+
+    /**
+     * @return returns a fraction that describes where along the {@param movementBounds} the
+     *         {@param stackBounds} are. If the {@param stackBounds} are not currently on the
+     *         {@param movementBounds} exactly, then they will be snapped to the movement bounds.
+     *
+     *         The fraction is defined in a clockwise fashion against the {@param movementBounds}:
+     *
+     *            0   1
+     *          4 +---+ 1
+     *            |   |
+     *          3 +---+ 2
+     *            3   2
+     */
+    public float getSnapFraction(Rect stackBounds, Rect movementBounds) {
+        final Rect tmpBounds = new Rect();
+        snapRectToClosestEdge(stackBounds, movementBounds, tmpBounds);
+        final float widthFraction = (float) (tmpBounds.left - movementBounds.left) /
+                movementBounds.width();
+        final float heightFraction = (float) (tmpBounds.top - movementBounds.top) /
+                movementBounds.height();
+        if (tmpBounds.top == movementBounds.top) {
+            return widthFraction;
+        } else if (tmpBounds.left == movementBounds.right) {
+            return 1f + heightFraction;
+        } else if (tmpBounds.top == movementBounds.bottom) {
+            return 2f + (1f - widthFraction);
+        } else {
+            return 3f + (1f - heightFraction);
+        }
+    }
+
+    /**
+     * Moves the {@param stackBounds} along the {@param movementBounds} to the given snap fraction.
+     * See {@link #getSnapFraction(Rect, Rect)}.
+     *
+     * The fraction is define in a clockwise fashion against the {@param movementBounds}:
+     *
+     *    0   1
+     *  4 +---+ 1
+     *    |   |
+     *  3 +---+ 2
+     *    3   2
+     */
+    public void applySnapFraction(Rect stackBounds, Rect movementBounds, float snapFraction) {
+        if (snapFraction < 1f) {
+            int offset = movementBounds.left + (int) (snapFraction * movementBounds.width());
+            stackBounds.offsetTo(offset, movementBounds.top);
+        } else if (snapFraction < 2f) {
+            snapFraction -= 1f;
+            int offset = movementBounds.top + (int) (snapFraction * movementBounds.height());
+            stackBounds.offsetTo(movementBounds.right, offset);
+        } else if (snapFraction < 3f) {
+            snapFraction -= 2f;
+            int offset = movementBounds.left + (int) ((1f - snapFraction) * movementBounds.width());
+            stackBounds.offsetTo(offset, movementBounds.bottom);
+        } else {
+            snapFraction -= 3f;
+            int offset = movementBounds.top + (int) ((1f - snapFraction) * movementBounds.height());
+            stackBounds.offsetTo(movementBounds.left, offset);
+        }
+    }
+
+    /**
+     * Adjusts {@param movementBoundsOut} so that it is the movement bounds for the given
+     * {@param stackBounds}.
+     */
+    public void getMovementBounds(Rect stackBounds, Rect insetBounds, Rect movementBoundsOut,
+            int bottomOffset) {
+        // Adjust the right/bottom to ensure the stack bounds never goes offscreen
+        movementBoundsOut.set(insetBounds);
+        movementBoundsOut.right = Math.max(insetBounds.left, insetBounds.right -
+                stackBounds.width());
+        movementBoundsOut.bottom = Math.max(insetBounds.top, insetBounds.bottom -
+                stackBounds.height());
+        movementBoundsOut.bottom -= bottomOffset;
+    }
+
+    /**
+     * @return the size of the PiP at the given {@param aspectRatio}, ensuring that the minimum edge
+     * is at least {@param minEdgeSize}.
+     */
+    public Size getSizeForAspectRatio(float aspectRatio, float minEdgeSize, int displayWidth,
+            int displayHeight) {
+        final int smallestDisplaySize = Math.min(displayWidth, displayHeight);
+        final int minSize = (int) Math.max(minEdgeSize, smallestDisplaySize * mDefaultSizePercent);
+
+        final int width;
+        final int height;
+        if (aspectRatio <= mMinAspectRatioForMinSize || aspectRatio > mMaxAspectRatioForMinSize) {
+            // Beyond these points, we can just use the min size as the shorter edge
+            if (aspectRatio <= 1) {
+                // Portrait, width is the minimum size
+                width = minSize;
+                height = Math.round(width / aspectRatio);
+            } else {
+                // Landscape, height is the minimum size
+                height = minSize;
+                width = Math.round(height * aspectRatio);
+            }
+        } else {
+            // Within these points, we ensure that the bounds fit within the radius of the limits
+            // at the points
+            final float widthAtMaxAspectRatioForMinSize = mMaxAspectRatioForMinSize * minSize;
+            final float radius = PointF.length(widthAtMaxAspectRatioForMinSize, minSize);
+            height = (int) Math.round(Math.sqrt((radius * radius) /
+                    (aspectRatio * aspectRatio + 1)));
+            width = Math.round(height * aspectRatio);
+        }
+        return new Size(width, height);
+    }
+
+    /**
+     * @return the adjusted size so that it conforms to the given aspectRatio, ensuring that the
+     * minimum edge is at least minEdgeSize.
+     */
+    public Size getSizeForAspectRatio(Size size, float aspectRatio, float minEdgeSize) {
+        final int smallestSize = Math.min(size.getWidth(), size.getHeight());
+        final int minSize = (int) Math.max(minEdgeSize, smallestSize);
+
+        final int width;
+        final int height;
+        if (aspectRatio <= 1) {
+            // Portrait, width is the minimum size.
+            width = minSize;
+            height = Math.round(width / aspectRatio);
+        } else {
+            // Landscape, height is the minimum size
+            height = minSize;
+            width = Math.round(height * aspectRatio);
+        }
+        return new Size(width, height);
+    }
+
+    /**
+     * Snaps the {@param stackBounds} to the closest edge of the {@param movementBounds} and writes
+     * the new bounds out to {@param boundsOut}.
+     */
+    public void snapRectToClosestEdge(Rect stackBounds, Rect movementBounds, Rect boundsOut) {
+        final int boundedLeft = Math.max(movementBounds.left, Math.min(movementBounds.right,
+                stackBounds.left));
+        final int boundedTop = Math.max(movementBounds.top, Math.min(movementBounds.bottom,
+                stackBounds.top));
+        boundsOut.set(stackBounds);
+
+        // Otherwise, just find the closest edge
+        final int fromLeft = Math.abs(stackBounds.left - movementBounds.left);
+        final int fromTop = Math.abs(stackBounds.top - movementBounds.top);
+        final int fromRight = Math.abs(movementBounds.right - stackBounds.left);
+        final int fromBottom = Math.abs(movementBounds.bottom - stackBounds.top);
+        final int shortest = Math.min(Math.min(fromLeft, fromRight), Math.min(fromTop, fromBottom));
+        if (shortest == fromLeft) {
+            boundsOut.offsetTo(movementBounds.left, boundedTop);
+        } else if (shortest == fromTop) {
+            boundsOut.offsetTo(boundedLeft, movementBounds.top);
+        } else if (shortest == fromRight) {
+            boundsOut.offsetTo(movementBounds.right, boundedTop);
+        } else {
+            boundsOut.offsetTo(boundedLeft, movementBounds.bottom);
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipSurfaceTransactionHelper.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipSurfaceTransactionHelper.java
new file mode 100644
index 0000000..b9a5536
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipSurfaceTransactionHelper.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.view.SurfaceControl;
+
+import com.android.wm.shell.R;
+
+/**
+ * Abstracts the common operations on {@link SurfaceControl.Transaction} for PiP transition.
+ */
+public class PipSurfaceTransactionHelper {
+
+    private final boolean mEnableCornerRadius;
+    private int mCornerRadius;
+
+    /** for {@link #scale(SurfaceControl.Transaction, SurfaceControl, Rect, Rect)} operation */
+    private final Matrix mTmpTransform = new Matrix();
+    private final float[] mTmpFloat9 = new float[9];
+    private final RectF mTmpSourceRectF = new RectF();
+    private final RectF mTmpDestinationRectF = new RectF();
+    private final Rect mTmpDestinationRect = new Rect();
+
+    public PipSurfaceTransactionHelper(Context context) {
+        final Resources res = context.getResources();
+        mEnableCornerRadius = res.getBoolean(R.bool.config_pipEnableRoundCorner);
+    }
+
+    /**
+     * Called when display size or font size of settings changed
+     *
+     * @param context the current context
+     */
+    public void onDensityOrFontScaleChanged(Context context) {
+        if (mEnableCornerRadius) {
+            final Resources res = context.getResources();
+            mCornerRadius = res.getDimensionPixelSize(R.dimen.pip_corner_radius);
+        }
+    }
+
+    /**
+     * Operates the alpha on a given transaction and leash
+     * @return same {@link PipSurfaceTransactionHelper} instance for method chaining
+     */
+    public PipSurfaceTransactionHelper alpha(SurfaceControl.Transaction tx, SurfaceControl leash,
+            float alpha) {
+        tx.setAlpha(leash, alpha);
+        return this;
+    }
+
+    /**
+     * Operates the crop (and position) on a given transaction and leash
+     * @return same {@link PipSurfaceTransactionHelper} instance for method chaining
+     */
+    public PipSurfaceTransactionHelper crop(SurfaceControl.Transaction tx, SurfaceControl leash,
+            Rect destinationBounds) {
+        tx.setWindowCrop(leash, destinationBounds.width(), destinationBounds.height())
+                .setPosition(leash, destinationBounds.left, destinationBounds.top);
+        return this;
+    }
+
+    /**
+     * Operates the scale (setMatrix) on a given transaction and leash
+     * @return same {@link PipSurfaceTransactionHelper} instance for method chaining
+     */
+    public PipSurfaceTransactionHelper scale(SurfaceControl.Transaction tx, SurfaceControl leash,
+            Rect sourceBounds, Rect destinationBounds) {
+        mTmpSourceRectF.set(sourceBounds);
+        mTmpDestinationRectF.set(destinationBounds);
+        mTmpTransform.setRectToRect(mTmpSourceRectF, mTmpDestinationRectF, Matrix.ScaleToFit.FILL);
+        tx.setMatrix(leash, mTmpTransform, mTmpFloat9)
+                .setPosition(leash, mTmpDestinationRectF.left, mTmpDestinationRectF.top);
+        return this;
+    }
+
+    /**
+     * Operates the scale (setMatrix) on a given transaction and leash
+     * @return same {@link PipSurfaceTransactionHelper} instance for method chaining
+     */
+    public PipSurfaceTransactionHelper scaleAndCrop(SurfaceControl.Transaction tx,
+            SurfaceControl leash,
+            Rect sourceBounds, Rect destinationBounds, Rect insets) {
+        mTmpSourceRectF.set(sourceBounds);
+        mTmpDestinationRect.set(sourceBounds);
+        mTmpDestinationRect.inset(insets);
+        // Scale by the shortest edge and offset such that the top/left of the scaled inset source
+        // rect aligns with the top/left of the destination bounds
+        final float scale = sourceBounds.width() <= sourceBounds.height()
+                ? (float) destinationBounds.width() / sourceBounds.width()
+                : (float) destinationBounds.height() / sourceBounds.height();
+        final float left = destinationBounds.left - insets.left * scale;
+        final float top = destinationBounds.top - insets.top * scale;
+        mTmpTransform.setScale(scale, scale);
+        tx.setMatrix(leash, mTmpTransform, mTmpFloat9)
+                .setWindowCrop(leash, mTmpDestinationRect)
+                .setPosition(leash, left, top);
+        return this;
+    }
+
+    /**
+     * Resets the scale (setMatrix) on a given transaction and leash if there's any
+     *
+     * @return same {@link PipSurfaceTransactionHelper} instance for method chaining
+     */
+    public PipSurfaceTransactionHelper resetScale(SurfaceControl.Transaction tx,
+            SurfaceControl leash,
+            Rect destinationBounds) {
+        tx.setMatrix(leash, Matrix.IDENTITY_MATRIX, mTmpFloat9)
+                .setPosition(leash, destinationBounds.left, destinationBounds.top);
+        return this;
+    }
+
+    /**
+     * Operates the round corner radius on a given transaction and leash
+     * @return same {@link PipSurfaceTransactionHelper} instance for method chaining
+     */
+    public PipSurfaceTransactionHelper round(SurfaceControl.Transaction tx, SurfaceControl leash,
+            boolean applyCornerRadius) {
+        if (mEnableCornerRadius) {
+            tx.setCornerRadius(leash, applyCornerRadius ? mCornerRadius : 0);
+        }
+        return this;
+    }
+
+    public interface SurfaceControlTransactionFactory {
+        SurfaceControl.Transaction getTransaction();
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipTaskOrganizer.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipTaskOrganizer.java
new file mode 100644
index 0000000..bb501fb
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipTaskOrganizer.java
@@ -0,0 +1,1108 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip;
+
+import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN;
+import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED;
+import static android.app.WindowConfiguration.WINDOWING_MODE_SPLIT_SCREEN_SECONDARY;
+import static android.app.WindowConfiguration.WINDOWING_MODE_UNDEFINED;
+
+import static com.android.wm.shell.pip.PipAnimationController.ANIM_TYPE_ALPHA;
+import static com.android.wm.shell.pip.PipAnimationController.ANIM_TYPE_BOUNDS;
+import static com.android.wm.shell.pip.PipAnimationController.TRANSITION_DIRECTION_LEAVE_PIP;
+import static com.android.wm.shell.pip.PipAnimationController.TRANSITION_DIRECTION_LEAVE_PIP_TO_SPLIT_SCREEN;
+import static com.android.wm.shell.pip.PipAnimationController.TRANSITION_DIRECTION_NONE;
+import static com.android.wm.shell.pip.PipAnimationController.TRANSITION_DIRECTION_REMOVE_STACK;
+import static com.android.wm.shell.pip.PipAnimationController.TRANSITION_DIRECTION_SAME;
+import static com.android.wm.shell.pip.PipAnimationController.TRANSITION_DIRECTION_TO_PIP;
+import static com.android.wm.shell.pip.PipAnimationController.isInPipDirection;
+import static com.android.wm.shell.pip.PipAnimationController.isOutPipDirection;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.ActivityManager;
+import android.app.ActivityTaskManager;
+import android.app.PictureInPictureParams;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.ActivityInfo;
+import android.content.res.Configuration;
+import android.graphics.Rect;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.util.Log;
+import android.util.Rational;
+import android.util.Size;
+import android.view.SurfaceControl;
+import android.view.SurfaceControlViewHost;
+import android.view.View;
+import android.view.WindowManager;
+import android.window.TaskOrganizer;
+import android.window.WindowContainerToken;
+import android.window.WindowContainerTransaction;
+import android.window.WindowContainerTransactionCallback;
+
+import com.android.internal.os.SomeArgs;
+import com.android.wm.shell.R;
+import com.android.wm.shell.ShellTaskOrganizer;
+import com.android.wm.shell.common.DisplayController;
+import com.android.wm.shell.pip.phone.PipMenuActivityController;
+import com.android.wm.shell.pip.phone.PipMotionHelper;
+import com.android.wm.shell.pip.phone.PipUpdateThread;
+import com.android.wm.shell.pip.phone.PipUtils;
+import com.android.wm.shell.splitscreen.SplitScreen;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+/**
+ * Manages PiP tasks such as resize and offset.
+ *
+ * This class listens on {@link TaskOrganizer} callbacks for windowing mode change
+ * both to and from PiP and issues corresponding animation if applicable.
+ * Normally, we apply series of {@link SurfaceControl.Transaction} when the animator is running
+ * and files a final {@link WindowContainerTransaction} at the end of the transition.
+ *
+ * This class is also responsible for general resize/offset PiP operations within SysUI component,
+ * see also {@link PipMotionHelper}.
+ */
+public class PipTaskOrganizer extends TaskOrganizer implements ShellTaskOrganizer.TaskListener,
+        DisplayController.OnDisplaysChangedListener {
+    private static final String TAG = PipTaskOrganizer.class.getSimpleName();
+    private static final boolean DEBUG = false;
+
+    private static final int MSG_RESIZE_IMMEDIATE = 1;
+    private static final int MSG_RESIZE_ANIMATE = 2;
+    private static final int MSG_OFFSET_ANIMATE = 3;
+    private static final int MSG_FINISH_RESIZE = 4;
+    private static final int MSG_RESIZE_USER = 5;
+
+    // Not a complete set of states but serves what we want right now.
+    private enum State {
+        UNDEFINED(0),
+        TASK_APPEARED(1),
+        ENTERING_PIP(2),
+        EXITING_PIP(3);
+
+        private final int mStateValue;
+
+        State(int value) {
+            mStateValue = value;
+        }
+
+        private boolean isInPip() {
+            return mStateValue >= TASK_APPEARED.mStateValue
+                    && mStateValue != EXITING_PIP.mStateValue;
+        }
+
+        /**
+         * Resize request can be initiated in other component, ignore if we are no longer in PIP,
+         * still waiting for animation or we're exiting from it.
+         *
+         * @return {@code true} if the resize request should be blocked/ignored.
+         */
+        private boolean shouldBlockResizeRequest() {
+            return mStateValue < ENTERING_PIP.mStateValue
+                    || mStateValue == EXITING_PIP.mStateValue;
+        }
+    }
+
+    private final Handler mMainHandler;
+    private final Handler mUpdateHandler;
+    private final PipBoundsHandler mPipBoundsHandler;
+    private final PipAnimationController mPipAnimationController;
+    private final PipUiEventLogger mPipUiEventLoggerLogger;
+    private final List<PipTransitionCallback> mPipTransitionCallbacks = new ArrayList<>();
+    private final Rect mLastReportedBounds = new Rect();
+    private final int mEnterExitAnimationDuration;
+    private final PipSurfaceTransactionHelper mSurfaceTransactionHelper;
+    private final Map<IBinder, Configuration> mInitialState = new HashMap<>();
+    private final Optional<SplitScreen> mSplitScreenOptional;
+    protected final ShellTaskOrganizer mTaskOrganizer;
+    private SurfaceControlViewHost mPipViewHost;
+    private SurfaceControl mPipMenuSurface;
+
+    // These callbacks are called on the update thread
+    private final PipAnimationController.PipAnimationCallback mPipAnimationCallback =
+            new PipAnimationController.PipAnimationCallback() {
+        @Override
+        public void onPipAnimationStart(PipAnimationController.PipTransitionAnimator animator) {
+            sendOnPipTransitionStarted(animator.getTransitionDirection());
+        }
+
+        @Override
+        public void onPipAnimationEnd(SurfaceControl.Transaction tx,
+                PipAnimationController.PipTransitionAnimator animator) {
+            finishResize(tx, animator.getDestinationBounds(), animator.getTransitionDirection(),
+                    animator.getAnimationType());
+            sendOnPipTransitionFinished(animator.getTransitionDirection());
+        }
+
+        @Override
+        public void onPipAnimationCancel(PipAnimationController.PipTransitionAnimator animator) {
+            sendOnPipTransitionCancelled(animator.getTransitionDirection());
+        }
+    };
+
+    @SuppressWarnings("unchecked")
+    private final Handler.Callback mUpdateCallbacks = (msg) -> {
+        SomeArgs args = (SomeArgs) msg.obj;
+        Consumer<Rect> updateBoundsCallback = (Consumer<Rect>) args.arg1;
+        switch (msg.what) {
+            case MSG_RESIZE_IMMEDIATE: {
+                Rect toBounds = (Rect) args.arg2;
+                resizePip(toBounds);
+                if (updateBoundsCallback != null) {
+                    updateBoundsCallback.accept(toBounds);
+                }
+                break;
+            }
+            case MSG_RESIZE_ANIMATE: {
+                Rect currentBounds = (Rect) args.arg2;
+                Rect toBounds = (Rect) args.arg3;
+                Rect sourceHintRect = (Rect) args.arg4;
+                int duration = args.argi2;
+                animateResizePip(currentBounds, toBounds, sourceHintRect,
+                        args.argi1 /* direction */, duration);
+                if (updateBoundsCallback != null) {
+                    updateBoundsCallback.accept(toBounds);
+                }
+                break;
+            }
+            case MSG_OFFSET_ANIMATE: {
+                Rect originalBounds = (Rect) args.arg2;
+                final int offset = args.argi1;
+                final int duration = args.argi2;
+                offsetPip(originalBounds, 0 /* xOffset */, offset, duration);
+                Rect toBounds = new Rect(originalBounds);
+                toBounds.offset(0, offset);
+                if (updateBoundsCallback != null) {
+                    updateBoundsCallback.accept(toBounds);
+                }
+                break;
+            }
+            case MSG_FINISH_RESIZE: {
+                SurfaceControl.Transaction tx = (SurfaceControl.Transaction) args.arg2;
+                Rect toBounds = (Rect) args.arg3;
+                finishResize(tx, toBounds, args.argi1 /* direction */, -1);
+                if (updateBoundsCallback != null) {
+                    updateBoundsCallback.accept(toBounds);
+                }
+                break;
+            }
+            case MSG_RESIZE_USER: {
+                Rect startBounds = (Rect) args.arg2;
+                Rect toBounds = (Rect) args.arg3;
+                userResizePip(startBounds, toBounds);
+                if (updateBoundsCallback != null) {
+                    updateBoundsCallback.accept(toBounds);
+                }
+                break;
+            }
+        }
+        args.recycle();
+        return true;
+    };
+
+    private ActivityManager.RunningTaskInfo mTaskInfo;
+    private WindowContainerToken mToken;
+    private SurfaceControl mLeash;
+    private State mState = State.UNDEFINED;
+    private @PipAnimationController.AnimationType int mOneShotAnimationType = ANIM_TYPE_BOUNDS;
+    private PipSurfaceTransactionHelper.SurfaceControlTransactionFactory
+            mSurfaceControlTransactionFactory;
+    private PictureInPictureParams mPictureInPictureParams;
+
+    /**
+     * If set to {@code true}, the entering animation will be skipped and we will wait for
+     * {@link #onFixedRotationFinished(int)} callback to actually enter PiP.
+     */
+    private boolean mShouldDeferEnteringPip;
+
+    public PipTaskOrganizer(Context context, @NonNull PipBoundsHandler boundsHandler,
+            @NonNull PipSurfaceTransactionHelper surfaceTransactionHelper,
+            Optional<SplitScreen> splitScreenOptional,
+            @NonNull DisplayController displayController,
+            @NonNull PipUiEventLogger pipUiEventLogger,
+            @NonNull ShellTaskOrganizer shellTaskOrganizer) {
+        mMainHandler = new Handler(Looper.getMainLooper());
+        mUpdateHandler = new Handler(PipUpdateThread.get().getLooper(), mUpdateCallbacks);
+        mPipBoundsHandler = boundsHandler;
+        mEnterExitAnimationDuration = context.getResources()
+                .getInteger(R.integer.config_pipResizeAnimationDuration);
+        mSurfaceTransactionHelper = surfaceTransactionHelper;
+        mPipAnimationController = new PipAnimationController(mSurfaceTransactionHelper);
+        mPipUiEventLoggerLogger = pipUiEventLogger;
+        mSurfaceControlTransactionFactory = SurfaceControl.Transaction::new;
+        mSplitScreenOptional = splitScreenOptional;
+        mTaskOrganizer = shellTaskOrganizer;
+
+        if (!PipUtils.hasSystemFeature(context)) {
+            Log.w(TAG, "Device not support PIP feature");
+        } else {
+            mTaskOrganizer.addListener(this, WINDOWING_MODE_PINNED);
+            displayController.addDisplayWindowListener(this);
+        }
+    }
+
+    public Handler getUpdateHandler() {
+        return mUpdateHandler;
+    }
+
+    public Rect getLastReportedBounds() {
+        return new Rect(mLastReportedBounds);
+    }
+
+    public Rect getCurrentOrAnimatingBounds() {
+        PipAnimationController.PipTransitionAnimator animator =
+                mPipAnimationController.getCurrentAnimator();
+        if (animator != null && animator.isRunning()) {
+            return new Rect(animator.getDestinationBounds());
+        }
+        return getLastReportedBounds();
+    }
+
+    public boolean isInPip() {
+        return mState.isInPip();
+    }
+
+    public boolean isDeferringEnterPipAnimation() {
+        return mState.isInPip() && mShouldDeferEnteringPip;
+    }
+
+    /**
+     * Registers {@link PipTransitionCallback} to receive transition callbacks.
+     */
+    public void registerPipTransitionCallback(PipTransitionCallback callback) {
+        mPipTransitionCallbacks.add(callback);
+    }
+
+    /**
+     * Sets the preferred animation type for one time.
+     * This is typically used to set the animation type to
+     * {@link PipAnimationController#ANIM_TYPE_ALPHA}.
+     */
+    public void setOneShotAnimationType(@PipAnimationController.AnimationType int animationType) {
+        mOneShotAnimationType = animationType;
+    }
+
+    /**
+     * Expands PiP to the previous bounds, this is done in two phases using
+     * {@link WindowContainerTransaction}
+     * - setActivityWindowingMode to either fullscreen or split-secondary at beginning of the
+     *   transaction. without changing the windowing mode of the Task itself. This makes sure the
+     *   activity render it's final configuration while the Task is still in PiP.
+     * - setWindowingMode to undefined at the end of transition
+     * @param animationDurationMs duration in millisecond for the exiting PiP transition
+     */
+    public void exitPip(int animationDurationMs) {
+        if (!mState.isInPip() || mState == State.EXITING_PIP || mToken == null) {
+            Log.wtf(TAG, "Not allowed to exitPip in current state"
+                    + " mState=" + mState + " mToken=" + mToken);
+            return;
+        }
+
+        final Configuration initialConfig = mInitialState.remove(mToken.asBinder());
+        if (initialConfig == null) {
+            Log.wtf(TAG, "Token not in record, this should not happen mToken=" + mToken);
+            return;
+        }
+        mPipUiEventLoggerLogger.log(
+                PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_EXPAND_TO_FULLSCREEN);
+        final boolean orientationDiffers = initialConfig.windowConfiguration.getRotation()
+                != mPipBoundsHandler.getDisplayRotation();
+        final WindowContainerTransaction wct = new WindowContainerTransaction();
+        final Rect destinationBounds = initialConfig.windowConfiguration.getBounds();
+        final int direction = syncWithSplitScreenBounds(destinationBounds)
+                ? TRANSITION_DIRECTION_LEAVE_PIP_TO_SPLIT_SCREEN
+                : TRANSITION_DIRECTION_LEAVE_PIP;
+        if (orientationDiffers) {
+            mState = State.EXITING_PIP;
+            // Send started callback though animation is ignored.
+            sendOnPipTransitionStarted(direction);
+            // Don't bother doing an animation if the display rotation differs or if it's in
+            // a non-supported windowing mode
+            applyWindowingModeChangeOnExit(wct, direction);
+            mTaskOrganizer.applyTransaction(wct);
+            // Send finished callback though animation is ignored.
+            sendOnPipTransitionFinished(direction);
+        } else {
+            final SurfaceControl.Transaction tx =
+                    mSurfaceControlTransactionFactory.getTransaction();
+            mSurfaceTransactionHelper.scale(tx, mLeash, destinationBounds,
+                    mLastReportedBounds);
+            tx.setWindowCrop(mLeash, destinationBounds.width(), destinationBounds.height());
+            // We set to fullscreen here for now, but later it will be set to UNDEFINED for
+            // the proper windowing mode to take place. See #applyWindowingModeChangeOnExit.
+            wct.setActivityWindowingMode(mToken,
+                    direction == TRANSITION_DIRECTION_LEAVE_PIP_TO_SPLIT_SCREEN
+                    ? WINDOWING_MODE_SPLIT_SCREEN_SECONDARY
+                    : WINDOWING_MODE_FULLSCREEN);
+            wct.setBounds(mToken, destinationBounds);
+            wct.setBoundsChangeTransaction(mToken, tx);
+            mTaskOrganizer.applySyncTransaction(wct, new WindowContainerTransactionCallback() {
+                @Override
+                public void onTransactionReady(int id, SurfaceControl.Transaction t) {
+                    t.apply();
+                    scheduleAnimateResizePip(mLastReportedBounds, destinationBounds,
+                            getValidSourceHintRect(mTaskInfo, destinationBounds), direction,
+                            animationDurationMs, null /* updateBoundsCallback */);
+                    mState = State.EXITING_PIP;
+                }
+            });
+        }
+    }
+
+    private void applyWindowingModeChangeOnExit(WindowContainerTransaction wct, int direction) {
+        // Reset the final windowing mode.
+        wct.setWindowingMode(mToken, getOutPipWindowingMode());
+        // Simply reset the activity mode set prior to the animation running.
+        wct.setActivityWindowingMode(mToken, WINDOWING_MODE_UNDEFINED);
+        mSplitScreenOptional.ifPresent(splitScreen -> {
+            if (direction == TRANSITION_DIRECTION_LEAVE_PIP_TO_SPLIT_SCREEN) {
+                wct.reparent(mToken, splitScreen.getSecondaryRoot(), true /* onTop */);
+            }
+        });
+    }
+
+    /**
+     * Removes PiP immediately.
+     */
+    public void removePip() {
+        if (!mState.isInPip() ||  mToken == null) {
+            Log.wtf(TAG, "Not allowed to removePip in current state"
+                    + " mState=" + mState + " mToken=" + mToken);
+            return;
+        }
+
+        // removePipImmediately is expected when the following animation finishes.
+        mUpdateHandler.post(() -> mPipAnimationController
+                .getAnimator(mLeash, mLastReportedBounds, 1f, 0f)
+                .setTransitionDirection(TRANSITION_DIRECTION_REMOVE_STACK)
+                .setPipAnimationCallback(mPipAnimationCallback)
+                .setDuration(mEnterExitAnimationDuration)
+                .start());
+        mInitialState.remove(mToken.asBinder());
+        mState = State.EXITING_PIP;
+    }
+
+    private void removePipImmediately() {
+        try {
+            // Reset the task bounds first to ensure the activity configuration is reset as well
+            final WindowContainerTransaction wct = new WindowContainerTransaction();
+            wct.setBounds(mToken, null);
+            mTaskOrganizer.applyTransaction(wct);
+
+            ActivityTaskManager.getService().removeStacksInWindowingModes(
+                    new int[]{ WINDOWING_MODE_PINNED });
+        } catch (RemoteException e) {
+            Log.e(TAG, "Failed to remove PiP", e);
+        }
+    }
+
+    @Override
+    public void onTaskAppeared(ActivityManager.RunningTaskInfo info, SurfaceControl leash) {
+        Objects.requireNonNull(info, "Requires RunningTaskInfo");
+        mTaskInfo = info;
+        mToken = mTaskInfo.token;
+        mState = State.TASK_APPEARED;
+        mLeash = leash;
+        mInitialState.put(mToken.asBinder(), new Configuration(mTaskInfo.configuration));
+        mPictureInPictureParams = mTaskInfo.pictureInPictureParams;
+
+        mPipUiEventLoggerLogger.setTaskInfo(mTaskInfo);
+        mPipUiEventLoggerLogger.log(PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_ENTER);
+
+        if (mShouldDeferEnteringPip) {
+            if (DEBUG) Log.d(TAG, "Defer entering PiP animation, fixed rotation is ongoing");
+            // if deferred, hide the surface till fixed rotation is completed
+            final SurfaceControl.Transaction tx =
+                    mSurfaceControlTransactionFactory.getTransaction();
+            tx.setAlpha(mLeash, 0f);
+            tx.show(mLeash);
+            tx.apply();
+            return;
+        }
+
+        final Rect destinationBounds = mPipBoundsHandler.getDestinationBounds(
+                mTaskInfo.topActivity, getAspectRatioOrDefault(mPictureInPictureParams),
+                null /* bounds */, getMinimalSize(mTaskInfo.topActivityInfo));
+        Objects.requireNonNull(destinationBounds, "Missing destination bounds");
+        final Rect currentBounds = mTaskInfo.configuration.windowConfiguration.getBounds();
+
+        if (mOneShotAnimationType == ANIM_TYPE_BOUNDS) {
+            final Rect sourceHintRect = getValidSourceHintRect(info, currentBounds);
+            scheduleAnimateResizePip(currentBounds, destinationBounds, sourceHintRect,
+                    TRANSITION_DIRECTION_TO_PIP, mEnterExitAnimationDuration,
+                    null /* updateBoundsCallback */);
+            mState = State.ENTERING_PIP;
+        } else if (mOneShotAnimationType == ANIM_TYPE_ALPHA) {
+            enterPipWithAlphaAnimation(destinationBounds, mEnterExitAnimationDuration);
+            mOneShotAnimationType = ANIM_TYPE_BOUNDS;
+        } else {
+            throw new RuntimeException("Unrecognized animation type: " + mOneShotAnimationType);
+        }
+    }
+
+    /**
+     * Returns the source hint rect if it is valid (if provided and is contained by the current
+     * task bounds).
+     */
+    private Rect getValidSourceHintRect(ActivityManager.RunningTaskInfo info, Rect sourceBounds) {
+        final Rect sourceHintRect = info.pictureInPictureParams != null
+                && info.pictureInPictureParams.hasSourceBoundsHint()
+                ? info.pictureInPictureParams.getSourceRectHint()
+                : null;
+        if (sourceHintRect != null && sourceBounds.contains(sourceHintRect)) {
+            return sourceHintRect;
+        }
+        return null;
+    }
+
+    private void enterPipWithAlphaAnimation(Rect destinationBounds, long durationMs) {
+        // If we are fading the PIP in, then we should move the pip to the final location as
+        // soon as possible, but set the alpha immediately since the transaction can take a
+        // while to process
+        final SurfaceControl.Transaction tx =
+                mSurfaceControlTransactionFactory.getTransaction();
+        tx.setAlpha(mLeash, 0f);
+        tx.apply();
+        final WindowContainerTransaction wct = new WindowContainerTransaction();
+        wct.setActivityWindowingMode(mToken, WINDOWING_MODE_UNDEFINED);
+        wct.setBounds(mToken, destinationBounds);
+        wct.scheduleFinishEnterPip(mToken, destinationBounds);
+        mTaskOrganizer.applySyncTransaction(wct, new WindowContainerTransactionCallback() {
+            @Override
+            public void onTransactionReady(int id, SurfaceControl.Transaction t) {
+                t.apply();
+                mUpdateHandler.post(() -> mPipAnimationController
+                        .getAnimator(mLeash, destinationBounds, 0f, 1f)
+                        .setTransitionDirection(TRANSITION_DIRECTION_TO_PIP)
+                        .setPipAnimationCallback(mPipAnimationCallback)
+                        .setDuration(durationMs)
+                        .start());
+                // mState is set right after the animation is kicked off to block any resize
+                // requests such as offsetPip that may have been called prior to the transition.
+                mState = State.ENTERING_PIP;
+            }
+        });
+    }
+
+    private void sendOnPipTransitionStarted(
+            @PipAnimationController.TransitionDirection int direction) {
+        final Rect pipBounds = new Rect(mLastReportedBounds);
+        runOnMainHandler(() -> {
+            for (int i = mPipTransitionCallbacks.size() - 1; i >= 0; i--) {
+                final PipTransitionCallback callback = mPipTransitionCallbacks.get(i);
+                callback.onPipTransitionStarted(mTaskInfo.baseActivity, direction, pipBounds);
+            }
+        });
+    }
+
+    private void sendOnPipTransitionFinished(
+            @PipAnimationController.TransitionDirection int direction) {
+        runOnMainHandler(() -> {
+            for (int i = mPipTransitionCallbacks.size() - 1; i >= 0; i--) {
+                final PipTransitionCallback callback = mPipTransitionCallbacks.get(i);
+                callback.onPipTransitionFinished(mTaskInfo.baseActivity, direction);
+            }
+        });
+    }
+
+    private void sendOnPipTransitionCancelled(
+            @PipAnimationController.TransitionDirection int direction) {
+        runOnMainHandler(() -> {
+            for (int i = mPipTransitionCallbacks.size() - 1; i >= 0; i--) {
+                final PipTransitionCallback callback = mPipTransitionCallbacks.get(i);
+                callback.onPipTransitionCanceled(mTaskInfo.baseActivity, direction);
+            }
+        });
+    }
+
+    private void runOnMainHandler(Runnable r) {
+        if (Looper.getMainLooper() == Looper.myLooper()) {
+            r.run();
+        } else {
+            mMainHandler.post(r);
+        }
+    }
+
+    /**
+     * Setup the ViewHost and attach the provided menu view to the ViewHost.
+     * @return The input token belonging to the PipMenuView.
+     */
+    public IBinder attachPipMenuViewHost(View menuView, WindowManager.LayoutParams lp) {
+        if (mPipMenuSurface != null) {
+            Log.e(TAG, "PIP Menu View already created and attached.");
+            return null;
+        }
+
+        if (mLeash == null) {
+            Log.e(TAG, "PiP Leash is not yet ready.");
+            return null;
+        }
+
+        if (Looper.getMainLooper() != Looper.myLooper()) {
+            throw new RuntimeException("PipMenuView needs to be attached on the main thread.");
+        }
+        final Context context = menuView.getContext();
+        mPipViewHost = new SurfaceControlViewHost(context, context.getDisplay(),
+                (android.os.Binder) null);
+        mPipMenuSurface = mPipViewHost.getSurfacePackage().getSurfaceControl();
+        SurfaceControl.Transaction transaction = new SurfaceControl.Transaction();
+        transaction.reparent(mPipMenuSurface, mLeash);
+        transaction.show(mPipMenuSurface);
+        transaction.setRelativeLayer(mPipMenuSurface, mLeash, 1);
+        transaction.apply();
+        mPipViewHost.setView(menuView, lp);
+
+        return mPipViewHost.getSurfacePackage().getInputToken();
+    }
+
+
+    /**
+     * Releases the PIP Menu's View host, remove it from PIP task surface.
+     */
+    public void detachPipMenuViewHost() {
+        if (mPipMenuSurface != null) {
+            SurfaceControl.Transaction transaction = new SurfaceControl.Transaction();
+            transaction.remove(mPipMenuSurface);
+            transaction.apply();
+            mPipMenuSurface = null;
+            mPipViewHost = null;
+        }
+    }
+
+    /**
+     * Return whether the PiP Menu has been attached to the leash yet.
+     */
+    public boolean isPipMenuViewHostAttached() {
+        return mPipViewHost != null;
+    }
+
+
+    /**
+     * Note that dismissing PiP is now originated from SystemUI, see {@link #exitPip(int)}.
+     * Meanwhile this callback is invoked whenever the task is removed. For instance:
+     *   - as a result of removeStacksInWindowingModes from WM
+     *   - activity itself is died
+     * Nevertheless, we simply update the internal state here as all the heavy lifting should
+     * have been done in WM.
+     */
+    @Override
+    public void onTaskVanished(ActivityManager.RunningTaskInfo info) {
+        if (!mState.isInPip()) {
+            return;
+        }
+        final WindowContainerToken token = info.token;
+        Objects.requireNonNull(token, "Requires valid WindowContainerToken");
+        if (token.asBinder() != mToken.asBinder()) {
+            Log.wtf(TAG, "Unrecognized token: " + token);
+            return;
+        }
+        mShouldDeferEnteringPip = false;
+        mPictureInPictureParams = null;
+        mState = State.UNDEFINED;
+        mPipUiEventLoggerLogger.setTaskInfo(null);
+    }
+
+    @Override
+    public void onTaskInfoChanged(ActivityManager.RunningTaskInfo info) {
+        Objects.requireNonNull(mToken, "onTaskInfoChanged requires valid existing mToken");
+        final PictureInPictureParams newParams = info.pictureInPictureParams;
+        if (newParams == null || !applyPictureInPictureParams(newParams)) {
+            Log.d(TAG, "Ignored onTaskInfoChanged with PiP param: " + newParams);
+            return;
+        }
+        final Rect destinationBounds = mPipBoundsHandler.getDestinationBounds(
+                info.topActivity, getAspectRatioOrDefault(newParams),
+                mLastReportedBounds, getMinimalSize(info.topActivityInfo),
+                true /* userCurrentMinEdgeSize */);
+        Objects.requireNonNull(destinationBounds, "Missing destination bounds");
+        scheduleAnimateResizePip(destinationBounds, mEnterExitAnimationDuration,
+                null /* updateBoundsCallback */);
+    }
+
+    @Override
+    public void onFixedRotationStarted(int displayId, int newRotation) {
+        mShouldDeferEnteringPip = true;
+    }
+
+    @Override
+    public void onFixedRotationFinished(int displayId) {
+        if (mShouldDeferEnteringPip && mState.isInPip()) {
+            final Rect destinationBounds = mPipBoundsHandler.getDestinationBounds(
+                    mTaskInfo.topActivity, getAspectRatioOrDefault(mPictureInPictureParams),
+                    null /* bounds */, getMinimalSize(mTaskInfo.topActivityInfo));
+            // schedule a regular animation to ensure all the callbacks are still being sent
+            enterPipWithAlphaAnimation(destinationBounds, 0 /* durationMs */);
+        }
+        mShouldDeferEnteringPip = false;
+    }
+
+    /**
+     * Called when display size or font size of settings changed
+     */
+    public void onDensityOrFontScaleChanged(Context context) {
+        mSurfaceTransactionHelper.onDensityOrFontScaleChanged(context);
+    }
+
+    /**
+     * TODO(b/152809058): consolidate the display info handling logic in SysUI
+     *
+     * @param destinationBoundsOut the current destination bounds will be populated to this param
+     */
+    @SuppressWarnings("unchecked")
+    public void onMovementBoundsChanged(Rect destinationBoundsOut, boolean fromRotation,
+            boolean fromImeAdjustment, boolean fromShelfAdjustment,
+            WindowContainerTransaction wct) {
+        final PipAnimationController.PipTransitionAnimator animator =
+                mPipAnimationController.getCurrentAnimator();
+        if (animator == null || !animator.isRunning()
+                || animator.getTransitionDirection() != TRANSITION_DIRECTION_TO_PIP) {
+            if (mState.isInPip() && fromRotation) {
+                // If we are rotating while there is a current animation, immediately cancel the
+                // animation (remove the listeners so we don't trigger the normal finish resize
+                // call that should only happen on the update thread)
+                int direction = TRANSITION_DIRECTION_NONE;
+                if (animator != null) {
+                    direction = animator.getTransitionDirection();
+                    animator.removeAllUpdateListeners();
+                    animator.removeAllListeners();
+                    animator.cancel();
+                    // Do notify the listeners that this was canceled
+                    sendOnPipTransitionCancelled(direction);
+                    sendOnPipTransitionFinished(direction);
+                }
+                mLastReportedBounds.set(destinationBoundsOut);
+
+                // Create a reset surface transaction for the new bounds and update the window
+                // container transaction
+                final SurfaceControl.Transaction tx = createFinishResizeSurfaceTransaction(
+                        destinationBoundsOut);
+                prepareFinishResizeTransaction(destinationBoundsOut, direction, tx, wct);
+            } else  {
+                // There could be an animation on-going. If there is one on-going, last-reported
+                // bounds isn't yet updated. We'll use the animator's bounds instead.
+                if (animator != null && animator.isRunning()) {
+                    if (!animator.getDestinationBounds().isEmpty()) {
+                        destinationBoundsOut.set(animator.getDestinationBounds());
+                    }
+                } else {
+                    if (!mLastReportedBounds.isEmpty()) {
+                        destinationBoundsOut.set(mLastReportedBounds);
+                    }
+                }
+            }
+            return;
+        }
+
+        final Rect currentDestinationBounds = animator.getDestinationBounds();
+        destinationBoundsOut.set(currentDestinationBounds);
+        if (!fromImeAdjustment && !fromShelfAdjustment
+                && mPipBoundsHandler.getDisplayBounds().contains(currentDestinationBounds)) {
+            // no need to update the destination bounds, bail early
+            return;
+        }
+
+        final Rect newDestinationBounds = mPipBoundsHandler.getDestinationBounds(
+                mTaskInfo.topActivity, getAspectRatioOrDefault(mPictureInPictureParams),
+                null /* bounds */, getMinimalSize(mTaskInfo.topActivityInfo));
+        if (newDestinationBounds.equals(currentDestinationBounds)) return;
+        if (animator.getAnimationType() == ANIM_TYPE_BOUNDS) {
+            animator.updateEndValue(newDestinationBounds);
+        }
+        animator.setDestinationBounds(newDestinationBounds);
+        destinationBoundsOut.set(newDestinationBounds);
+    }
+
+    /**
+     * @return {@code true} if the aspect ratio is changed since no other parameters within
+     * {@link PictureInPictureParams} would affect the bounds.
+     */
+    private boolean applyPictureInPictureParams(@NonNull PictureInPictureParams params) {
+        final Rational currentAspectRatio =
+                mPictureInPictureParams != null ? mPictureInPictureParams.getAspectRatioRational()
+                        : null;
+        final boolean aspectRatioChanged = !Objects.equals(currentAspectRatio,
+                params.getAspectRatioRational());
+        mPictureInPictureParams = params;
+        if (aspectRatioChanged) {
+            mPipBoundsHandler.onAspectRatioChanged(params.getAspectRatio());
+        }
+        return aspectRatioChanged;
+    }
+
+    /**
+     * Animates resizing of the pinned stack given the duration.
+     */
+    public void scheduleAnimateResizePip(Rect toBounds, int duration,
+            Consumer<Rect> updateBoundsCallback) {
+        if (mShouldDeferEnteringPip) {
+            Log.d(TAG, "skip scheduleAnimateResizePip, entering pip deferred");
+            return;
+        }
+        scheduleAnimateResizePip(mLastReportedBounds, toBounds, null /* sourceHintRect */,
+                TRANSITION_DIRECTION_NONE, duration, updateBoundsCallback);
+    }
+
+    private void scheduleAnimateResizePip(Rect currentBounds, Rect destinationBounds,
+            Rect sourceHintRect, @PipAnimationController.TransitionDirection int direction,
+            int durationMs, Consumer<Rect> updateBoundsCallback) {
+        if (!mState.isInPip()) {
+            // TODO: tend to use shouldBlockResizeRequest here as well but need to consider
+            // the fact that when in exitPip, scheduleAnimateResizePip is executed in the window
+            // container transaction callback and we want to set the mState immediately.
+            return;
+        }
+
+        SomeArgs args = SomeArgs.obtain();
+        args.arg1 = updateBoundsCallback;
+        args.arg2 = currentBounds;
+        args.arg3 = destinationBounds;
+        args.arg4 = sourceHintRect;
+        args.argi1 = direction;
+        args.argi2 = durationMs;
+        mUpdateHandler.sendMessage(mUpdateHandler.obtainMessage(MSG_RESIZE_ANIMATE, args));
+    }
+
+    /**
+     * Directly perform manipulation/resize on the leash. This will not perform any
+     * {@link WindowContainerTransaction} until {@link #scheduleFinishResizePip} is called.
+     */
+    public void scheduleResizePip(Rect toBounds, Consumer<Rect> updateBoundsCallback) {
+        SomeArgs args = SomeArgs.obtain();
+        args.arg1 = updateBoundsCallback;
+        args.arg2 = toBounds;
+        mUpdateHandler.sendMessage(mUpdateHandler.obtainMessage(MSG_RESIZE_IMMEDIATE, args));
+    }
+
+    /**
+     * Directly perform a scaled matrix transformation on the leash. This will not perform any
+     * {@link WindowContainerTransaction} until {@link #scheduleFinishResizePip} is called.
+     */
+    public void scheduleUserResizePip(Rect startBounds, Rect toBounds,
+            Consumer<Rect> updateBoundsCallback) {
+        SomeArgs args = SomeArgs.obtain();
+        args.arg1 = updateBoundsCallback;
+        args.arg2 = startBounds;
+        args.arg3 = toBounds;
+        mUpdateHandler.sendMessage(mUpdateHandler.obtainMessage(MSG_RESIZE_USER, args));
+    }
+
+    /**
+     * Finish an intermediate resize operation. This is expected to be called after
+     * {@link #scheduleResizePip}.
+     */
+    public void scheduleFinishResizePip(Rect destinationBounds) {
+        scheduleFinishResizePip(destinationBounds, null /* updateBoundsCallback */);
+    }
+
+    /**
+     * Same as {@link #scheduleFinishResizePip} but with a callback.
+     */
+    public void scheduleFinishResizePip(Rect destinationBounds,
+            Consumer<Rect> updateBoundsCallback) {
+        scheduleFinishResizePip(destinationBounds, TRANSITION_DIRECTION_NONE, updateBoundsCallback);
+    }
+
+    private void scheduleFinishResizePip(Rect destinationBounds,
+            @PipAnimationController.TransitionDirection int direction,
+            Consumer<Rect> updateBoundsCallback) {
+        if (mState.shouldBlockResizeRequest()) {
+            return;
+        }
+
+        SomeArgs args = SomeArgs.obtain();
+        args.arg1 = updateBoundsCallback;
+        args.arg2 = createFinishResizeSurfaceTransaction(
+                destinationBounds);
+        args.arg3 = destinationBounds;
+        args.argi1 = direction;
+        mUpdateHandler.sendMessage(mUpdateHandler.obtainMessage(MSG_FINISH_RESIZE, args));
+    }
+
+    private SurfaceControl.Transaction createFinishResizeSurfaceTransaction(
+            Rect destinationBounds) {
+        final SurfaceControl.Transaction tx = mSurfaceControlTransactionFactory.getTransaction();
+        mSurfaceTransactionHelper
+                .crop(tx, mLeash, destinationBounds)
+                .resetScale(tx, mLeash, destinationBounds)
+                .round(tx, mLeash, mState.isInPip());
+        return tx;
+    }
+
+    /**
+     * Offset the PiP window by a given offset on Y-axis, triggered also from screen rotation.
+     */
+    public void scheduleOffsetPip(Rect originalBounds, int offset, int duration,
+            Consumer<Rect> updateBoundsCallback) {
+        if (mState.shouldBlockResizeRequest()) {
+            return;
+        }
+        if (mShouldDeferEnteringPip) {
+            Log.d(TAG, "skip scheduleOffsetPip, entering pip deferred");
+            return;
+        }
+        SomeArgs args = SomeArgs.obtain();
+        args.arg1 = updateBoundsCallback;
+        args.arg2 = originalBounds;
+        // offset would be zero if triggered from screen rotation.
+        args.argi1 = offset;
+        args.argi2 = duration;
+        mUpdateHandler.sendMessage(mUpdateHandler.obtainMessage(MSG_OFFSET_ANIMATE, args));
+    }
+
+    private void offsetPip(Rect originalBounds, int xOffset, int yOffset, int durationMs) {
+        if (Looper.myLooper() != mUpdateHandler.getLooper()) {
+            throw new RuntimeException("Callers should call scheduleOffsetPip() instead of this "
+                    + "directly");
+        }
+        if (mTaskInfo == null) {
+            Log.w(TAG, "mTaskInfo is not set");
+            return;
+        }
+        final Rect destinationBounds = new Rect(originalBounds);
+        destinationBounds.offset(xOffset, yOffset);
+        animateResizePip(originalBounds, destinationBounds, null /* sourceHintRect */,
+                TRANSITION_DIRECTION_SAME, durationMs);
+    }
+
+    private void resizePip(Rect destinationBounds) {
+        if (Looper.myLooper() != mUpdateHandler.getLooper()) {
+            throw new RuntimeException("Callers should call scheduleResizePip() instead of this "
+                    + "directly");
+        }
+        // Could happen when exitPip
+        if (mToken == null || mLeash == null) {
+            Log.w(TAG, "Abort animation, invalid leash");
+            return;
+        }
+        mLastReportedBounds.set(destinationBounds);
+        final SurfaceControl.Transaction tx = mSurfaceControlTransactionFactory.getTransaction();
+        mSurfaceTransactionHelper
+                .crop(tx, mLeash, destinationBounds)
+                .round(tx, mLeash, mState.isInPip());
+        tx.apply();
+    }
+
+    private void userResizePip(Rect startBounds, Rect destinationBounds) {
+        if (Looper.myLooper() != mUpdateHandler.getLooper()) {
+            throw new RuntimeException("Callers should call scheduleUserResizePip() instead of "
+                    + "this directly");
+        }
+        // Could happen when exitPip
+        if (mToken == null || mLeash == null) {
+            Log.w(TAG, "Abort animation, invalid leash");
+            return;
+        }
+
+        if (startBounds.isEmpty() || destinationBounds.isEmpty()) {
+            Log.w(TAG, "Attempted to user resize PIP to or from empty bounds, aborting.");
+            return;
+        }
+
+        final SurfaceControl.Transaction tx = mSurfaceControlTransactionFactory.getTransaction();
+        mSurfaceTransactionHelper.scale(tx, mLeash, startBounds, destinationBounds);
+        tx.apply();
+    }
+
+    private void finishResize(SurfaceControl.Transaction tx, Rect destinationBounds,
+            @PipAnimationController.TransitionDirection int direction,
+            @PipAnimationController.AnimationType int type) {
+        if (Looper.myLooper() != mUpdateHandler.getLooper()) {
+            throw new RuntimeException("Callers should call scheduleResizePip() instead of this "
+                    + "directly");
+        }
+        mLastReportedBounds.set(destinationBounds);
+        if (direction == TRANSITION_DIRECTION_REMOVE_STACK) {
+            removePipImmediately();
+            return;
+        } else if (isInPipDirection(direction) && type == ANIM_TYPE_ALPHA) {
+            return;
+        }
+
+        WindowContainerTransaction wct = new WindowContainerTransaction();
+        prepareFinishResizeTransaction(destinationBounds, direction, tx, wct);
+        applyFinishBoundsResize(wct, direction);
+        runOnMainHandler(() -> {
+            if (mPipViewHost != null) {
+                mPipViewHost.relayout(PipMenuActivityController.getPipMenuLayoutParams(
+                        destinationBounds.width(), destinationBounds.height()));
+            }
+        });
+    }
+
+    private void prepareFinishResizeTransaction(Rect destinationBounds,
+            @PipAnimationController.TransitionDirection int direction,
+            SurfaceControl.Transaction tx,
+            WindowContainerTransaction wct) {
+        final Rect taskBounds;
+        if (isInPipDirection(direction)) {
+            // If we are animating from fullscreen using a bounds animation, then reset the
+            // activity windowing mode set by WM, and set the task bounds to the final bounds
+            taskBounds = destinationBounds;
+            wct.setActivityWindowingMode(mToken, WINDOWING_MODE_UNDEFINED);
+            wct.scheduleFinishEnterPip(mToken, destinationBounds);
+        } else if (isOutPipDirection(direction)) {
+            // If we are animating to fullscreen, then we need to reset the override bounds
+            // on the task to ensure that the task "matches" the parent's bounds.
+            taskBounds = (direction == TRANSITION_DIRECTION_LEAVE_PIP)
+                    ? null : destinationBounds;
+            applyWindowingModeChangeOnExit(wct, direction);
+        } else {
+            // Just a resize in PIP
+            taskBounds = destinationBounds;
+        }
+
+        wct.setBounds(mToken, taskBounds);
+        wct.setBoundsChangeTransaction(mToken, tx);
+    }
+
+    /**
+     * Applies the window container transaction to finish a bounds resize.
+     *
+     * Called by {@link #finishResize(SurfaceControl.Transaction, Rect, int, int)}} once it has
+     * finished preparing the transaction. It allows subclasses to modify the transaction before
+     * applying it.
+     */
+    public void applyFinishBoundsResize(@NonNull WindowContainerTransaction wct,
+            @PipAnimationController.TransitionDirection int direction) {
+        mTaskOrganizer.applyTransaction(wct);
+    }
+
+    /**
+     * The windowing mode to restore to when resizing out of PIP direction. Defaults to undefined
+     * and can be overridden to restore to an alternate windowing mode.
+     */
+    public int getOutPipWindowingMode() {
+        // By default, simply reset the windowing mode to undefined.
+        return WINDOWING_MODE_UNDEFINED;
+    }
+
+
+    private void animateResizePip(Rect currentBounds, Rect destinationBounds, Rect sourceHintRect,
+            @PipAnimationController.TransitionDirection int direction, int durationMs) {
+        if (Looper.myLooper() != mUpdateHandler.getLooper()) {
+            throw new RuntimeException("Callers should call scheduleAnimateResizePip() instead of "
+                    + "this directly");
+        }
+        // Could happen when exitPip
+        if (mToken == null || mLeash == null) {
+            Log.w(TAG, "Abort animation, invalid leash");
+            return;
+        }
+        mPipAnimationController
+                .getAnimator(mLeash, currentBounds, destinationBounds, sourceHintRect, direction)
+                .setTransitionDirection(direction)
+                .setPipAnimationCallback(mPipAnimationCallback)
+                .setDuration(durationMs)
+                .start();
+    }
+
+    private Size getMinimalSize(ActivityInfo activityInfo) {
+        if (activityInfo == null || activityInfo.windowLayout == null) {
+            return null;
+        }
+        final ActivityInfo.WindowLayout windowLayout = activityInfo.windowLayout;
+        // -1 will be populated if an activity specifies defaultWidth/defaultHeight in <layout>
+        // without minWidth/minHeight
+        if (windowLayout.minWidth > 0 && windowLayout.minHeight > 0) {
+            return new Size(windowLayout.minWidth, windowLayout.minHeight);
+        }
+        return null;
+    }
+
+    private float getAspectRatioOrDefault(@Nullable PictureInPictureParams params) {
+        return params == null || !params.hasSetAspectRatio()
+                ? mPipBoundsHandler.getDefaultAspectRatio()
+                : params.getAspectRatio();
+    }
+
+    /**
+     * Sync with {@link SplitScreen} on destination bounds if PiP is going to split screen.
+     *
+     * @param destinationBoundsOut contain the updated destination bounds if applicable
+     * @return {@code true} if destinationBounds is altered for split screen
+     */
+    private boolean syncWithSplitScreenBounds(Rect destinationBoundsOut) {
+        if (!mSplitScreenOptional.isPresent()) {
+            return false;
+        }
+
+        SplitScreen splitScreen = mSplitScreenOptional.get();
+        if (!splitScreen.isDividerVisible()) {
+            // fail early if system is not in split screen mode
+            return false;
+        }
+
+        // PiP window will go to split-secondary mode instead of fullscreen, populates the
+        // split screen bounds here.
+        destinationBoundsOut.set(splitScreen.getDividerView()
+                .getNonMinimizedSplitScreenSecondaryBounds());
+        return true;
+    }
+
+    /**
+     * Dumps internal states.
+     */
+    public void dump(PrintWriter pw, String prefix) {
+        final String innerPrefix = prefix + "  ";
+        pw.println(prefix + TAG);
+        pw.println(innerPrefix + "mTaskInfo=" + mTaskInfo);
+        pw.println(innerPrefix + "mToken=" + mToken
+                + " binder=" + (mToken != null ? mToken.asBinder() : null));
+        pw.println(innerPrefix + "mLeash=" + mLeash);
+        pw.println(innerPrefix + "mState=" + mState);
+        pw.println(innerPrefix + "mOneShotAnimationType=" + mOneShotAnimationType);
+        pw.println(innerPrefix + "mPictureInPictureParams=" + mPictureInPictureParams);
+        pw.println(innerPrefix + "mLastReportedBounds=" + mLastReportedBounds);
+        pw.println(innerPrefix + "mInitialState:");
+        for (Map.Entry<IBinder, Configuration> e : mInitialState.entrySet()) {
+            pw.println(innerPrefix + "  binder=" + e.getKey()
+                    + " winConfig=" + e.getValue().windowConfiguration);
+        }
+    }
+
+    /**
+     * Callback interface for PiP transitions (both from and to PiP mode)
+     */
+    public interface PipTransitionCallback {
+        /**
+         * Callback when the pip transition is started.
+         */
+        void onPipTransitionStarted(ComponentName activity, int direction, Rect pipBounds);
+
+        /**
+         * Callback when the pip transition is finished.
+         */
+        void onPipTransitionFinished(ComponentName activity, int direction);
+
+        /**
+         * Callback when the pip transition is cancelled.
+         */
+        void onPipTransitionCanceled(ComponentName activity, int direction);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipUiEventLogger.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipUiEventLogger.java
new file mode 100644
index 0000000..de3bb29
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipUiEventLogger.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip;
+
+import android.app.TaskInfo;
+import android.content.pm.PackageManager;
+
+import com.android.internal.logging.UiEvent;
+import com.android.internal.logging.UiEventLogger;
+
+/**
+ * Helper class that ends PiP log to UiEvent, see also go/uievent
+ */
+public class PipUiEventLogger {
+
+    private static final int INVALID_PACKAGE_UID = -1;
+
+    private final UiEventLogger mUiEventLogger;
+    private final PackageManager mPackageManager;
+
+    private String mPackageName;
+    private int mPackageUid = INVALID_PACKAGE_UID;
+
+    public PipUiEventLogger(UiEventLogger uiEventLogger, PackageManager packageManager) {
+        mUiEventLogger = uiEventLogger;
+        mPackageManager = packageManager;
+    }
+
+    public void setTaskInfo(TaskInfo taskInfo) {
+        if (taskInfo == null) {
+            mPackageName = null;
+            mPackageUid = INVALID_PACKAGE_UID;
+        } else {
+            mPackageName = taskInfo.topActivity.getPackageName();
+            mPackageUid = getUid(mPackageName, taskInfo.userId);
+        }
+    }
+
+    /**
+     * Sends log via UiEvent, reference go/uievent for how to debug locally
+     */
+    public void log(PipUiEventEnum event) {
+        if (mPackageName == null || mPackageUid == INVALID_PACKAGE_UID) {
+            return;
+        }
+        mUiEventLogger.log(event, mPackageUid, mPackageName);
+    }
+
+    private int getUid(String packageName, int userId) {
+        int uid = INVALID_PACKAGE_UID;
+        try {
+            uid = mPackageManager.getApplicationInfoAsUser(
+                    packageName, 0 /* ApplicationInfoFlags */, userId).uid;
+        } catch (PackageManager.NameNotFoundException e) {
+            // do nothing.
+        }
+        return uid;
+    }
+
+    /**
+     * Enums for logging the PiP events to UiEvent
+     */
+    public enum PipUiEventEnum implements UiEventLogger.UiEventEnum {
+        @UiEvent(doc = "Activity enters picture-in-picture mode")
+        PICTURE_IN_PICTURE_ENTER(603),
+
+        @UiEvent(doc = "Expands from picture-in-picture to fullscreen")
+        PICTURE_IN_PICTURE_EXPAND_TO_FULLSCREEN(604),
+
+        @UiEvent(doc = "Removes picture-in-picture by tap close button")
+        PICTURE_IN_PICTURE_TAP_TO_REMOVE(605),
+
+        @UiEvent(doc = "Removes picture-in-picture by drag to dismiss area")
+        PICTURE_IN_PICTURE_DRAG_TO_REMOVE(606),
+
+        @UiEvent(doc = "Shows picture-in-picture menu")
+        PICTURE_IN_PICTURE_SHOW_MENU(607),
+
+        @UiEvent(doc = "Hides picture-in-picture menu")
+        PICTURE_IN_PICTURE_HIDE_MENU(608),
+
+        @UiEvent(doc = "Changes the aspect ratio of picture-in-picture window. This is inherited"
+                + " from previous Tron-based logging and currently not in use.")
+        PICTURE_IN_PICTURE_CHANGE_ASPECT_RATIO(609),
+
+        @UiEvent(doc = "User resize of the picture-in-picture window")
+        PICTURE_IN_PICTURE_RESIZE(610);
+
+        private final int mId;
+
+        PipUiEventEnum(int id) {
+            mId = id;
+        }
+
+        @Override
+        public int getId() {
+            return mId;
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipAccessibilityInteractionConnection.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipAccessibilityInteractionConnection.java
new file mode 100644
index 0000000..fddd547
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipAccessibilityInteractionConnection.java
@@ -0,0 +1,269 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.graphics.Region;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.RemoteException;
+import android.view.MagnificationSpec;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityWindowInfo;
+import android.view.accessibility.IAccessibilityInteractionConnection;
+import android.view.accessibility.IAccessibilityInteractionConnectionCallback;
+
+import com.android.wm.shell.R;
+import com.android.wm.shell.pip.PipSnapAlgorithm;
+import com.android.wm.shell.pip.PipTaskOrganizer;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Expose the touch actions to accessibility as if this object were a window with a single view.
+ * That pseudo-view exposes all of the actions this object can perform.
+ */
+public class PipAccessibilityInteractionConnection
+        extends IAccessibilityInteractionConnection.Stub {
+
+    public interface AccessibilityCallbacks {
+        void onAccessibilityShowMenu();
+    }
+
+    private static final long ACCESSIBILITY_NODE_ID = 1;
+    private List<AccessibilityNodeInfo> mAccessibilityNodeInfoList;
+
+    private Context mContext;
+    private Handler mHandler;
+    private PipMotionHelper mMotionHelper;
+    private PipTaskOrganizer mTaskOrganizer;
+    private PipSnapAlgorithm mSnapAlgorithm;
+    private Runnable mUpdateMovementBoundCallback;
+    private AccessibilityCallbacks mCallbacks;
+
+    private final Rect mNormalBounds = new Rect();
+    private final Rect mExpandedBounds = new Rect();
+    private final Rect mNormalMovementBounds = new Rect();
+    private final Rect mExpandedMovementBounds = new Rect();
+    private Rect mTmpBounds = new Rect();
+
+    public PipAccessibilityInteractionConnection(Context context, PipMotionHelper motionHelper,
+            PipTaskOrganizer taskOrganizer, PipSnapAlgorithm snapAlgorithm,
+            AccessibilityCallbacks callbacks, Runnable updateMovementBoundCallback,
+            Handler handler) {
+        mContext = context;
+        mHandler = handler;
+        mMotionHelper = motionHelper;
+        mTaskOrganizer = taskOrganizer;
+        mSnapAlgorithm = snapAlgorithm;
+        mUpdateMovementBoundCallback = updateMovementBoundCallback;
+        mCallbacks = callbacks;
+    }
+
+    @Override
+    public void findAccessibilityNodeInfoByAccessibilityId(long accessibilityNodeId,
+            Region interactiveRegion, int interactionId,
+            IAccessibilityInteractionConnectionCallback callback, int flags,
+            int interrogatingPid, long interrogatingTid, MagnificationSpec spec, Bundle args) {
+        try {
+            callback.setFindAccessibilityNodeInfosResult(
+                    (accessibilityNodeId == AccessibilityNodeInfo.ROOT_NODE_ID)
+                            ? getNodeList() : null, interactionId);
+        } catch (RemoteException re) {
+                /* best effort - ignore */
+        }
+    }
+
+    @Override
+    public void performAccessibilityAction(long accessibilityNodeId, int action,
+            Bundle arguments, int interactionId,
+            IAccessibilityInteractionConnectionCallback callback, int flags,
+            int interrogatingPid, long interrogatingTid) {
+        // We only support one view. A request for anything else is invalid
+        boolean result = false;
+        if (accessibilityNodeId == AccessibilityNodeInfo.ROOT_NODE_ID) {
+
+            // R constants are not final so this cannot be put in the switch-case.
+            if (action == R.id.action_pip_resize) {
+                if (mMotionHelper.getBounds().width() == mNormalBounds.width()
+                        && mMotionHelper.getBounds().height() == mNormalBounds.height()) {
+                    setToExpandedBounds();
+                } else {
+                    setToNormalBounds();
+                }
+                result = true;
+            } else {
+                switch (action) {
+                    case AccessibilityNodeInfo.ACTION_CLICK:
+                        mHandler.post(() -> {
+                            mCallbacks.onAccessibilityShowMenu();
+                        });
+                        result = true;
+                        break;
+                    case AccessibilityNodeInfo.ACTION_DISMISS:
+                        mMotionHelper.dismissPip();
+                        result = true;
+                        break;
+                    case com.android.internal.R.id.accessibilityActionMoveWindow:
+                        int newX = arguments.getInt(
+                                AccessibilityNodeInfo.ACTION_ARGUMENT_MOVE_WINDOW_X);
+                        int newY = arguments.getInt(
+                                AccessibilityNodeInfo.ACTION_ARGUMENT_MOVE_WINDOW_Y);
+                        Rect pipBounds = new Rect();
+                        pipBounds.set(mMotionHelper.getBounds());
+                        mTmpBounds.offsetTo(newX, newY);
+                        mMotionHelper.movePip(mTmpBounds);
+                        result = true;
+                        break;
+                    case AccessibilityNodeInfo.ACTION_EXPAND:
+                        mMotionHelper.expandLeavePip();
+                        result = true;
+                        break;
+                    default:
+                        // Leave result as false
+                }
+            }
+        }
+        try {
+            callback.setPerformAccessibilityActionResult(result, interactionId);
+        } catch (RemoteException re) {
+                /* best effort - ignore */
+        }
+    }
+
+    private void setToExpandedBounds() {
+        float savedSnapFraction = mSnapAlgorithm.getSnapFraction(
+                new Rect(mTaskOrganizer.getLastReportedBounds()), mNormalMovementBounds);
+        mSnapAlgorithm.applySnapFraction(mExpandedBounds, mExpandedMovementBounds,
+                savedSnapFraction);
+        mTaskOrganizer.scheduleFinishResizePip(mExpandedBounds, (Rect bounds) -> {
+            mMotionHelper.synchronizePinnedStackBounds();
+            mUpdateMovementBoundCallback.run();
+        });
+    }
+
+    private void setToNormalBounds() {
+        float savedSnapFraction = mSnapAlgorithm.getSnapFraction(
+                new Rect(mTaskOrganizer.getLastReportedBounds()), mExpandedMovementBounds);
+        mSnapAlgorithm.applySnapFraction(mNormalBounds, mNormalMovementBounds, savedSnapFraction);
+        mTaskOrganizer.scheduleFinishResizePip(mNormalBounds, (Rect bounds) -> {
+            mMotionHelper.synchronizePinnedStackBounds();
+            mUpdateMovementBoundCallback.run();
+        });
+    }
+
+    @Override
+    public void findAccessibilityNodeInfosByViewId(long accessibilityNodeId,
+            String viewId, Region interactiveRegion, int interactionId,
+            IAccessibilityInteractionConnectionCallback callback, int flags,
+            int interrogatingPid, long interrogatingTid, MagnificationSpec spec) {
+        // We have no view with a proper ID
+        try {
+            callback.setFindAccessibilityNodeInfoResult(null, interactionId);
+        } catch (RemoteException re) {
+            /* best effort - ignore */
+        }
+    }
+
+    @Override
+    public void findAccessibilityNodeInfosByText(long accessibilityNodeId, String text,
+            Region interactiveRegion, int interactionId,
+            IAccessibilityInteractionConnectionCallback callback, int flags,
+            int interrogatingPid, long interrogatingTid, MagnificationSpec spec) {
+        // We have no view with text
+        try {
+            callback.setFindAccessibilityNodeInfoResult(null, interactionId);
+        } catch (RemoteException re) {
+            /* best effort - ignore */
+        }
+    }
+
+    @Override
+    public void findFocus(long accessibilityNodeId, int focusType, Region interactiveRegion,
+            int interactionId, IAccessibilityInteractionConnectionCallback callback, int flags,
+            int interrogatingPid, long interrogatingTid, MagnificationSpec spec) {
+        // We have no view that can take focus
+        try {
+            callback.setFindAccessibilityNodeInfoResult(null, interactionId);
+        } catch (RemoteException re) {
+            /* best effort - ignore */
+        }
+    }
+
+    @Override
+    public void focusSearch(long accessibilityNodeId, int direction, Region interactiveRegion,
+            int interactionId, IAccessibilityInteractionConnectionCallback callback, int flags,
+            int interrogatingPid, long interrogatingTid, MagnificationSpec spec) {
+        // We have no view that can take focus
+        try {
+            callback.setFindAccessibilityNodeInfoResult(null, interactionId);
+        } catch (RemoteException re) {
+            /* best effort - ignore */
+        }
+    }
+
+    @Override
+    public void clearAccessibilityFocus() {
+        // We should not be here.
+    }
+
+    @Override
+    public void notifyOutsideTouch() {
+        // Do nothing.
+    }
+
+    /**
+     * Update the normal and expanded bounds so they can be used for Resize.
+     */
+    void onMovementBoundsChanged(Rect normalBounds, Rect expandedBounds, Rect normalMovementBounds,
+            Rect expandedMovementBounds) {
+        mNormalBounds.set(normalBounds);
+        mExpandedBounds.set(expandedBounds);
+        mNormalMovementBounds.set(normalMovementBounds);
+        mExpandedMovementBounds.set(expandedMovementBounds);
+    }
+
+    /**
+     * Update the Root node with PIP Accessibility action items.
+     */
+    public static AccessibilityNodeInfo obtainRootAccessibilityNodeInfo(Context context) {
+        AccessibilityNodeInfo info = AccessibilityNodeInfo.obtain();
+        info.setSourceNodeId(AccessibilityNodeInfo.ROOT_NODE_ID,
+                AccessibilityWindowInfo.PICTURE_IN_PICTURE_ACTION_REPLACER_WINDOW_ID);
+        info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK);
+        info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_DISMISS);
+        info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_MOVE_WINDOW);
+        info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND);
+        info.addAction(new AccessibilityNodeInfo.AccessibilityAction(R.id.action_pip_resize,
+                context.getString(R.string.accessibility_action_pip_resize)));
+        info.setImportantForAccessibility(true);
+        info.setClickable(true);
+        info.setVisibleToUser(true);
+        return info;
+    }
+
+    private List<AccessibilityNodeInfo> getNodeList() {
+        if (mAccessibilityNodeInfoList == null) {
+            mAccessibilityNodeInfoList = new ArrayList<>(1);
+        }
+        AccessibilityNodeInfo info = obtainRootAccessibilityNodeInfo(mContext);
+        mAccessibilityNodeInfoList.clear();
+        mAccessibilityNodeInfoList.add(info);
+        return mAccessibilityNodeInfoList;
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipAppOpsListener.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipAppOpsListener.java
new file mode 100644
index 0000000..6b6b521
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipAppOpsListener.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+import static android.app.AppOpsManager.MODE_ALLOWED;
+import static android.app.AppOpsManager.OP_PICTURE_IN_PICTURE;
+
+import android.app.AppOpsManager;
+import android.app.AppOpsManager.OnOpChangedListener;
+import android.app.IActivityManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.os.Handler;
+import android.util.Pair;
+
+public class PipAppOpsListener {
+    private static final String TAG = PipAppOpsListener.class.getSimpleName();
+
+    private Context mContext;
+    private Handler mHandler;
+    private IActivityManager mActivityManager;
+    private AppOpsManager mAppOpsManager;
+    private Callback mCallback;
+
+    private AppOpsManager.OnOpChangedListener mAppOpsChangedListener = new OnOpChangedListener() {
+        @Override
+        public void onOpChanged(String op, String packageName) {
+            try {
+                // Dismiss the PiP once the user disables the app ops setting for that package
+                final Pair<ComponentName, Integer> topPipActivityInfo =
+                        PipUtils.getTopPipActivity(mContext, mActivityManager);
+                if (topPipActivityInfo.first != null) {
+                    final ApplicationInfo appInfo = mContext.getPackageManager()
+                            .getApplicationInfoAsUser(packageName, 0, topPipActivityInfo.second);
+                    if (appInfo.packageName.equals(topPipActivityInfo.first.getPackageName()) &&
+                            mAppOpsManager.checkOpNoThrow(OP_PICTURE_IN_PICTURE, appInfo.uid,
+                                    packageName) != MODE_ALLOWED) {
+                        mHandler.post(() -> mCallback.dismissPip());
+                    }
+                }
+            } catch (NameNotFoundException e) {
+                // Unregister the listener if the package can't be found
+                unregisterAppOpsListener();
+            }
+        }
+    };
+
+    public PipAppOpsListener(Context context, IActivityManager activityManager,
+            Callback callback) {
+        mContext = context;
+        mHandler = new Handler(mContext.getMainLooper());
+        mActivityManager = activityManager;
+        mAppOpsManager = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
+        mCallback = callback;
+    }
+
+    public void onActivityPinned(String packageName) {
+        // Register for changes to the app ops setting for this package while it is in PiP
+        registerAppOpsListener(packageName);
+    }
+
+    public void onActivityUnpinned() {
+        // Unregister for changes to the previously PiP'ed package
+        unregisterAppOpsListener();
+    }
+
+    private void registerAppOpsListener(String packageName) {
+        mAppOpsManager.startWatchingMode(OP_PICTURE_IN_PICTURE, packageName,
+                mAppOpsChangedListener);
+    }
+
+    private void unregisterAppOpsListener() {
+        mAppOpsManager.stopWatchingMode(mAppOpsChangedListener);
+    }
+
+    /** Callback for PipAppOpsListener to request changes to the PIP window. */
+    public interface Callback {
+        /** Dismisses the PIP window. */
+        void dismissPip();
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipController.java
new file mode 100644
index 0000000..5193656
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipController.java
@@ -0,0 +1,442 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED;
+
+import static com.android.wm.shell.pip.PipAnimationController.isOutPipDirection;
+
+import android.annotation.Nullable;
+import android.app.ActivityManager;
+import android.app.RemoteAction;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.ParceledListSlice;
+import android.graphics.Rect;
+import android.os.Handler;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.util.Log;
+import android.view.DisplayInfo;
+import android.view.IPinnedStackController;
+import android.window.WindowContainerTransaction;
+
+import com.android.wm.shell.WindowManagerShellWrapper;
+import com.android.wm.shell.common.DisplayChangeController;
+import com.android.wm.shell.common.DisplayController;
+import com.android.wm.shell.pip.PinnedStackListenerForwarder;
+import com.android.wm.shell.pip.Pip;
+import com.android.wm.shell.pip.PipBoundsHandler;
+import com.android.wm.shell.pip.PipTaskOrganizer;
+
+import java.io.PrintWriter;
+import java.util.function.Consumer;
+
+/**
+ * Manages the picture-in-picture (PIP) UI and states for Phones.
+ */
+public class PipController implements Pip, PipTaskOrganizer.PipTransitionCallback {
+    private static final String TAG = "PipController";
+
+    private Context mContext;
+    private Handler mHandler = new Handler();
+
+    private final DisplayInfo mTmpDisplayInfo = new DisplayInfo();
+    private final Rect mTmpInsetBounds = new Rect();
+    private final Rect mTmpNormalBounds = new Rect();
+    protected final Rect mReentryBounds = new Rect();
+
+    private DisplayController mDisplayController;
+    private PipAppOpsListener mAppOpsListener;
+    private PipBoundsHandler mPipBoundsHandler;
+    private PipMediaController mMediaController;
+    private PipTouchHandler mTouchHandler;
+    private Consumer<Boolean> mPinnedStackAnimationRecentsCallback;
+    private WindowManagerShellWrapper mWindowManagerShellWrapper;
+
+    private boolean mIsInFixedRotation;
+
+    protected PipMenuActivityController mMenuController;
+    protected PipTaskOrganizer mPipTaskOrganizer;
+
+    /**
+     * Handler for display rotation changes.
+     */
+    private final DisplayChangeController.OnDisplayChangingListener mRotationController = (
+            int displayId, int fromRotation, int toRotation, WindowContainerTransaction t) -> {
+        if (!mPipTaskOrganizer.isInPip() || mPipTaskOrganizer.isDeferringEnterPipAnimation()) {
+            // Skip if we aren't in PIP or haven't actually entered PIP yet. We still need to update
+            // the display layout in the bounds handler in this case.
+            mPipBoundsHandler.onDisplayRotationChangedNotInPip(mContext, toRotation);
+            return;
+        }
+        // If there is an animation running (ie. from a shelf offset), then ensure that we calculate
+        // the bounds for the next orientation using the destination bounds of the animation
+        // TODO: Techincally this should account for movement animation bounds as well
+        Rect currentBounds = mPipTaskOrganizer.getCurrentOrAnimatingBounds();
+        final boolean changed = mPipBoundsHandler.onDisplayRotationChanged(mContext,
+                mTmpNormalBounds, currentBounds, mTmpInsetBounds, displayId, fromRotation,
+                toRotation, t);
+        if (changed) {
+            // If the pip was in the offset zone earlier, adjust the new bounds to the bottom of the
+            // movement bounds
+            mTouchHandler.adjustBoundsForRotation(mTmpNormalBounds,
+                    mPipTaskOrganizer.getLastReportedBounds(), mTmpInsetBounds);
+
+            // The bounds are being applied to a specific snap fraction, so reset any known offsets
+            // for the previous orientation before updating the movement bounds.
+            // We perform the resets if and only if this callback is due to screen rotation but
+            // not during the fixed rotation. In fixed rotation case, app is about to enter PiP
+            // and we need the offsets preserved to calculate the destination bounds.
+            if (!mIsInFixedRotation) {
+                mPipBoundsHandler.setShelfHeight(false, 0);
+                mPipBoundsHandler.onImeVisibilityChanged(false, 0);
+                mTouchHandler.onShelfVisibilityChanged(false, 0);
+                mTouchHandler.onImeVisibilityChanged(false, 0);
+            }
+
+            updateMovementBounds(mTmpNormalBounds, true /* fromRotation */,
+                    false /* fromImeAdjustment */, false /* fromShelfAdjustment */, t);
+        }
+    };
+
+    private DisplayController.OnDisplaysChangedListener mFixedRotationListener =
+            new DisplayController.OnDisplaysChangedListener() {
+                @Override
+                public void onFixedRotationStarted(int displayId, int newRotation) {
+                    mIsInFixedRotation = true;
+                }
+
+                @Override
+                public void onFixedRotationFinished(int displayId) {
+                    mIsInFixedRotation = false;
+                }
+
+                @Override
+                public void onDisplayAdded(int displayId) {
+                    mPipBoundsHandler.setDisplayLayout(
+                            mDisplayController.getDisplayLayout(displayId));
+                }
+            };
+
+    /**
+     * Handler for messages from the PIP controller.
+     */
+    private class PipControllerPinnedStackListener extends
+            PinnedStackListenerForwarder.PinnedStackListener {
+        @Override
+        public void onListenerRegistered(IPinnedStackController controller) {
+            mHandler.post(() -> mTouchHandler.setPinnedStackController(controller));
+        }
+
+        @Override
+        public void onImeVisibilityChanged(boolean imeVisible, int imeHeight) {
+            mHandler.post(() -> {
+                mPipBoundsHandler.onImeVisibilityChanged(imeVisible, imeHeight);
+                mTouchHandler.onImeVisibilityChanged(imeVisible, imeHeight);
+            });
+        }
+
+        @Override
+        public void onMovementBoundsChanged(boolean fromImeAdjustment) {
+            mHandler.post(() -> updateMovementBounds(null /* toBounds */,
+                    false /* fromRotation */, fromImeAdjustment, false /* fromShelfAdjustment */,
+                    null /* windowContainerTransaction */));
+        }
+
+        @Override
+        public void onActionsChanged(ParceledListSlice<RemoteAction> actions) {
+            mHandler.post(() -> mMenuController.setAppActions(actions));
+        }
+
+        @Override
+        public void onActivityHidden(ComponentName componentName) {
+            mHandler.post(() -> mPipBoundsHandler.onResetReentryBounds(componentName));
+        }
+
+        @Override
+        public void onDisplayInfoChanged(DisplayInfo displayInfo) {
+            mHandler.post(() -> mPipBoundsHandler.onDisplayInfoChanged(displayInfo));
+        }
+
+        @Override
+        public void onConfigurationChanged() {
+            mHandler.post(() -> {
+                mPipBoundsHandler.onConfigurationChanged(mContext);
+                mTouchHandler.onConfigurationChanged();
+            });
+        }
+
+        @Override
+        public void onAspectRatioChanged(float aspectRatio) {
+            mHandler.post(() -> {
+                mPipBoundsHandler.onAspectRatioChanged(aspectRatio);
+                mTouchHandler.onAspectRatioChanged();
+            });
+        }
+    }
+
+    public PipController(Context context,
+            DisplayController displayController,
+            PipAppOpsListener pipAppOpsListener,
+            PipBoundsHandler pipBoundsHandler,
+            PipMediaController pipMediaController,
+            PipMenuActivityController pipMenuActivityController,
+            PipTaskOrganizer pipTaskOrganizer,
+            PipTouchHandler pipTouchHandler,
+            WindowManagerShellWrapper windowManagerShellWrapper
+    ) {
+        mContext = context;
+
+        if (PipUtils.hasSystemFeature(mContext)) {
+            initController(context, displayController, pipAppOpsListener, pipBoundsHandler,
+                    pipMediaController, pipMenuActivityController, pipTaskOrganizer,
+                    pipTouchHandler, windowManagerShellWrapper);
+        } else {
+            Log.w(TAG, "Device not support PIP feature");
+        }
+    }
+
+    private void initController(Context context,
+            DisplayController displayController,
+            PipAppOpsListener pipAppOpsListener,
+            PipBoundsHandler pipBoundsHandler,
+            PipMediaController pipMediaController,
+            PipMenuActivityController pipMenuActivityController,
+            PipTaskOrganizer pipTaskOrganizer,
+            PipTouchHandler pipTouchHandler,
+            WindowManagerShellWrapper windowManagerShellWrapper) {
+
+        // Ensure that we are the primary user's SystemUI.
+        final int processUser = UserManager.get(context).getUserHandle();
+        if (processUser != UserHandle.USER_SYSTEM) {
+            throw new IllegalStateException("Non-primary Pip component not currently supported.");
+        }
+
+        mWindowManagerShellWrapper = windowManagerShellWrapper;
+        mDisplayController = displayController;
+        mPipBoundsHandler = pipBoundsHandler;
+        mPipTaskOrganizer = pipTaskOrganizer;
+        mPipTaskOrganizer.registerPipTransitionCallback(this);
+        mMediaController = pipMediaController;
+        mMenuController = pipMenuActivityController;
+        mTouchHandler = pipTouchHandler;
+        mAppOpsListener = pipAppOpsListener;
+        displayController.addDisplayChangingController(mRotationController);
+        displayController.addDisplayWindowListener(mFixedRotationListener);
+
+        // Ensure that we have the display info in case we get calls to update the bounds before the
+        // listener calls back
+        final DisplayInfo displayInfo = new DisplayInfo();
+        context.getDisplay().getDisplayInfo(displayInfo);
+        mPipBoundsHandler.onDisplayInfoChanged(displayInfo);
+
+        try {
+            mWindowManagerShellWrapper.addPinnedStackListener(
+                    new PipControllerPinnedStackListener());
+        } catch (RemoteException e) {
+            Log.e(TAG, "Failed to register pinned stack listener", e);
+        }
+    }
+
+    @Override
+    public void onDensityOrFontScaleChanged() {
+        mHandler.post(() -> {
+            mPipTaskOrganizer.onDensityOrFontScaleChanged(mContext);
+        });
+    }
+
+    @Override
+    public void onActivityPinned(String packageName) {
+        mHandler.post(() -> {
+            mTouchHandler.onActivityPinned();
+            mMediaController.onActivityPinned();
+            mMenuController.onActivityPinned();
+            mAppOpsListener.onActivityPinned(packageName);
+        });
+    }
+
+    @Override
+    public void onActivityUnpinned(ComponentName topActivity) {
+        mHandler.post(() -> {
+            mMenuController.onActivityUnpinned();
+            mTouchHandler.onActivityUnpinned(topActivity);
+            mAppOpsListener.onActivityUnpinned();
+        });
+    }
+
+    @Override
+    public void onActivityRestartAttempt(ActivityManager.RunningTaskInfo task,
+            boolean clearedTask) {
+        if (task.configuration.windowConfiguration.getWindowingMode()
+                != WINDOWING_MODE_PINNED) {
+            return;
+        }
+        mTouchHandler.getMotionHelper().expandLeavePip(clearedTask /* skipAnimation */);
+    }
+
+    @Override
+    public void onOverlayChanged() {
+        mHandler.post(() -> {
+            mPipBoundsHandler.onOverlayChanged(mContext, mContext.getDisplay());
+            updateMovementBounds(null /* toBounds */,
+                    false /* fromRotation */, false /* fromImeAdjustment */,
+                    false /* fromShelfAdjustment */,
+                    null /* windowContainerTransaction */);
+        });
+    }
+
+    @Override
+    public void registerSessionListenerForCurrentUser() {
+        mMediaController.registerSessionListenerForCurrentUser();
+    }
+
+    @Override
+    public void onSystemUiStateChanged(boolean isValidState, int flag) {
+        mTouchHandler.onSystemUiStateChanged(isValidState);
+    }
+
+    /**
+     * Expands the PIP.
+     */
+    @Override
+    public void expandPip() {
+        mTouchHandler.getMotionHelper().expandLeavePip(false /* skipAnimation */);
+    }
+
+    @Override
+    public PipTouchHandler getPipTouchHandler() {
+        return mTouchHandler;
+    }
+
+    /**
+     * Hides the PIP menu.
+     */
+    @Override
+    public void hidePipMenu(Runnable onStartCallback, Runnable onEndCallback) {
+        mMenuController.hideMenu(onStartCallback, onEndCallback);
+    }
+
+    /**
+     * Sent from KEYCODE_WINDOW handler in PhoneWindowManager, to request the menu to be shown.
+     */
+    public void showPictureInPictureMenu() {
+        mTouchHandler.showPictureInPictureMenu();
+    }
+
+    /**
+     * Sets a customized touch gesture that replaces the default one.
+     */
+    public void setTouchGesture(PipTouchGesture gesture) {
+        mTouchHandler.setTouchGesture(gesture);
+    }
+
+    /**
+     * Sets both shelf visibility and its height.
+     */
+    @Override
+    public void setShelfHeight(boolean visible, int height) {
+        mHandler.post(() -> {
+            final int shelfHeight = visible ? height : 0;
+            final boolean changed = mPipBoundsHandler.setShelfHeight(visible, shelfHeight);
+            if (changed) {
+                mTouchHandler.onShelfVisibilityChanged(visible, shelfHeight);
+                updateMovementBounds(mPipTaskOrganizer.getLastReportedBounds(),
+                        false /* fromRotation */, false /* fromImeAdjustment */,
+                        true /* fromShelfAdjustment */, null /* windowContainerTransaction */);
+            }
+        });
+    }
+
+    @Override
+    public void setPinnedStackAnimationType(int animationType) {
+        mHandler.post(() -> mPipTaskOrganizer.setOneShotAnimationType(animationType));
+    }
+
+    @Override
+    public void setPinnedStackAnimationListener(Consumer<Boolean> callback) {
+        mHandler.post(() -> mPinnedStackAnimationRecentsCallback = callback);
+    }
+
+    @Override
+    public void onPipTransitionStarted(ComponentName activity, int direction, Rect pipBounds) {
+        if (isOutPipDirection(direction)) {
+            // Exiting PIP, save the reentry bounds to restore to when re-entering.
+            updateReentryBounds(pipBounds);
+            mPipBoundsHandler.onSaveReentryBounds(activity, mReentryBounds);
+        }
+        // Disable touches while the animation is running
+        mTouchHandler.setTouchEnabled(false);
+        if (mPinnedStackAnimationRecentsCallback != null) {
+            mPinnedStackAnimationRecentsCallback.accept(true);
+        }
+    }
+
+    /**
+     * Update the bounds used to save the re-entry size and snap fraction when exiting PIP.
+     */
+    public void updateReentryBounds(Rect bounds) {
+        final Rect reentryBounds = mTouchHandler.getUserResizeBounds();
+        float snapFraction = mPipBoundsHandler.getSnapFraction(bounds);
+        mPipBoundsHandler.applySnapFraction(reentryBounds, snapFraction);
+        mReentryBounds.set(reentryBounds);
+    }
+
+    @Override
+    public void onPipTransitionFinished(ComponentName activity, int direction) {
+        onPipTransitionFinishedOrCanceled(direction);
+    }
+
+    @Override
+    public void onPipTransitionCanceled(ComponentName activity, int direction) {
+        onPipTransitionFinishedOrCanceled(direction);
+    }
+
+    private void onPipTransitionFinishedOrCanceled(int direction) {
+        // Re-enable touches after the animation completes
+        mTouchHandler.setTouchEnabled(true);
+        mTouchHandler.onPinnedStackAnimationEnded(direction);
+        mMenuController.onPinnedStackAnimationEnded();
+    }
+
+    private void updateMovementBounds(@Nullable Rect toBounds, boolean fromRotation,
+            boolean fromImeAdjustment, boolean fromShelfAdjustment,
+            WindowContainerTransaction wct) {
+        // Populate inset / normal bounds and DisplayInfo from mPipBoundsHandler before
+        // passing to mTouchHandler/mPipTaskOrganizer
+        final Rect outBounds = new Rect(toBounds);
+        mPipBoundsHandler.onMovementBoundsChanged(mTmpInsetBounds, mTmpNormalBounds,
+                outBounds, mTmpDisplayInfo);
+        // mTouchHandler would rely on the bounds populated from mPipTaskOrganizer
+        mPipTaskOrganizer.onMovementBoundsChanged(outBounds, fromRotation, fromImeAdjustment,
+                fromShelfAdjustment, wct);
+        mTouchHandler.onMovementBoundsChanged(mTmpInsetBounds, mTmpNormalBounds,
+                outBounds, fromImeAdjustment, fromShelfAdjustment,
+                mTmpDisplayInfo.rotation);
+    }
+
+    @Override
+    public void dump(PrintWriter pw) {
+        final String innerPrefix = "  ";
+        pw.println(TAG);
+        mMenuController.dump(pw, innerPrefix);
+        mTouchHandler.dump(pw, innerPrefix);
+        mPipBoundsHandler.dump(pw, innerPrefix);
+        mPipTaskOrganizer.dump(pw, innerPrefix);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMediaController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMediaController.java
new file mode 100644
index 0000000..4a8db6b
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMediaController.java
@@ -0,0 +1,262 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
+
+import android.app.IActivityManager;
+import android.app.PendingIntent;
+import android.app.RemoteAction;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.graphics.drawable.Icon;
+import android.media.session.MediaController;
+import android.media.session.MediaSession;
+import android.media.session.MediaSessionManager;
+import android.media.session.PlaybackState;
+import android.os.UserHandle;
+
+import com.android.wm.shell.R;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Interfaces with the {@link MediaSessionManager} to compose the right set of actions to show (only
+ * if there are no actions from the PiP activity itself). The active media controller is only set
+ * when there is a media session from the top PiP activity.
+ */
+public class PipMediaController {
+
+    private static final String ACTION_PLAY = "com.android.wm.shell.pip.phone.PLAY";
+    private static final String ACTION_PAUSE = "com.android.wm.shell.pip.phone.PAUSE";
+    private static final String ACTION_NEXT = "com.android.wm.shell.pip.phone.NEXT";
+    private static final String ACTION_PREV = "com.android.wm.shell.pip.phone.PREV";
+
+    /**
+     * A listener interface to receive notification on changes to the media actions.
+     */
+    public interface ActionListener {
+        /**
+         * Called when the media actions changes.
+         */
+        void onMediaActionsChanged(List<RemoteAction> actions);
+    }
+
+    private final Context mContext;
+    private final IActivityManager mActivityManager;
+
+    private final MediaSessionManager mMediaSessionManager;
+    private MediaController mMediaController;
+
+    private RemoteAction mPauseAction;
+    private RemoteAction mPlayAction;
+    private RemoteAction mNextAction;
+    private RemoteAction mPrevAction;
+
+    private BroadcastReceiver mPlayPauseActionReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            final String action = intent.getAction();
+            if (action.equals(ACTION_PLAY)) {
+                mMediaController.getTransportControls().play();
+            } else if (action.equals(ACTION_PAUSE)) {
+                mMediaController.getTransportControls().pause();
+            } else if (action.equals(ACTION_NEXT)) {
+                mMediaController.getTransportControls().skipToNext();
+            } else if (action.equals(ACTION_PREV)) {
+                mMediaController.getTransportControls().skipToPrevious();
+            }
+        }
+    };
+
+    private final MediaController.Callback mPlaybackChangedListener =
+            new MediaController.Callback() {
+                @Override
+                public void onPlaybackStateChanged(PlaybackState state) {
+                    notifyActionsChanged();
+                }
+            };
+
+    private final MediaSessionManager.OnActiveSessionsChangedListener mSessionsChangedListener =
+            controllers -> resolveActiveMediaController(controllers);
+
+    private ArrayList<ActionListener> mListeners = new ArrayList<>();
+
+    public PipMediaController(Context context, IActivityManager activityManager) {
+        mContext = context;
+        mActivityManager = activityManager;
+        IntentFilter mediaControlFilter = new IntentFilter();
+        mediaControlFilter.addAction(ACTION_PLAY);
+        mediaControlFilter.addAction(ACTION_PAUSE);
+        mediaControlFilter.addAction(ACTION_NEXT);
+        mediaControlFilter.addAction(ACTION_PREV);
+        mContext.registerReceiver(mPlayPauseActionReceiver, mediaControlFilter,
+                UserHandle.USER_ALL);
+
+        createMediaActions();
+        mMediaSessionManager =
+                (MediaSessionManager) context.getSystemService(Context.MEDIA_SESSION_SERVICE);
+    }
+
+    /**
+     * Handles when an activity is pinned.
+     */
+    public void onActivityPinned() {
+        // Once we enter PiP, try to find the active media controller for the top most activity
+        resolveActiveMediaController(mMediaSessionManager.getActiveSessionsForUser(null,
+                UserHandle.USER_CURRENT));
+    }
+
+    /**
+     * Adds a new media action listener.
+     */
+    public void addListener(ActionListener listener) {
+        if (!mListeners.contains(listener)) {
+            mListeners.add(listener);
+            listener.onMediaActionsChanged(getMediaActions());
+        }
+    }
+
+    /**
+     * Removes a media action listener.
+     */
+    public void removeListener(ActionListener listener) {
+        listener.onMediaActionsChanged(Collections.EMPTY_LIST);
+        mListeners.remove(listener);
+    }
+
+    /**
+     * Gets the set of media actions currently available.
+     */
+    private List<RemoteAction> getMediaActions() {
+        if (mMediaController == null || mMediaController.getPlaybackState() == null) {
+            return Collections.EMPTY_LIST;
+        }
+
+        ArrayList<RemoteAction> mediaActions = new ArrayList<>();
+        int state = mMediaController.getPlaybackState().getState();
+        boolean isPlaying = MediaSession.isActiveState(state);
+        long actions = mMediaController.getPlaybackState().getActions();
+
+        // Prev action
+        mPrevAction.setEnabled((actions & PlaybackState.ACTION_SKIP_TO_PREVIOUS) != 0);
+        mediaActions.add(mPrevAction);
+
+        // Play/pause action
+        if (!isPlaying && ((actions & PlaybackState.ACTION_PLAY) != 0)) {
+            mediaActions.add(mPlayAction);
+        } else if (isPlaying && ((actions & PlaybackState.ACTION_PAUSE) != 0)) {
+            mediaActions.add(mPauseAction);
+        }
+
+        // Next action
+        mNextAction.setEnabled((actions & PlaybackState.ACTION_SKIP_TO_NEXT) != 0);
+        mediaActions.add(mNextAction);
+        return mediaActions;
+    }
+
+    /**
+     * Creates the standard media buttons that we may show.
+     */
+    private void createMediaActions() {
+        String pauseDescription = mContext.getString(R.string.pip_pause);
+        mPauseAction = new RemoteAction(Icon.createWithResource(mContext,
+                R.drawable.pip_ic_pause_white), pauseDescription, pauseDescription,
+                PendingIntent.getBroadcast(mContext, 0, new Intent(ACTION_PAUSE),
+                        FLAG_UPDATE_CURRENT));
+
+        String playDescription = mContext.getString(R.string.pip_play);
+        mPlayAction = new RemoteAction(Icon.createWithResource(mContext,
+                R.drawable.pip_ic_play_arrow_white), playDescription, playDescription,
+                PendingIntent.getBroadcast(mContext, 0, new Intent(ACTION_PLAY),
+                        FLAG_UPDATE_CURRENT));
+
+        String nextDescription = mContext.getString(R.string.pip_skip_to_next);
+        mNextAction = new RemoteAction(Icon.createWithResource(mContext,
+                R.drawable.pip_ic_skip_next_white), nextDescription, nextDescription,
+                PendingIntent.getBroadcast(mContext, 0, new Intent(ACTION_NEXT),
+                        FLAG_UPDATE_CURRENT));
+
+        String prevDescription = mContext.getString(R.string.pip_skip_to_prev);
+        mPrevAction = new RemoteAction(Icon.createWithResource(mContext,
+                R.drawable.pip_ic_skip_previous_white), prevDescription, prevDescription,
+                PendingIntent.getBroadcast(mContext, 0, new Intent(ACTION_PREV),
+                        FLAG_UPDATE_CURRENT));
+    }
+
+    /**
+     * Re-registers the session listener for the current user.
+     */
+    public void registerSessionListenerForCurrentUser() {
+        mMediaSessionManager.removeOnActiveSessionsChangedListener(mSessionsChangedListener);
+        mMediaSessionManager.addOnActiveSessionsChangedListener(mSessionsChangedListener, null,
+                UserHandle.USER_CURRENT, null);
+    }
+
+    /**
+     * Tries to find and set the active media controller for the top PiP activity.
+     */
+    private void resolveActiveMediaController(List<MediaController> controllers) {
+        if (controllers != null) {
+            final ComponentName topActivity = PipUtils.getTopPipActivity(mContext,
+                    mActivityManager).first;
+            if (topActivity != null) {
+                for (int i = 0; i < controllers.size(); i++) {
+                    final MediaController controller = controllers.get(i);
+                    if (controller.getPackageName().equals(topActivity.getPackageName())) {
+                        setActiveMediaController(controller);
+                        return;
+                    }
+                }
+            }
+        }
+        setActiveMediaController(null);
+    }
+
+    /**
+     * Sets the active media controller for the top PiP activity.
+     */
+    private void setActiveMediaController(MediaController controller) {
+        if (controller != mMediaController) {
+            if (mMediaController != null) {
+                mMediaController.unregisterCallback(mPlaybackChangedListener);
+            }
+            mMediaController = controller;
+            if (controller != null) {
+                controller.registerCallback(mPlaybackChangedListener);
+            }
+            notifyActionsChanged();
+
+            // TODO(winsonc): Consider if we want to close the PIP after a timeout (like on TV)
+        }
+    }
+
+    /**
+     * Notifies all listeners that the actions have changed.
+     */
+    private void notifyActionsChanged() {
+        if (!mListeners.isEmpty()) {
+            List<RemoteAction> actions = getMediaActions();
+            mListeners.forEach(l -> l.onMediaActionsChanged(actions));
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMenuActivityController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMenuActivityController.java
new file mode 100644
index 0000000..c53803a7
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMenuActivityController.java
@@ -0,0 +1,409 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+import static android.app.WindowConfiguration.ACTIVITY_TYPE_UNDEFINED;
+import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED;
+
+import android.app.ActivityTaskManager;
+import android.app.ActivityTaskManager.RootTaskInfo;
+import android.app.RemoteAction;
+import android.content.Context;
+import android.content.pm.ParceledListSlice;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.os.Debug;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.WindowManager;
+import android.view.WindowManagerGlobal;
+
+import com.android.wm.shell.pip.PipTaskOrganizer;
+import com.android.wm.shell.pip.phone.PipMediaController.ActionListener;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Manages the PiP menu activity which can show menu options or a scrim.
+ *
+ * The current media session provides actions whenever there are no valid actions provided by the
+ * current PiP activity. Otherwise, those actions always take precedence.
+ */
+public class PipMenuActivityController {
+
+    private static final String TAG = "PipMenuActController";
+    private static final boolean DEBUG = false;
+
+    public static final int MENU_STATE_NONE = 0;
+    public static final int MENU_STATE_CLOSE = 1;
+    public static final int MENU_STATE_FULL = 2;
+
+    /**
+     * A listener interface to receive notification on changes in PIP.
+     */
+    public interface Listener {
+        /**
+         * Called when the PIP menu visibility changes.
+         *
+         * @param menuState the current state of the menu
+         * @param resize whether or not to resize the PiP with the state change
+         */
+        void onPipMenuStateChanged(int menuState, boolean resize, Runnable callback);
+
+        /**
+         * Called when the PIP requested to be expanded.
+         */
+        void onPipExpand();
+
+        /**
+         * Called when the PIP requested to be dismissed.
+         */
+        void onPipDismiss();
+
+        /**
+         * Called when the PIP requested to show the menu.
+         */
+        void onPipShowMenu();
+    }
+
+    private Context mContext;
+    private PipTaskOrganizer mPipTaskOrganizer;
+    private PipMediaController mMediaController;
+
+    private ArrayList<Listener> mListeners = new ArrayList<>();
+    private ParceledListSlice<RemoteAction> mAppActions;
+    private ParceledListSlice<RemoteAction> mMediaActions;
+    private int mMenuState;
+
+    private PipMenuView mPipMenuView;
+    private IBinder mPipMenuInputToken;
+
+    private ActionListener mMediaActionListener = new ActionListener() {
+        @Override
+        public void onMediaActionsChanged(List<RemoteAction> mediaActions) {
+            mMediaActions = new ParceledListSlice<>(mediaActions);
+            updateMenuActions();
+        }
+    };
+
+    public PipMenuActivityController(Context context,
+            PipMediaController mediaController, PipTaskOrganizer pipTaskOrganizer) {
+        mContext = context;
+        mMediaController = mediaController;
+        mPipTaskOrganizer = pipTaskOrganizer;
+    }
+
+    public boolean isMenuVisible() {
+        return mPipMenuView != null && mMenuState != MENU_STATE_NONE;
+    }
+
+    public void onActivityPinned() {
+        attachPipMenuView();
+    }
+
+    public void onActivityUnpinned() {
+        hideMenu();
+        mPipTaskOrganizer.detachPipMenuViewHost();
+        mPipMenuView = null;
+        mPipMenuInputToken = null;
+    }
+
+    public void onPinnedStackAnimationEnded() {
+        if (isMenuVisible()) {
+            mPipMenuView.onPipAnimationEnded();
+        }
+    }
+
+    private void attachPipMenuView() {
+        if (mPipMenuView == null) {
+            mPipMenuView = new PipMenuView(mContext, this);
+
+        }
+
+        // If we haven't gotten the input toekn, that means we haven't had a success attempt
+        // yet at attaching the PipMenuView
+        if (mPipMenuInputToken == null) {
+            mPipMenuInputToken = mPipTaskOrganizer.attachPipMenuViewHost(mPipMenuView,
+                    getPipMenuLayoutParams(0, 0));
+        }
+    }
+
+    /**
+     * Adds a new menu activity listener.
+     */
+    public void addListener(Listener listener) {
+        if (!mListeners.contains(listener)) {
+            mListeners.add(listener);
+        }
+    }
+
+    /**
+     * Updates the appearance of the menu and scrim on top of the PiP while dismissing.
+     */
+    public void setDismissFraction(float fraction) {
+        final boolean isMenuVisible = isMenuVisible();
+        if (DEBUG) {
+            Log.d(TAG, "setDismissFraction() isMenuVisible=" + isMenuVisible
+                    + " fraction=" + fraction);
+        }
+        if (isMenuVisible) {
+            mPipMenuView.updateDismissFraction(fraction);
+        }
+    }
+
+    /**
+     * Similar to {@link #showMenu(int, Rect, boolean, boolean, boolean)} but only show the menu
+     * upon PiP window transition is finished.
+     */
+    public void showMenuWithDelay(int menuState, Rect stackBounds, boolean allowMenuTimeout,
+            boolean willResizeMenu, boolean showResizeHandle) {
+        // hide all visible controls including close button and etc. first, this is to ensure
+        // menu is totally invisible during the transition to eliminate unpleasant artifacts
+        fadeOutMenu();
+        showMenuInternal(menuState, stackBounds, allowMenuTimeout, willResizeMenu,
+                true /* withDelay */, showResizeHandle);
+    }
+
+    /**
+     * Shows the menu activity immediately.
+     */
+    public void showMenu(int menuState, Rect stackBounds, boolean allowMenuTimeout,
+            boolean willResizeMenu, boolean showResizeHandle) {
+        showMenuInternal(menuState, stackBounds, allowMenuTimeout, willResizeMenu,
+                false /* withDelay */, showResizeHandle);
+    }
+
+    private void showMenuInternal(int menuState, Rect stackBounds, boolean allowMenuTimeout,
+            boolean willResizeMenu, boolean withDelay, boolean showResizeHandle) {
+        if (DEBUG) {
+            Log.d(TAG, "showMenu() state=" + menuState
+                    + " isMenuVisible=" + isMenuVisible()
+                    + " allowMenuTimeout=" + allowMenuTimeout
+                    + " willResizeMenu=" + willResizeMenu
+                    + " withDelay=" + withDelay
+                    + " showResizeHandle=" + showResizeHandle
+                    + " callers=\n" + Debug.getCallers(5, "    "));
+        }
+
+        if (!mPipTaskOrganizer.isPipMenuViewHostAttached()) {
+            Log.d(TAG, "PipMenu has not been attached yet. Attaching now at showMenuInternal().");
+            attachPipMenuView();
+        }
+
+        mPipMenuView.showMenu(menuState, stackBounds, allowMenuTimeout, willResizeMenu, withDelay,
+                showResizeHandle);
+    }
+
+    /**
+     * Pokes the menu, indicating that the user is interacting with it.
+     */
+    public void pokeMenu() {
+        final boolean isMenuVisible = isMenuVisible();
+        if (DEBUG) {
+            Log.d(TAG, "pokeMenu() isMenuVisible=" + isMenuVisible);
+        }
+        if (isMenuVisible) {
+            mPipMenuView.pokeMenu();
+        }
+    }
+
+    private void fadeOutMenu() {
+        final boolean isMenuVisible = isMenuVisible();
+        if (DEBUG) {
+            Log.d(TAG, "fadeOutMenu() isMenuVisible=" + isMenuVisible);
+        }
+        if (isMenuVisible) {
+            mPipMenuView.fadeOutMenu();
+        }
+    }
+
+    /**
+     * Hides the menu activity.
+     */
+    public void hideMenu() {
+        final boolean isMenuVisible = isMenuVisible();
+        if (DEBUG) {
+            Log.d(TAG, "hideMenu() state=" + mMenuState
+                    + " isMenuVisible=" + isMenuVisible
+                    + " callers=\n" + Debug.getCallers(5, "    "));
+        }
+        if (isMenuVisible) {
+            mPipMenuView.hideMenu();
+        }
+    }
+
+    /**
+     * Hides the menu activity.
+     */
+    public void hideMenu(Runnable onStartCallback, Runnable onEndCallback) {
+        if (isMenuVisible()) {
+            // If the menu is visible in either the closed or full state, then hide the menu and
+            // trigger the animation trigger afterwards
+            if (onStartCallback != null) {
+                onStartCallback.run();
+            }
+            mPipMenuView.hideMenu(onEndCallback);
+        }
+    }
+
+    /**
+     * Preemptively mark the menu as invisible, used when we are directly manipulating the pinned
+     * stack and don't want to trigger a resize which can animate the stack in a conflicting way
+     * (ie. when manually expanding or dismissing).
+     */
+    public void hideMenuWithoutResize() {
+        onMenuStateChanged(MENU_STATE_NONE, false /* resize */, null /* callback */);
+    }
+
+    /**
+     * Sets the menu actions to the actions provided by the current PiP activity.
+     */
+    public void setAppActions(ParceledListSlice<RemoteAction> appActions) {
+        mAppActions = appActions;
+        updateMenuActions();
+    }
+
+    void onPipExpand() {
+        mListeners.forEach(Listener::onPipExpand);
+    }
+
+    void onPipDismiss() {
+        mListeners.forEach(Listener::onPipDismiss);
+    }
+
+    void onPipShowMenu() {
+        mListeners.forEach(Listener::onPipShowMenu);
+    }
+
+    /**
+     * @return the best set of actions to show in the PiP menu.
+     */
+    private ParceledListSlice<RemoteAction> resolveMenuActions() {
+        if (isValidActions(mAppActions)) {
+            return mAppActions;
+        }
+        return mMediaActions;
+    }
+
+    /**
+     * Returns a default LayoutParams for the PIP Menu.
+     * @param width the PIP stack width.
+     * @param height the PIP stack height.
+     */
+    public static WindowManager.LayoutParams getPipMenuLayoutParams(int width, int height) {
+        return new WindowManager.LayoutParams(width, height,
+                WindowManager.LayoutParams.TYPE_APPLICATION, 0, PixelFormat.TRANSLUCENT);
+    }
+
+    /**
+     * Updates the PiP menu with the best set of actions provided.
+     */
+    private void updateMenuActions() {
+        if (isMenuVisible()) {
+            // Fetch the pinned stack bounds
+            Rect stackBounds = null;
+            try {
+                RootTaskInfo pinnedTaskInfo = ActivityTaskManager.getService().getRootTaskInfo(
+                        WINDOWING_MODE_PINNED, ACTIVITY_TYPE_UNDEFINED);
+                if (pinnedTaskInfo != null) {
+                    stackBounds = pinnedTaskInfo.bounds;
+                }
+            } catch (RemoteException e) {
+                Log.e(TAG, "Error showing PIP menu", e);
+            }
+
+            mPipMenuView.setActions(stackBounds, resolveMenuActions().getList());
+        }
+    }
+
+    /**
+     * Returns whether the set of actions are valid.
+     */
+    private static boolean isValidActions(ParceledListSlice<?> actions) {
+        return actions != null && actions.getList().size() > 0;
+    }
+
+    /**
+     * Handles changes in menu visibility.
+     */
+    void onMenuStateChanged(int menuState, boolean resize, Runnable callback) {
+        if (DEBUG) {
+            Log.d(TAG, "onMenuStateChanged() mMenuState=" + mMenuState
+                    + " menuState=" + menuState + " resize=" + resize
+                    + " callers=\n" + Debug.getCallers(5, "    "));
+        }
+
+        if (menuState != mMenuState) {
+            mListeners.forEach(l -> l.onPipMenuStateChanged(menuState, resize, callback));
+            if (menuState == MENU_STATE_FULL) {
+                // Once visible, start listening for media action changes. This call will trigger
+                // the menu actions to be updated again.
+                mMediaController.addListener(mMediaActionListener);
+            } else {
+                // Once hidden, stop listening for media action changes. This call will trigger
+                // the menu actions to be updated again.
+                mMediaController.removeListener(mMediaActionListener);
+            }
+
+            try {
+                WindowManagerGlobal.getWindowSession().grantEmbeddedWindowFocus(null /* window */,
+                        mPipMenuInputToken, menuState != MENU_STATE_NONE /* grantFocus */);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Unable to update focus as menu appears/disappears", e);
+            }
+        }
+        mMenuState = menuState;
+    }
+
+    /**
+     * Handles a pointer event sent from pip input consumer.
+     */
+    void handlePointerEvent(MotionEvent ev) {
+        if (ev.isTouchEvent()) {
+            mPipMenuView.dispatchTouchEvent(ev);
+        } else {
+            mPipMenuView.dispatchGenericMotionEvent(ev);
+        }
+    }
+
+    /**
+     * Tell the PIP Menu to recalculate its layout given its current position on the display.
+     */
+    public void updateMenuLayout(Rect bounds) {
+        final boolean isMenuVisible = isMenuVisible();
+        if (DEBUG) {
+            Log.d(TAG, "updateMenuLayout() state=" + mMenuState
+                    + " isMenuVisible=" + isMenuVisible
+                    + " callers=\n" + Debug.getCallers(5, "    "));
+        }
+        if (isMenuVisible) {
+            mPipMenuView.updateMenuLayout(bounds);
+        }
+    }
+
+    public void dump(PrintWriter pw, String prefix) {
+        final String innerPrefix = prefix + "  ";
+        pw.println(prefix + TAG);
+        pw.println(innerPrefix + "mMenuState=" + mMenuState);
+        pw.println(innerPrefix + "mPipMenuView=" + mPipMenuView);
+        pw.println(innerPrefix + "mListeners=" + mListeners.size());
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMenuIconsAlgorithm.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMenuIconsAlgorithm.java
new file mode 100644
index 0000000..985cd0f
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMenuIconsAlgorithm.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.util.Log;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+/**
+ * Helper class to calculate and place the menu icons on the PIP Menu.
+ */
+public class PipMenuIconsAlgorithm {
+
+    private static final String TAG = "PipMenuIconsAlgorithm";
+
+    private boolean mFinishedLayout = false;
+    protected ViewGroup mViewRoot;
+    protected ViewGroup mTopEndContainer;
+    protected View mDragHandle;
+    protected View mSettingsButton;
+    protected View mDismissButton;
+
+    protected PipMenuIconsAlgorithm(Context context) {
+    }
+
+    /**
+     * Bind the necessary views.
+     */
+    public void bindViews(ViewGroup viewRoot, ViewGroup topEndContainer, View dragHandle,
+            View settingsButton, View dismissButton) {
+        mViewRoot = viewRoot;
+        mTopEndContainer = topEndContainer;
+        mDragHandle = dragHandle;
+        mSettingsButton = settingsButton;
+        mDismissButton = dismissButton;
+    }
+
+    /**
+     * Updates the position of the drag handle based on where the PIP window is on the screen.
+     */
+    public void onBoundsChanged(Rect bounds) {
+        if (mViewRoot == null || mTopEndContainer == null || mDragHandle == null
+                || mSettingsButton == null || mDismissButton == null) {
+            Log.e(TAG, "One if the required views is null.");
+        }
+
+        //We only need to calculate the layout once since it does not change.
+        if (!mFinishedLayout) {
+            mTopEndContainer.removeView(mSettingsButton);
+            mViewRoot.addView(mSettingsButton);
+
+            setLayoutGravity(mDragHandle, Gravity.START | Gravity.TOP);
+            setLayoutGravity(mSettingsButton, Gravity.START | Gravity.TOP);
+            mFinishedLayout = true;
+        }
+    }
+
+    /**
+     * Set the gravity on the given view.
+     */
+    protected static void setLayoutGravity(View v, int gravity) {
+        if (v.getLayoutParams() instanceof FrameLayout.LayoutParams) {
+            FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) v.getLayoutParams();
+            params.gravity = gravity;
+            v.setLayoutParams(params);
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMenuView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMenuView.java
new file mode 100644
index 0000000..24e49f8
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMenuView.java
@@ -0,0 +1,493 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK;
+import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
+import static android.provider.Settings.ACTION_PICTURE_IN_PICTURE_SETTINGS;
+import static android.view.accessibility.AccessibilityManager.FLAG_CONTENT_CONTROLS;
+import static android.view.accessibility.AccessibilityManager.FLAG_CONTENT_ICONS;
+import static android.view.accessibility.AccessibilityNodeInfo.ACTION_CLICK;
+
+import static com.android.wm.shell.pip.phone.PipMenuActivityController.MENU_STATE_CLOSE;
+import static com.android.wm.shell.pip.phone.PipMenuActivityController.MENU_STATE_FULL;
+import static com.android.wm.shell.pip.phone.PipMenuActivityController.MENU_STATE_NONE;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+import android.app.ActivityManager;
+import android.app.PendingIntent.CanceledException;
+import android.app.RemoteAction;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.UserHandle;
+import android.util.Log;
+import android.util.Pair;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.accessibility.AccessibilityManager;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.FrameLayout;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+
+import com.android.wm.shell.R;
+import com.android.wm.shell.animation.Interpolators;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Translucent window that gets started on top of a task in PIP to allow the user to control it.
+ */
+public class PipMenuView extends FrameLayout {
+
+    private static final String TAG = "PipMenuView";
+
+    private static final int MESSAGE_INVALID_TYPE = -1;
+    public static final int MESSAGE_MENU_EXPANDED = 8;
+
+    private static final int INITIAL_DISMISS_DELAY = 3500;
+    private static final int POST_INTERACTION_DISMISS_DELAY = 2000;
+    private static final long MENU_FADE_DURATION = 125;
+    private static final long MENU_SLOW_FADE_DURATION = 175;
+    private static final long MENU_SHOW_ON_EXPAND_START_DELAY = 30;
+
+    private static final float MENU_BACKGROUND_ALPHA = 0.3f;
+    private static final float DISMISS_BACKGROUND_ALPHA = 0.6f;
+
+    private static final float DISABLED_ACTION_ALPHA = 0.54f;
+
+    private static final boolean ENABLE_RESIZE_HANDLE = false;
+
+    private int mMenuState;
+    private boolean mResize = true;
+    private boolean mAllowMenuTimeout = true;
+    private boolean mAllowTouches = true;
+
+    private final List<RemoteAction> mActions = new ArrayList<>();
+
+    private AccessibilityManager mAccessibilityManager;
+    private Drawable mBackgroundDrawable;
+    private View mMenuContainer;
+    private LinearLayout mActionsGroup;
+    private int mBetweenActionPaddingLand;
+
+    private AnimatorSet mMenuContainerAnimator;
+    private PipMenuActivityController mController;
+
+    private ValueAnimator.AnimatorUpdateListener mMenuBgUpdateListener =
+            new ValueAnimator.AnimatorUpdateListener() {
+                @Override
+                public void onAnimationUpdate(ValueAnimator animation) {
+                    final float alpha = (float) animation.getAnimatedValue();
+                    mBackgroundDrawable.setAlpha((int) (MENU_BACKGROUND_ALPHA * alpha * 255));
+                }
+            };
+
+    private Handler mHandler = new Handler();
+
+    private final Runnable mHideMenuRunnable = this::hideMenu;
+
+    protected View mViewRoot;
+    protected View mSettingsButton;
+    protected View mDismissButton;
+    protected View mResizeHandle;
+    protected View mTopEndContainer;
+    protected PipMenuIconsAlgorithm mPipMenuIconsAlgorithm;
+
+    public PipMenuView(Context context, PipMenuActivityController controller) {
+        super(context, null, 0);
+        mContext = context;
+        mController = controller;
+
+        mAccessibilityManager = context.getSystemService(AccessibilityManager.class);
+        inflate(context, R.layout.pip_menu, this);
+
+        mBackgroundDrawable = new ColorDrawable(Color.BLACK);
+        mBackgroundDrawable.setAlpha(0);
+        mViewRoot = findViewById(R.id.background);
+        mViewRoot.setBackground(mBackgroundDrawable);
+        mMenuContainer = findViewById(R.id.menu_container);
+        mMenuContainer.setAlpha(0);
+        mTopEndContainer = findViewById(R.id.top_end_container);
+        mSettingsButton = findViewById(R.id.settings);
+        mSettingsButton.setAlpha(0);
+        mSettingsButton.setOnClickListener((v) -> {
+            if (v.getAlpha() != 0) {
+                showSettings();
+            }
+        });
+        mDismissButton = findViewById(R.id.dismiss);
+        mDismissButton.setAlpha(0);
+        mDismissButton.setOnClickListener(v -> dismissPip());
+        findViewById(R.id.expand_button).setOnClickListener(v -> {
+            if (mMenuContainer.getAlpha() != 0) {
+                expandPip();
+            }
+        });
+
+        mResizeHandle = findViewById(R.id.resize_handle);
+        mResizeHandle.setAlpha(0);
+        mActionsGroup = findViewById(R.id.actions_group);
+        mBetweenActionPaddingLand = getResources().getDimensionPixelSize(
+                R.dimen.pip_between_action_padding_land);
+        mPipMenuIconsAlgorithm = new PipMenuIconsAlgorithm(mContext);
+        mPipMenuIconsAlgorithm.bindViews((ViewGroup) mViewRoot, (ViewGroup) mTopEndContainer,
+                mResizeHandle, mSettingsButton, mDismissButton);
+
+        initAccessibility();
+    }
+
+    private void initAccessibility() {
+        this.setAccessibilityDelegate(new View.AccessibilityDelegate() {
+            @Override
+            public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
+                super.onInitializeAccessibilityNodeInfo(host, info);
+                String label = getResources().getString(R.string.pip_menu_title);
+                info.addAction(new AccessibilityNodeInfo.AccessibilityAction(ACTION_CLICK, label));
+            }
+
+            @Override
+            public boolean performAccessibilityAction(View host, int action, Bundle args) {
+                if (action == ACTION_CLICK && mMenuState == MENU_STATE_CLOSE) {
+                    mController.onPipShowMenu();
+                }
+                return super.performAccessibilityAction(host, action, args);
+            }
+        });
+    }
+
+    @Override
+    public boolean onKeyUp(int keyCode, KeyEvent event) {
+        if (keyCode == KeyEvent.KEYCODE_ESCAPE) {
+            hideMenu();
+            return true;
+        }
+        return super.onKeyUp(keyCode, event);
+    }
+
+    @Override
+    public boolean dispatchTouchEvent(MotionEvent ev) {
+        if (!mAllowTouches) {
+            return false;
+        }
+
+        if (mAllowMenuTimeout) {
+            repostDelayedHide(POST_INTERACTION_DISMISS_DELAY);
+        }
+
+        return super.dispatchTouchEvent(ev);
+    }
+
+    @Override
+    public boolean dispatchGenericMotionEvent(MotionEvent event) {
+        if (mAllowMenuTimeout) {
+            repostDelayedHide(POST_INTERACTION_DISMISS_DELAY);
+        }
+
+        return super.dispatchGenericMotionEvent(event);
+    }
+
+    void showMenu(int menuState, Rect stackBounds, boolean allowMenuTimeout,
+            boolean resizeMenuOnShow, boolean withDelay, boolean showResizeHandle) {
+        mAllowMenuTimeout = allowMenuTimeout;
+        if (mMenuState != menuState) {
+            // Disallow touches if the menu needs to resize while showing, and we are transitioning
+            // to/from a full menu state.
+            boolean disallowTouchesUntilAnimationEnd = resizeMenuOnShow
+                    && (mMenuState == MENU_STATE_FULL || menuState == MENU_STATE_FULL);
+            mAllowTouches = !disallowTouchesUntilAnimationEnd;
+            cancelDelayedHide();
+            updateActionViews(stackBounds);
+            if (mMenuContainerAnimator != null) {
+                mMenuContainerAnimator.cancel();
+            }
+            mMenuContainerAnimator = new AnimatorSet();
+            ObjectAnimator menuAnim = ObjectAnimator.ofFloat(mMenuContainer, View.ALPHA,
+                    mMenuContainer.getAlpha(), 1f);
+            menuAnim.addUpdateListener(mMenuBgUpdateListener);
+            ObjectAnimator settingsAnim = ObjectAnimator.ofFloat(mSettingsButton, View.ALPHA,
+                    mSettingsButton.getAlpha(), 1f);
+            ObjectAnimator dismissAnim = ObjectAnimator.ofFloat(mDismissButton, View.ALPHA,
+                    mDismissButton.getAlpha(), 1f);
+            ObjectAnimator resizeAnim = ObjectAnimator.ofFloat(mResizeHandle, View.ALPHA,
+                    mResizeHandle.getAlpha(),
+                    ENABLE_RESIZE_HANDLE && menuState == MENU_STATE_CLOSE && showResizeHandle
+                            ? 1f : 0f);
+            if (menuState == MENU_STATE_FULL) {
+                mMenuContainerAnimator.playTogether(menuAnim, settingsAnim, dismissAnim,
+                        resizeAnim);
+            } else {
+                mMenuContainerAnimator.playTogether(dismissAnim, resizeAnim);
+            }
+            mMenuContainerAnimator.setInterpolator(Interpolators.ALPHA_IN);
+            mMenuContainerAnimator.setDuration(menuState == MENU_STATE_CLOSE
+                    ? MENU_FADE_DURATION
+                    : MENU_SLOW_FADE_DURATION);
+            if (allowMenuTimeout) {
+                mMenuContainerAnimator.addListener(new AnimatorListenerAdapter() {
+                    @Override
+                    public void onAnimationEnd(Animator animation) {
+                        repostDelayedHide(INITIAL_DISMISS_DELAY);
+                    }
+                });
+            }
+            if (withDelay) {
+                // starts the menu container animation after window expansion is completed
+                notifyMenuStateChange(menuState, resizeMenuOnShow, () -> {
+                    if (mMenuContainerAnimator == null) {
+                        return;
+                    }
+                    mMenuContainerAnimator.setStartDelay(MENU_SHOW_ON_EXPAND_START_DELAY);
+                    mMenuContainerAnimator.start();
+                });
+            } else {
+                notifyMenuStateChange(menuState, resizeMenuOnShow, null);
+                mMenuContainerAnimator.start();
+            }
+        } else {
+            // If we are already visible, then just start the delayed dismiss and unregister any
+            // existing input consumers from the previous drag
+            if (allowMenuTimeout) {
+                repostDelayedHide(POST_INTERACTION_DISMISS_DELAY);
+            }
+        }
+    }
+
+    /**
+     * Different from {@link #hideMenu()}, this function does not try to finish this menu activity
+     * and instead, it fades out the controls by setting the alpha to 0 directly without menu
+     * visibility callbacks invoked.
+     */
+    void fadeOutMenu() {
+        mMenuContainer.setAlpha(0f);
+        mSettingsButton.setAlpha(0f);
+        mDismissButton.setAlpha(0f);
+        mResizeHandle.setAlpha(0f);
+    }
+
+    void pokeMenu() {
+        cancelDelayedHide();
+    }
+
+    void onPipAnimationEnded() {
+        mAllowTouches = true;
+    }
+
+    void updateMenuLayout(Rect bounds) {
+        mPipMenuIconsAlgorithm.onBoundsChanged(bounds);
+    }
+
+    void hideMenu() {
+        hideMenu(null);
+    }
+
+    void hideMenu(Runnable animationEndCallback) {
+        hideMenu(animationEndCallback, true /* notifyMenuVisibility */, true /* animate */);
+    }
+
+    private void hideMenu(final Runnable animationFinishedRunnable, boolean notifyMenuVisibility,
+            boolean animate) {
+        if (mMenuState != MENU_STATE_NONE) {
+            cancelDelayedHide();
+            if (notifyMenuVisibility) {
+                notifyMenuStateChange(MENU_STATE_NONE, mResize, null);
+            }
+            mMenuContainerAnimator = new AnimatorSet();
+            ObjectAnimator menuAnim = ObjectAnimator.ofFloat(mMenuContainer, View.ALPHA,
+                    mMenuContainer.getAlpha(), 0f);
+            menuAnim.addUpdateListener(mMenuBgUpdateListener);
+            ObjectAnimator settingsAnim = ObjectAnimator.ofFloat(mSettingsButton, View.ALPHA,
+                    mSettingsButton.getAlpha(), 0f);
+            ObjectAnimator dismissAnim = ObjectAnimator.ofFloat(mDismissButton, View.ALPHA,
+                    mDismissButton.getAlpha(), 0f);
+            ObjectAnimator resizeAnim = ObjectAnimator.ofFloat(mResizeHandle, View.ALPHA,
+                    mResizeHandle.getAlpha(), 0f);
+            mMenuContainerAnimator.playTogether(menuAnim, settingsAnim, dismissAnim, resizeAnim);
+            mMenuContainerAnimator.setInterpolator(Interpolators.ALPHA_OUT);
+            mMenuContainerAnimator.setDuration(animate ? MENU_FADE_DURATION : 0);
+            mMenuContainerAnimator.addListener(new AnimatorListenerAdapter() {
+                @Override
+                public void onAnimationEnd(Animator animation) {
+                    if (animationFinishedRunnable != null) {
+                        animationFinishedRunnable.run();
+                    }
+                }
+            });
+            mMenuContainerAnimator.start();
+        }
+    }
+
+    void setActions(Rect stackBounds, List<RemoteAction> actions) {
+        mActions.clear();
+        mActions.addAll(actions);
+        updateActionViews(stackBounds);
+    }
+
+    private void updateActionViews(Rect stackBounds) {
+        ViewGroup expandContainer = findViewById(R.id.expand_container);
+        ViewGroup actionsContainer = findViewById(R.id.actions_container);
+        actionsContainer.setOnTouchListener((v, ev) -> {
+            // Do nothing, prevent click through to parent
+            return true;
+        });
+
+        if (mActions.isEmpty() || mMenuState == MENU_STATE_CLOSE) {
+            actionsContainer.setVisibility(View.INVISIBLE);
+        } else {
+            actionsContainer.setVisibility(View.VISIBLE);
+            if (mActionsGroup != null) {
+                // Ensure we have as many buttons as actions
+                final LayoutInflater inflater = LayoutInflater.from(mContext);
+                while (mActionsGroup.getChildCount() < mActions.size()) {
+                    final ImageButton actionView = (ImageButton) inflater.inflate(
+                            R.layout.pip_menu_action, mActionsGroup, false);
+                    mActionsGroup.addView(actionView);
+                }
+
+                // Update the visibility of all views
+                for (int i = 0; i < mActionsGroup.getChildCount(); i++) {
+                    mActionsGroup.getChildAt(i).setVisibility(i < mActions.size()
+                            ? View.VISIBLE
+                            : View.GONE);
+                }
+
+                // Recreate the layout
+                final boolean isLandscapePip = stackBounds != null
+                        && (stackBounds.width() > stackBounds.height());
+                for (int i = 0; i < mActions.size(); i++) {
+                    final RemoteAction action = mActions.get(i);
+                    final ImageButton actionView = (ImageButton) mActionsGroup.getChildAt(i);
+
+                    // TODO: Check if the action drawable has changed before we reload it
+                    action.getIcon().loadDrawableAsync(mContext, d -> {
+                        if (d != null) {
+                            d.setTint(Color.WHITE);
+                            actionView.setImageDrawable(d);
+                        }
+                    }, mHandler);
+                    actionView.setContentDescription(action.getContentDescription());
+                    if (action.isEnabled()) {
+                        actionView.setOnClickListener(v -> {
+                            mHandler.post(() -> {
+                                try {
+                                    action.getActionIntent().send();
+                                } catch (CanceledException e) {
+                                    Log.w(TAG, "Failed to send action", e);
+                                }
+                            });
+                        });
+                    }
+                    actionView.setEnabled(action.isEnabled());
+                    actionView.setAlpha(action.isEnabled() ? 1f : DISABLED_ACTION_ALPHA);
+
+                    // Update the margin between actions
+                    LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams)
+                            actionView.getLayoutParams();
+                    lp.leftMargin = (isLandscapePip && i > 0) ? mBetweenActionPaddingLand : 0;
+                }
+            }
+
+            // Update the expand container margin to adjust the center of the expand button to
+            // account for the existence of the action container
+            FrameLayout.LayoutParams expandedLp =
+                    (FrameLayout.LayoutParams) expandContainer.getLayoutParams();
+            expandedLp.topMargin = getResources().getDimensionPixelSize(
+                    R.dimen.pip_action_padding);
+            expandedLp.bottomMargin = getResources().getDimensionPixelSize(
+                    R.dimen.pip_expand_container_edge_margin);
+            expandContainer.requestLayout();
+        }
+    }
+
+    void updateDismissFraction(float fraction) {
+        int alpha;
+        final float menuAlpha = 1 - fraction;
+        if (mMenuState == MENU_STATE_FULL) {
+            mMenuContainer.setAlpha(menuAlpha);
+            mSettingsButton.setAlpha(menuAlpha);
+            mDismissButton.setAlpha(menuAlpha);
+            final float interpolatedAlpha =
+                    MENU_BACKGROUND_ALPHA * menuAlpha + DISMISS_BACKGROUND_ALPHA * fraction;
+            alpha = (int) (interpolatedAlpha * 255);
+        } else {
+            if (mMenuState == MENU_STATE_CLOSE) {
+                mDismissButton.setAlpha(menuAlpha);
+            }
+            alpha = (int) (fraction * DISMISS_BACKGROUND_ALPHA * 255);
+        }
+        mBackgroundDrawable.setAlpha(alpha);
+    }
+
+    private void notifyMenuStateChange(int menuState, boolean resize, Runnable callback) {
+        mMenuState = menuState;
+        mController.onMenuStateChanged(menuState, resize, callback);
+    }
+
+    private void expandPip() {
+        // Do not notify menu visibility when hiding the menu, the controller will do this when it
+        // handles the message
+        hideMenu(mController::onPipExpand, false /* notifyMenuVisibility */, true /* animate */);
+    }
+
+    private void dismissPip() {
+        // Since tapping on the close-button invokes a double-tap wait callback in PipTouchHandler,
+        // we want to disable animating the fadeout animation of the buttons in order to call on
+        // PipTouchHandler#onPipDismiss fast enough.
+        final boolean animate = mMenuState != MENU_STATE_CLOSE;
+        // Do not notify menu visibility when hiding the menu, the controller will do this when it
+        // handles the message
+        hideMenu(mController::onPipDismiss, false /* notifyMenuVisibility */, animate);
+    }
+
+    private void showSettings() {
+        final Pair<ComponentName, Integer> topPipActivityInfo =
+                PipUtils.getTopPipActivity(mContext, ActivityManager.getService());
+        if (topPipActivityInfo.first != null) {
+            final Intent settingsIntent = new Intent(ACTION_PICTURE_IN_PICTURE_SETTINGS,
+                    Uri.fromParts("package", topPipActivityInfo.first.getPackageName(), null));
+            settingsIntent.setFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TASK);
+            mContext.startActivityAsUser(settingsIntent, UserHandle.CURRENT);
+        }
+    }
+
+    private void cancelDelayedHide() {
+        mHandler.removeCallbacks(mHideMenuRunnable);
+    }
+
+    private void repostDelayedHide(int delay) {
+        int recommendedTimeout = mAccessibilityManager.getRecommendedTimeoutMillis(delay,
+                FLAG_CONTENT_ICONS | FLAG_CONTENT_CONTROLS);
+        mHandler.removeCallbacks(mHideMenuRunnable);
+        mHandler.postDelayed(mHideMenuRunnable, recommendedTimeout);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMotionHelper.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMotionHelper.java
new file mode 100644
index 0000000..cc86cf9
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipMotionHelper.java
@@ -0,0 +1,667 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.ComponentName;
+import android.content.Context;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.os.Debug;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.view.Choreographer;
+
+import androidx.annotation.VisibleForTesting;
+import androidx.dynamicanimation.animation.AnimationHandler;
+import androidx.dynamicanimation.animation.AnimationHandler.FrameCallbackScheduler;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.android.wm.shell.animation.FloatProperties;
+import com.android.wm.shell.animation.PhysicsAnimator;
+import com.android.wm.shell.common.FloatingContentCoordinator;
+import com.android.wm.shell.common.magnetictarget.MagnetizedObject;
+import com.android.wm.shell.pip.PipSnapAlgorithm;
+import com.android.wm.shell.pip.PipTaskOrganizer;
+
+import java.io.PrintWriter;
+import java.util.function.Consumer;
+
+import kotlin.Unit;
+import kotlin.jvm.functions.Function0;
+
+/**
+ * A helper to animate and manipulate the PiP.
+ */
+public class PipMotionHelper implements PipAppOpsListener.Callback,
+        FloatingContentCoordinator.FloatingContent {
+
+    private static final String TAG = "PipMotionHelper";
+    private static final boolean DEBUG = false;
+
+    private static final int SHRINK_STACK_FROM_MENU_DURATION = 250;
+    private static final int EXPAND_STACK_TO_MENU_DURATION = 250;
+    private static final int LEAVE_PIP_DURATION = 300;
+    private static final int SHIFT_DURATION = 300;
+    private static final float STASH_RATIO = 0.25f;
+
+    /** Friction to use for PIP when it moves via physics fling animations. */
+    private static final float DEFAULT_FRICTION = 2f;
+
+    private final Context mContext;
+    private final PipTaskOrganizer mPipTaskOrganizer;
+
+    private PipMenuActivityController mMenuController;
+    private PipSnapAlgorithm mSnapAlgorithm;
+
+    private final Handler mMainHandler = new Handler(Looper.getMainLooper());
+
+    /** PIP's current bounds on the screen. */
+    private final Rect mBounds = new Rect();
+
+    /** The bounds within which PIP's top-left coordinate is allowed to move. */
+    private final Rect mMovementBounds = new Rect();
+
+    /** The region that all of PIP must stay within. */
+    private final Rect mFloatingAllowedArea = new Rect();
+
+    /**
+     * Temporary bounds used when PIP is being dragged or animated. These bounds are applied to PIP
+     * using {@link PipTaskOrganizer#scheduleUserResizePip}, so that we can animate shrinking into
+     * and expanding out of the magnetic dismiss target.
+     *
+     * Once PIP is done being dragged or animated, we set {@link #mBounds} equal to these temporary
+     * bounds, and call {@link PipTaskOrganizer#scheduleFinishResizePip} to 'officially' move PIP to
+     * its new bounds.
+     */
+    private final Rect mTemporaryBounds = new Rect();
+
+    /** The destination bounds to which PIP is animating. */
+    private final Rect mAnimatingToBounds = new Rect();
+
+    /** Coordinator instance for resolving conflicts with other floating content. */
+    private FloatingContentCoordinator mFloatingContentCoordinator;
+
+    private ThreadLocal<AnimationHandler> mSfAnimationHandlerThreadLocal =
+            ThreadLocal.withInitial(() -> {
+                FrameCallbackScheduler scheduler = runnable ->
+                        Choreographer.getSfInstance().postFrameCallback(t -> runnable.run());
+                AnimationHandler handler = new AnimationHandler(scheduler);
+                return handler;
+            });
+
+    /**
+     * PhysicsAnimator instance for animating {@link #mTemporaryBounds} using physics animations.
+     */
+    private PhysicsAnimator<Rect> mTemporaryBoundsPhysicsAnimator = PhysicsAnimator.getInstance(
+            mTemporaryBounds);
+
+    private MagnetizedObject<Rect> mMagnetizedPip;
+
+    /**
+     * Update listener that resizes the PIP to {@link #mTemporaryBounds}.
+     */
+    private final PhysicsAnimator.UpdateListener<Rect> mResizePipUpdateListener;
+
+    /** FlingConfig instances provided to PhysicsAnimator for fling gestures. */
+    private PhysicsAnimator.FlingConfig mFlingConfigX;
+    private PhysicsAnimator.FlingConfig mFlingConfigY;
+    /** FlingConfig instances proviced to PhysicsAnimator for stashing. */
+    private PhysicsAnimator.FlingConfig mStashConfigX;
+
+    /** SpringConfig to use for fling-then-spring animations. */
+    private final PhysicsAnimator.SpringConfig mSpringConfig =
+            new PhysicsAnimator.SpringConfig(
+                    SpringForce.STIFFNESS_MEDIUM, SpringForce.DAMPING_RATIO_LOW_BOUNCY);
+
+    /** SpringConfig to use for springing PIP away from conflicting floating content. */
+    private final PhysicsAnimator.SpringConfig mConflictResolutionSpringConfig =
+                new PhysicsAnimator.SpringConfig(
+                        SpringForce.STIFFNESS_LOW, SpringForce.DAMPING_RATIO_LOW_BOUNCY);
+
+    private final Consumer<Rect> mUpdateBoundsCallback = (Rect newBounds) -> {
+        mMainHandler.post(() -> {
+            mMenuController.updateMenuLayout(newBounds);
+            mBounds.set(newBounds);
+        });
+    };
+
+    /**
+     * Whether we're springing to the touch event location (vs. moving it to that position
+     * instantly). We spring-to-touch after PIP is dragged out of the magnetic target, since it was
+     * 'stuck' in the target and needs to catch up to the touch location.
+     */
+    private boolean mSpringingToTouch = false;
+
+    /**
+     * Whether PIP was released in the dismiss target, and will be animated out and dismissed
+     * shortly.
+     */
+    private boolean mDismissalPending = false;
+
+    /**
+     * Gets set in {@link #animateToExpandedState(Rect, Rect, Rect, Runnable)}, this callback is
+     * used to show menu activity when the expand animation is completed.
+     */
+    private Runnable mPostPipTransitionCallback;
+
+    private final PipTaskOrganizer.PipTransitionCallback mPipTransitionCallback =
+            new PipTaskOrganizer.PipTransitionCallback() {
+        @Override
+        public void onPipTransitionStarted(ComponentName activity, int direction, Rect pipBounds) {}
+
+        @Override
+        public void onPipTransitionFinished(ComponentName activity, int direction) {
+            if (mPostPipTransitionCallback != null) {
+                mPostPipTransitionCallback.run();
+                mPostPipTransitionCallback = null;
+            }
+        }
+
+        @Override
+        public void onPipTransitionCanceled(ComponentName activity, int direction) {}
+    };
+
+    public PipMotionHelper(Context context, PipTaskOrganizer pipTaskOrganizer,
+            PipMenuActivityController menuController, PipSnapAlgorithm snapAlgorithm,
+            FloatingContentCoordinator floatingContentCoordinator) {
+        mContext = context;
+        mPipTaskOrganizer = pipTaskOrganizer;
+        mMenuController = menuController;
+        mSnapAlgorithm = snapAlgorithm;
+        mFloatingContentCoordinator = floatingContentCoordinator;
+        mPipTaskOrganizer.registerPipTransitionCallback(mPipTransitionCallback);
+        mTemporaryBoundsPhysicsAnimator.setCustomAnimationHandler(
+                mSfAnimationHandlerThreadLocal.get());
+
+        mResizePipUpdateListener = (target, values) -> {
+            if (!mTemporaryBounds.isEmpty()) {
+                mPipTaskOrganizer.scheduleUserResizePip(
+                        mBounds, mTemporaryBounds, null);
+            }
+        };
+    }
+
+    @NonNull
+    @Override
+    public Rect getFloatingBoundsOnScreen() {
+        return !mAnimatingToBounds.isEmpty() ? mAnimatingToBounds : mBounds;
+    }
+
+    @NonNull
+    @Override
+    public Rect getAllowedFloatingBoundsRegion() {
+        return mFloatingAllowedArea;
+    }
+
+    @Override
+    public void moveToBounds(@NonNull Rect bounds) {
+        animateToBounds(bounds, mConflictResolutionSpringConfig);
+    }
+
+    /**
+     * Synchronizes the current bounds with the pinned stack, cancelling any ongoing animations.
+     */
+    void synchronizePinnedStackBounds() {
+        cancelAnimations();
+        mBounds.set(mPipTaskOrganizer.getLastReportedBounds());
+        mTemporaryBounds.setEmpty();
+
+        if (mPipTaskOrganizer.isInPip()) {
+            mFloatingContentCoordinator.onContentMoved(this);
+        }
+    }
+
+    boolean isAnimating() {
+        return mTemporaryBoundsPhysicsAnimator.isRunning();
+    }
+
+    /**
+     * Tries to move the pinned stack to the given {@param bounds}.
+     */
+    void movePip(Rect toBounds) {
+        movePip(toBounds, false /* isDragging */);
+    }
+
+    /**
+     * Tries to move the pinned stack to the given {@param bounds}.
+     *
+     * @param isDragging Whether this movement is the result of a drag touch gesture. If so, we
+     *                   won't notify the floating content coordinator of this move, since that will
+     *                   happen when the gesture ends.
+     */
+    void movePip(Rect toBounds, boolean isDragging) {
+        if (!isDragging) {
+            mFloatingContentCoordinator.onContentMoved(this);
+        }
+
+        if (!mSpringingToTouch) {
+            // If we are moving PIP directly to the touch event locations, cancel any animations and
+            // move PIP to the given bounds.
+            cancelAnimations();
+
+            if (!isDragging) {
+                resizePipUnchecked(toBounds);
+                mBounds.set(toBounds);
+            } else {
+                mTemporaryBounds.set(toBounds);
+                mPipTaskOrganizer.scheduleUserResizePip(mBounds, mTemporaryBounds,
+                        (Rect newBounds) -> {
+                            mMainHandler.post(() -> {
+                                mMenuController.updateMenuLayout(newBounds);
+                            });
+                    });
+            }
+        } else {
+            // If PIP is 'catching up' after being stuck in the dismiss target, update the animation
+            // to spring towards the new touch location.
+            mTemporaryBoundsPhysicsAnimator
+                    .spring(FloatProperties.RECT_WIDTH, mBounds.width(), mSpringConfig)
+                    .spring(FloatProperties.RECT_HEIGHT, mBounds.height(), mSpringConfig)
+                    .spring(FloatProperties.RECT_X, toBounds.left, mSpringConfig)
+                    .spring(FloatProperties.RECT_Y, toBounds.top, mSpringConfig);
+
+            startBoundsAnimator(toBounds.left /* toX */, toBounds.top /* toY */,
+                    false /* dismiss */);
+        }
+    }
+
+    /** Animates the PIP into the dismiss target, scaling it down. */
+    void animateIntoDismissTarget(
+            MagnetizedObject.MagneticTarget target,
+            float velX, float velY,
+            boolean flung, Function0<Unit> after) {
+        final PointF targetCenter = target.getCenterOnScreen();
+
+        final float desiredWidth = mBounds.width() / 2;
+        final float desiredHeight = mBounds.height() / 2;
+
+        final float destinationX = targetCenter.x - (desiredWidth / 2f);
+        final float destinationY = targetCenter.y - (desiredHeight / 2f);
+
+        // If we're already in the dismiss target area, then there won't be a move to set the
+        // temporary bounds, so just initialize it to the current bounds
+        if (mTemporaryBounds.isEmpty()) {
+            mTemporaryBounds.set(mBounds);
+        }
+        mTemporaryBoundsPhysicsAnimator
+                .spring(FloatProperties.RECT_X, destinationX, velX, mSpringConfig)
+                .spring(FloatProperties.RECT_Y, destinationY, velY, mSpringConfig)
+                .spring(FloatProperties.RECT_WIDTH, desiredWidth, mSpringConfig)
+                .spring(FloatProperties.RECT_HEIGHT, desiredHeight, mSpringConfig)
+                .withEndActions(after);
+
+        startBoundsAnimator(destinationX, destinationY, false);
+    }
+
+    /** Set whether we're springing-to-touch to catch up after being stuck in the dismiss target. */
+    void setSpringingToTouch(boolean springingToTouch) {
+        mSpringingToTouch = springingToTouch;
+    }
+
+    /**
+     * Resizes the pinned stack back to unknown windowing mode, which could be freeform or
+     *      * fullscreen depending on the display area's windowing mode.
+     */
+    void expandLeavePip() {
+        expandLeavePip(false /* skipAnimation */);
+    }
+
+    /**
+     * Resizes the pinned stack back to unknown windowing mode, which could be freeform or
+     * fullscreen depending on the display area's windowing mode.
+     */
+    void expandLeavePip(boolean skipAnimation) {
+        if (DEBUG) {
+            Log.d(TAG, "exitPip: skipAnimation=" + skipAnimation
+                    + " callers=\n" + Debug.getCallers(5, "    "));
+        }
+        cancelAnimations();
+        mMenuController.hideMenuWithoutResize();
+        mPipTaskOrganizer.getUpdateHandler().post(() -> {
+            mPipTaskOrganizer.exitPip(skipAnimation
+                    ? 0
+                    : LEAVE_PIP_DURATION);
+        });
+    }
+
+    /**
+     * Dismisses the pinned stack.
+     */
+    @Override
+    public void dismissPip() {
+        if (DEBUG) {
+            Log.d(TAG, "removePip: callers=\n" + Debug.getCallers(5, "    "));
+        }
+        cancelAnimations();
+        mMenuController.hideMenuWithoutResize();
+        mPipTaskOrganizer.removePip();
+    }
+
+    /** Sets the movement bounds to use to constrain PIP position animations. */
+    void setCurrentMovementBounds(Rect movementBounds) {
+        mMovementBounds.set(movementBounds);
+        rebuildFlingConfigs();
+
+        // The movement bounds represent the area within which we can move PIP's top-left position.
+        // The allowed area for all of PIP is those bounds plus PIP's width and height.
+        mFloatingAllowedArea.set(mMovementBounds);
+        mFloatingAllowedArea.right += mBounds.width();
+        mFloatingAllowedArea.bottom += mBounds.height();
+    }
+
+    /**
+     * @return the PiP bounds.
+     */
+    Rect getBounds() {
+        return mBounds;
+    }
+
+    /**
+     * Returns the PIP bounds if we're not animating, or the current, temporary animating bounds
+     * otherwise.
+     */
+    Rect getPossiblyAnimatingBounds() {
+        return mTemporaryBounds.isEmpty() ? mBounds : mTemporaryBounds;
+    }
+
+    /**
+     * Flings the PiP to the closest snap target.
+     */
+    void flingToSnapTarget(
+            float velocityX, float velocityY,
+            @Nullable Runnable updateAction, @Nullable Runnable endAction) {
+        movetoTarget(velocityX, velocityY, updateAction, endAction, false /* isStash */);
+    }
+
+    /**
+     * Stash PiP to the closest edge.
+     */
+    void stashToEdge(
+            float velocityX, float velocityY,
+            @Nullable Runnable updateAction, @Nullable Runnable endAction) {
+        movetoTarget(velocityX, velocityY, updateAction, endAction, true /* isStash */);
+    }
+
+    private void movetoTarget(
+            float velocityX, float velocityY,
+            @Nullable Runnable updateAction, @Nullable Runnable endAction, boolean isStash) {
+        // If we're flinging to a snap target now, we're not springing to catch up to the touch
+        // location now.
+        mSpringingToTouch = false;
+
+        mTemporaryBoundsPhysicsAnimator
+                .spring(FloatProperties.RECT_WIDTH, mBounds.width(), mSpringConfig)
+                .spring(FloatProperties.RECT_HEIGHT, mBounds.height(), mSpringConfig)
+                .flingThenSpring(
+                        FloatProperties.RECT_X, velocityX, isStash ? mStashConfigX : mFlingConfigX,
+                        mSpringConfig, true /* flingMustReachMinOrMax */)
+                .flingThenSpring(
+                        FloatProperties.RECT_Y, velocityY, mFlingConfigY, mSpringConfig)
+                .withEndActions(endAction);
+
+        if (updateAction != null) {
+            mTemporaryBoundsPhysicsAnimator.addUpdateListener(
+                    (target, values) -> updateAction.run());
+        }
+
+        final float offset = ((float) mBounds.width()) * (1.0f - STASH_RATIO);
+        final float leftEdge = isStash ? mMovementBounds.left - offset : mMovementBounds.left;
+        final float rightEdge = isStash ?  mMovementBounds.right + offset : mMovementBounds.right;
+
+        final float xEndValue = velocityX < 0 ? leftEdge : rightEdge;
+        final float estimatedFlingYEndValue =
+                PhysicsAnimator.estimateFlingEndValue(
+                        mTemporaryBounds.top, velocityY, mFlingConfigY);
+
+        startBoundsAnimator(xEndValue /* toX */, estimatedFlingYEndValue /* toY */,
+                false /* dismiss */);
+    }
+
+    /**
+     * Animates PIP to the provided bounds, using physics animations and the given spring
+     * configuration
+     */
+    void animateToBounds(Rect bounds, PhysicsAnimator.SpringConfig springConfig) {
+        if (!mTemporaryBoundsPhysicsAnimator.isRunning()) {
+            // Animate from the current bounds if we're not already animating.
+            mTemporaryBounds.set(mBounds);
+        }
+
+        mTemporaryBoundsPhysicsAnimator
+                .spring(FloatProperties.RECT_X, bounds.left, springConfig)
+                .spring(FloatProperties.RECT_Y, bounds.top, springConfig);
+        startBoundsAnimator(bounds.left /* toX */, bounds.top /* toY */,
+                false /* dismiss */);
+    }
+
+    /**
+     * Animates the dismissal of the PiP off the edge of the screen.
+     */
+    void animateDismiss() {
+        // Animate off the bottom of the screen, then dismiss PIP.
+        mTemporaryBoundsPhysicsAnimator
+                .spring(FloatProperties.RECT_Y,
+                        mMovementBounds.bottom + mBounds.height() * 2,
+                        0,
+                        mSpringConfig)
+                .withEndActions(this::dismissPip);
+
+        startBoundsAnimator(
+                mBounds.left /* toX */, mBounds.bottom + mBounds.height() /* toY */,
+                true /* dismiss */);
+
+        mDismissalPending = false;
+    }
+
+    /**
+     * Animates the PiP to the expanded state to show the menu.
+     */
+    float animateToExpandedState(Rect expandedBounds, Rect movementBounds,
+            Rect expandedMovementBounds, Runnable callback) {
+        float savedSnapFraction = mSnapAlgorithm.getSnapFraction(new Rect(mBounds), movementBounds);
+        mSnapAlgorithm.applySnapFraction(expandedBounds, expandedMovementBounds, savedSnapFraction);
+        mPostPipTransitionCallback = callback;
+        resizeAndAnimatePipUnchecked(expandedBounds, EXPAND_STACK_TO_MENU_DURATION);
+        return savedSnapFraction;
+    }
+
+    /**
+     * Animates the PiP from the expanded state to the normal state after the menu is hidden.
+     */
+    void animateToUnexpandedState(Rect normalBounds, float savedSnapFraction,
+            Rect normalMovementBounds, Rect currentMovementBounds, boolean immediate) {
+        if (savedSnapFraction < 0f) {
+            // If there are no saved snap fractions, then just use the current bounds
+            savedSnapFraction = mSnapAlgorithm.getSnapFraction(new Rect(mBounds),
+                    currentMovementBounds);
+        }
+        mSnapAlgorithm.applySnapFraction(normalBounds, normalMovementBounds, savedSnapFraction);
+
+        if (immediate) {
+            movePip(normalBounds);
+        } else {
+            resizeAndAnimatePipUnchecked(normalBounds, SHRINK_STACK_FROM_MENU_DURATION);
+        }
+    }
+
+    /**
+     * Animates the PiP to offset it from the IME or shelf.
+     */
+    @VisibleForTesting
+    public void animateToOffset(Rect originalBounds, int offset) {
+        if (DEBUG) {
+            Log.d(TAG, "animateToOffset: originalBounds=" + originalBounds + " offset=" + offset
+                    + " callers=\n" + Debug.getCallers(5, "    "));
+        }
+        cancelAnimations();
+        mPipTaskOrganizer.scheduleOffsetPip(originalBounds, offset, SHIFT_DURATION,
+                mUpdateBoundsCallback);
+    }
+
+    /**
+     * Cancels all existing animations.
+     */
+    private void cancelAnimations() {
+        mTemporaryBoundsPhysicsAnimator.cancel();
+        mAnimatingToBounds.setEmpty();
+        mSpringingToTouch = false;
+    }
+
+    /** Set new fling configs whose min/max values respect the given movement bounds. */
+    private void rebuildFlingConfigs() {
+        mFlingConfigX = new PhysicsAnimator.FlingConfig(
+                DEFAULT_FRICTION, mMovementBounds.left, mMovementBounds.right);
+        mFlingConfigY = new PhysicsAnimator.FlingConfig(
+                DEFAULT_FRICTION, mMovementBounds.top, mMovementBounds.bottom);
+        final float offset = ((float) mBounds.width()) * (1.0f - STASH_RATIO);
+        mStashConfigX = new PhysicsAnimator.FlingConfig(
+                DEFAULT_FRICTION, mMovementBounds.left - offset, mMovementBounds.right + offset);
+    }
+
+    /**
+     * Starts the physics animator which will update the animated PIP bounds using physics
+     * animations, as well as the TimeAnimator which will apply those bounds to PIP.
+     *
+     * This will also add end actions to the bounds animator that cancel the TimeAnimator and update
+     * the 'real' bounds to equal the final animated bounds.
+     */
+    private void startBoundsAnimator(float toX, float toY, boolean dismiss) {
+        if (!mSpringingToTouch) {
+            cancelAnimations();
+        }
+
+        // Set animatingToBounds directly to avoid allocating a new Rect, but then call
+        // setAnimatingToBounds to run the normal logic for changing animatingToBounds.
+        mAnimatingToBounds.set(
+                (int) toX,
+                (int) toY,
+                (int) toX + mBounds.width(),
+                (int) toY + mBounds.height());
+        setAnimatingToBounds(mAnimatingToBounds);
+
+        if (!mTemporaryBoundsPhysicsAnimator.isRunning()) {
+            mTemporaryBoundsPhysicsAnimator
+                    .addUpdateListener(mResizePipUpdateListener)
+                    .withEndActions(this::onBoundsAnimationEnd);
+        }
+
+        mTemporaryBoundsPhysicsAnimator.start();
+    }
+
+    /**
+     * Notify that PIP was released in the dismiss target and will be animated out and dismissed
+     * shortly.
+     */
+    void notifyDismissalPending() {
+        mDismissalPending = true;
+    }
+
+    private void onBoundsAnimationEnd() {
+        if (!mDismissalPending
+                && !mSpringingToTouch
+                && !mMagnetizedPip.getObjectStuckToTarget()) {
+            mBounds.set(mTemporaryBounds);
+            if (!mDismissalPending) {
+                // do not schedule resize if PiP is dismissing, which may cause app re-open to
+                // mBounds instead of it's normal bounds.
+                mPipTaskOrganizer.scheduleFinishResizePip(mBounds);
+            }
+            mTemporaryBounds.setEmpty();
+        }
+
+        mAnimatingToBounds.setEmpty();
+        mSpringingToTouch = false;
+        mDismissalPending = false;
+    }
+
+    /**
+     * Notifies the floating coordinator that we're moving, and sets {@link #mAnimatingToBounds} so
+     * we return these bounds from
+     * {@link FloatingContentCoordinator.FloatingContent#getFloatingBoundsOnScreen()}.
+     */
+    private void setAnimatingToBounds(Rect bounds) {
+        mAnimatingToBounds.set(bounds);
+        mFloatingContentCoordinator.onContentMoved(this);
+    }
+
+    /**
+     * Directly resizes the PiP to the given {@param bounds}.
+     */
+    private void resizePipUnchecked(Rect toBounds) {
+        if (DEBUG) {
+            Log.d(TAG, "resizePipUnchecked: toBounds=" + toBounds
+                    + " callers=\n" + Debug.getCallers(5, "    "));
+        }
+        if (!toBounds.equals(mBounds)) {
+            mPipTaskOrganizer.scheduleResizePip(toBounds, mUpdateBoundsCallback);
+        }
+    }
+
+    /**
+     * Directly resizes the PiP to the given {@param bounds}.
+     */
+    private void resizeAndAnimatePipUnchecked(Rect toBounds, int duration) {
+        if (DEBUG) {
+            Log.d(TAG, "resizeAndAnimatePipUnchecked: toBounds=" + toBounds
+                    + " duration=" + duration + " callers=\n" + Debug.getCallers(5, "    "));
+        }
+
+        // Intentionally resize here even if the current bounds match the destination bounds.
+        // This is so all the proper callbacks are performed.
+        mPipTaskOrganizer.scheduleAnimateResizePip(toBounds, duration, mUpdateBoundsCallback);
+        setAnimatingToBounds(toBounds);
+    }
+
+    /**
+     * Returns a MagnetizedObject wrapper for PIP's animated bounds. This is provided to the
+     * magnetic dismiss target so it can calculate PIP's size and position.
+     */
+    MagnetizedObject<Rect> getMagnetizedPip() {
+        if (mMagnetizedPip == null) {
+            mMagnetizedPip = new MagnetizedObject<Rect>(
+                    mContext, mTemporaryBounds, FloatProperties.RECT_X, FloatProperties.RECT_Y) {
+                @Override
+                public float getWidth(@NonNull Rect animatedPipBounds) {
+                    return animatedPipBounds.width();
+                }
+
+                @Override
+                public float getHeight(@NonNull Rect animatedPipBounds) {
+                    return animatedPipBounds.height();
+                }
+
+                @Override
+                public void getLocationOnScreen(
+                        @NonNull Rect animatedPipBounds, @NonNull int[] loc) {
+                    loc[0] = animatedPipBounds.left;
+                    loc[1] = animatedPipBounds.top;
+                }
+            };
+        }
+
+        return mMagnetizedPip;
+    }
+
+    public void dump(PrintWriter pw, String prefix) {
+        final String innerPrefix = prefix + "  ";
+        pw.println(prefix + TAG);
+        pw.println(innerPrefix + "mBounds=" + mBounds);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipResizeGestureHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipResizeGestureHandler.java
new file mode 100644
index 0000000..ef38755
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipResizeGestureHandler.java
@@ -0,0 +1,523 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+import static com.android.internal.config.sysui.SystemUiDeviceConfigFlags.PIP_PINCH_RESIZE;
+import static com.android.internal.policy.TaskResizingAlgorithm.CTRL_BOTTOM;
+import static com.android.internal.policy.TaskResizingAlgorithm.CTRL_LEFT;
+import static com.android.internal.policy.TaskResizingAlgorithm.CTRL_NONE;
+import static com.android.internal.policy.TaskResizingAlgorithm.CTRL_RIGHT;
+import static com.android.internal.policy.TaskResizingAlgorithm.CTRL_TOP;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Point;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.graphics.Region;
+import android.hardware.input.InputManager;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.DeviceConfig;
+import android.view.BatchedInputEventReceiver;
+import android.view.Choreographer;
+import android.view.InputChannel;
+import android.view.InputEvent;
+import android.view.InputEventReceiver;
+import android.view.InputMonitor;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+import android.view.ViewConfiguration;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.android.internal.policy.TaskResizingAlgorithm;
+import com.android.wm.shell.R;
+import com.android.wm.shell.pip.PipBoundsHandler;
+import com.android.wm.shell.pip.PipTaskOrganizer;
+import com.android.wm.shell.pip.PipUiEventLogger;
+
+import java.io.PrintWriter;
+import java.util.concurrent.Executor;
+import java.util.function.Function;
+
+/**
+ * Helper on top of PipTouchHandler that handles inputs OUTSIDE of the PIP window, which is used to
+ * trigger dynamic resize.
+ */
+public class PipResizeGestureHandler {
+
+    private static final String TAG = "PipResizeGestureHandler";
+    private static final float PINCH_THRESHOLD = 0.05f;
+    private static final float STARTING_SCALE_FACTOR = 1.0f;
+
+    private final Context mContext;
+    private final PipBoundsHandler mPipBoundsHandler;
+    private final PipMotionHelper mMotionHelper;
+    private final int mDisplayId;
+    private final Executor mMainExecutor;
+    private final ScaleGestureDetector mScaleGestureDetector;
+    private final Region mTmpRegion = new Region();
+
+    private final PointF mDownPoint = new PointF();
+    private final Point mMaxSize = new Point();
+    private final Point mMinSize = new Point();
+    private final Rect mLastResizeBounds = new Rect();
+    private final Rect mUserResizeBounds = new Rect();
+    private final Rect mLastDownBounds = new Rect();
+    private final Rect mDragCornerSize = new Rect();
+    private final Rect mTmpTopLeftCorner = new Rect();
+    private final Rect mTmpTopRightCorner = new Rect();
+    private final Rect mTmpBottomLeftCorner = new Rect();
+    private final Rect mTmpBottomRightCorner = new Rect();
+    private final Rect mDisplayBounds = new Rect();
+    private final Function<Rect, Rect> mMovementBoundsSupplier;
+    private final Runnable mUpdateMovementBoundsRunnable;
+
+    private int mDelta;
+    private float mTouchSlop;
+    private boolean mAllowGesture;
+    private boolean mIsAttached;
+    private boolean mIsEnabled;
+    private boolean mEnablePinchResize;
+    private boolean mIsSysUiStateValid;
+    private boolean mThresholdCrossed;
+    private boolean mUsingPinchToZoom = false;
+    private float mScaleFactor = STARTING_SCALE_FACTOR;
+
+    private InputMonitor mInputMonitor;
+    private InputEventReceiver mInputEventReceiver;
+    private PipTaskOrganizer mPipTaskOrganizer;
+    private PipMenuActivityController mPipMenuActivityController;
+    private PipUiEventLogger mPipUiEventLogger;
+
+    private int mCtrlType;
+
+    public PipResizeGestureHandler(Context context, PipBoundsHandler pipBoundsHandler,
+            PipMotionHelper motionHelper, PipTaskOrganizer pipTaskOrganizer,
+            Function<Rect, Rect> movementBoundsSupplier, Runnable updateMovementBoundsRunnable,
+            PipUiEventLogger pipUiEventLogger, PipMenuActivityController menuActivityController) {
+        mContext = context;
+        mDisplayId = context.getDisplayId();
+        mMainExecutor = context.getMainExecutor();
+        mPipBoundsHandler = pipBoundsHandler;
+        mMotionHelper = motionHelper;
+        mPipTaskOrganizer = pipTaskOrganizer;
+        mMovementBoundsSupplier = movementBoundsSupplier;
+        mUpdateMovementBoundsRunnable = updateMovementBoundsRunnable;
+        mPipMenuActivityController = menuActivityController;
+        mPipUiEventLogger = pipUiEventLogger;
+
+        context.getDisplay().getRealSize(mMaxSize);
+        reloadResources();
+
+        mScaleGestureDetector = new ScaleGestureDetector(context,
+                new ScaleGestureDetector.OnScaleGestureListener() {
+                    @Override
+                    public boolean onScale(ScaleGestureDetector detector) {
+                        mScaleFactor *= detector.getScaleFactor();
+
+                        if (!mThresholdCrossed
+                                && (mScaleFactor > (STARTING_SCALE_FACTOR + PINCH_THRESHOLD)
+                                || mScaleFactor < (STARTING_SCALE_FACTOR - PINCH_THRESHOLD))) {
+                            mThresholdCrossed = true;
+                            mInputMonitor.pilferPointers();
+                        }
+                        if (mThresholdCrossed) {
+                            int height = Math.min(mMaxSize.y, Math.max(mMinSize.y,
+                                    (int) (mScaleFactor * mLastDownBounds.height())));
+                            int width = Math.min(mMaxSize.x, Math.max(mMinSize.x,
+                                    (int) (mScaleFactor * mLastDownBounds.width())));
+                            int top, bottom, left, right;
+
+                            if ((mCtrlType & CTRL_TOP) != 0) {
+                                top = mLastDownBounds.bottom - height;
+                                bottom = mLastDownBounds.bottom;
+                            } else {
+                                top = mLastDownBounds.top;
+                                bottom = mLastDownBounds.top + height;
+                            }
+
+                            if ((mCtrlType & CTRL_LEFT) != 0) {
+                                left = mLastDownBounds.right - width;
+                                right = mLastDownBounds.right;
+                            } else {
+                                left = mLastDownBounds.left;
+                                right = mLastDownBounds.left + width;
+                            }
+
+                            mLastResizeBounds.set(left, top, right, bottom);
+                            mPipTaskOrganizer.scheduleUserResizePip(mLastDownBounds,
+                                    mLastResizeBounds,
+                                    null);
+                        }
+                        return true;
+                    }
+
+                    @Override
+                    public boolean onScaleBegin(ScaleGestureDetector detector) {
+                        setCtrlTypeForPinchToZoom();
+                        return true;
+                    }
+
+                    @Override
+                    public void onScaleEnd(ScaleGestureDetector detector) {
+                        mScaleFactor = STARTING_SCALE_FACTOR;
+                        finishResize();
+                    }
+                });
+
+        mEnablePinchResize = DeviceConfig.getBoolean(
+                DeviceConfig.NAMESPACE_SYSTEMUI,
+                PIP_PINCH_RESIZE,
+                /* defaultValue = */ false);
+        DeviceConfig.addOnPropertiesChangedListener(DeviceConfig.NAMESPACE_SYSTEMUI, mMainExecutor,
+                new DeviceConfig.OnPropertiesChangedListener() {
+                    @Override
+                    public void onPropertiesChanged(DeviceConfig.Properties properties) {
+                        if (properties.getKeyset().contains(PIP_PINCH_RESIZE)) {
+                            mEnablePinchResize = properties.getBoolean(
+                                    PIP_PINCH_RESIZE, /* defaultValue = */ false);
+                        }
+                    }
+                });
+    }
+
+    public void onConfigurationChanged() {
+        reloadResources();
+    }
+
+    /**
+     * Called when SysUI state changed.
+     *
+     * @param isSysUiStateValid Is SysUI valid or not.
+     */
+    public void onSystemUiStateChanged(boolean isSysUiStateValid) {
+        mIsSysUiStateValid = isSysUiStateValid;
+    }
+
+    private void reloadResources() {
+        final Resources res = mContext.getResources();
+        mDelta = res.getDimensionPixelSize(R.dimen.pip_resize_edge_size);
+        mTouchSlop = ViewConfiguration.get(mContext).getScaledTouchSlop();
+    }
+
+    private void resetDragCorners() {
+        mDragCornerSize.set(0, 0, mDelta, mDelta);
+        mTmpTopLeftCorner.set(mDragCornerSize);
+        mTmpTopRightCorner.set(mDragCornerSize);
+        mTmpBottomLeftCorner.set(mDragCornerSize);
+        mTmpBottomRightCorner.set(mDragCornerSize);
+    }
+
+    private void disposeInputChannel() {
+        if (mInputEventReceiver != null) {
+            mInputEventReceiver.dispose();
+            mInputEventReceiver = null;
+        }
+        if (mInputMonitor != null) {
+            mInputMonitor.dispose();
+            mInputMonitor = null;
+        }
+    }
+
+    void onActivityPinned() {
+        mIsAttached = true;
+        updateIsEnabled();
+    }
+
+    void onActivityUnpinned() {
+        mIsAttached = false;
+        mUserResizeBounds.setEmpty();
+        updateIsEnabled();
+    }
+
+    private void updateIsEnabled() {
+        boolean isEnabled = mIsAttached;
+        if (isEnabled == mIsEnabled) {
+            return;
+        }
+        mIsEnabled = isEnabled;
+        disposeInputChannel();
+
+        if (mIsEnabled) {
+            // Register input event receiver
+            mInputMonitor = InputManager.getInstance().monitorGestureInput(
+                    "pip-resize", mDisplayId);
+            mInputEventReceiver = new SysUiInputEventReceiver(
+                    mInputMonitor.getInputChannel(), Looper.getMainLooper());
+        }
+    }
+
+    private void onInputEvent(InputEvent ev) {
+        if (ev instanceof MotionEvent) {
+            if (mUsingPinchToZoom) {
+                mScaleGestureDetector.onTouchEvent((MotionEvent) ev);
+            } else {
+                onDragCornerResize((MotionEvent) ev);
+            }
+        }
+    }
+
+    /**
+     * Check whether the current x,y coordinate is within the region in which drag-resize should
+     * start.
+     * This consists of 4 small squares on the 4 corners of the PIP window, a quarter of which
+     * overlaps with the PIP window while the rest goes outside of the PIP window.
+     *  _ _           _ _
+     * |_|_|_________|_|_|
+     * |_|_|         |_|_|
+     *   |     PIP     |
+     *   |   WINDOW    |
+     *  _|_           _|_
+     * |_|_|_________|_|_|
+     * |_|_|         |_|_|
+     */
+    public boolean isWithinTouchRegion(int x, int y) {
+        final Rect currentPipBounds = mMotionHelper.getBounds();
+        if (currentPipBounds == null) {
+            return false;
+        }
+        resetDragCorners();
+        mTmpTopLeftCorner.offset(currentPipBounds.left - mDelta / 2,
+                currentPipBounds.top - mDelta /  2);
+        mTmpTopRightCorner.offset(currentPipBounds.right - mDelta / 2,
+                currentPipBounds.top - mDelta /  2);
+        mTmpBottomLeftCorner.offset(currentPipBounds.left - mDelta / 2,
+                currentPipBounds.bottom - mDelta /  2);
+        mTmpBottomRightCorner.offset(currentPipBounds.right - mDelta / 2,
+                currentPipBounds.bottom - mDelta /  2);
+
+        mTmpRegion.setEmpty();
+        mTmpRegion.op(mTmpTopLeftCorner, Region.Op.UNION);
+        mTmpRegion.op(mTmpTopRightCorner, Region.Op.UNION);
+        mTmpRegion.op(mTmpBottomLeftCorner, Region.Op.UNION);
+        mTmpRegion.op(mTmpBottomRightCorner, Region.Op.UNION);
+
+        return mTmpRegion.contains(x, y);
+    }
+
+    public boolean isUsingPinchToZoom() {
+        return mEnablePinchResize;
+    }
+
+    public boolean willStartResizeGesture(MotionEvent ev) {
+        if (isInValidSysUiState()) {
+            switch (ev.getActionMasked()) {
+                case MotionEvent.ACTION_DOWN:
+                    // Always pass the DOWN event to the ScaleGestureDetector
+                    mScaleGestureDetector.onTouchEvent(ev);
+                    if (isWithinTouchRegion((int) ev.getRawX(), (int) ev.getRawY())) {
+                        return true;
+                    }
+                    break;
+
+                case MotionEvent.ACTION_POINTER_DOWN:
+                    if (mEnablePinchResize && ev.getPointerCount() == 2) {
+                        mUsingPinchToZoom = true;
+                        return true;
+                    }
+                    break;
+
+                default:
+                    break;
+            }
+        }
+        return false;
+    }
+
+    private void setCtrlTypeForPinchToZoom() {
+        final Rect currentPipBounds = mMotionHelper.getBounds();
+        mLastDownBounds.set(mMotionHelper.getBounds());
+
+        Rect movementBounds = mMovementBoundsSupplier.apply(currentPipBounds);
+        mDisplayBounds.set(movementBounds.left,
+                movementBounds.top,
+                movementBounds.right + currentPipBounds.width(),
+                movementBounds.bottom + currentPipBounds.height());
+
+        if (currentPipBounds.left == mDisplayBounds.left) {
+            mCtrlType |= CTRL_RIGHT;
+        } else {
+            mCtrlType |= CTRL_LEFT;
+        }
+
+        if (currentPipBounds.top > mDisplayBounds.top + mDisplayBounds.height()) {
+            mCtrlType |= CTRL_TOP;
+        } else {
+            mCtrlType |= CTRL_BOTTOM;
+        }
+    }
+
+    private void setCtrlType(int x, int y) {
+        final Rect currentPipBounds = mMotionHelper.getBounds();
+
+        Rect movementBounds = mMovementBoundsSupplier.apply(currentPipBounds);
+        mDisplayBounds.set(movementBounds.left,
+                movementBounds.top,
+                movementBounds.right + currentPipBounds.width(),
+                movementBounds.bottom + currentPipBounds.height());
+
+        if (mTmpTopLeftCorner.contains(x, y) && currentPipBounds.top != mDisplayBounds.top
+                && currentPipBounds.left != mDisplayBounds.left) {
+            mCtrlType |= CTRL_LEFT;
+            mCtrlType |= CTRL_TOP;
+        }
+        if (mTmpTopRightCorner.contains(x, y) && currentPipBounds.top != mDisplayBounds.top
+                && currentPipBounds.right != mDisplayBounds.right) {
+            mCtrlType |= CTRL_RIGHT;
+            mCtrlType |= CTRL_TOP;
+        }
+        if (mTmpBottomRightCorner.contains(x, y)
+                && currentPipBounds.bottom != mDisplayBounds.bottom
+                && currentPipBounds.right != mDisplayBounds.right) {
+            mCtrlType |= CTRL_RIGHT;
+            mCtrlType |= CTRL_BOTTOM;
+        }
+        if (mTmpBottomLeftCorner.contains(x, y)
+                && currentPipBounds.bottom != mDisplayBounds.bottom
+                && currentPipBounds.left != mDisplayBounds.left) {
+            mCtrlType |= CTRL_LEFT;
+            mCtrlType |= CTRL_BOTTOM;
+        }
+    }
+
+    private boolean isInValidSysUiState() {
+        return mIsSysUiStateValid;
+    }
+
+    private void onDragCornerResize(MotionEvent ev) {
+        int action = ev.getActionMasked();
+        float x = ev.getX();
+        float y = ev.getY();
+        if (action == MotionEvent.ACTION_DOWN) {
+            final Rect currentPipBounds = mMotionHelper.getBounds();
+            mLastResizeBounds.setEmpty();
+            mAllowGesture = isInValidSysUiState() && isWithinTouchRegion((int) x, (int) y);
+            if (mAllowGesture) {
+                setCtrlType((int) x, (int) y);
+                mDownPoint.set(x, y);
+                mLastDownBounds.set(mMotionHelper.getBounds());
+            }
+            if (!currentPipBounds.contains((int) ev.getX(), (int) ev.getY())
+                    && mPipMenuActivityController.isMenuVisible()) {
+                mPipMenuActivityController.hideMenu();
+            }
+
+        } else if (mAllowGesture) {
+            switch (action) {
+                case MotionEvent.ACTION_POINTER_DOWN:
+                    // We do not support multi touch for resizing via drag
+                    mAllowGesture = false;
+                    break;
+                case MotionEvent.ACTION_MOVE:
+                    // Capture inputs
+                    if (!mThresholdCrossed
+                            && Math.hypot(x - mDownPoint.x, y - mDownPoint.y) > mTouchSlop) {
+                        mThresholdCrossed = true;
+                        // Reset the down to begin resizing from this point
+                        mDownPoint.set(x, y);
+                        mInputMonitor.pilferPointers();
+                    }
+                    if (mThresholdCrossed) {
+                        if (mPipMenuActivityController.isMenuVisible()) {
+                            mPipMenuActivityController.hideMenuWithoutResize();
+                            mPipMenuActivityController.hideMenu();
+                        }
+                        final Rect currentPipBounds = mMotionHelper.getBounds();
+                        mLastResizeBounds.set(TaskResizingAlgorithm.resizeDrag(x, y,
+                                mDownPoint.x, mDownPoint.y, currentPipBounds, mCtrlType, mMinSize.x,
+                                mMinSize.y, mMaxSize, true,
+                                mLastDownBounds.width() > mLastDownBounds.height()));
+                        mPipBoundsHandler.transformBoundsToAspectRatio(mLastResizeBounds);
+                        mPipTaskOrganizer.scheduleUserResizePip(mLastDownBounds, mLastResizeBounds,
+                                null);
+                    }
+                    break;
+                case MotionEvent.ACTION_UP:
+                case MotionEvent.ACTION_CANCEL:
+                    finishResize();
+                    break;
+            }
+        }
+    }
+
+    private void finishResize() {
+        if (!mLastResizeBounds.isEmpty()) {
+            mUserResizeBounds.set(mLastResizeBounds);
+            mPipTaskOrganizer.scheduleFinishResizePip(mLastResizeBounds,
+                    (Rect bounds) -> {
+                        new Handler(Looper.getMainLooper()).post(() -> {
+                            mMotionHelper.synchronizePinnedStackBounds();
+                            mUpdateMovementBoundsRunnable.run();
+                            resetState();
+                        });
+                    });
+            mPipUiEventLogger.log(
+                    PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_RESIZE);
+        } else {
+            resetState();
+        }
+    }
+
+    private void resetState() {
+        mCtrlType = CTRL_NONE;
+        mUsingPinchToZoom = false;
+        mAllowGesture = false;
+        mThresholdCrossed = false;
+    }
+
+    void setUserResizeBounds(Rect bounds) {
+        mUserResizeBounds.set(bounds);
+    }
+
+    void invalidateUserResizeBounds() {
+        mUserResizeBounds.setEmpty();
+    }
+
+    Rect getUserResizeBounds() {
+        return mUserResizeBounds;
+    }
+
+    @VisibleForTesting public void updateMaxSize(int maxX, int maxY) {
+        mMaxSize.set(maxX, maxY);
+    }
+
+    @VisibleForTesting public void updateMinSize(int minX, int minY) {
+        mMinSize.set(minX, minY);
+    }
+
+    public void dump(PrintWriter pw, String prefix) {
+        final String innerPrefix = prefix + "  ";
+        pw.println(prefix + TAG);
+        pw.println(innerPrefix + "mAllowGesture=" + mAllowGesture);
+        pw.println(innerPrefix + "mIsAttached=" + mIsAttached);
+        pw.println(innerPrefix + "mIsEnabled=" + mIsEnabled);
+        pw.println(innerPrefix + "mEnablePinchResize=" + mEnablePinchResize);
+        pw.println(innerPrefix + "mThresholdCrossed=" + mThresholdCrossed);
+    }
+
+    class SysUiInputEventReceiver extends BatchedInputEventReceiver {
+        SysUiInputEventReceiver(InputChannel channel, Looper looper) {
+            super(channel, looper, Choreographer.getSfInstance());
+        }
+
+        public void onInputEvent(InputEvent event) {
+            PipResizeGestureHandler.this.onInputEvent(event);
+            finishInputEvent(event, true);
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchGesture.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchGesture.java
new file mode 100644
index 0000000..1a3cc8b
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchGesture.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+/**
+ * A generic interface for a touch gesture.
+ */
+public abstract class PipTouchGesture {
+
+    /**
+     * Handle the touch down.
+     */
+    public void onDown(PipTouchState touchState) {}
+
+    /**
+     * Handle the touch move, and return whether the event was consumed.
+     */
+    public boolean onMove(PipTouchState touchState) {
+        return false;
+    }
+
+    /**
+     * Handle the touch up, and return whether the gesture was consumed.
+     */
+    public boolean onUp(PipTouchState touchState) {
+        return false;
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchHandler.java
new file mode 100644
index 0000000..6b31772
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchHandler.java
@@ -0,0 +1,1135 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+import static com.android.internal.config.sysui.SystemUiDeviceConfigFlags.PIP_STASHING;
+import static com.android.wm.shell.pip.PipAnimationController.TRANSITION_DIRECTION_TO_PIP;
+import static com.android.wm.shell.pip.phone.PipMenuActivityController.MENU_STATE_CLOSE;
+import static com.android.wm.shell.pip.phone.PipMenuActivityController.MENU_STATE_FULL;
+import static com.android.wm.shell.pip.phone.PipMenuActivityController.MENU_STATE_NONE;
+
+import android.annotation.SuppressLint;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.PixelFormat;
+import android.graphics.Point;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.graphics.drawable.TransitionDrawable;
+import android.os.Handler;
+import android.os.RemoteException;
+import android.provider.DeviceConfig;
+import android.util.Log;
+import android.util.Size;
+import android.view.Gravity;
+import android.view.IPinnedStackController;
+import android.view.InputEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityManager;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityWindowInfo;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.wm.shell.R;
+import com.android.wm.shell.animation.PhysicsAnimator;
+import com.android.wm.shell.common.DismissCircleView;
+import com.android.wm.shell.common.FloatingContentCoordinator;
+import com.android.wm.shell.common.magnetictarget.MagnetizedObject;
+import com.android.wm.shell.pip.PipAnimationController;
+import com.android.wm.shell.pip.PipBoundsHandler;
+import com.android.wm.shell.pip.PipTaskOrganizer;
+import com.android.wm.shell.pip.PipUiEventLogger;
+
+import java.io.PrintWriter;
+
+import kotlin.Unit;
+
+/**
+ * Manages all the touch handling for PIP on the Phone, including moving, dismissing and expanding
+ * the PIP.
+ */
+public class PipTouchHandler {
+    private static final String TAG = "PipTouchHandler";
+
+    /** Duration of the dismiss scrim fading in/out. */
+    private static final int DISMISS_TRANSITION_DURATION_MS = 200;
+
+    /* The multiplier to apply scale the target size by when applying the magnetic field radius */
+    private static final float MAGNETIC_FIELD_RADIUS_MULTIPLIER = 1.25f;
+
+    // Allow dragging the PIP to a location to close it
+    private final boolean mEnableDismissDragToEdge;
+    // Allow PIP to resize to a slightly bigger state upon touch
+    private final boolean mEnableResize;
+    private final Context mContext;
+    private final WindowManager mWindowManager;
+    private final PipBoundsHandler mPipBoundsHandler;
+    private final PipUiEventLogger mPipUiEventLogger;
+
+    private PipResizeGestureHandler mPipResizeGestureHandler;
+    private IPinnedStackController mPinnedStackController;
+
+    private final PipMenuActivityController mMenuController;
+    private final AccessibilityManager mAccessibilityManager;
+    private boolean mShowPipMenuOnAnimationEnd = false;
+
+    /**
+     * Whether PIP stash is enabled or not. When enabled, if at the time of fling-release the
+     * PIP bounds is outside the left/right edge of the screen, it will be shown in "stashed" mode,
+     * where PIP will only show partially.
+     */
+    private boolean mEnableStash = false;
+
+    /**
+     * MagnetizedObject wrapper for PIP. This allows the magnetic target library to locate and move
+     * PIP.
+     */
+    private MagnetizedObject<Rect> mMagnetizedPip;
+
+    /**
+     * Container for the dismiss circle, so that it can be animated within the container via
+     * translation rather than within the WindowManager via slow layout animations.
+     */
+    private ViewGroup mTargetViewContainer;
+
+    /** Circle view used to render the dismiss target. */
+    private DismissCircleView mTargetView;
+
+    /**
+     * MagneticTarget instance wrapping the target view and allowing us to set its magnetic radius.
+     */
+    private MagnetizedObject.MagneticTarget mMagneticTarget;
+
+    /** PhysicsAnimator instance for animating the dismiss target in/out. */
+    private PhysicsAnimator<View> mMagneticTargetAnimator;
+
+    /** Default configuration to use for springing the dismiss target in/out. */
+    private final PhysicsAnimator.SpringConfig mTargetSpringConfig =
+            new PhysicsAnimator.SpringConfig(
+                    SpringForce.STIFFNESS_LOW, SpringForce.DAMPING_RATIO_LOW_BOUNCY);
+
+    // The current movement bounds
+    private Rect mMovementBounds = new Rect();
+
+    // The reference inset bounds, used to determine the dismiss fraction
+    private Rect mInsetBounds = new Rect();
+    // The reference bounds used to calculate the normal/expanded target bounds
+    private Rect mNormalBounds = new Rect();
+    @VisibleForTesting public Rect mNormalMovementBounds = new Rect();
+    private Rect mExpandedBounds = new Rect();
+    @VisibleForTesting public Rect mExpandedMovementBounds = new Rect();
+    private int mExpandedShortestEdgeSize;
+
+    // Used to workaround an issue where the WM rotation happens before we are notified, allowing
+    // us to send stale bounds
+    private int mDeferResizeToNormalBoundsUntilRotation = -1;
+    private int mDisplayRotation;
+
+    /**
+     * Runnable that can be posted delayed to show the target. This needs to be saved as a member
+     * variable so we can pass it to removeCallbacks.
+     */
+    private Runnable mShowTargetAction = this::showDismissTargetMaybe;
+
+    private Handler mHandler = new Handler();
+
+    // Behaviour states
+    private int mMenuState = MENU_STATE_NONE;
+    private boolean mIsImeShowing;
+    private int mImeHeight;
+    private int mImeOffset;
+    private int mDismissAreaHeight;
+    private boolean mIsShelfShowing;
+    private int mShelfHeight;
+    private int mMovementBoundsExtraOffsets;
+    private int mBottomOffsetBufferPx;
+    private float mSavedSnapFraction = -1f;
+    private boolean mSendingHoverAccessibilityEvents;
+    private boolean mMovementWithinDismiss;
+    private PipAccessibilityInteractionConnection mConnection;
+
+    // Touch state
+    private final PipTouchState mTouchState;
+    private final FloatingContentCoordinator mFloatingContentCoordinator;
+    private PipMotionHelper mMotionHelper;
+    private PipTouchGesture mGesture;
+
+    // Temp vars
+    private final Rect mTmpBounds = new Rect();
+
+    /**
+     * A listener for the PIP menu activity.
+     */
+    private class PipMenuListener implements PipMenuActivityController.Listener {
+        @Override
+        public void onPipMenuStateChanged(int menuState, boolean resize, Runnable callback) {
+            setMenuState(menuState, resize, callback);
+        }
+
+        @Override
+        public void onPipExpand() {
+            mMotionHelper.expandLeavePip();
+        }
+
+        @Override
+        public void onPipDismiss() {
+            mPipUiEventLogger.log(PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_TAP_TO_REMOVE);
+            mTouchState.removeDoubleTapTimeoutCallback();
+            mMotionHelper.dismissPip();
+        }
+
+        @Override
+        public void onPipShowMenu() {
+            mMenuController.showMenu(MENU_STATE_FULL, mMotionHelper.getBounds(),
+                    true /* allowMenuTimeout */, willResizeMenu(), shouldShowResizeHandle());
+        }
+    }
+
+    @SuppressLint("InflateParams")
+    public PipTouchHandler(Context context,
+            PipMenuActivityController menuController,
+            PipBoundsHandler pipBoundsHandler,
+            PipTaskOrganizer pipTaskOrganizer,
+            FloatingContentCoordinator floatingContentCoordinator,
+            PipUiEventLogger pipUiEventLogger) {
+        // Initialize the Pip input consumer
+        mContext = context;
+        mAccessibilityManager = context.getSystemService(AccessibilityManager.class);
+        mPipBoundsHandler = pipBoundsHandler;
+        mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
+        mMenuController = menuController;
+        mMenuController.addListener(new PipMenuListener());
+        mGesture = new DefaultPipTouchGesture();
+        mMotionHelper = new PipMotionHelper(mContext, pipTaskOrganizer, mMenuController,
+                mPipBoundsHandler.getSnapAlgorithm(), floatingContentCoordinator);
+        mPipResizeGestureHandler =
+                new PipResizeGestureHandler(context, pipBoundsHandler, mMotionHelper,
+                        pipTaskOrganizer, this::getMovementBounds,
+                        this::updateMovementBounds, pipUiEventLogger, menuController);
+        mTouchState = new PipTouchState(ViewConfiguration.get(context), mHandler,
+                () -> mMenuController.showMenuWithDelay(MENU_STATE_FULL, mMotionHelper.getBounds(),
+                        true /* allowMenuTimeout */, willResizeMenu(), shouldShowResizeHandle()),
+                menuController::hideMenu);
+
+        Resources res = context.getResources();
+        mEnableDismissDragToEdge = res.getBoolean(R.bool.config_pipEnableDismissDragToEdge);
+        mEnableResize = res.getBoolean(R.bool.config_pipEnableResizeForMenu);
+        reloadResources();
+
+        mFloatingContentCoordinator = floatingContentCoordinator;
+        mConnection = new PipAccessibilityInteractionConnection(mContext, mMotionHelper,
+                pipTaskOrganizer, mPipBoundsHandler.getSnapAlgorithm(),
+                this::onAccessibilityShowMenu, this::updateMovementBounds, mHandler);
+
+        mPipUiEventLogger = pipUiEventLogger;
+
+        mTargetView = new DismissCircleView(context);
+        mTargetViewContainer = new FrameLayout(context);
+        mTargetViewContainer.setBackgroundDrawable(
+                context.getDrawable(R.drawable.floating_dismiss_gradient_transition));
+        mTargetViewContainer.setClipChildren(false);
+        mTargetViewContainer.addView(mTargetView);
+
+        mMagnetizedPip = mMotionHelper.getMagnetizedPip();
+        mMagneticTarget = mMagnetizedPip.addTarget(mTargetView, 0);
+        updateMagneticTargetSize();
+
+        mMagnetizedPip.setAnimateStuckToTarget(
+                (target, velX, velY, flung, after) -> {
+                    if (mEnableDismissDragToEdge) {
+                        mMotionHelper.animateIntoDismissTarget(target, velX, velY, flung, after);
+                    }
+                    return Unit.INSTANCE;
+                });
+        mMagnetizedPip.setMagnetListener(new MagnetizedObject.MagnetListener() {
+            @Override
+            public void onStuckToTarget(@NonNull MagnetizedObject.MagneticTarget target) {
+                // Show the dismiss target, in case the initial touch event occurred within the
+                // magnetic field radius.
+                if (mEnableDismissDragToEdge) {
+                    showDismissTargetMaybe();
+                }
+            }
+
+            @Override
+            public void onUnstuckFromTarget(@NonNull MagnetizedObject.MagneticTarget target,
+                    float velX, float velY, boolean wasFlungOut) {
+                if (wasFlungOut) {
+                    mMotionHelper.flingToSnapTarget(velX, velY, null, null);
+                    hideDismissTarget();
+                } else {
+                    mMotionHelper.setSpringingToTouch(true);
+                }
+            }
+
+            @Override
+            public void onReleasedInTarget(@NonNull MagnetizedObject.MagneticTarget target) {
+                mMotionHelper.notifyDismissalPending();
+
+                mHandler.post(() -> {
+                    mMotionHelper.animateDismiss();
+                    hideDismissTarget();
+                });
+
+                mPipUiEventLogger.log(
+                        PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_DRAG_TO_REMOVE);
+            }
+        });
+
+        mMagneticTargetAnimator = PhysicsAnimator.getInstance(mTargetView);
+
+        mEnableStash = DeviceConfig.getBoolean(
+                DeviceConfig.NAMESPACE_SYSTEMUI,
+                PIP_STASHING,
+                /* defaultValue = */ false);
+        DeviceConfig.addOnPropertiesChangedListener(DeviceConfig.NAMESPACE_SYSTEMUI,
+                context.getMainExecutor(),
+                properties -> {
+                    if (properties.getKeyset().contains(PIP_STASHING)) {
+                        mEnableStash = properties.getBoolean(
+                                PIP_STASHING, /* defaultValue = */ false);
+                    }
+                });
+    }
+
+    private void reloadResources() {
+        final Resources res = mContext.getResources();
+        mBottomOffsetBufferPx = res.getDimensionPixelSize(R.dimen.pip_bottom_offset_buffer);
+        mExpandedShortestEdgeSize = res.getDimensionPixelSize(
+                R.dimen.pip_expanded_shortest_edge_size);
+        mImeOffset = res.getDimensionPixelSize(R.dimen.pip_ime_offset);
+        mDismissAreaHeight = res.getDimensionPixelSize(R.dimen.floating_dismiss_gradient_height);
+        updateMagneticTargetSize();
+    }
+
+    private void updateMagneticTargetSize() {
+        if (mTargetView == null) {
+            return;
+        }
+
+        final Resources res = mContext.getResources();
+        final int targetSize = res.getDimensionPixelSize(R.dimen.dismiss_circle_size);
+        final FrameLayout.LayoutParams newParams =
+                new FrameLayout.LayoutParams(targetSize, targetSize);
+        newParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM;
+        newParams.bottomMargin = mContext.getResources().getDimensionPixelSize(
+                R.dimen.floating_dismiss_bottom_margin);
+        mTargetView.setLayoutParams(newParams);
+
+        // Set the magnetic field radius equal to the target size from the center of the target
+        mMagneticTarget.setMagneticFieldRadiusPx(
+                (int) (targetSize * MAGNETIC_FIELD_RADIUS_MULTIPLIER));
+    }
+
+    private boolean shouldShowResizeHandle() {
+        return false;
+    }
+
+    public void setTouchGesture(PipTouchGesture gesture) {
+        mGesture = gesture;
+    }
+
+    public void setTouchEnabled(boolean enabled) {
+        mTouchState.setAllowTouches(enabled);
+    }
+
+    public void showPictureInPictureMenu() {
+        // Only show the menu if the user isn't currently interacting with the PiP
+        if (!mTouchState.isUserInteracting()) {
+            mMenuController.showMenu(MENU_STATE_FULL, mMotionHelper.getBounds(),
+                    false /* allowMenuTimeout */, willResizeMenu(),
+                    shouldShowResizeHandle());
+        }
+    }
+
+    public void onActivityPinned() {
+        createOrUpdateDismissTarget();
+
+        mShowPipMenuOnAnimationEnd = true;
+        mPipResizeGestureHandler.onActivityPinned();
+        mFloatingContentCoordinator.onContentAdded(mMotionHelper);
+    }
+
+    public void onActivityUnpinned(ComponentName topPipActivity) {
+        if (topPipActivity == null) {
+            // Clean up state after the last PiP activity is removed
+            cleanUpDismissTarget();
+
+            mFloatingContentCoordinator.onContentRemoved(mMotionHelper);
+        }
+        mPipResizeGestureHandler.onActivityUnpinned();
+    }
+
+    public void onPinnedStackAnimationEnded(
+            @PipAnimationController.TransitionDirection int direction) {
+        // Always synchronize the motion helper bounds once PiP animations finish
+        mMotionHelper.synchronizePinnedStackBounds();
+        updateMovementBounds();
+        if (direction == TRANSITION_DIRECTION_TO_PIP) {
+            // Set the initial bounds as the user resize bounds.
+            mPipResizeGestureHandler.setUserResizeBounds(mMotionHelper.getBounds());
+        }
+
+        if (mShowPipMenuOnAnimationEnd) {
+            mMenuController.showMenu(MENU_STATE_CLOSE, mMotionHelper.getBounds(),
+                    true /* allowMenuTimeout */, false /* willResizeMenu */,
+                    shouldShowResizeHandle());
+            mShowPipMenuOnAnimationEnd = false;
+        }
+    }
+
+    public void onConfigurationChanged() {
+        mPipResizeGestureHandler.onConfigurationChanged();
+        mMotionHelper.synchronizePinnedStackBounds();
+        reloadResources();
+
+        // Recreate the dismiss target for the new orientation.
+        createOrUpdateDismissTarget();
+    }
+
+    public void onImeVisibilityChanged(boolean imeVisible, int imeHeight) {
+        mIsImeShowing = imeVisible;
+        mImeHeight = imeHeight;
+    }
+
+    public void onShelfVisibilityChanged(boolean shelfVisible, int shelfHeight) {
+        mIsShelfShowing = shelfVisible;
+        mShelfHeight = shelfHeight;
+    }
+
+    /**
+     * Called when SysUI state changed.
+     *
+     * @param isSysUiStateValid Is SysUI valid or not.
+     */
+    public void onSystemUiStateChanged(boolean isSysUiStateValid) {
+        mPipResizeGestureHandler.onSystemUiStateChanged(isSysUiStateValid);
+    }
+
+    public void adjustBoundsForRotation(Rect outBounds, Rect curBounds, Rect insetBounds) {
+        final Rect toMovementBounds = new Rect();
+        mPipBoundsHandler.getSnapAlgorithm().getMovementBounds(outBounds, insetBounds,
+                toMovementBounds, 0);
+        final int prevBottom = mMovementBounds.bottom - mMovementBoundsExtraOffsets;
+        if ((prevBottom - mBottomOffsetBufferPx) <= curBounds.top) {
+            outBounds.offsetTo(outBounds.left, toMovementBounds.bottom);
+        }
+    }
+
+    /**
+     * Responds to IPinnedStackListener on resetting aspect ratio for the pinned window.
+     */
+    public void onAspectRatioChanged() {
+        mPipResizeGestureHandler.invalidateUserResizeBounds();
+    }
+
+    public void onMovementBoundsChanged(Rect insetBounds, Rect normalBounds, Rect curBounds,
+            boolean fromImeAdjustment, boolean fromShelfAdjustment, int displayRotation) {
+        // Set the user resized bounds equal to the new normal bounds in case they were
+        // invalidated (e.g. by an aspect ratio change).
+        if (mPipResizeGestureHandler.getUserResizeBounds().isEmpty()) {
+            mPipResizeGestureHandler.setUserResizeBounds(normalBounds);
+        }
+
+        final int bottomOffset = mIsImeShowing ? mImeHeight : 0;
+        final boolean fromDisplayRotationChanged = (mDisplayRotation != displayRotation);
+        if (fromDisplayRotationChanged) {
+            mTouchState.reset();
+        }
+
+        // Re-calculate the expanded bounds
+        mNormalBounds.set(normalBounds);
+        Rect normalMovementBounds = new Rect();
+        mPipBoundsHandler.getSnapAlgorithm().getMovementBounds(mNormalBounds, insetBounds,
+                normalMovementBounds, bottomOffset);
+
+        if (mMovementBounds.isEmpty()) {
+            // mMovementBounds is not initialized yet and a clean movement bounds without
+            // bottom offset shall be used later in this function.
+            mPipBoundsHandler.getSnapAlgorithm().getMovementBounds(curBounds, insetBounds,
+                    mMovementBounds, 0 /* bottomOffset */);
+        }
+
+        // Calculate the expanded size
+        float aspectRatio = (float) normalBounds.width() / normalBounds.height();
+        Point displaySize = new Point();
+        mContext.getDisplay().getRealSize(displaySize);
+        Size expandedSize = mPipBoundsHandler.getSnapAlgorithm().getSizeForAspectRatio(aspectRatio,
+                mExpandedShortestEdgeSize, displaySize.x, displaySize.y);
+        mExpandedBounds.set(0, 0, expandedSize.getWidth(), expandedSize.getHeight());
+        Rect expandedMovementBounds = new Rect();
+        mPipBoundsHandler.getSnapAlgorithm().getMovementBounds(mExpandedBounds, insetBounds,
+                expandedMovementBounds, bottomOffset);
+
+        mPipResizeGestureHandler.updateMinSize(mNormalBounds.width(), mNormalBounds.height());
+        mPipResizeGestureHandler.updateMaxSize(mExpandedBounds.width(), mExpandedBounds.height());
+
+        // The extra offset does not really affect the movement bounds, but are applied based on the
+        // current state (ime showing, or shelf offset) when we need to actually shift
+        int extraOffset = Math.max(
+                mIsImeShowing ? mImeOffset : 0,
+                !mIsImeShowing && mIsShelfShowing ? mShelfHeight : 0);
+
+        // If this is from an IME or shelf adjustment, then we should move the PiP so that it is not
+        // occluded by the IME or shelf.
+        if (fromImeAdjustment || fromShelfAdjustment) {
+            if (mTouchState.isUserInteracting()) {
+                // Defer the update of the current movement bounds until after the user finishes
+                // touching the screen
+            } else {
+                final boolean isExpanded = mMenuState == MENU_STATE_FULL && willResizeMenu();
+                final Rect toMovementBounds = new Rect();
+                mPipBoundsHandler.getSnapAlgorithm().getMovementBounds(curBounds, insetBounds,
+                        toMovementBounds, mIsImeShowing ? mImeHeight : 0);
+                final int prevBottom = mMovementBounds.bottom - mMovementBoundsExtraOffsets;
+                // This is to handle landscape fullscreen IMEs, don't apply the extra offset in this
+                // case
+                final int toBottom = toMovementBounds.bottom < toMovementBounds.top
+                        ? toMovementBounds.bottom
+                        : toMovementBounds.bottom - extraOffset;
+
+                if (isExpanded) {
+                    curBounds.set(mExpandedBounds);
+                    mPipBoundsHandler.getSnapAlgorithm().applySnapFraction(curBounds,
+                            toMovementBounds, mSavedSnapFraction);
+                }
+
+                if (prevBottom < toBottom) {
+                    // The movement bounds are expanding
+                    if (curBounds.top > prevBottom - mBottomOffsetBufferPx) {
+                        mMotionHelper.animateToOffset(curBounds, toBottom - curBounds.top);
+                    }
+                } else if (prevBottom > toBottom) {
+                    // The movement bounds are shrinking
+                    if (curBounds.top > toBottom - mBottomOffsetBufferPx) {
+                        mMotionHelper.animateToOffset(curBounds, toBottom - curBounds.top);
+                    }
+                }
+            }
+        }
+
+        // Update the movement bounds after doing the calculations based on the old movement bounds
+        // above
+        mNormalMovementBounds.set(normalMovementBounds);
+        mExpandedMovementBounds.set(expandedMovementBounds);
+        mDisplayRotation = displayRotation;
+        mInsetBounds.set(insetBounds);
+        updateMovementBounds();
+        mMovementBoundsExtraOffsets = extraOffset;
+        mConnection.onMovementBoundsChanged(mNormalBounds, mExpandedBounds, mNormalMovementBounds,
+                mExpandedMovementBounds);
+
+        // If we have a deferred resize, apply it now
+        if (mDeferResizeToNormalBoundsUntilRotation == displayRotation) {
+            mMotionHelper.animateToUnexpandedState(normalBounds, mSavedSnapFraction,
+                    mNormalMovementBounds, mMovementBounds, true /* immediate */);
+            mSavedSnapFraction = -1f;
+            mDeferResizeToNormalBoundsUntilRotation = -1;
+        }
+    }
+
+    /** Adds the magnetic target view to the WindowManager so it's ready to be animated in. */
+    private void createOrUpdateDismissTarget() {
+        if (!mTargetViewContainer.isAttachedToWindow()) {
+            mHandler.removeCallbacks(mShowTargetAction);
+            mMagneticTargetAnimator.cancel();
+
+            mTargetViewContainer.setVisibility(View.INVISIBLE);
+
+            try {
+                mWindowManager.addView(mTargetViewContainer, getDismissTargetLayoutParams());
+            } catch (IllegalStateException e) {
+                // This shouldn't happen, but if the target is already added, just update its layout
+                // params.
+                mWindowManager.updateViewLayout(
+                        mTargetViewContainer, getDismissTargetLayoutParams());
+            }
+        } else {
+            mWindowManager.updateViewLayout(mTargetViewContainer, getDismissTargetLayoutParams());
+        }
+    }
+
+    /** Returns layout params for the dismiss target, using the latest display metrics. */
+    private WindowManager.LayoutParams getDismissTargetLayoutParams() {
+        final Point windowSize = new Point();
+        mWindowManager.getDefaultDisplay().getRealSize(windowSize);
+
+        final WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
+                WindowManager.LayoutParams.MATCH_PARENT,
+                mDismissAreaHeight,
+                0, windowSize.y - mDismissAreaHeight,
+                WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANEL,
+                WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
+                        | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
+                        | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
+                PixelFormat.TRANSLUCENT);
+
+        lp.setTitle("pip-dismiss-overlay");
+        lp.privateFlags |= WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS;
+        lp.setFitInsetsTypes(0 /* types */);
+
+        return lp;
+    }
+
+    /** Makes the dismiss target visible and animates it in, if it isn't already visible. */
+    private void showDismissTargetMaybe() {
+        createOrUpdateDismissTarget();
+
+        if (mTargetViewContainer.getVisibility() != View.VISIBLE) {
+
+            mTargetView.setTranslationY(mTargetViewContainer.getHeight());
+            mTargetViewContainer.setVisibility(View.VISIBLE);
+
+            // Cancel in case we were in the middle of animating it out.
+            mMagneticTargetAnimator.cancel();
+            mMagneticTargetAnimator
+                    .spring(DynamicAnimation.TRANSLATION_Y, 0f, mTargetSpringConfig)
+                    .start();
+
+            ((TransitionDrawable) mTargetViewContainer.getBackground()).startTransition(
+                    DISMISS_TRANSITION_DURATION_MS);
+        }
+    }
+
+    /** Animates the magnetic dismiss target out and then sets it to GONE. */
+    private void hideDismissTarget() {
+        mHandler.removeCallbacks(mShowTargetAction);
+        mMagneticTargetAnimator
+                .spring(DynamicAnimation.TRANSLATION_Y,
+                        mTargetViewContainer.getHeight(),
+                        mTargetSpringConfig)
+                .withEndActions(() -> mTargetViewContainer.setVisibility(View.GONE))
+                .start();
+
+        ((TransitionDrawable) mTargetViewContainer.getBackground()).reverseTransition(
+                DISMISS_TRANSITION_DURATION_MS);
+    }
+
+    /**
+     * Removes the dismiss target and cancels any pending callbacks to show it.
+     */
+    private void cleanUpDismissTarget() {
+        mHandler.removeCallbacks(mShowTargetAction);
+
+        if (mTargetViewContainer.isAttachedToWindow()) {
+            mWindowManager.removeViewImmediate(mTargetViewContainer);
+        }
+    }
+
+    /**
+     * TODO Add appropriate description
+     */
+    public void onRegistrationChanged(boolean isRegistered) {
+        mAccessibilityManager.setPictureInPictureActionReplacingConnection(isRegistered
+                ? mConnection : null);
+        if (!isRegistered && mTouchState.isUserInteracting()) {
+            // If the input consumer is unregistered while the user is interacting, then we may not
+            // get the final TOUCH_UP event, so clean up the dismiss target as well
+            cleanUpDismissTarget();
+        }
+    }
+
+    private void onAccessibilityShowMenu() {
+        mMenuController.showMenu(MENU_STATE_FULL, mMotionHelper.getBounds(),
+                true /* allowMenuTimeout */, willResizeMenu(),
+                shouldShowResizeHandle());
+    }
+
+    /**
+     * TODO Add appropriate description
+     */
+    public boolean handleTouchEvent(InputEvent inputEvent) {
+        // Skip any non motion events
+        if (!(inputEvent instanceof MotionEvent)) {
+            return true;
+        }
+        // Skip touch handling until we are bound to the controller
+        if (mPinnedStackController == null) {
+            return true;
+        }
+
+        MotionEvent ev = (MotionEvent) inputEvent;
+        if (mPipResizeGestureHandler.willStartResizeGesture(ev)) {
+            // Initialize the touch state for the gesture, but immediately reset to invalidate the
+            // gesture
+            mTouchState.onTouchEvent(ev);
+            mTouchState.reset();
+            return true;
+        }
+
+        if ((ev.getAction() == MotionEvent.ACTION_DOWN || mTouchState.isUserInteracting())
+                && mMagnetizedPip.maybeConsumeMotionEvent(ev)) {
+            // If the first touch event occurs within the magnetic field, pass the ACTION_DOWN event
+            // to the touch state. Touch state needs a DOWN event in order to later process MOVE
+            // events it'll receive if the object is dragged out of the magnetic field.
+            if (ev.getAction() == MotionEvent.ACTION_DOWN) {
+                mTouchState.onTouchEvent(ev);
+            }
+
+            // Continue tracking velocity when the object is in the magnetic field, since we want to
+            // respect touch input velocity if the object is dragged out and then flung.
+            mTouchState.addMovementToVelocityTracker(ev);
+
+            return true;
+        }
+
+        // Update the touch state
+        mTouchState.onTouchEvent(ev);
+
+        boolean shouldDeliverToMenu = mMenuState != MENU_STATE_NONE;
+
+        switch (ev.getAction()) {
+            case MotionEvent.ACTION_DOWN: {
+                mGesture.onDown(mTouchState);
+                break;
+            }
+            case MotionEvent.ACTION_MOVE: {
+                if (mGesture.onMove(mTouchState)) {
+                    break;
+                }
+
+                shouldDeliverToMenu = !mTouchState.isDragging();
+                break;
+            }
+            case MotionEvent.ACTION_UP: {
+                // Update the movement bounds again if the state has changed since the user started
+                // dragging (ie. when the IME shows)
+                updateMovementBounds();
+
+                if (mGesture.onUp(mTouchState)) {
+                    break;
+                }
+
+                // Fall through to clean up
+            }
+            case MotionEvent.ACTION_CANCEL: {
+                shouldDeliverToMenu = !mTouchState.startedDragging() && !mTouchState.isDragging();
+                mTouchState.reset();
+                break;
+            }
+            case MotionEvent.ACTION_HOVER_ENTER:
+                // If Touch Exploration is enabled, some a11y services (e.g. Talkback) is probably
+                // on and changing MotionEvents into HoverEvents.
+                // Let's not enable menu show/hide for a11y services.
+                if (!mAccessibilityManager.isTouchExplorationEnabled()) {
+                    mTouchState.removeHoverExitTimeoutCallback();
+                    mMenuController.showMenu(MENU_STATE_FULL, mMotionHelper.getBounds(),
+                            false /* allowMenuTimeout */, false /* willResizeMenu */,
+                            shouldShowResizeHandle());
+                }
+            case MotionEvent.ACTION_HOVER_MOVE: {
+                if (!shouldDeliverToMenu && !mSendingHoverAccessibilityEvents) {
+                    sendAccessibilityHoverEvent(AccessibilityEvent.TYPE_VIEW_HOVER_ENTER);
+                    mSendingHoverAccessibilityEvents = true;
+                }
+                break;
+            }
+            case MotionEvent.ACTION_HOVER_EXIT: {
+                // If Touch Exploration is enabled, some a11y services (e.g. Talkback) is probably
+                // on and changing MotionEvents into HoverEvents.
+                // Let's not enable menu show/hide for a11y services.
+                if (!mAccessibilityManager.isTouchExplorationEnabled()) {
+                    mTouchState.scheduleHoverExitTimeoutCallback();
+                }
+                if (!shouldDeliverToMenu && mSendingHoverAccessibilityEvents) {
+                    sendAccessibilityHoverEvent(AccessibilityEvent.TYPE_VIEW_HOVER_EXIT);
+                    mSendingHoverAccessibilityEvents = false;
+                }
+                break;
+            }
+        }
+
+        // Deliver the event to PipMenuActivity to handle button click if the menu has shown.
+        if (shouldDeliverToMenu) {
+            final MotionEvent cloneEvent = MotionEvent.obtain(ev);
+            // Send the cancel event and cancel menu timeout if it starts to drag.
+            if (mTouchState.startedDragging()) {
+                cloneEvent.setAction(MotionEvent.ACTION_CANCEL);
+                mMenuController.pokeMenu();
+            }
+
+            mMenuController.handlePointerEvent(cloneEvent);
+        }
+
+        return true;
+    }
+
+    private void sendAccessibilityHoverEvent(int type) {
+        if (!mAccessibilityManager.isEnabled()) {
+            return;
+        }
+
+        AccessibilityEvent event = AccessibilityEvent.obtain(type);
+        event.setImportantForAccessibility(true);
+        event.setSourceNodeId(AccessibilityNodeInfo.ROOT_NODE_ID);
+        event.setWindowId(
+                AccessibilityWindowInfo.PICTURE_IN_PICTURE_ACTION_REPLACER_WINDOW_ID);
+        mAccessibilityManager.sendAccessibilityEvent(event);
+    }
+
+    /**
+     * Updates the appearance of the menu and scrim on top of the PiP while dismissing.
+     */
+    private void updateDismissFraction() {
+        if (mMenuController != null) {
+            Rect bounds = mMotionHelper.getBounds();
+            final float target = mInsetBounds.bottom;
+            float fraction = 0f;
+            if (bounds.bottom > target) {
+                final float distance = bounds.bottom - target;
+                fraction = Math.min(distance / bounds.height(), 1f);
+            }
+            if (Float.compare(fraction, 0f) != 0 || mMenuController.isMenuVisible()) {
+                // Update if the fraction > 0, or if fraction == 0 and the menu was already visible
+                mMenuController.setDismissFraction(fraction);
+            }
+        }
+    }
+
+    /**
+     * Sets the controller to update the system of changes from user interaction.
+     */
+    void setPinnedStackController(IPinnedStackController controller) {
+        mPinnedStackController = controller;
+    }
+
+    /**
+     * Sets the menu visibility.
+     */
+    private void setMenuState(int menuState, boolean resize, Runnable callback) {
+        if (mMenuState == menuState && !resize) {
+            return;
+        }
+
+        if (menuState == MENU_STATE_FULL && mMenuState != MENU_STATE_FULL) {
+            // Save the current snap fraction and if we do not drag or move the PiP, then
+            // we store back to this snap fraction.  Otherwise, we'll reset the snap
+            // fraction and snap to the closest edge.
+            if (resize) {
+                animateToExpandedState(callback);
+            }
+        } else if (menuState == MENU_STATE_NONE && mMenuState == MENU_STATE_FULL) {
+            // Try and restore the PiP to the closest edge, using the saved snap fraction
+            // if possible
+            if (resize) {
+                if (mDeferResizeToNormalBoundsUntilRotation == -1) {
+                    // This is a very special case: when the menu is expanded and visible,
+                    // navigating to another activity can trigger auto-enter PiP, and if the
+                    // revealed activity has a forced rotation set, then the controller will get
+                    // updated with the new rotation of the display. However, at the same time,
+                    // SystemUI will try to hide the menu by creating an animation to the normal
+                    // bounds which are now stale.  In such a case we defer the animation to the
+                    // normal bounds until after the next onMovementBoundsChanged() call to get the
+                    // bounds in the new orientation
+                    try {
+                        int displayRotation = mPinnedStackController.getDisplayRotation();
+                        if (mDisplayRotation != displayRotation) {
+                            mDeferResizeToNormalBoundsUntilRotation = displayRotation;
+                        }
+                    } catch (RemoteException e) {
+                        Log.e(TAG, "Could not get display rotation from controller");
+                    }
+                }
+
+                if (mDeferResizeToNormalBoundsUntilRotation == -1) {
+                    animateToUnexpandedState(getUserResizeBounds());
+                }
+            } else {
+                mSavedSnapFraction = -1f;
+            }
+        }
+        mMenuState = menuState;
+        updateMovementBounds();
+        // If pip menu has dismissed, we should register the A11y ActionReplacingConnection for pip
+        // as well, or it can't handle a11y focus and pip menu can't perform any action.
+        onRegistrationChanged(menuState == MENU_STATE_NONE);
+        if (menuState == MENU_STATE_NONE) {
+            mPipUiEventLogger.log(PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_HIDE_MENU);
+        } else if (menuState == MENU_STATE_FULL) {
+            mPipUiEventLogger.log(PipUiEventLogger.PipUiEventEnum.PICTURE_IN_PICTURE_SHOW_MENU);
+        }
+    }
+
+    private void animateToExpandedState(Runnable callback) {
+        Rect expandedBounds = new Rect(mExpandedBounds);
+        mSavedSnapFraction = mMotionHelper.animateToExpandedState(expandedBounds,
+                mMovementBounds, mExpandedMovementBounds, callback);
+    }
+
+    private void animateToUnexpandedState(Rect restoreBounds) {
+        Rect restoredMovementBounds = new Rect();
+        mPipBoundsHandler.getSnapAlgorithm().getMovementBounds(restoreBounds,
+                mInsetBounds, restoredMovementBounds, mIsImeShowing ? mImeHeight : 0);
+        mMotionHelper.animateToUnexpandedState(restoreBounds, mSavedSnapFraction,
+                restoredMovementBounds, mMovementBounds, false /* immediate */);
+        mSavedSnapFraction = -1f;
+    }
+
+    /**
+     * @return the motion helper.
+     */
+    public PipMotionHelper getMotionHelper() {
+        return mMotionHelper;
+    }
+
+    @VisibleForTesting
+    public PipResizeGestureHandler getPipResizeGestureHandler() {
+        return mPipResizeGestureHandler;
+    }
+
+    @VisibleForTesting
+    public void setPipResizeGestureHandler(PipResizeGestureHandler pipResizeGestureHandler) {
+        mPipResizeGestureHandler = pipResizeGestureHandler;
+    }
+
+    @VisibleForTesting
+    public void setPipMotionHelper(PipMotionHelper pipMotionHelper) {
+        mMotionHelper = pipMotionHelper;
+    }
+
+    /**
+     * @return the unexpanded bounds.
+     */
+    public Rect getNormalBounds() {
+        return mNormalBounds;
+    }
+
+    Rect getUserResizeBounds() {
+        return mPipResizeGestureHandler.getUserResizeBounds();
+    }
+
+    /**
+     * Gesture controlling normal movement of the PIP.
+     */
+    private class DefaultPipTouchGesture extends PipTouchGesture {
+        private final Point mStartPosition = new Point();
+        private final PointF mDelta = new PointF();
+        private boolean mShouldHideMenuAfterFling;
+
+        @Override
+        public void onDown(PipTouchState touchState) {
+            if (!touchState.isUserInteracting()) {
+                return;
+            }
+
+            Rect bounds = mMotionHelper.getPossiblyAnimatingBounds();
+            mDelta.set(0f, 0f);
+            mStartPosition.set(bounds.left, bounds.top);
+            mMovementWithinDismiss = touchState.getDownTouchPosition().y >= mMovementBounds.bottom;
+            mMotionHelper.setSpringingToTouch(false);
+
+            // If the menu is still visible then just poke the menu
+            // so that it will timeout after the user stops touching it
+            if (mMenuState != MENU_STATE_NONE) {
+                mMenuController.pokeMenu();
+            }
+        }
+
+        @Override
+        public boolean onMove(PipTouchState touchState) {
+            if (!touchState.isUserInteracting()) {
+                return false;
+            }
+
+            if (touchState.startedDragging()) {
+                mSavedSnapFraction = -1f;
+
+                if (mEnableDismissDragToEdge) {
+                    if (mTargetViewContainer.getVisibility() != View.VISIBLE) {
+                        mHandler.removeCallbacks(mShowTargetAction);
+                        showDismissTargetMaybe();
+                    }
+                }
+            }
+
+            if (touchState.isDragging()) {
+                // Move the pinned stack freely
+                final PointF lastDelta = touchState.getLastTouchDelta();
+                float lastX = mStartPosition.x + mDelta.x;
+                float lastY = mStartPosition.y + mDelta.y;
+                float left = lastX + lastDelta.x;
+                float top = lastY + lastDelta.y;
+
+                // Add to the cumulative delta after bounding the position
+                mDelta.x += left - lastX;
+                mDelta.y += top - lastY;
+
+                mTmpBounds.set(mMotionHelper.getPossiblyAnimatingBounds());
+                mTmpBounds.offsetTo((int) left, (int) top);
+                mMotionHelper.movePip(mTmpBounds, true /* isDragging */);
+
+                final PointF curPos = touchState.getLastTouchPosition();
+                if (mMovementWithinDismiss) {
+                    // Track if movement remains near the bottom edge to identify swipe to dismiss
+                    mMovementWithinDismiss = curPos.y >= mMovementBounds.bottom;
+                }
+                return true;
+            }
+            return false;
+        }
+
+        @Override
+        public boolean onUp(PipTouchState touchState) {
+            if (mEnableDismissDragToEdge) {
+                hideDismissTarget();
+            }
+
+            if (!touchState.isUserInteracting()) {
+                return false;
+            }
+
+            final PointF vel = touchState.getVelocity();
+
+            if (touchState.isDragging()) {
+                if (mMenuState != MENU_STATE_NONE) {
+                    // If the menu is still visible, then just poke the menu so that
+                    // it will timeout after the user stops touching it
+                    mMenuController.showMenu(mMenuState, mMotionHelper.getBounds(),
+                            true /* allowMenuTimeout */, willResizeMenu(),
+                            shouldShowResizeHandle());
+                }
+                mShouldHideMenuAfterFling = mMenuState == MENU_STATE_NONE;
+
+                // Reset the touch state on up before the fling settles
+                mTouchState.reset();
+                final Rect animatingBounds = mMotionHelper.getPossiblyAnimatingBounds();
+                // If User releases the PIP window while it's out of the display bounds, put
+                // PIP into stashed mode.
+                if (mEnableStash
+                        && (animatingBounds.right > mPipBoundsHandler.getDisplayBounds().right
+                        || animatingBounds.left < mPipBoundsHandler.getDisplayBounds().left)) {
+                    mMotionHelper.stashToEdge(vel.x, vel.y,
+                            PipTouchHandler.this::updateDismissFraction /* updateAction */,
+                            this::flingEndAction /* endAction */);
+                } else {
+                    mMotionHelper.flingToSnapTarget(vel.x, vel.y,
+                            PipTouchHandler.this::updateDismissFraction /* updateAction */,
+                            this::flingEndAction /* endAction */);
+                }
+            } else if (mTouchState.isDoubleTap()) {
+                // If using pinch to zoom, double-tap functions as resizing between max/min size
+                if (mPipResizeGestureHandler.isUsingPinchToZoom()) {
+                    final boolean toExpand =
+                            mMotionHelper.getBounds().width() < mExpandedBounds.width()
+                            && mMotionHelper.getBounds().height() < mExpandedBounds.height();
+                    mPipResizeGestureHandler.setUserResizeBounds(toExpand ? mExpandedBounds
+                            : mNormalBounds);
+                    if (toExpand) {
+                        animateToExpandedState(null);
+                    } else {
+                        animateToUnexpandedState(mNormalBounds);
+                    }
+                } else {
+                    // Expand to fullscreen if this is a double tap
+                    // the PiP should be frozen until the transition ends
+                    setTouchEnabled(false);
+                    mMotionHelper.expandLeavePip();
+                }
+            } else if (mMenuState != MENU_STATE_FULL) {
+                if (!mTouchState.isWaitingForDoubleTap()) {
+                    // User has stalled long enough for this not to be a drag or a double tap, just
+                    // expand the menu
+                    mMenuController.showMenu(MENU_STATE_FULL, mMotionHelper.getBounds(),
+                            true /* allowMenuTimeout */, willResizeMenu(),
+                            shouldShowResizeHandle());
+                } else {
+                    // Next touch event _may_ be the second tap for the double-tap, schedule a
+                    // fallback runnable to trigger the menu if no touch event occurs before the
+                    // next tap
+                    mTouchState.scheduleDoubleTapTimeoutCallback();
+                }
+            }
+            return true;
+        }
+
+        private void flingEndAction() {
+            if (mShouldHideMenuAfterFling) {
+                // If the menu is not visible, then we can still be showing the activity for the
+                // dismiss overlay, so just finish it after the animation completes
+                mMenuController.hideMenu();
+            }
+        }
+    }
+
+    /**
+     * Updates the current movement bounds based on whether the menu is currently visible and
+     * resized.
+     */
+    private void updateMovementBounds() {
+        mPipBoundsHandler.getSnapAlgorithm().getMovementBounds(mMotionHelper.getBounds(),
+                mInsetBounds, mMovementBounds, mIsImeShowing ? mImeHeight : 0);
+        mMotionHelper.setCurrentMovementBounds(mMovementBounds);
+
+        boolean isMenuExpanded = mMenuState == MENU_STATE_FULL;
+        mPipBoundsHandler.setMinEdgeSize(
+                isMenuExpanded && willResizeMenu() ? mExpandedShortestEdgeSize : 0);
+    }
+
+    private Rect getMovementBounds(Rect curBounds) {
+        Rect movementBounds = new Rect();
+        mPipBoundsHandler.getSnapAlgorithm().getMovementBounds(curBounds, mInsetBounds,
+                movementBounds, mIsImeShowing ? mImeHeight : 0);
+        return movementBounds;
+    }
+
+    /**
+     * @return whether the menu will resize as a part of showing the full menu.
+     */
+    private boolean willResizeMenu() {
+        if (!mEnableResize) {
+            return false;
+        }
+        return mExpandedBounds.width() != mNormalBounds.width()
+                || mExpandedBounds.height() != mNormalBounds.height();
+    }
+
+    public void dump(PrintWriter pw, String prefix) {
+        final String innerPrefix = prefix + "  ";
+        pw.println(prefix + TAG);
+        pw.println(innerPrefix + "mMovementBounds=" + mMovementBounds);
+        pw.println(innerPrefix + "mNormalBounds=" + mNormalBounds);
+        pw.println(innerPrefix + "mNormalMovementBounds=" + mNormalMovementBounds);
+        pw.println(innerPrefix + "mExpandedBounds=" + mExpandedBounds);
+        pw.println(innerPrefix + "mExpandedMovementBounds=" + mExpandedMovementBounds);
+        pw.println(innerPrefix + "mMenuState=" + mMenuState);
+        pw.println(innerPrefix + "mIsImeShowing=" + mIsImeShowing);
+        pw.println(innerPrefix + "mImeHeight=" + mImeHeight);
+        pw.println(innerPrefix + "mIsShelfShowing=" + mIsShelfShowing);
+        pw.println(innerPrefix + "mShelfHeight=" + mShelfHeight);
+        pw.println(innerPrefix + "mSavedSnapFraction=" + mSavedSnapFraction);
+        pw.println(innerPrefix + "mEnableDragToEdgeDismiss=" + mEnableDismissDragToEdge);
+        pw.println(innerPrefix + "mMovementBoundsExtraOffsets=" + mMovementBoundsExtraOffsets);
+        mPipBoundsHandler.dump(pw, innerPrefix);
+        mTouchState.dump(pw, innerPrefix);
+        mMotionHelper.dump(pw, innerPrefix);
+        if (mPipResizeGestureHandler != null) {
+            mPipResizeGestureHandler.dump(pw, innerPrefix);
+        }
+    }
+
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchState.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchState.java
new file mode 100644
index 0000000..21715077
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipTouchState.java
@@ -0,0 +1,391 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+import android.graphics.PointF;
+import android.os.Handler;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.ViewConfiguration;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.io.PrintWriter;
+
+/**
+ * This keeps track of the touch state throughout the current touch gesture.
+ */
+public class PipTouchState {
+    private static final String TAG = "PipTouchState";
+    private static final boolean DEBUG = false;
+
+    @VisibleForTesting
+    public static final long DOUBLE_TAP_TIMEOUT = 200;
+    static final long HOVER_EXIT_TIMEOUT = 50;
+
+    private final Handler mHandler;
+    private final ViewConfiguration mViewConfig;
+    private final Runnable mDoubleTapTimeoutCallback;
+    private final Runnable mHoverExitTimeoutCallback;
+
+    private VelocityTracker mVelocityTracker;
+    private long mDownTouchTime = 0;
+    private long mLastDownTouchTime = 0;
+    private long mUpTouchTime = 0;
+    private final PointF mDownTouch = new PointF();
+    private final PointF mDownDelta = new PointF();
+    private final PointF mLastTouch = new PointF();
+    private final PointF mLastDelta = new PointF();
+    private final PointF mVelocity = new PointF();
+    private boolean mAllowTouches = true;
+    private boolean mIsUserInteracting = false;
+    // Set to true only if the multiple taps occur within the double tap timeout
+    private boolean mIsDoubleTap = false;
+    // Set to true only if a gesture
+    private boolean mIsWaitingForDoubleTap = false;
+    private boolean mIsDragging = false;
+    // The previous gesture was a drag
+    private boolean mPreviouslyDragging = false;
+    private boolean mStartedDragging = false;
+    private boolean mAllowDraggingOffscreen = false;
+    private int mActivePointerId;
+
+    public PipTouchState(ViewConfiguration viewConfig, Handler handler,
+            Runnable doubleTapTimeoutCallback, Runnable hoverExitTimeoutCallback) {
+        mViewConfig = viewConfig;
+        mHandler = handler;
+        mDoubleTapTimeoutCallback = doubleTapTimeoutCallback;
+        mHoverExitTimeoutCallback = hoverExitTimeoutCallback;
+    }
+
+    /**
+     * Resets this state.
+     */
+    public void reset() {
+        mAllowDraggingOffscreen = false;
+        mIsDragging = false;
+        mStartedDragging = false;
+        mIsUserInteracting = false;
+    }
+
+    /**
+     * Processes a given touch event and updates the state.
+     */
+    public void onTouchEvent(MotionEvent ev) {
+        switch (ev.getActionMasked()) {
+            case MotionEvent.ACTION_DOWN: {
+                if (!mAllowTouches) {
+                    return;
+                }
+
+                // Initialize the velocity tracker
+                initOrResetVelocityTracker();
+                addMovementToVelocityTracker(ev);
+
+                mActivePointerId = ev.getPointerId(0);
+                if (DEBUG) {
+                    Log.e(TAG, "Setting active pointer id on DOWN: " + mActivePointerId);
+                }
+                mLastTouch.set(ev.getRawX(), ev.getRawY());
+                mDownTouch.set(mLastTouch);
+                mAllowDraggingOffscreen = true;
+                mIsUserInteracting = true;
+                mDownTouchTime = ev.getEventTime();
+                mIsDoubleTap = !mPreviouslyDragging
+                        && (mDownTouchTime - mLastDownTouchTime) < DOUBLE_TAP_TIMEOUT;
+                mIsWaitingForDoubleTap = false;
+                mIsDragging = false;
+                mLastDownTouchTime = mDownTouchTime;
+                if (mDoubleTapTimeoutCallback != null) {
+                    mHandler.removeCallbacks(mDoubleTapTimeoutCallback);
+                }
+                break;
+            }
+            case MotionEvent.ACTION_MOVE: {
+                // Skip event if we did not start processing this touch gesture
+                if (!mIsUserInteracting) {
+                    break;
+                }
+
+                // Update the velocity tracker
+                addMovementToVelocityTracker(ev);
+                int pointerIndex = ev.findPointerIndex(mActivePointerId);
+                if (pointerIndex == -1) {
+                    Log.e(TAG, "Invalid active pointer id on MOVE: " + mActivePointerId);
+                    break;
+                }
+
+                float x = ev.getRawX(pointerIndex);
+                float y = ev.getRawY(pointerIndex);
+                mLastDelta.set(x - mLastTouch.x, y - mLastTouch.y);
+                mDownDelta.set(x - mDownTouch.x, y - mDownTouch.y);
+
+                boolean hasMovedBeyondTap = mDownDelta.length() > mViewConfig.getScaledTouchSlop();
+                if (!mIsDragging) {
+                    if (hasMovedBeyondTap) {
+                        mIsDragging = true;
+                        mStartedDragging = true;
+                    }
+                } else {
+                    mStartedDragging = false;
+                }
+                mLastTouch.set(x, y);
+                break;
+            }
+            case MotionEvent.ACTION_POINTER_UP: {
+                // Skip event if we did not start processing this touch gesture
+                if (!mIsUserInteracting) {
+                    break;
+                }
+
+                // Update the velocity tracker
+                addMovementToVelocityTracker(ev);
+
+                int pointerIndex = ev.getActionIndex();
+                int pointerId = ev.getPointerId(pointerIndex);
+                if (pointerId == mActivePointerId) {
+                    // Select a new active pointer id and reset the movement state
+                    final int newPointerIndex = (pointerIndex == 0) ? 1 : 0;
+                    mActivePointerId = ev.getPointerId(newPointerIndex);
+                    if (DEBUG) {
+                        Log.e(TAG,
+                                "Relinquish active pointer id on POINTER_UP: " + mActivePointerId);
+                    }
+                    mLastTouch.set(ev.getRawX(newPointerIndex), ev.getRawY(newPointerIndex));
+                }
+                break;
+            }
+            case MotionEvent.ACTION_UP: {
+                // Skip event if we did not start processing this touch gesture
+                if (!mIsUserInteracting) {
+                    break;
+                }
+
+                // Update the velocity tracker
+                addMovementToVelocityTracker(ev);
+                mVelocityTracker.computeCurrentVelocity(1000,
+                        mViewConfig.getScaledMaximumFlingVelocity());
+                mVelocity.set(mVelocityTracker.getXVelocity(), mVelocityTracker.getYVelocity());
+
+                int pointerIndex = ev.findPointerIndex(mActivePointerId);
+                if (pointerIndex == -1) {
+                    Log.e(TAG, "Invalid active pointer id on UP: " + mActivePointerId);
+                    break;
+                }
+
+                mUpTouchTime = ev.getEventTime();
+                mLastTouch.set(ev.getRawX(pointerIndex), ev.getRawY(pointerIndex));
+                mPreviouslyDragging = mIsDragging;
+                mIsWaitingForDoubleTap = !mIsDoubleTap && !mIsDragging
+                        && (mUpTouchTime - mDownTouchTime) < DOUBLE_TAP_TIMEOUT;
+
+                // Fall through to clean up
+            }
+            case MotionEvent.ACTION_CANCEL: {
+                recycleVelocityTracker();
+                break;
+            }
+            case MotionEvent.ACTION_BUTTON_PRESS: {
+                removeHoverExitTimeoutCallback();
+                break;
+            }
+        }
+    }
+
+    /**
+     * @return the velocity of the active touch pointer at the point it is lifted off the screen.
+     */
+    public PointF getVelocity() {
+        return mVelocity;
+    }
+
+    /**
+     * @return the last touch position of the active pointer.
+     */
+    public PointF getLastTouchPosition() {
+        return mLastTouch;
+    }
+
+    /**
+     * @return the movement delta between the last handled touch event and the previous touch
+     * position.
+     */
+    public PointF getLastTouchDelta() {
+        return mLastDelta;
+    }
+
+    /**
+     * @return the down touch position.
+     */
+    public PointF getDownTouchPosition() {
+        return mDownTouch;
+    }
+
+    /**
+     * @return the movement delta between the last handled touch event and the down touch
+     * position.
+     */
+    public PointF getDownTouchDelta() {
+        return mDownDelta;
+    }
+
+    /**
+     * @return whether the user has started dragging.
+     */
+    public boolean isDragging() {
+        return mIsDragging;
+    }
+
+    /**
+     * @return whether the user is currently interacting with the PiP.
+     */
+    public boolean isUserInteracting() {
+        return mIsUserInteracting;
+    }
+
+    /**
+     * @return whether the user has started dragging just in the last handled touch event.
+     */
+    public boolean startedDragging() {
+        return mStartedDragging;
+    }
+
+    /**
+     * Sets whether touching is currently allowed.
+     */
+    public void setAllowTouches(boolean allowTouches) {
+        mAllowTouches = allowTouches;
+
+        // If the user happens to touch down before this is sent from the system during a transition
+        // then block any additional handling by resetting the state now
+        if (mIsUserInteracting) {
+            reset();
+        }
+    }
+
+    /**
+     * Disallows dragging offscreen for the duration of the current gesture.
+     */
+    public void setDisallowDraggingOffscreen() {
+        mAllowDraggingOffscreen = false;
+    }
+
+    /**
+     * @return whether dragging offscreen is allowed during this gesture.
+     */
+    public boolean allowDraggingOffscreen() {
+        return mAllowDraggingOffscreen;
+    }
+
+    /**
+     * @return whether this gesture is a double-tap.
+     */
+    public boolean isDoubleTap() {
+        return mIsDoubleTap;
+    }
+
+    /**
+     * @return whether this gesture will potentially lead to a following double-tap.
+     */
+    public boolean isWaitingForDoubleTap() {
+        return mIsWaitingForDoubleTap;
+    }
+
+    /**
+     * Schedules the callback to run if the next double tap does not occur.  Only runs if
+     * isWaitingForDoubleTap() is true.
+     */
+    public void scheduleDoubleTapTimeoutCallback() {
+        if (mIsWaitingForDoubleTap) {
+            long delay = getDoubleTapTimeoutCallbackDelay();
+            mHandler.removeCallbacks(mDoubleTapTimeoutCallback);
+            mHandler.postDelayed(mDoubleTapTimeoutCallback, delay);
+        }
+    }
+
+    @VisibleForTesting
+    public long getDoubleTapTimeoutCallbackDelay() {
+        if (mIsWaitingForDoubleTap) {
+            return Math.max(0, DOUBLE_TAP_TIMEOUT - (mUpTouchTime - mDownTouchTime));
+        }
+        return -1;
+    }
+
+    /**
+     * Removes the timeout callback if it's in queue.
+     */
+    public void removeDoubleTapTimeoutCallback() {
+        mIsWaitingForDoubleTap = false;
+        mHandler.removeCallbacks(mDoubleTapTimeoutCallback);
+    }
+
+    @VisibleForTesting
+    public void scheduleHoverExitTimeoutCallback() {
+        mHandler.removeCallbacks(mHoverExitTimeoutCallback);
+        mHandler.postDelayed(mHoverExitTimeoutCallback, HOVER_EXIT_TIMEOUT);
+    }
+
+    void removeHoverExitTimeoutCallback() {
+        mHandler.removeCallbacks(mHoverExitTimeoutCallback);
+    }
+
+    void addMovementToVelocityTracker(MotionEvent event) {
+        if (mVelocityTracker == null) {
+            return;
+        }
+
+        // Add movement to velocity tracker using raw screen X and Y coordinates instead
+        // of window coordinates because the window frame may be moving at the same time.
+        float deltaX = event.getRawX() - event.getX();
+        float deltaY = event.getRawY() - event.getY();
+        event.offsetLocation(deltaX, deltaY);
+        mVelocityTracker.addMovement(event);
+        event.offsetLocation(-deltaX, -deltaY);
+    }
+
+    private void initOrResetVelocityTracker() {
+        if (mVelocityTracker == null) {
+            mVelocityTracker = VelocityTracker.obtain();
+        } else {
+            mVelocityTracker.clear();
+        }
+    }
+
+    private void recycleVelocityTracker() {
+        if (mVelocityTracker != null) {
+            mVelocityTracker.recycle();
+            mVelocityTracker = null;
+        }
+    }
+
+    public void dump(PrintWriter pw, String prefix) {
+        final String innerPrefix = prefix + "  ";
+        pw.println(prefix + TAG);
+        pw.println(innerPrefix + "mAllowTouches=" + mAllowTouches);
+        pw.println(innerPrefix + "mActivePointerId=" + mActivePointerId);
+        pw.println(innerPrefix + "mDownTouch=" + mDownTouch);
+        pw.println(innerPrefix + "mDownDelta=" + mDownDelta);
+        pw.println(innerPrefix + "mLastTouch=" + mLastTouch);
+        pw.println(innerPrefix + "mLastDelta=" + mLastDelta);
+        pw.println(innerPrefix + "mVelocity=" + mVelocity);
+        pw.println(innerPrefix + "mIsUserInteracting=" + mIsUserInteracting);
+        pw.println(innerPrefix + "mIsDragging=" + mIsDragging);
+        pw.println(innerPrefix + "mStartedDragging=" + mStartedDragging);
+        pw.println(innerPrefix + "mAllowDraggingOffscreen=" + mAllowDraggingOffscreen);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipUpdateThread.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipUpdateThread.java
new file mode 100644
index 0000000..d686cac
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipUpdateThread.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+import android.os.Handler;
+import android.os.HandlerThread;
+
+/**
+ * Similar to {@link com.android.internal.os.BackgroundThread}, this is a shared singleton
+ * foreground thread for each process for updating PIP.
+ */
+public final class PipUpdateThread extends HandlerThread {
+    private static PipUpdateThread sInstance;
+    private static Handler sHandler;
+
+    private PipUpdateThread() {
+        super("pip");
+    }
+
+    private static void ensureThreadLocked() {
+        if (sInstance == null) {
+            sInstance = new PipUpdateThread();
+            sInstance.start();
+            sHandler = new Handler(sInstance.getLooper());
+        }
+    }
+
+    /**
+     * @return the static update thread instance
+     */
+    public static PipUpdateThread get() {
+        synchronized (PipUpdateThread.class) {
+            ensureThreadLocked();
+            return sInstance;
+        }
+    }
+    /**
+     * @return the static update thread handler instance
+     */
+    public static Handler getHandler() {
+        synchronized (PipUpdateThread.class) {
+            ensureThreadLocked();
+            return sHandler;
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipUtils.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipUtils.java
new file mode 100644
index 0000000..6a58ce0
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/phone/PipUtils.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.phone;
+
+import static android.app.WindowConfiguration.ACTIVITY_TYPE_UNDEFINED;
+import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED;
+import static android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE;
+
+import android.app.ActivityTaskManager;
+import android.app.ActivityTaskManager.RootTaskInfo;
+import android.app.IActivityManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.os.RemoteException;
+import android.util.Log;
+import android.util.Pair;
+
+public class PipUtils {
+
+    private static final String TAG = "PipUtils";
+
+    /**
+     * @return the ComponentName and user id of the top non-SystemUI activity in the pinned stack.
+     * The component name may be null if no such activity exists.
+     */
+    public static Pair<ComponentName, Integer> getTopPipActivity(Context context,
+            IActivityManager activityManager) {
+        try {
+            final String sysUiPackageName = context.getPackageName();
+            final RootTaskInfo pinnedTaskInfo = ActivityTaskManager.getService().getRootTaskInfo(
+                    WINDOWING_MODE_PINNED, ACTIVITY_TYPE_UNDEFINED);
+            if (pinnedTaskInfo != null && pinnedTaskInfo.childTaskIds != null
+                    && pinnedTaskInfo.childTaskIds.length > 0) {
+                for (int i = pinnedTaskInfo.childTaskNames.length - 1; i >= 0; i--) {
+                    ComponentName cn = ComponentName.unflattenFromString(
+                            pinnedTaskInfo.childTaskNames[i]);
+                    if (cn != null && !cn.getPackageName().equals(sysUiPackageName)) {
+                        return new Pair<>(cn, pinnedTaskInfo.childTaskUserIds[i]);
+                    }
+                }
+            }
+        } catch (RemoteException e) {
+            Log.w(TAG, "Unable to get pinned stack.");
+        }
+        return new Pair<>(null, 0);
+    }
+
+    /**
+     * The util to check if device has PIP feature
+     *
+     * @param context application context
+     * @return true if device has PIP feature, false otherwise.
+     */
+    public static boolean hasSystemFeature(Context context) {
+        return context.getPackageManager().hasSystemFeature(FEATURE_PICTURE_IN_PICTURE);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipControlButtonView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipControlButtonView.java
new file mode 100644
index 0000000..4e82bb5
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipControlButtonView.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.tv;
+
+import android.animation.Animator;
+import android.animation.AnimatorInflater;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.android.wm.shell.R;
+
+/**
+ * A view containing PIP controls including fullscreen, close, and media controls.
+ */
+public class PipControlButtonView extends RelativeLayout {
+
+    private OnFocusChangeListener mFocusChangeListener;
+    private ImageView mIconImageView;
+    ImageView mButtonImageView;
+    private TextView mDescriptionTextView;
+    private Animator mTextFocusGainAnimator;
+    private Animator mButtonFocusGainAnimator;
+    private Animator mTextFocusLossAnimator;
+    private Animator mButtonFocusLossAnimator;
+
+    private final OnFocusChangeListener mInternalFocusChangeListener =
+            new OnFocusChangeListener() {
+                @Override
+                public void onFocusChange(View v, boolean hasFocus) {
+                    if (hasFocus) {
+                        startFocusGainAnimation();
+                    } else {
+                        startFocusLossAnimation();
+                    }
+
+                    if (mFocusChangeListener != null) {
+                        mFocusChangeListener.onFocusChange(PipControlButtonView.this, hasFocus);
+                    }
+                }
+            };
+
+    public PipControlButtonView(Context context) {
+        this(context, null, 0, 0);
+    }
+
+    public PipControlButtonView(Context context, AttributeSet attrs) {
+        this(context, attrs, 0, 0);
+    }
+
+    public PipControlButtonView(Context context, AttributeSet attrs, int defStyleAttr) {
+        this(context, attrs, defStyleAttr, 0);
+    }
+
+    public PipControlButtonView(
+            Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+        super(context, attrs, defStyleAttr, defStyleRes);
+        LayoutInflater inflater = (LayoutInflater) getContext()
+                .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+        inflater.inflate(R.layout.tv_pip_control_button, this);
+
+        mIconImageView = findViewById(R.id.icon);
+        mButtonImageView = findViewById(R.id.button);
+        mDescriptionTextView = findViewById(R.id.desc);
+
+        int[] values = new int[]{android.R.attr.src, android.R.attr.text};
+        TypedArray typedArray = context.obtainStyledAttributes(attrs, values, defStyleAttr,
+                defStyleRes);
+
+        setImageResource(typedArray.getResourceId(0, 0));
+        setText(typedArray.getResourceId(1, 0));
+
+        typedArray.recycle();
+    }
+
+    @Override
+    public void onFinishInflate() {
+        super.onFinishInflate();
+        mButtonImageView.setOnFocusChangeListener(mInternalFocusChangeListener);
+
+        mTextFocusGainAnimator = AnimatorInflater.loadAnimator(getContext(),
+                R.anim.tv_pip_controls_focus_gain_animation);
+        mTextFocusGainAnimator.setTarget(mDescriptionTextView);
+        mButtonFocusGainAnimator = AnimatorInflater.loadAnimator(getContext(),
+                R.anim.tv_pip_controls_focus_gain_animation);
+        mButtonFocusGainAnimator.setTarget(mButtonImageView);
+
+        mTextFocusLossAnimator = AnimatorInflater.loadAnimator(getContext(),
+                R.anim.tv_pip_controls_focus_loss_animation);
+        mTextFocusLossAnimator.setTarget(mDescriptionTextView);
+        mButtonFocusLossAnimator = AnimatorInflater.loadAnimator(getContext(),
+                R.anim.tv_pip_controls_focus_loss_animation);
+        mButtonFocusLossAnimator.setTarget(mButtonImageView);
+    }
+
+    @Override
+    public void setOnClickListener(OnClickListener listener) {
+        mButtonImageView.setOnClickListener(listener);
+    }
+
+    @Override
+    public void setOnFocusChangeListener(OnFocusChangeListener listener) {
+        mFocusChangeListener = listener;
+    }
+
+    /**
+     * Sets the drawable for the button with the given drawable.
+     */
+    public void setImageDrawable(Drawable d) {
+        mIconImageView.setImageDrawable(d);
+    }
+
+    /**
+     * Sets the drawable for the button with the given resource id.
+     */
+    public void setImageResource(int resId) {
+        if (resId != 0) {
+            mIconImageView.setImageResource(resId);
+        }
+    }
+
+    /**
+     * Sets the text for description the with the given string.
+     */
+    public void setText(CharSequence text) {
+        mButtonImageView.setContentDescription(text);
+        mDescriptionTextView.setText(text);
+    }
+
+    /**
+     * Sets the text for description the with the given resource id.
+     */
+    public void setText(int resId) {
+        if (resId != 0) {
+            mButtonImageView.setContentDescription(getContext().getString(resId));
+            mDescriptionTextView.setText(resId);
+        }
+    }
+
+    private static void cancelAnimator(Animator animator) {
+        if (animator.isStarted()) {
+            animator.cancel();
+        }
+    }
+
+    /**
+     * Starts the focus gain animation.
+     */
+    public void startFocusGainAnimation() {
+        cancelAnimator(mButtonFocusLossAnimator);
+        cancelAnimator(mTextFocusLossAnimator);
+        mTextFocusGainAnimator.start();
+        if (mButtonImageView.getAlpha() < 1f) {
+            // If we had faded out the ripple drawable, run our manual focus change animation.
+            // See the comment at {@link #startFocusLossAnimation()} for the reason of manual
+            // animator.
+            mButtonFocusGainAnimator.start();
+        }
+    }
+
+    /**
+     * Starts the focus loss animation.
+     */
+    public void startFocusLossAnimation() {
+        cancelAnimator(mButtonFocusGainAnimator);
+        cancelAnimator(mTextFocusGainAnimator);
+        mTextFocusLossAnimator.start();
+        if (mButtonImageView.hasFocus()) {
+            // Button uses ripple that has the default animation for the focus changes.
+            // Howevever, it doesn't expose the API to fade out while it is focused,
+            // so we should manually run the fade out animation when PIP controls row loses focus.
+            mButtonFocusLossAnimator.start();
+        }
+    }
+
+    /**
+     * Resets to initial state.
+     */
+    public void reset() {
+        cancelAnimator(mButtonFocusGainAnimator);
+        cancelAnimator(mTextFocusGainAnimator);
+        cancelAnimator(mButtonFocusLossAnimator);
+        cancelAnimator(mTextFocusLossAnimator);
+        mButtonImageView.setAlpha(1f);
+        mDescriptionTextView.setAlpha(mButtonImageView.hasFocus() ? 1f : 0f);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipController.java
new file mode 100644
index 0000000..3eec20f
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipController.java
@@ -0,0 +1,733 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.tv;
+
+import static android.app.ActivityTaskManager.INVALID_STACK_ID;
+import static android.app.WindowConfiguration.ACTIVITY_TYPE_UNDEFINED;
+import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED;
+
+import android.app.ActivityManager;
+import android.app.ActivityTaskManager;
+import android.app.ActivityTaskManager.RootTaskInfo;
+import android.app.IActivityTaskManager;
+import android.app.RemoteAction;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.ParceledListSlice;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.graphics.Rect;
+import android.media.session.MediaController;
+import android.media.session.MediaSessionManager;
+import android.media.session.PlaybackState;
+import android.os.Debug;
+import android.os.Handler;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.DisplayInfo;
+
+import com.android.wm.shell.R;
+import com.android.wm.shell.WindowManagerShellWrapper;
+import com.android.wm.shell.pip.PinnedStackListenerForwarder;
+import com.android.wm.shell.pip.Pip;
+import com.android.wm.shell.pip.PipBoundsHandler;
+import com.android.wm.shell.pip.PipTaskOrganizer;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Manages the picture-in-picture (PIP) UI and states.
+ */
+public class PipController implements Pip, PipTaskOrganizer.PipTransitionCallback {
+    private static final String TAG = "PipController";
+    static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
+
+    /**
+     * Unknown or invalid state
+     */
+    public static final int STATE_UNKNOWN = -1;
+    /**
+     * State when there's no PIP.
+     */
+    public static final int STATE_NO_PIP = 0;
+    /**
+     * State when PIP is shown. This is used as default PIP state.
+     */
+    public static final int STATE_PIP = 1;
+    /**
+     * State when PIP menu dialog is shown.
+     */
+    public static final int STATE_PIP_MENU = 2;
+
+    private static final int TASK_ID_NO_PIP = -1;
+    private static final int INVALID_RESOURCE_TYPE = -1;
+
+    public static final int SUSPEND_PIP_RESIZE_REASON_WAITING_FOR_MENU_ACTIVITY_FINISH = 0x1;
+
+    /**
+     * PIPed activity is playing a media and it can be paused.
+     */
+    static final int PLAYBACK_STATE_PLAYING = 0;
+    /**
+     * PIPed activity has a paused media and it can be played.
+     */
+    static final int PLAYBACK_STATE_PAUSED = 1;
+    /**
+     * Users are unable to control PIPed activity's media playback.
+     */
+    static final int PLAYBACK_STATE_UNAVAILABLE = 2;
+
+    private static final int CLOSE_PIP_WHEN_MEDIA_SESSION_GONE_TIMEOUT_MS = 3000;
+
+    private int mSuspendPipResizingReason;
+
+    private Context mContext;
+    private PipBoundsHandler mPipBoundsHandler;
+    private PipTaskOrganizer mPipTaskOrganizer;
+    private IActivityTaskManager mActivityTaskManager;
+    private MediaSessionManager mMediaSessionManager;
+    private int mState = STATE_NO_PIP;
+    private int mResumeResizePinnedStackRunnableState = STATE_NO_PIP;
+    private final Handler mHandler = new Handler();
+    private List<Listener> mListeners = new ArrayList<>();
+    private List<MediaListener> mMediaListeners = new ArrayList<>();
+    private Rect mPipBounds;
+    private Rect mDefaultPipBounds = new Rect();
+    private Rect mMenuModePipBounds;
+    private int mLastOrientation = Configuration.ORIENTATION_UNDEFINED;
+    private boolean mInitialized;
+    private int mPipTaskId = TASK_ID_NO_PIP;
+    private int mPinnedStackId = INVALID_STACK_ID;
+    private ComponentName mPipComponentName;
+    private MediaController mPipMediaController;
+    private String[] mLastPackagesResourceGranted;
+    private PipNotification mPipNotification;
+    private ParceledListSlice<RemoteAction> mCustomActions;
+    private WindowManagerShellWrapper mWindowManagerShellWrapper;
+    private int mResizeAnimationDuration;
+
+    // Used to calculate the movement bounds
+    private final DisplayInfo mTmpDisplayInfo = new DisplayInfo();
+    private final Rect mTmpInsetBounds = new Rect();
+
+    // Keeps track of the IME visibility to adjust the PiP when the IME is visible
+    private boolean mImeVisible;
+    private int mImeHeightAdjustment;
+
+    private final Runnable mResizePinnedStackRunnable =
+            () -> resizePinnedStack(mResumeResizePinnedStackRunnableState);
+    private final Runnable mClosePipRunnable = () -> closePip();
+    private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            String action = intent.getAction();
+            if (Intent.ACTION_MEDIA_RESOURCE_GRANTED.equals(action)) {
+                String[] packageNames = intent.getStringArrayExtra(Intent.EXTRA_PACKAGES);
+                int resourceType = intent.getIntExtra(Intent.EXTRA_MEDIA_RESOURCE_TYPE,
+                        INVALID_RESOURCE_TYPE);
+                if (packageNames != null && packageNames.length > 0
+                        && resourceType == Intent.EXTRA_MEDIA_RESOURCE_TYPE_VIDEO_CODEC) {
+                    handleMediaResourceGranted(packageNames);
+                }
+            }
+
+        }
+    };
+    private final MediaSessionManager.OnActiveSessionsChangedListener mActiveMediaSessionListener =
+            controllers -> updateMediaController(controllers);
+    private final PinnedStackListenerForwarder.PinnedStackListener mPinnedStackListener =
+            new PipControllerPinnedStackListener();
+
+    @Override
+    public void registerSessionListenerForCurrentUser() {
+        // TODO Need confirm if TV have to re-registers when switch user
+        mMediaSessionManager.removeOnActiveSessionsChangedListener(mActiveMediaSessionListener);
+        mMediaSessionManager.addOnActiveSessionsChangedListener(mActiveMediaSessionListener, null,
+                UserHandle.USER_CURRENT, null);
+    }
+
+    /**
+     * Handler for messages from the PIP controller.
+     */
+    private class PipControllerPinnedStackListener extends
+            PinnedStackListenerForwarder.PinnedStackListener {
+        @Override
+        public void onImeVisibilityChanged(boolean imeVisible, int imeHeight) {
+            mHandler.post(() -> {
+                mPipBoundsHandler.onImeVisibilityChanged(imeVisible, imeHeight);
+                if (mState == STATE_PIP) {
+                    if (mImeVisible != imeVisible) {
+                        if (imeVisible) {
+                            // Save the IME height adjustment, and offset to not occlude the IME
+                            mPipBounds.offset(0, -imeHeight);
+                            mImeHeightAdjustment = imeHeight;
+                        } else {
+                            // Apply the inverse adjustment when the IME is hidden
+                            mPipBounds.offset(0, mImeHeightAdjustment);
+                        }
+                        mImeVisible = imeVisible;
+                        resizePinnedStack(STATE_PIP);
+                    }
+                }
+            });
+        }
+
+        @Override
+        public void onMovementBoundsChanged(boolean fromImeAdjustment) {
+            mHandler.post(() -> {
+                // Populate the inset / normal bounds and DisplayInfo from mPipBoundsHandler first.
+                mPipBoundsHandler.onMovementBoundsChanged(mTmpInsetBounds, mPipBounds,
+                        mDefaultPipBounds, mTmpDisplayInfo);
+            });
+        }
+
+        @Override
+        public void onActionsChanged(ParceledListSlice<RemoteAction> actions) {
+            mCustomActions = actions;
+            mHandler.post(() -> {
+                for (int i = mListeners.size() - 1; i >= 0; --i) {
+                    mListeners.get(i).onPipMenuActionsChanged(mCustomActions);
+                }
+            });
+        }
+    }
+
+    public PipController(Context context,
+            PipBoundsHandler pipBoundsHandler,
+            PipTaskOrganizer pipTaskOrganizer,
+            WindowManagerShellWrapper windowManagerShellWrapper
+    ) {
+        if (!mInitialized) {
+            mInitialized = true;
+            mContext = context;
+            mPipNotification = new PipNotification(context, this);
+            mPipBoundsHandler = pipBoundsHandler;
+            // Ensure that we have the display info in case we get calls to update the bounds
+            // before the listener calls back
+            final DisplayInfo displayInfo = new DisplayInfo();
+            context.getDisplay().getDisplayInfo(displayInfo);
+            mPipBoundsHandler.onDisplayInfoChanged(displayInfo);
+
+            mResizeAnimationDuration = context.getResources()
+                    .getInteger(R.integer.config_pipResizeAnimationDuration);
+            mPipTaskOrganizer = pipTaskOrganizer;
+            mPipTaskOrganizer.registerPipTransitionCallback(this);
+            mActivityTaskManager = ActivityTaskManager.getService();
+            IntentFilter intentFilter = new IntentFilter();
+            intentFilter.addAction(Intent.ACTION_MEDIA_RESOURCE_GRANTED);
+            mContext.registerReceiver(mBroadcastReceiver, intentFilter, UserHandle.USER_ALL);
+
+            // Initialize the last orientation and apply the current configuration
+            Configuration initialConfig = mContext.getResources().getConfiguration();
+            mLastOrientation = initialConfig.orientation;
+            loadConfigurationsAndApply(initialConfig);
+
+            mMediaSessionManager = mContext.getSystemService(MediaSessionManager.class);
+            mWindowManagerShellWrapper = windowManagerShellWrapper;
+            try {
+                mWindowManagerShellWrapper.addPinnedStackListener(mPinnedStackListener);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Failed to register pinned stack listener", e);
+            }
+        }
+
+        // TODO(b/169395392) Refactor PipMenuActivity to PipMenuView
+        PipMenuActivity.setPipController(this);
+    }
+
+    private void loadConfigurationsAndApply(Configuration newConfig) {
+        if (mLastOrientation != newConfig.orientation) {
+            // Don't resize the pinned stack on orientation change. TV does not care about this case
+            // and this could clobber the existing animation to the new bounds calculated by WM.
+            mLastOrientation = newConfig.orientation;
+            return;
+        }
+
+        Resources res = mContext.getResources();
+        mMenuModePipBounds = Rect.unflattenFromString(res.getString(
+                R.string.pip_menu_bounds));
+
+        // Reset the PIP bounds and apply. PIP bounds can be changed by two reasons.
+        //   1. Configuration changed due to the language change (RTL <-> RTL)
+        //   2. SystemUI restarts after the crash
+        mPipBounds = mDefaultPipBounds;
+        resizePinnedStack(getPinnedTaskInfo() == null ? STATE_NO_PIP : STATE_PIP);
+    }
+
+    /**
+     * Updates the PIP per configuration changed.
+     */
+    public void onConfigurationChanged(Configuration newConfig) {
+        loadConfigurationsAndApply(newConfig);
+        mPipNotification.onConfigurationChanged(mContext);
+    }
+
+    /**
+     * Shows the picture-in-picture menu if an activity is in picture-in-picture mode.
+     */
+    public void showPictureInPictureMenu() {
+        if (DEBUG) Log.d(TAG, "showPictureInPictureMenu(), current state=" + getStateDescription());
+
+        if (getState() == STATE_PIP) {
+            resizePinnedStack(STATE_PIP_MENU);
+        }
+    }
+
+    /**
+     * Closes PIP (PIPed activity and PIP system UI).
+     */
+    public void closePip() {
+        if (DEBUG) Log.d(TAG, "closePip(), current state=" + getStateDescription());
+
+        closePipInternal(true);
+    }
+
+    private void closePipInternal(boolean removePipStack) {
+        if (DEBUG) {
+            Log.d(TAG,
+                    "closePipInternal() removePipStack=" + removePipStack + ", current state="
+                            + getStateDescription());
+        }
+
+        mState = STATE_NO_PIP;
+        mPipTaskId = TASK_ID_NO_PIP;
+        mPipMediaController = null;
+        mMediaSessionManager.removeOnActiveSessionsChangedListener(mActiveMediaSessionListener);
+        if (removePipStack) {
+            try {
+                mActivityTaskManager.removeStack(mPinnedStackId);
+            } catch (RemoteException e) {
+                Log.e(TAG, "removeStack failed", e);
+            } finally {
+                mPinnedStackId = INVALID_STACK_ID;
+            }
+        }
+        for (int i = mListeners.size() - 1; i >= 0; --i) {
+            mListeners.get(i).onPipActivityClosed();
+        }
+        mHandler.removeCallbacks(mClosePipRunnable);
+    }
+
+    /**
+     * Moves the PIPed activity to the fullscreen and closes PIP system UI.
+     */
+    public void movePipToFullscreen() {
+        if (DEBUG) Log.d(TAG, "movePipToFullscreen(), current state=" + getStateDescription());
+
+        mPipTaskId = TASK_ID_NO_PIP;
+        for (int i = mListeners.size() - 1; i >= 0; --i) {
+            mListeners.get(i).onMoveToFullscreen();
+        }
+        resizePinnedStack(STATE_NO_PIP);
+    }
+
+    @Override
+    public void onActivityPinned(String packageName) {
+        if (DEBUG) Log.d(TAG, "onActivityPinned()");
+
+        RootTaskInfo taskInfo = getPinnedTaskInfo();
+        if (taskInfo == null) {
+            Log.w(TAG, "Cannot find pinned stack");
+            return;
+        }
+        if (DEBUG) Log.d(TAG, "PINNED_STACK:" + taskInfo);
+        mPinnedStackId = taskInfo.taskId;
+        mPipTaskId = taskInfo.childTaskIds[taskInfo.childTaskIds.length - 1];
+        mPipComponentName = ComponentName.unflattenFromString(
+                taskInfo.childTaskNames[taskInfo.childTaskNames.length - 1]);
+        // Set state to STATE_PIP so we show it when the pinned stack animation ends.
+        mState = STATE_PIP;
+        mMediaSessionManager.addOnActiveSessionsChangedListener(
+                mActiveMediaSessionListener, null);
+        updateMediaController(mMediaSessionManager.getActiveSessions(null));
+        for (int i = mListeners.size() - 1; i >= 0; i--) {
+            mListeners.get(i).onPipEntered(packageName);
+        }
+    }
+
+    @Override
+    public void onActivityRestartAttempt(ActivityManager.RunningTaskInfo task,
+            boolean clearedTask) {
+        if (task.configuration.windowConfiguration.getWindowingMode()
+                != WINDOWING_MODE_PINNED) {
+            return;
+        }
+        if (DEBUG) Log.d(TAG, "onPinnedActivityRestartAttempt()");
+
+        // If PIPed activity is launched again by Launcher or intent, make it fullscreen.
+        movePipToFullscreen();
+    }
+
+    @Override
+    public void onTaskStackChanged() {
+        if (DEBUG) Log.d(TAG, "onTaskStackChanged()");
+
+        if (getState() != STATE_NO_PIP) {
+            boolean hasPip = false;
+
+            RootTaskInfo taskInfo = getPinnedTaskInfo();
+            if (taskInfo == null || taskInfo.childTaskIds == null) {
+                Log.w(TAG, "There is nothing in pinned stack");
+                closePipInternal(false);
+                return;
+            }
+            for (int i = taskInfo.childTaskIds.length - 1; i >= 0; --i) {
+                if (taskInfo.childTaskIds[i] == mPipTaskId) {
+                    // PIP task is still alive.
+                    hasPip = true;
+                    break;
+                }
+            }
+            if (!hasPip) {
+                // PIP task doesn't exist anymore in PINNED_STACK.
+                closePipInternal(true);
+                return;
+            }
+        }
+        if (getState() == STATE_PIP) {
+            if (mPipBounds != mDefaultPipBounds) {
+                mPipBounds = mDefaultPipBounds;
+                resizePinnedStack(STATE_PIP);
+            }
+        }
+    }
+
+    /**
+     * Suspends resizing operation on the Pip until {@link #resumePipResizing} is called
+     *
+     * @param reason The reason for suspending resizing operations on the Pip.
+     */
+    public void suspendPipResizing(int reason) {
+        if (DEBUG) {
+            Log.d(TAG,
+                    "suspendPipResizing() reason=" + reason + " callers=" + Debug.getCallers(2));
+        }
+
+        mSuspendPipResizingReason |= reason;
+    }
+
+    /**
+     * Resumes resizing operation on the Pip that was previously suspended.
+     *
+     * @param reason The reason resizing operations on the Pip was suspended.
+     */
+    public void resumePipResizing(int reason) {
+        if ((mSuspendPipResizingReason & reason) == 0) {
+            return;
+        }
+        if (DEBUG) {
+            Log.d(TAG,
+                    "resumePipResizing() reason=" + reason + " callers=" + Debug.getCallers(2));
+        }
+        mSuspendPipResizingReason &= ~reason;
+        mHandler.post(mResizePinnedStackRunnable);
+    }
+
+    /**
+     * Resize the Pip to the appropriate size for the input state.
+     *
+     * @param state In Pip state also used to determine the new size for the Pip.
+     */
+    public void resizePinnedStack(int state) {
+        if (DEBUG) {
+            Log.d(TAG, "resizePinnedStack() state=" + stateToName(state) + ", current state="
+                    + getStateDescription(), new Exception());
+        }
+
+        boolean wasStateNoPip = (mState == STATE_NO_PIP);
+        for (int i = mListeners.size() - 1; i >= 0; --i) {
+            mListeners.get(i).onPipResizeAboutToStart();
+        }
+        if (mSuspendPipResizingReason != 0) {
+            mResumeResizePinnedStackRunnableState = state;
+            if (DEBUG) {
+                Log.d(TAG, "resizePinnedStack() deferring"
+                        + " mSuspendPipResizingReason=" + mSuspendPipResizingReason
+                        + " mResumeResizePinnedStackRunnableState="
+                        + stateToName(mResumeResizePinnedStackRunnableState));
+            }
+            return;
+        }
+        mState = state;
+        final Rect newBounds;
+        switch (mState) {
+            case STATE_NO_PIP:
+                newBounds = null;
+                // If the state was already STATE_NO_PIP, then do not resize the stack below as it
+                // will not exist
+                if (wasStateNoPip) {
+                    return;
+                }
+                break;
+            case STATE_PIP_MENU:
+                newBounds = mMenuModePipBounds;
+                break;
+            case STATE_PIP: // fallthrough
+            default:
+                newBounds = mPipBounds;
+                break;
+        }
+        if (newBounds != null) {
+            mPipTaskOrganizer.scheduleAnimateResizePip(newBounds, mResizeAnimationDuration, null);
+        } else {
+            mPipTaskOrganizer.exitPip(mResizeAnimationDuration);
+        }
+    }
+
+    /**
+     * @return the current state, or the pending state if the state change was previously suspended.
+     */
+    private int getState() {
+        if (mSuspendPipResizingReason != 0) {
+            return mResumeResizePinnedStackRunnableState;
+        }
+        return mState;
+    }
+
+    /**
+     * Shows PIP menu UI by launching {@link PipMenuActivity}. It also locates the pinned
+     * stack to the centered PIP bound {@link R.config_centeredPictureInPictureBounds}.
+     */
+    private void showPipMenu() {
+        if (DEBUG) Log.d(TAG, "showPipMenu(), current state=" + getStateDescription());
+
+        mState = STATE_PIP_MENU;
+        for (int i = mListeners.size() - 1; i >= 0; --i) {
+            mListeners.get(i).onShowPipMenu();
+        }
+        Intent intent = new Intent(mContext, PipMenuActivity.class);
+        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        intent.putExtra(PipMenuActivity.EXTRA_CUSTOM_ACTIONS, mCustomActions);
+        mContext.startActivity(intent);
+    }
+
+    /**
+     * Adds a {@link Listener} to PipController.
+     */
+    public void addListener(Listener listener) {
+        mListeners.add(listener);
+    }
+
+    /**
+     * Removes a {@link Listener} from PipController.
+     */
+    public void removeListener(Listener listener) {
+        mListeners.remove(listener);
+    }
+
+    /**
+     * Adds a {@link MediaListener} to PipController.
+     */
+    public void addMediaListener(MediaListener listener) {
+        mMediaListeners.add(listener);
+    }
+
+    /**
+     * Removes a {@link MediaListener} from PipController.
+     */
+    public void removeMediaListener(MediaListener listener) {
+        mMediaListeners.remove(listener);
+    }
+
+    /**
+     * Returns {@code true} if PIP is shown.
+     */
+    public boolean isPipShown() {
+        return mState != STATE_NO_PIP;
+    }
+
+    private RootTaskInfo getPinnedTaskInfo() {
+        RootTaskInfo taskInfo = null;
+        try {
+            taskInfo = ActivityTaskManager.getService().getRootTaskInfo(
+                    WINDOWING_MODE_PINNED, ACTIVITY_TYPE_UNDEFINED);
+        } catch (RemoteException e) {
+            Log.e(TAG, "getRootTaskInfo failed", e);
+        }
+        return taskInfo;
+    }
+
+    private void handleMediaResourceGranted(String[] packageNames) {
+        if (getState() == STATE_NO_PIP) {
+            mLastPackagesResourceGranted = packageNames;
+        } else {
+            boolean requestedFromLastPackages = false;
+            if (mLastPackagesResourceGranted != null) {
+                for (String packageName : mLastPackagesResourceGranted) {
+                    for (String newPackageName : packageNames) {
+                        if (TextUtils.equals(newPackageName, packageName)) {
+                            requestedFromLastPackages = true;
+                            break;
+                        }
+                    }
+                }
+            }
+            mLastPackagesResourceGranted = packageNames;
+            if (!requestedFromLastPackages) {
+                closePip();
+            }
+        }
+    }
+
+    private void updateMediaController(List<MediaController> controllers) {
+        MediaController mediaController = null;
+        if (controllers != null && getState() != STATE_NO_PIP && mPipComponentName != null) {
+            for (int i = controllers.size() - 1; i >= 0; i--) {
+                MediaController controller = controllers.get(i);
+                // We assumes that an app with PIPable activity
+                // keeps the single instance of media controller especially when PIP is on.
+                if (controller.getPackageName().equals(mPipComponentName.getPackageName())) {
+                    mediaController = controller;
+                    break;
+                }
+            }
+        }
+        if (mPipMediaController != mediaController) {
+            mPipMediaController = mediaController;
+            for (int i = mMediaListeners.size() - 1; i >= 0; i--) {
+                mMediaListeners.get(i).onMediaControllerChanged();
+            }
+            if (mPipMediaController == null) {
+                mHandler.postDelayed(mClosePipRunnable,
+                        CLOSE_PIP_WHEN_MEDIA_SESSION_GONE_TIMEOUT_MS);
+            } else {
+                mHandler.removeCallbacks(mClosePipRunnable);
+            }
+        }
+    }
+
+    /**
+     * Gets the {@link android.media.session.MediaController} for the PIPed activity.
+     */
+    public MediaController getMediaController() {
+        return mPipMediaController;
+    }
+
+    @Override
+    public void hidePipMenu(Runnable onStartCallback, Runnable onEndCallback) {
+
+    }
+
+    /**
+     * Returns the PIPed activity's playback state.
+     * This returns one of {@link #PLAYBACK_STATE_PLAYING}, {@link #PLAYBACK_STATE_PAUSED},
+     * or {@link #PLAYBACK_STATE_UNAVAILABLE}.
+     */
+    public int getPlaybackState() {
+        if (mPipMediaController == null || mPipMediaController.getPlaybackState() == null) {
+            return PLAYBACK_STATE_UNAVAILABLE;
+        }
+        int state = mPipMediaController.getPlaybackState().getState();
+        boolean isPlaying = (state == PlaybackState.STATE_BUFFERING
+                || state == PlaybackState.STATE_CONNECTING
+                || state == PlaybackState.STATE_PLAYING
+                || state == PlaybackState.STATE_FAST_FORWARDING
+                || state == PlaybackState.STATE_REWINDING
+                || state == PlaybackState.STATE_SKIPPING_TO_PREVIOUS
+                || state == PlaybackState.STATE_SKIPPING_TO_NEXT);
+        long actions = mPipMediaController.getPlaybackState().getActions();
+        if (!isPlaying && ((actions & PlaybackState.ACTION_PLAY) != 0)) {
+            return PLAYBACK_STATE_PAUSED;
+        } else if (isPlaying && ((actions & PlaybackState.ACTION_PAUSE) != 0)) {
+            return PLAYBACK_STATE_PLAYING;
+        }
+        return PLAYBACK_STATE_UNAVAILABLE;
+    }
+
+    @Override
+    public void onPipTransitionStarted(ComponentName activity, int direction, Rect pipBounds) {
+    }
+
+    @Override
+    public void onPipTransitionFinished(ComponentName activity, int direction) {
+        onPipTransitionFinishedOrCanceled();
+    }
+
+    @Override
+    public void onPipTransitionCanceled(ComponentName activity, int direction) {
+        onPipTransitionFinishedOrCanceled();
+    }
+
+    private void onPipTransitionFinishedOrCanceled() {
+        if (DEBUG) Log.d(TAG, "onPipTransitionFinishedOrCanceled()");
+
+        if (getState() == STATE_PIP_MENU) {
+            showPipMenu();
+        }
+    }
+
+    /**
+     * A listener interface to receive notification on changes in PIP.
+     */
+    public interface Listener {
+        /**
+         * Invoked when an activity is pinned and PIP manager is set corresponding information.
+         * Classes must use this instead of {@link android.app.ITaskStackListener.onActivityPinned}
+         * because there's no guarantee for the PIP manager be return relavent information
+         * correctly. (e.g. {@link Pip.isPipShown}).
+         */
+        void onPipEntered(String packageName);
+        /** Invoked when a PIPed activity is closed. */
+        void onPipActivityClosed();
+        /** Invoked when the PIP menu gets shown. */
+        void onShowPipMenu();
+        /** Invoked when the PIP menu actions change. */
+        void onPipMenuActionsChanged(ParceledListSlice<RemoteAction> actions);
+        /** Invoked when the PIPed activity is about to return back to the fullscreen. */
+        void onMoveToFullscreen();
+        /** Invoked when we are above to start resizing the Pip. */
+        void onPipResizeAboutToStart();
+    }
+
+    /**
+     * A listener interface to receive change in PIP's media controller
+     */
+    public interface MediaListener {
+        /** Invoked when the MediaController on PIPed activity is changed. */
+        void onMediaControllerChanged();
+    }
+
+    private String getStateDescription() {
+        if (mSuspendPipResizingReason == 0) {
+            return stateToName(mState);
+        }
+        return stateToName(mResumeResizePinnedStackRunnableState) + " (while " + stateToName(mState)
+                + " is suspended)";
+    }
+
+    private static String stateToName(int state) {
+        switch (state) {
+            case STATE_NO_PIP:
+                return "NO_PIP";
+
+            case STATE_PIP:
+                return "PIP";
+
+            case STATE_PIP_MENU:
+                return "PIP_MENU";
+
+            default:
+                return "UNKNOWN(" + state + ")";
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipControlsView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipControlsView.java
new file mode 100644
index 0000000..14960c3
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipControlsView.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.tv;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.widget.LinearLayout;
+
+import com.android.wm.shell.R;
+
+
+/**
+ * A view containing PIP controls including fullscreen, close, and media controls.
+ */
+public class PipControlsView extends LinearLayout {
+
+    public PipControlsView(Context context) {
+        this(context, null);
+    }
+
+    public PipControlsView(Context context, AttributeSet attrs) {
+        this(context, attrs, 0);
+    }
+
+    public PipControlsView(Context context, AttributeSet attrs, int defStyleAttr) {
+        this(context, attrs, defStyleAttr, 0);
+    }
+
+    public PipControlsView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+        super(context, attrs, defStyleAttr, defStyleRes);
+        LayoutInflater layoutInflater = (LayoutInflater) getContext().getSystemService(
+                Context.LAYOUT_INFLATER_SERVICE);
+        layoutInflater.inflate(R.layout.tv_pip_controls, this);
+        setOrientation(LinearLayout.HORIZONTAL);
+        setGravity(Gravity.TOP | Gravity.CENTER_HORIZONTAL);
+    }
+
+    PipControlButtonView getFullButtonView() {
+        return findViewById(R.id.full_button);
+    }
+
+    PipControlButtonView getCloseButtonView() {
+        return findViewById(R.id.close_button);
+    }
+
+    PipControlButtonView getPlayPauseButtonView() {
+        return findViewById(R.id.play_pause_button);
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipControlsViewController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipControlsViewController.java
new file mode 100644
index 0000000..f66e902
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipControlsViewController.java
@@ -0,0 +1,254 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.tv;
+
+import android.app.PendingIntent;
+import android.app.RemoteAction;
+import android.graphics.Color;
+import android.media.session.MediaController;
+import android.media.session.PlaybackState;
+import android.os.Handler;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+
+import com.android.wm.shell.R;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+
+/**
+ * Controller for {@link PipControlsView}.
+ */
+public class PipControlsViewController {
+    private static final String TAG = PipControlsViewController.class.getSimpleName();
+
+    private static final float DISABLED_ACTION_ALPHA = 0.54f;
+
+    private final PipControlsView mView;
+    private final LayoutInflater mLayoutInflater;
+    private final Handler mHandler;
+    private final PipController mPipController;
+    private final PipControlButtonView mPlayPauseButtonView;
+    private MediaController mMediaController;
+    private PipControlButtonView mFocusedChild;
+    private Listener mListener;
+    private ArrayList<PipControlButtonView> mCustomButtonViews = new ArrayList<>();
+    private List<RemoteAction> mCustomActions = new ArrayList<>();
+
+    public PipControlsView getView() {
+        return mView;
+    }
+
+    /**
+     * An interface to listen user action.
+     */
+    public interface Listener {
+        /**
+         * Called when a user clicks close PIP button.
+         */
+        void onClosed();
+    }
+
+    private View.OnAttachStateChangeListener
+            mOnAttachStateChangeListener =
+            new View.OnAttachStateChangeListener() {
+                @Override
+                public void onViewAttachedToWindow(View v) {
+                    updateMediaController();
+                    mPipController.addMediaListener(mPipMediaListener);
+                }
+
+                @Override
+                public void onViewDetachedFromWindow(View v) {
+                    mPipController.removeMediaListener(mPipMediaListener);
+                }
+            };
+
+    private MediaController.Callback mMediaControllerCallback = new MediaController.Callback() {
+        @Override
+        public void onPlaybackStateChanged(PlaybackState state) {
+            updateUserActions();
+        }
+    };
+
+    private final PipController.MediaListener mPipMediaListener = this::updateMediaController;
+
+    private final View.OnFocusChangeListener
+            mFocusChangeListener =
+            new View.OnFocusChangeListener() {
+                @Override
+                public void onFocusChange(View view, boolean hasFocus) {
+                    if (hasFocus) {
+                        mFocusedChild = (PipControlButtonView) view;
+                    } else if (mFocusedChild == view) {
+                        mFocusedChild = null;
+                    }
+                }
+            };
+
+    public PipControlsViewController(PipControlsView view, PipController pipController,
+            LayoutInflater layoutInflater, Handler handler) {
+        super();
+        mView = view;
+        mPipController = pipController;
+        mLayoutInflater = layoutInflater;
+        mHandler = handler;
+
+        mView.addOnAttachStateChangeListener(mOnAttachStateChangeListener);
+        if (mView.isAttachedToWindow()) {
+            mOnAttachStateChangeListener.onViewAttachedToWindow(mView);
+        }
+
+        View fullButtonView = mView.getFullButtonView();
+        fullButtonView.setOnFocusChangeListener(mFocusChangeListener);
+        fullButtonView.setOnClickListener(mView -> mPipController.movePipToFullscreen());
+
+        View closeButtonView = mView.getCloseButtonView();
+        closeButtonView.setOnFocusChangeListener(mFocusChangeListener);
+        closeButtonView.setOnClickListener(v -> {
+            mPipController.closePip();
+            if (mListener != null) {
+                mListener.onClosed();
+            }
+        });
+
+        mPlayPauseButtonView = mView.getPlayPauseButtonView();
+        mPlayPauseButtonView.setOnFocusChangeListener(mFocusChangeListener);
+        mPlayPauseButtonView.setOnClickListener(v -> {
+            if (mMediaController == null || mMediaController.getPlaybackState() == null) {
+                return;
+            }
+            final int playbackState = mPipController.getPlaybackState();
+            if (playbackState == PipController.PLAYBACK_STATE_PAUSED) {
+                mMediaController.getTransportControls().play();
+            } else if (playbackState == PipController.PLAYBACK_STATE_PLAYING) {
+                mMediaController.getTransportControls().pause();
+            }
+
+            // View will be updated later in {@link mMediaControllerCallback}
+        });
+    }
+
+    private void updateMediaController() {
+        AtomicReference<MediaController> newController = new AtomicReference<>();
+        newController.set(mPipController.getMediaController());
+
+        if (newController.get() == null || mMediaController == newController.get()) {
+            return;
+        }
+        if (mMediaController != null) {
+            mMediaController.unregisterCallback(mMediaControllerCallback);
+        }
+        mMediaController = newController.get();
+        if (mMediaController != null) {
+            mMediaController.registerCallback(mMediaControllerCallback);
+        }
+        updateUserActions();
+    }
+
+    /**
+     * Updates the actions for the PIP. If there are no custom actions, then the media session
+     * actions are shown.
+     */
+    private void updateUserActions() {
+        if (!mCustomActions.isEmpty()) {
+            // Ensure we have as many buttons as actions
+            while (mCustomButtonViews.size() < mCustomActions.size()) {
+                PipControlButtonView buttonView = (PipControlButtonView) mLayoutInflater.inflate(
+                        R.layout.tv_pip_custom_control, mView, false);
+                mView.addView(buttonView);
+                mCustomButtonViews.add(buttonView);
+            }
+
+            // Update the visibility of all views
+            for (int i = 0; i < mCustomButtonViews.size(); i++) {
+                mCustomButtonViews.get(i).setVisibility(
+                        i < mCustomActions.size() ? View.VISIBLE : View.GONE);
+            }
+
+            // Update the state and visibility of the action buttons, and hide the rest
+            for (int i = 0; i < mCustomActions.size(); i++) {
+                final RemoteAction action = mCustomActions.get(i);
+                PipControlButtonView actionView = mCustomButtonViews.get(i);
+
+                // TODO: Check if the action drawable has changed before we reload it
+                action.getIcon().loadDrawableAsync(mView.getContext(), d -> {
+                    d.setTint(Color.WHITE);
+                    actionView.setImageDrawable(d);
+                }, mHandler);
+                actionView.setText(action.getContentDescription());
+                if (action.isEnabled()) {
+                    actionView.setOnClickListener(v -> {
+                        try {
+                            action.getActionIntent().send();
+                        } catch (PendingIntent.CanceledException e) {
+                            Log.w(TAG, "Failed to send action", e);
+                        }
+                    });
+                }
+                actionView.setEnabled(action.isEnabled());
+                actionView.setAlpha(action.isEnabled() ? 1f : DISABLED_ACTION_ALPHA);
+            }
+
+            // Hide the media session buttons
+            mPlayPauseButtonView.setVisibility(View.GONE);
+        } else {
+            AtomicInteger state = new AtomicInteger(PipController.STATE_UNKNOWN);
+            state.set(mPipController.getPlaybackState());
+            if (state.get() == PipController.STATE_UNKNOWN
+                    || state.get() == PipController.PLAYBACK_STATE_UNAVAILABLE) {
+                mPlayPauseButtonView.setVisibility(View.GONE);
+            } else {
+                mPlayPauseButtonView.setVisibility(View.VISIBLE);
+                if (state.get() == PipController.PLAYBACK_STATE_PLAYING) {
+                    mPlayPauseButtonView.setImageResource(R.drawable.pip_ic_pause_white);
+                    mPlayPauseButtonView.setText(R.string.pip_pause);
+                } else {
+                    mPlayPauseButtonView.setImageResource(R.drawable.pip_ic_play_arrow_white);
+                    mPlayPauseButtonView.setText(R.string.pip_play);
+                }
+            }
+
+            // Hide all the custom action buttons
+            for (int i = 0; i < mCustomButtonViews.size(); i++) {
+                mCustomButtonViews.get(i).setVisibility(View.GONE);
+            }
+        }
+    }
+
+
+    /**
+     * Sets the {@link Listener} to listen user actions.
+     */
+    public void setListener(Listener listener) {
+        mListener = listener;
+    }
+
+
+    /**
+     * Updates the set of activity-defined actions.
+     */
+    public void setActions(List<? extends RemoteAction> actions) {
+        mCustomActions.clear();
+        mCustomActions.addAll(actions);
+        updateUserActions();
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipMenuActivity.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipMenuActivity.java
new file mode 100644
index 0000000..06d2408
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipMenuActivity.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.tv;
+
+import android.animation.Animator;
+import android.animation.AnimatorInflater;
+import android.app.Activity;
+import android.app.RemoteAction;
+import android.content.Intent;
+import android.content.pm.ParceledListSlice;
+import android.os.Bundle;
+import android.util.Log;
+
+import com.android.wm.shell.R;
+
+import java.util.Collections;
+
+/**
+ * Activity to show the PIP menu to control PIP.
+ * TODO(b/169395392) Refactor PipMenuActivity to PipMenuView
+ */
+public class PipMenuActivity extends Activity implements PipController.Listener {
+    private static final String TAG = "PipMenuActivity";
+    private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
+
+    static final String EXTRA_CUSTOM_ACTIONS = "custom_actions";
+
+    private static PipController sPipController;
+
+    private Animator mFadeInAnimation;
+    private Animator mFadeOutAnimation;
+    private boolean mRestorePipSizeWhenClose;
+    private PipControlsViewController mPipControlsViewController;
+
+    @Override
+    protected void onCreate(Bundle bundle) {
+        if (DEBUG) Log.d(TAG, "onCreate()");
+
+        super.onCreate(bundle);
+        if (sPipController == null || sPipController.isPipShown()) {
+            finish();
+        }
+        setContentView(R.layout.tv_pip_menu);
+        mPipControlsViewController = new PipControlsViewController(
+                findViewById(R.id.pip_controls), sPipController,
+                getLayoutInflater(), getApplicationContext().getMainThreadHandler());
+        sPipController.addListener(this);
+        mRestorePipSizeWhenClose = true;
+        mFadeInAnimation = AnimatorInflater.loadAnimator(
+                this, R.anim.tv_pip_menu_fade_in_animation);
+        mFadeInAnimation.setTarget(mPipControlsViewController.getView());
+        mFadeOutAnimation = AnimatorInflater.loadAnimator(
+                this, R.anim.tv_pip_menu_fade_out_animation);
+        mFadeOutAnimation.setTarget(mPipControlsViewController.getView());
+
+        onPipMenuActionsChanged(getIntent().getParcelableExtra(EXTRA_CUSTOM_ACTIONS));
+    }
+
+    @Override
+    protected void onNewIntent(Intent intent) {
+        if (DEBUG) Log.d(TAG, "onNewIntent(), intent=" + intent);
+        super.onNewIntent(intent);
+
+        onPipMenuActionsChanged(getIntent().getParcelableExtra(EXTRA_CUSTOM_ACTIONS));
+    }
+
+    private void restorePipAndFinish() {
+        if (DEBUG) Log.d(TAG, "restorePipAndFinish()");
+
+        if (mRestorePipSizeWhenClose) {
+            if (DEBUG) Log.d(TAG, "   > restoring to the default position");
+
+            // When PIP menu activity is closed, restore to the default position.
+            sPipController.resizePinnedStack(PipController.STATE_PIP);
+        }
+        finish();
+    }
+
+    @Override
+    public void onResume() {
+        if (DEBUG) Log.d(TAG, "onResume()");
+
+        super.onResume();
+        mFadeInAnimation.start();
+    }
+
+    @Override
+    public void onPause() {
+        if (DEBUG) Log.d(TAG, "onPause()");
+
+        super.onPause();
+        mFadeOutAnimation.start();
+        restorePipAndFinish();
+    }
+
+    @Override
+    protected void onDestroy() {
+        if (DEBUG) Log.d(TAG, "onDestroy()");
+
+        super.onDestroy();
+        sPipController.removeListener(this);
+        sPipController.resumePipResizing(
+                PipController.SUSPEND_PIP_RESIZE_REASON_WAITING_FOR_MENU_ACTIVITY_FINISH);
+    }
+
+    @Override
+    public void onBackPressed() {
+        if (DEBUG) Log.d(TAG, "onBackPressed()");
+
+        restorePipAndFinish();
+    }
+
+    @Override
+    public void onPipEntered(String packageName) {
+        if (DEBUG) Log.d(TAG, "onPipEntered(), packageName=" + packageName);
+    }
+
+    @Override
+    public void onPipActivityClosed() {
+        if (DEBUG) Log.d(TAG, "onPipActivityClosed()");
+
+        finish();
+    }
+
+    @Override
+    public void onPipMenuActionsChanged(ParceledListSlice<RemoteAction> actions) {
+        if (DEBUG) Log.d(TAG, "onPipMenuActionsChanged()");
+
+        boolean hasCustomActions = actions != null && !actions.getList().isEmpty();
+        mPipControlsViewController.setActions(
+                hasCustomActions ? actions.getList() : Collections.emptyList());
+    }
+
+    @Override
+    public void onShowPipMenu() {
+        if (DEBUG) Log.d(TAG, "onShowPipMenu()");
+    }
+
+    @Override
+    public void onMoveToFullscreen() {
+        if (DEBUG) Log.d(TAG, "onMoveToFullscreen()");
+
+        // Moving PIP to fullscreen is implemented by resizing PINNED_STACK with null bounds.
+        // This conflicts with restoring PIP position, so disable it.
+        mRestorePipSizeWhenClose = false;
+        finish();
+    }
+
+    @Override
+    public void onPipResizeAboutToStart() {
+        if (DEBUG) Log.d(TAG, "onPipResizeAboutToStart()");
+
+        finish();
+        sPipController.suspendPipResizing(
+                PipController.SUSPEND_PIP_RESIZE_REASON_WAITING_FOR_MENU_ACTIVITY_FINISH);
+    }
+
+    @Override
+    public void finish() {
+        if (DEBUG) Log.d(TAG, "finish()", new RuntimeException());
+
+        super.finish();
+    }
+
+    /**
+     * TODO(b/169395392) Refactor PipMenuActivity to PipMenuView
+     *
+     * @param pipController The singleton pipController instance for TV
+     */
+    public static void setPipController(PipController pipController) {
+        if (sPipController != null) {
+            return;
+        }
+        sPipController = pipController;
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipNotification.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipNotification.java
new file mode 100644
index 0000000..7433085
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/PipNotification.java
@@ -0,0 +1,279 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip.tv;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.RemoteAction;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ParceledListSlice;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.media.MediaMetadata;
+import android.media.session.MediaController;
+import android.media.session.PlaybackState;
+import android.os.UserHandle;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.android.internal.messages.nano.SystemMessageProto.SystemMessage;
+import com.android.wm.shell.R;
+
+/**
+ * A notification that informs users that PIP is running and also provides PIP controls.
+ * <p>Once it's created, it will manage the PIP notification UI by itself except for handling
+ * configuration changes.
+ */
+public class PipNotification {
+    private static final String TAG = "PipNotification";
+    private static final String NOTIFICATION_TAG = PipNotification.class.getSimpleName();
+    private static final boolean DEBUG = PipController.DEBUG;
+
+    private static final String ACTION_MENU = "PipNotification.menu";
+    private static final String ACTION_CLOSE = "PipNotification.close";
+
+    public static final String NOTIFICATION_CHANNEL_TVPIP = "TPP";
+
+    private final PackageManager mPackageManager;
+
+    private final PipController mPipController;
+
+    private final NotificationManager mNotificationManager;
+    private final Notification.Builder mNotificationBuilder;
+
+    private MediaController mMediaController;
+    private String mDefaultTitle;
+    private int mDefaultIconResId;
+
+    /** Package name for the application that owns PiP window. */
+    private String mPackageName;
+    private boolean mNotified;
+    private String mMediaTitle;
+    private Bitmap mArt;
+
+    private PipController.Listener mPipListener = new PipController.Listener() {
+        @Override
+        public void onPipEntered(String packageName) {
+            mPackageName = packageName;
+            updateMediaControllerMetadata();
+            notifyPipNotification();
+        }
+
+        @Override
+        public void onPipActivityClosed() {
+            dismissPipNotification();
+            mPackageName = null;
+        }
+
+        @Override
+        public void onShowPipMenu() {
+            // no-op.
+        }
+
+        @Override
+        public void onPipMenuActionsChanged(ParceledListSlice<RemoteAction> actions) {
+            // no-op.
+        }
+
+        @Override
+        public void onMoveToFullscreen() {
+            dismissPipNotification();
+            mPackageName = null;
+        }
+
+        @Override
+        public void onPipResizeAboutToStart() {
+            // no-op.
+        }
+    };
+
+    private MediaController.Callback mMediaControllerCallback = new MediaController.Callback() {
+        @Override
+        public void onPlaybackStateChanged(PlaybackState state) {
+            if (updateMediaControllerMetadata() && mNotified) {
+                // update notification
+                notifyPipNotification();
+            }
+        }
+
+        @Override
+        public void onMetadataChanged(MediaMetadata metadata) {
+            if (updateMediaControllerMetadata() && mNotified) {
+                // update notification
+                notifyPipNotification();
+            }
+        }
+    };
+
+    private final PipController.MediaListener mPipMediaListener =
+            new PipController.MediaListener() {
+                @Override
+                public void onMediaControllerChanged() {
+                    MediaController newController = mPipController.getMediaController();
+                    if (newController == null || mMediaController == newController) {
+                        return;
+                    }
+                    if (mMediaController != null) {
+                        mMediaController.unregisterCallback(mMediaControllerCallback);
+                    }
+                    mMediaController = newController;
+                    if (mMediaController != null) {
+                        mMediaController.registerCallback(mMediaControllerCallback);
+                    }
+                    if (updateMediaControllerMetadata() && mNotified) {
+                        // update notification
+                        notifyPipNotification();
+                    }
+                }
+            };
+
+    private final BroadcastReceiver mEventReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            if (DEBUG) {
+                Log.d(TAG, "Received " + intent.getAction() + " from the notification UI");
+            }
+            switch (intent.getAction()) {
+                case ACTION_MENU:
+                    mPipController.showPictureInPictureMenu();
+                    break;
+                case ACTION_CLOSE:
+                    mPipController.closePip();
+                    break;
+            }
+        }
+    };
+
+    public PipNotification(Context context, PipController pipController) {
+        mPackageManager = context.getPackageManager();
+
+        mNotificationManager = (NotificationManager) context.getSystemService(
+                Context.NOTIFICATION_SERVICE);
+
+        mNotificationBuilder = new Notification.Builder(context, NOTIFICATION_CHANNEL_TVPIP)
+                .setLocalOnly(true)
+                .setOngoing(false)
+                .setCategory(Notification.CATEGORY_SYSTEM)
+                .extend(new Notification.TvExtender()
+                        .setContentIntent(createPendingIntent(context, ACTION_MENU))
+                        .setDeleteIntent(createPendingIntent(context, ACTION_CLOSE)));
+
+        mPipController = pipController;
+        pipController.addListener(mPipListener);
+        pipController.addMediaListener(mPipMediaListener);
+
+        IntentFilter intentFilter = new IntentFilter();
+        intentFilter.addAction(ACTION_MENU);
+        intentFilter.addAction(ACTION_CLOSE);
+        context.registerReceiver(mEventReceiver, intentFilter, UserHandle.USER_ALL);
+
+        onConfigurationChanged(context);
+    }
+
+    /**
+     * Called by {@link PipController} when the configuration is changed.
+     */
+    void onConfigurationChanged(Context context) {
+        Resources res = context.getResources();
+        mDefaultTitle = res.getString(R.string.pip_notification_unknown_title);
+        mDefaultIconResId = R.drawable.pip_icon;
+        if (mNotified) {
+            // update notification
+            notifyPipNotification();
+        }
+    }
+
+    private void notifyPipNotification() {
+        mNotified = true;
+        mNotificationBuilder
+                .setShowWhen(true)
+                .setWhen(System.currentTimeMillis())
+                .setSmallIcon(mDefaultIconResId)
+                .setContentTitle(getNotificationTitle());
+        if (mArt != null) {
+            mNotificationBuilder.setStyle(new Notification.BigPictureStyle()
+                    .bigPicture(mArt));
+        } else {
+            mNotificationBuilder.setStyle(null);
+        }
+        mNotificationManager.notify(NOTIFICATION_TAG, SystemMessage.NOTE_TV_PIP,
+                mNotificationBuilder.build());
+    }
+
+    private void dismissPipNotification() {
+        mNotified = false;
+        mNotificationManager.cancel(NOTIFICATION_TAG, SystemMessage.NOTE_TV_PIP);
+    }
+
+    private boolean updateMediaControllerMetadata() {
+        String title = null;
+        Bitmap art = null;
+        if (mPipController.getMediaController() != null) {
+            MediaMetadata metadata = mPipController.getMediaController().getMetadata();
+            if (metadata != null) {
+                title = metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE);
+                if (TextUtils.isEmpty(title)) {
+                    title = metadata.getString(MediaMetadata.METADATA_KEY_TITLE);
+                }
+                art = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART);
+                if (art == null) {
+                    art = metadata.getBitmap(MediaMetadata.METADATA_KEY_ART);
+                }
+            }
+        }
+        if (!TextUtils.equals(title, mMediaTitle) || art != mArt) {
+            mMediaTitle = title;
+            mArt = art;
+            return true;
+        }
+        return false;
+    }
+
+
+    private String getNotificationTitle() {
+        if (!TextUtils.isEmpty(mMediaTitle)) {
+            return mMediaTitle;
+        }
+
+        final String applicationTitle = getApplicationLabel(mPackageName);
+        if (!TextUtils.isEmpty(applicationTitle)) {
+            return applicationTitle;
+        }
+
+        return mDefaultTitle;
+    }
+
+    private String getApplicationLabel(String packageName) {
+        try {
+            final ApplicationInfo appInfo = mPackageManager.getApplicationInfo(packageName, 0);
+            return mPackageManager.getApplicationLabel(appInfo).toString();
+        } catch (PackageManager.NameNotFoundException e) {
+            return null;
+        }
+    }
+
+    private static PendingIntent createPendingIntent(Context context, String action) {
+        return PendingIntent.getBroadcast(context, 0,
+                new Intent(action), PendingIntent.FLAG_CANCEL_CURRENT);
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/Android.bp b/libs/WindowManager/Shell/tests/unittest/Android.bp
index 937b00b..9940ea5 100644
--- a/libs/WindowManager/Shell/tests/unittest/Android.bp
+++ b/libs/WindowManager/Shell/tests/unittest/Android.bp
@@ -23,20 +23,30 @@
         "androidx.test.runner",
         "androidx.test.rules",
         "androidx.test.ext.junit",
+        "androidx.dynamicanimation_dynamicanimation",
+        "dagger2",
+        "kotlinx-coroutines-android",
+        "kotlinx-coroutines-core",
         "mockito-target-extended-minus-junit4",
         "truth-prebuilt",
         "testables",
     ],
+
     libs: [
         "android.test.mock",
         "android.test.base",
         "android.test.runner",
     ],
+
     jni_libs: [
         "libdexmakerjvmtiagent",
         "libstaticjvmtiagent",
     ],
 
+    kotlincflags: ["-Xjvm-default=enable"],
+
+    plugins: ["dagger2-compiler"],
+
     optimize: {
         enabled: false,
     },
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/animation/PhysicsAnimatorTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/animation/PhysicsAnimatorTest.kt
new file mode 100644
index 0000000..4bd9bed
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/animation/PhysicsAnimatorTest.kt
@@ -0,0 +1,627 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.animation
+
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import android.util.ArrayMap
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.dynamicanimation.animation.DynamicAnimation
+import androidx.dynamicanimation.animation.FloatPropertyCompat
+import androidx.dynamicanimation.animation.SpringForce
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.wm.shell.animation.PhysicsAnimator.EndListener
+import com.android.wm.shell.animation.PhysicsAnimator.UpdateListener
+import com.android.wm.shell.animation.PhysicsAnimatorTestUtils.clearAnimationUpdateFrames
+import com.android.wm.shell.animation.PhysicsAnimatorTestUtils.getAnimationUpdateFrames
+import com.android.wm.shell.animation.PhysicsAnimatorTestUtils.verifyAnimationUpdateFrames
+import org.junit.After
+import org.junit.Assert
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyFloat
+import org.mockito.Mockito
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.MockitoAnnotations
+
+@TestableLooper.RunWithLooper
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+@Ignore("Blocking presubmits - investigating in b/158697054")
+class PhysicsAnimatorTest : SysuiTestCase() {
+    private lateinit var viewGroup: ViewGroup
+    private lateinit var testView: View
+    private lateinit var testView2: View
+
+    private lateinit var animator: PhysicsAnimator<View>
+
+    private val springConfig = PhysicsAnimator.SpringConfig(
+            SpringForce.STIFFNESS_LOW, SpringForce.DAMPING_RATIO_NO_BOUNCY)
+    private val flingConfig = PhysicsAnimator.FlingConfig(2f)
+
+    private lateinit var mockUpdateListener: UpdateListener<View>
+    private lateinit var mockEndListener: EndListener<View>
+    private lateinit var mockEndAction: Runnable
+
+    private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
+
+    @Before
+    fun setup() {
+        MockitoAnnotations.initMocks(this)
+
+        mockUpdateListener = mock(UpdateListener::class.java) as UpdateListener<View>
+        mockEndListener = mock(EndListener::class.java) as EndListener<View>
+        mockEndAction = mock(Runnable::class.java)
+
+        viewGroup = FrameLayout(context)
+        testView = View(context)
+        testView2 = View(context)
+        viewGroup.addView(testView)
+        viewGroup.addView(testView2)
+
+        PhysicsAnimatorTestUtils.prepareForTest()
+
+        // Most of our tests involve checking the end state of animations, so we want calls that
+        // start animations to block the test thread until the animations have ended.
+        PhysicsAnimatorTestUtils.setAllAnimationsBlock(true)
+
+        animator = PhysicsAnimator.getInstance(testView)
+    }
+
+    @After
+    fun tearDown() {
+        PhysicsAnimatorTestUtils.tearDown()
+    }
+
+    @Test
+    fun testOneAnimatorPerView() {
+        assertEquals(animator, PhysicsAnimator.getInstance(testView))
+        assertEquals(PhysicsAnimator.getInstance(testView), PhysicsAnimator.getInstance(testView))
+        assertNotEquals(animator, PhysicsAnimator.getInstance(testView2))
+    }
+
+    @Test
+    fun testSpringOneProperty() {
+        animator
+                .spring(DynamicAnimation.TRANSLATION_X, 50f, springConfig)
+                .start()
+
+        assertEquals(testView.translationX, 50f, 1f)
+    }
+
+    @Test
+    fun testSpringMultipleProperties() {
+        animator
+                .spring(DynamicAnimation.TRANSLATION_X, 10f, springConfig)
+                .spring(DynamicAnimation.TRANSLATION_Y, 50f, springConfig)
+                .spring(DynamicAnimation.SCALE_Y, 1.1f, springConfig)
+                .start()
+
+        assertEquals(10f, testView.translationX, 1f)
+        assertEquals(50f, testView.translationY, 1f)
+        assertEquals(1.1f, testView.scaleY, 0.01f)
+    }
+
+    @Test
+    fun testFling() {
+        val startTime = System.currentTimeMillis()
+
+        animator
+                .fling(DynamicAnimation.TRANSLATION_X, 1000f /* startVelocity */, flingConfig)
+                .fling(DynamicAnimation.TRANSLATION_Y, 500f, flingConfig)
+                .start()
+
+        val elapsedTimeSeconds = (System.currentTimeMillis() - startTime) / 1000f
+
+        // If the fling worked, the view should be somewhere between its starting position and the
+        // and the theoretical no-friction maximum of startVelocity (in pixels per second)
+        // multiplied by elapsedTimeSeconds. We can't calculate an exact expected location for a
+        // fling, so this is close enough.
+        assertTrue(testView.translationX > 0f)
+        assertTrue(testView.translationX < 1000f * elapsedTimeSeconds)
+        assertTrue(testView.translationY > 0f)
+        assertTrue(testView.translationY < 500f * elapsedTimeSeconds)
+    }
+
+    @Test
+    @Throws(InterruptedException::class)
+    @Ignore("Increasingly flaky")
+    fun testEndListenersAndActions() {
+        PhysicsAnimatorTestUtils.setAllAnimationsBlock(false)
+        animator
+                .spring(DynamicAnimation.TRANSLATION_X, 10f, springConfig)
+                .spring(DynamicAnimation.TRANSLATION_Y, 500f, springConfig)
+                .addEndListener(mockEndListener)
+                .withEndActions(mockEndAction::run)
+                .start()
+
+        PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(animator, DynamicAnimation.TRANSLATION_X)
+
+        // Once TRANSLATION_X is done, the view should be at x = 10...
+        assertEquals(10f, testView.translationX, 1f)
+
+        // / ...TRANSLATION_Y should still be running...
+        assertTrue(animator.isPropertyAnimating(DynamicAnimation.TRANSLATION_Y))
+
+        // ...and our end listener should have been called with x = 10, velocity = 0, and allEnded =
+        // false since TRANSLATION_Y is still running.
+        verify(mockEndListener).onAnimationEnd(
+                testView,
+                DynamicAnimation.TRANSLATION_X,
+                wasFling = false,
+                canceled = false,
+                finalValue = 10f,
+                finalVelocity = 0f,
+                allRelevantPropertyAnimsEnded = false)
+        verifyNoMoreInteractions(mockEndListener)
+
+        // The end action should not have been run yet.
+        verify(mockEndAction, times(0)).run()
+
+        // Block until TRANSLATION_Y finishes.
+        PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(animator, DynamicAnimation.TRANSLATION_Y)
+
+        // The view should have been moved.
+        assertEquals(10f, testView.translationX, 1f)
+        assertEquals(500f, testView.translationY, 1f)
+
+        // The end listener should have been called, this time with TRANSLATION_Y, y = 50, and
+        // allEnded = true.
+        verify(mockEndListener).onAnimationEnd(
+                testView,
+                DynamicAnimation.TRANSLATION_Y,
+                wasFling = false,
+                canceled = false,
+                finalValue = 500f,
+                finalVelocity = 0f,
+                allRelevantPropertyAnimsEnded = true)
+        verifyNoMoreInteractions(mockEndListener)
+
+        // Now that all properties are done animating, the end action should have been called.
+        verify(mockEndAction, times(1)).run()
+    }
+
+    @Test
+    fun testUpdateListeners() {
+        animator
+                .spring(DynamicAnimation.TRANSLATION_X, 100f, springConfig)
+                .spring(DynamicAnimation.TRANSLATION_Y, 50f, springConfig)
+                .addUpdateListener(object : UpdateListener<View> {
+                    override fun onAnimationUpdateForProperty(
+                        target: View,
+                        values: UpdateMap<View>
+                    ) {
+                        mockUpdateListener.onAnimationUpdateForProperty(target, values)
+                    }
+                })
+                .start()
+
+        verifyUpdateListenerCalls(animator, mockUpdateListener)
+    }
+
+    @Test
+    fun testListenersNotCalledOnSubsequentAnimations() {
+        animator
+                .spring(DynamicAnimation.TRANSLATION_X, 10f, springConfig)
+                .addUpdateListener(mockUpdateListener)
+                .addEndListener(mockEndListener)
+                .withEndActions(mockEndAction::run)
+                .start()
+
+        verifyUpdateListenerCalls(animator, mockUpdateListener)
+        verify(mockEndListener, times(1)).onAnimationEnd(
+                eq(testView), eq(DynamicAnimation.TRANSLATION_X), eq(false), eq(false), anyFloat(),
+                anyFloat(), eq(true))
+        verify(mockEndAction, times(1)).run()
+
+        animator
+                .spring(DynamicAnimation.TRANSLATION_X, 0f, springConfig)
+                .start()
+
+        // We didn't pass any of the listeners/actions to the subsequent animation, so they should
+        // never have been called.
+        verifyNoMoreInteractions(mockUpdateListener)
+        verifyNoMoreInteractions(mockEndListener)
+        verifyNoMoreInteractions(mockEndAction)
+    }
+
+    @Test
+    @Throws(InterruptedException::class)
+    fun testAnimationsUpdatedWhileInMotion() {
+        PhysicsAnimatorTestUtils.setAllAnimationsBlock(false)
+
+        // Spring towards x = 100f.
+        animator
+                .spring(
+                        DynamicAnimation.TRANSLATION_X,
+                        100f,
+                        springConfig)
+                .start()
+
+        // Block until it reaches x = 50f.
+        PhysicsAnimatorTestUtils.blockUntilFirstAnimationFrameWhereTrue(
+                animator) { view -> view.translationX > 50f }
+
+        // Translation X value at the time of reversing the animation to spring to x = 0f.
+        val reversalTranslationX = testView.translationX
+
+        // Spring back towards 0f.
+        animator
+                .spring(
+                        DynamicAnimation.TRANSLATION_X,
+                        0f,
+                        // Lower the stiffness to ensure the update listener receives at least one
+                        // update frame where the view has continued to move to the right.
+                        springConfig.apply { stiffness = SpringForce.STIFFNESS_LOW })
+                .start()
+
+        // Wait for TRANSLATION_X.
+        PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(animator, DynamicAnimation.TRANSLATION_X)
+
+        // Verify that the animation continued past the X value at the time of reversal, before
+        // springing back. This ensures the change in direction was not abrupt.
+        verifyAnimationUpdateFrames(
+                animator, DynamicAnimation.TRANSLATION_X,
+                { u -> u.value > reversalTranslationX },
+                { u -> u.value < reversalTranslationX })
+
+        // Verify that the view is where it should be.
+        assertEquals(0f, testView.translationX, 1f)
+    }
+
+    @Test
+    @Throws(InterruptedException::class)
+    @Ignore("Sporadically flaking.")
+    fun testAnimationsUpdatedWhileInMotion_originalListenersStillCalled() {
+        PhysicsAnimatorTestUtils.setAllAnimationsBlock(false)
+
+        // Spring TRANSLATION_X to 100f, with an update and end listener provided.
+        animator
+                .spring(
+                        DynamicAnimation.TRANSLATION_X,
+                        100f,
+                        // Use very low stiffness to ensure that all of the keyframes we're testing
+                        // for are reported to the update listener.
+                        springConfig.apply { stiffness = SpringForce.STIFFNESS_VERY_LOW })
+                .addUpdateListener(mockUpdateListener)
+                .addEndListener(mockEndListener)
+                .start()
+
+        // Wait until the animation is halfway there.
+        PhysicsAnimatorTestUtils.blockUntilFirstAnimationFrameWhereTrue(
+                animator) { view -> view.translationX > 50f }
+
+        // The end listener shouldn't have been called since the animation hasn't ended.
+        verifyNoMoreInteractions(mockEndListener)
+
+        // Make sure we called the update listener with appropriate values.
+        verifyAnimationUpdateFrames(animator, DynamicAnimation.TRANSLATION_X,
+                { u -> u.value > 0f },
+                { u -> u.value >= 50f })
+
+        // Mock a second end listener.
+        val secondEndListener = mock(EndListener::class.java) as EndListener<View>
+        val secondUpdateListener = mock(UpdateListener::class.java) as UpdateListener<View>
+
+        // Start a new animation that springs both TRANSLATION_X and TRANSLATION_Y, and provide it
+        // the second end listener. This new end listener should be called for the end of
+        // TRANSLATION_X and TRANSLATION_Y, with allEnded = true when both have ended.
+        animator
+                .spring(DynamicAnimation.TRANSLATION_X, 200f, springConfig)
+                .spring(DynamicAnimation.TRANSLATION_Y, 4000f, springConfig)
+                .addUpdateListener(secondUpdateListener)
+                .addEndListener(secondEndListener)
+                .start()
+
+        // Wait for TRANSLATION_X to end.
+        PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(animator, DynamicAnimation.TRANSLATION_X)
+
+        // The update listener provided to the initial animation call (the one that only animated
+        // TRANSLATION_X) should have been called with values on the way to x = 200f. This is
+        // because the second animation call updated the original TRANSLATION_X animation.
+        verifyAnimationUpdateFrames(
+                animator, DynamicAnimation.TRANSLATION_X,
+                { u -> u.value > 100f }, { u -> u.value >= 200f })
+
+        // The original end listener should also have been called, with allEnded = true since it was
+        // provided to an animator that animated only TRANSLATION_X.
+        verify(mockEndListener, times(1))
+                .onAnimationEnd(
+                        testView, DynamicAnimation.TRANSLATION_X,
+                        wasFling = false,
+                        canceled = false,
+                        finalValue = 200f,
+                        finalVelocity = 0f,
+                        allRelevantPropertyAnimsEnded = true)
+        verifyNoMoreInteractions(mockEndListener)
+
+        // The second end listener should have been called, but with allEnded = false since it was
+        // provided to an animator that animated both TRANSLATION_X and TRANSLATION_Y.
+        verify(secondEndListener, times(1))
+                .onAnimationEnd(testView, DynamicAnimation.TRANSLATION_X,
+                        wasFling = false,
+                        canceled = false,
+                        finalValue = 200f,
+                        finalVelocity = 0f,
+                        allRelevantPropertyAnimsEnded = false)
+        verifyNoMoreInteractions(secondEndListener)
+
+        PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(animator, DynamicAnimation.TRANSLATION_Y)
+
+        // The original end listener shouldn't receive any callbacks because it was not provided to
+        // an animator that animated TRANSLATION_Y.
+        verifyNoMoreInteractions(mockEndListener)
+
+        verify(secondEndListener, times(1))
+                .onAnimationEnd(testView, DynamicAnimation.TRANSLATION_Y,
+                        wasFling = false,
+                        canceled = false,
+                        finalValue = 4000f,
+                        finalVelocity = 0f,
+                        allRelevantPropertyAnimsEnded = true)
+        verifyNoMoreInteractions(secondEndListener)
+    }
+
+    @Test
+    fun testFlingRespectsMinMax() {
+        animator
+                .fling(DynamicAnimation.TRANSLATION_X,
+                        startVelocity = 1000f,
+                        friction = 1.1f,
+                        max = 10f)
+                .addEndListener(mockEndListener)
+                .start()
+
+        // Ensure that the view stopped at x = 10f, and the end listener was called once with that
+        // value.
+        assertEquals(10f, testView.translationX, 1f)
+        verify(mockEndListener, times(1))
+                .onAnimationEnd(
+                        eq(testView), eq(DynamicAnimation.TRANSLATION_X), eq(true), eq(false),
+                        eq(10f), anyFloat(), eq(true))
+
+        animator
+                .fling(
+                        DynamicAnimation.TRANSLATION_X,
+                        startVelocity = -1000f,
+                        friction = 1.1f,
+                        min = -5f)
+                .addEndListener(mockEndListener)
+                .start()
+
+        // Ensure that the view stopped at x = -5f, and the end listener was called once with that
+        // value.
+        assertEquals(-5f, testView.translationX, 1f)
+        verify(mockEndListener, times(1))
+                .onAnimationEnd(
+                        eq(testView), eq(DynamicAnimation.TRANSLATION_X), eq(true), eq(false),
+                        eq(-5f), anyFloat(), eq(true))
+    }
+
+    @Test
+    fun testIsPropertyAnimating() {
+        PhysicsAnimatorTestUtils.setAllAnimationsBlock(false)
+
+        testView.physicsAnimator
+                .spring(DynamicAnimation.TRANSLATION_X, 500f, springConfig)
+                .fling(DynamicAnimation.TRANSLATION_Y, 10f, flingConfig)
+                .spring(DynamicAnimation.TRANSLATION_Z, 1000f, springConfig)
+                .start()
+
+        // All of the properties we just started should be animating.
+        assertTrue(testView.physicsAnimator.isPropertyAnimating(DynamicAnimation.TRANSLATION_X))
+        assertTrue(testView.physicsAnimator.isPropertyAnimating(DynamicAnimation.TRANSLATION_Y))
+        assertTrue(testView.physicsAnimator.isPropertyAnimating(DynamicAnimation.TRANSLATION_Z))
+
+        // Block until x and y end.
+        PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(testView.physicsAnimator,
+                DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y)
+
+        // Verify that x and y are no longer animating, but that Z is (it's springing to 1000f).
+        assertFalse(testView.physicsAnimator.isPropertyAnimating(DynamicAnimation.TRANSLATION_X))
+        assertFalse(testView.physicsAnimator.isPropertyAnimating(DynamicAnimation.TRANSLATION_Y))
+        assertTrue(testView.physicsAnimator.isPropertyAnimating(DynamicAnimation.TRANSLATION_Z))
+
+        PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_Z)
+
+        assertFalse(testView.physicsAnimator.isPropertyAnimating(DynamicAnimation.TRANSLATION_X))
+        assertFalse(testView.physicsAnimator.isPropertyAnimating(DynamicAnimation.TRANSLATION_Y))
+        assertFalse(testView.physicsAnimator.isPropertyAnimating(DynamicAnimation.TRANSLATION_Z))
+    }
+
+    @Test
+    fun testExtensionProperty() {
+        testView
+                .physicsAnimator
+                .spring(DynamicAnimation.TRANSLATION_X, 200f)
+                .start()
+
+        assertEquals(200f, testView.translationX, 1f)
+    }
+
+    @Test
+    @Ignore("Sporadically flaking.")
+    fun testFlingThenSpring() {
+        PhysicsAnimatorTestUtils.setAllAnimationsBlock(false)
+
+        // Start at 500f and fling hard to the left. We should quickly reach the 250f minimum, fly
+        // past it since there's so much velocity remaining, then spring back to 250f.
+        testView.translationX = 500f
+        animator
+                .flingThenSpring(
+                        DynamicAnimation.TRANSLATION_X,
+                        -5000f,
+                        flingConfig.apply { min = 250f },
+                        springConfig)
+                .addUpdateListener(mockUpdateListener)
+                .addEndListener(mockEndListener)
+                .withEndActions(mockEndAction::run)
+                .start()
+
+        // Block until we pass the minimum.
+        PhysicsAnimatorTestUtils.blockUntilFirstAnimationFrameWhereTrue(
+                animator) { v -> v.translationX <= 250f }
+
+        // Double check that the view is there.
+        assertTrue(testView.translationX <= 250f)
+
+        // The update listener should have been called with a value < 500f, and then a value less
+        // than or equal to the 250f minimum.
+        verifyAnimationUpdateFrames(animator, DynamicAnimation.TRANSLATION_X,
+                { u -> u.value < 500f },
+                { u -> u.value <= 250f })
+
+        // Despite the fact that the fling has ended, the end listener shouldn't have been called
+        // since we're about to begin springing the same property.
+        verifyNoMoreInteractions(mockEndListener)
+        verifyNoMoreInteractions(mockEndAction)
+
+        // Wait for the spring to finish.
+        PhysicsAnimatorTestUtils.blockUntilAnimationsEnd(DynamicAnimation.TRANSLATION_X)
+
+        // Make sure we continued past 250f since the spring should have been started with some
+        // remaining negative velocity from the fling.
+        verifyAnimationUpdateFrames(animator, DynamicAnimation.TRANSLATION_X,
+                { u -> u.value < 250f })
+
+        // At this point, the animation end listener should have been called once, and only once,
+        // when the spring ended at 250f.
+        verify(mockEndListener).onAnimationEnd(testView, DynamicAnimation.TRANSLATION_X,
+                wasFling = false,
+                canceled = false,
+                finalValue = 250f,
+                finalVelocity = 0f,
+                allRelevantPropertyAnimsEnded = true)
+        verifyNoMoreInteractions(mockEndListener)
+
+        // The end action should also have been called once.
+        verify(mockEndAction, times(1)).run()
+        verifyNoMoreInteractions(mockEndAction)
+
+        assertEquals(250f, testView.translationX)
+    }
+
+    @Test
+    fun testFlingThenSpring_objectOutsideFlingBounds() {
+        // Start the view at x = -500, well outside the fling bounds of min = 0f, with strong
+        // negative velocity.
+        testView.translationX = -500f
+        animator
+                .flingThenSpring(
+                        DynamicAnimation.TRANSLATION_X,
+                        -5000f,
+                        flingConfig.apply { min = 0f },
+                        springConfig)
+                .addUpdateListener(mockUpdateListener)
+                .addEndListener(mockEndListener)
+                .withEndActions(mockEndAction::run)
+                .start()
+
+        // The initial -5000f velocity should result in frames to the left of -500f before the view
+        // springs back towards 0f.
+        verifyAnimationUpdateFrames(
+                animator, DynamicAnimation.TRANSLATION_X,
+                { u -> u.value < -500f },
+                { u -> u.value > -500f })
+
+        // We should end up at the fling min.
+        assertEquals(0f, testView.translationX, 1f)
+    }
+
+    @Test
+    fun testFlingToMinMaxThenSpring() {
+        // Start at x = 500f.
+        testView.translationX = 500f
+
+        // Fling to the left at the very sad rate of -1 pixels per second. That won't get us much of
+        // anywhere, and certainly not to the 0f min.
+        animator
+                // Good thing we have flingToMinMaxThenSpring!
+                .flingThenSpring(
+                        DynamicAnimation.TRANSLATION_X,
+                        -10000f,
+                        flingConfig.apply { min = 0f },
+                        springConfig,
+                        flingMustReachMinOrMax = true)
+                .addUpdateListener(mockUpdateListener)
+                .addEndListener(mockEndListener)
+                .withEndActions(mockEndAction::run)
+                .start()
+
+        // Thanks, flingToMinMaxThenSpring, for adding enough velocity to get us here.
+        assertEquals(0f, testView.translationX, 1f)
+    }
+
+    /**
+     * Verifies that the calls to the mock update listener match the animation update frames
+     * reported by the test internal listener, in order.
+     */
+    private fun <T : Any> verifyUpdateListenerCalls(
+        animator: PhysicsAnimator<T>,
+        mockUpdateListener: UpdateListener<T>
+    ) {
+        val updates = getAnimationUpdateFrames(animator)
+
+        for (invocation in Mockito.mockingDetails(mockUpdateListener).invocations) {
+
+            // Grab the update map of Property -> AnimationUpdate that was passed to the mock update
+            // listener.
+            val updateMap = invocation.arguments[1]
+                    as ArrayMap<FloatPropertyCompat<in T>, PhysicsAnimator.AnimationUpdate>
+
+            //
+            for ((property, update) in updateMap) {
+                val updatesForProperty = updates[property]!!
+
+                // This update should be the next one in the list for this property.
+                if (update != updatesForProperty[0]) {
+                    Assert.fail("The update listener was called with an unexpected value: $update.")
+                }
+
+                updatesForProperty.remove(update)
+            }
+
+            val target = animator.weakTarget.get()
+            assertNotNull(target)
+            // Mark this invocation verified.
+            verify(mockUpdateListener).onAnimationUpdateForProperty(target!!, updateMap)
+        }
+
+        verifyNoMoreInteractions(mockUpdateListener)
+
+        // Since we were removing values as matching invocations were found, there should no longer
+        // be any values remaining. If there are, it means the update listener wasn't notified when
+        // it should have been.
+        assertEquals(0,
+                updates.values.fold(0, { count, propertyUpdates -> count + propertyUpdates.size }))
+
+        clearAnimationUpdateFrames(animator)
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/magnetictarget/MagnetizedObjectTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/magnetictarget/MagnetizedObjectTest.kt
new file mode 100644
index 0000000..fe53641
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/magnetictarget/MagnetizedObjectTest.kt
@@ -0,0 +1,454 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.common.magnetictarget
+
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import android.view.MotionEvent
+import android.view.View
+import androidx.dynamicanimation.animation.FloatPropertyCompat
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.util.animation.PhysicsAnimatorTestUtils
+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.ArgumentMatchers
+import org.mockito.ArgumentMatchers.anyFloat
+import org.mockito.Mockito
+import org.mockito.Mockito.`when`
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+
+@TestableLooper.RunWithLooper
+@RunWith(AndroidTestingRunner::class)
+@SmallTest
+class MagnetizedObjectTest : SysuiTestCase() {
+    /** Incrementing value for fake MotionEvent timestamps. */
+    private var time = 0L
+
+    /** Value to add to each new MotionEvent's timestamp. */
+    private var timeStep = 100
+
+    private val underlyingObject = this
+
+    private lateinit var targetView: View
+
+    private val targetSize = 200
+    private val targetCenterX = 500
+    private val targetCenterY = 900
+    private val magneticFieldRadius = 200
+
+    private var objectX = 0f
+    private var objectY = 0f
+    private val objectSize = 50f
+
+    private lateinit var magneticTarget: MagnetizedObject.MagneticTarget
+    private lateinit var magnetizedObject: MagnetizedObject<*>
+    private lateinit var magnetListener: MagnetizedObject.MagnetListener
+
+    private val xProperty = object : FloatPropertyCompat<MagnetizedObjectTest>("") {
+        override fun setValue(target: MagnetizedObjectTest?, value: Float) {
+            objectX = value
+        }
+        override fun getValue(target: MagnetizedObjectTest?): Float {
+            return objectX
+        }
+    }
+
+    private val yProperty = object : FloatPropertyCompat<MagnetizedObjectTest>("") {
+        override fun setValue(target: MagnetizedObjectTest?, value: Float) {
+            objectY = value
+        }
+
+        override fun getValue(target: MagnetizedObjectTest?): Float {
+            return objectY
+        }
+    }
+
+    @Before
+    fun setup() {
+        PhysicsAnimatorTestUtils.prepareForTest()
+
+        // Mock the view since a real view's getLocationOnScreen() won't work unless it's attached
+        // to a real window (it'll always return x = 0, y = 0).
+        targetView = mock(View::class.java)
+        `when`(targetView.context).thenReturn(context)
+
+        // The mock target view will pretend that it's 200x200, and at (400, 800). This means it's
+        // occupying the bounds (400, 800, 600, 1000) and it has a center of (500, 900).
+        `when`(targetView.width).thenReturn(targetSize)  // width = 200
+        `when`(targetView.height).thenReturn(targetSize) // height = 200
+        doAnswer { invocation ->
+            (invocation.arguments[0] as IntArray).also { location ->
+                // Return the top left of the target.
+                location[0] = targetCenterX - targetSize / 2 // x = 400
+                location[1] = targetCenterY - targetSize / 2 // y = 800
+            }
+        }.`when`(targetView).getLocationOnScreen(ArgumentMatchers.any())
+        doAnswer { invocation ->
+            (invocation.arguments[0] as Runnable).run()
+            true
+        }.`when`(targetView).post(ArgumentMatchers.any())
+        `when`(targetView.context).thenReturn(context)
+
+        magneticTarget = MagnetizedObject.MagneticTarget(targetView, magneticFieldRadius)
+
+        magnetListener = mock(MagnetizedObject.MagnetListener::class.java)
+        magnetizedObject = object : MagnetizedObject<MagnetizedObjectTest>(
+                context, underlyingObject, xProperty, yProperty) {
+            override fun getWidth(underlyingObject: MagnetizedObjectTest): Float {
+                return objectSize
+            }
+
+            override fun getHeight(underlyingObject: MagnetizedObjectTest): Float {
+                return objectSize
+            }
+
+            override fun getLocationOnScreen(
+                underlyingObject: MagnetizedObjectTest,
+                loc: IntArray
+            ) {
+                loc[0] = objectX.toInt()
+                loc[1] = objectY.toInt() }
+        }
+
+        magnetizedObject.magnetListener = magnetListener
+        magnetizedObject.addTarget(magneticTarget)
+
+        timeStep = 100
+    }
+
+    @Test
+    fun testMotionEventConsumption() {
+        // Start at (0, 0). No magnetic field here.
+        assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
+                x = 0, y = 0, action = MotionEvent.ACTION_DOWN)))
+
+        // Move to (400, 400), which is solidly outside the magnetic field.
+        assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
+                x = 200, y = 200)))
+
+        // Move to (305, 705). This would be in the magnetic field radius if magnetic fields were
+        // square. It's not, because they're not.
+        assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
+                x = targetCenterX - magneticFieldRadius + 5,
+                y = targetCenterY - magneticFieldRadius + 5)))
+
+        // Move to (400, 800). That's solidly in the radius so the magnetic target should begin
+        // consuming events.
+        assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
+                x = targetCenterX - 100,
+                y = targetCenterY - 100)))
+
+        // Release at (400, 800). Since we're in the magnetic target, it should return true and
+        // consume the ACTION_UP.
+        assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
+                x = 400, y = 800, action = MotionEvent.ACTION_UP)))
+
+        // ACTION_DOWN outside the field.
+        assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
+                x = 200, y = 200, action = MotionEvent.ACTION_DOWN)))
+
+        // Move to the center. We absolutely should consume events there.
+        assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
+                x = targetCenterX,
+                y = targetCenterY)))
+
+        // Drag out to (0, 0) and we should be returning false again.
+        assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
+                x = 0, y = 0)))
+
+        // The ACTION_UP event shouldn't be consumed either since it's outside the field.
+        assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
+                x = 0, y = 0, action = MotionEvent.ACTION_UP)))
+    }
+
+    @Test
+    fun testMotionEventConsumption_downInMagneticField() {
+        // We should not consume DOWN events even if they occur in the field.
+        assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
+                x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_DOWN)))
+    }
+
+    @Test
+    fun testMoveIntoAroundAndOutOfMagneticField() {
+        // Move around but don't touch the magnetic field.
+        dispatchMotionEvents(
+                getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN),
+                getMotionEvent(x = 100, y = 100),
+                getMotionEvent(x = 200, y = 200))
+
+        // You can't become unstuck if you were never stuck in the first place.
+        verify(magnetListener, never()).onStuckToTarget(magneticTarget)
+        verify(magnetListener, never()).onUnstuckFromTarget(
+                eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
+                eq(false))
+
+        // Move into and then around inside the magnetic field.
+        dispatchMotionEvents(
+                getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100),
+                getMotionEvent(x = targetCenterX, y = targetCenterY),
+                getMotionEvent(x = targetCenterX + 100, y = targetCenterY + 100))
+
+        // We should only have received one call to onStuckToTarget and none to unstuck.
+        verify(magnetListener, times(1)).onStuckToTarget(magneticTarget)
+        verify(magnetListener, never()).onUnstuckFromTarget(
+                eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
+                eq(false))
+
+        // Move out of the field and then release.
+        dispatchMotionEvents(
+                getMotionEvent(x = 100, y = 100),
+                getMotionEvent(x = 100, y = 100, action = MotionEvent.ACTION_UP))
+
+        // We should have received one unstuck call and no more stuck calls. We also should never
+        // have received an onReleasedInTarget call.
+        verify(magnetListener, times(1)).onUnstuckFromTarget(
+                eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
+                eq(false))
+        verifyNoMoreInteractions(magnetListener)
+    }
+
+    @Test
+    fun testMoveIntoOutOfAndBackIntoMagneticField() {
+        // Move into the field
+        dispatchMotionEvents(
+                getMotionEvent(
+                        x = targetCenterX - magneticFieldRadius,
+                        y = targetCenterY - magneticFieldRadius,
+                        action = MotionEvent.ACTION_DOWN),
+                getMotionEvent(
+                        x = targetCenterX, y = targetCenterY))
+
+        verify(magnetListener, times(1)).onStuckToTarget(magneticTarget)
+        verify(magnetListener, never()).onReleasedInTarget(magneticTarget)
+
+        // Move back out.
+        dispatchMotionEvents(
+                getMotionEvent(
+                        x = targetCenterX - magneticFieldRadius,
+                        y = targetCenterY - magneticFieldRadius))
+
+        verify(magnetListener, times(1)).onUnstuckFromTarget(
+                eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
+                eq(false))
+        verify(magnetListener, never()).onReleasedInTarget(magneticTarget)
+
+        // Move in again and release in the magnetic field.
+        dispatchMotionEvents(
+                getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100),
+                getMotionEvent(x = targetCenterX + 50, y = targetCenterY + 50),
+                getMotionEvent(x = targetCenterX, y = targetCenterY),
+                getMotionEvent(
+                        x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_UP))
+
+        verify(magnetListener, times(2)).onStuckToTarget(magneticTarget)
+        verify(magnetListener).onReleasedInTarget(magneticTarget)
+        verifyNoMoreInteractions(magnetListener)
+    }
+
+    @Test
+    fun testFlingTowardsTarget_towardsTarget() {
+        timeStep = 10
+
+        // Forcefully fling the object towards the target (but never touch the magnetic field).
+        dispatchMotionEvents(
+                getMotionEvent(
+                        x = targetCenterX,
+                        y = 0,
+                        action = MotionEvent.ACTION_DOWN),
+                getMotionEvent(
+                        x = targetCenterX,
+                        y = targetCenterY / 2),
+                getMotionEvent(
+                        x = targetCenterX,
+                        y = targetCenterY - magneticFieldRadius * 2,
+                        action = MotionEvent.ACTION_UP))
+
+        // Nevertheless it should have ended up stuck to the target.
+        verify(magnetListener, times(1)).onStuckToTarget(magneticTarget)
+    }
+
+    @Test
+    fun testFlingTowardsTarget_towardsButTooSlow() {
+        // Very, very slowly fling the object towards the target (but never touch the magnetic
+        // field). This value is only used to create MotionEvent timestamps, it will not block the
+        // test for 10 seconds.
+        timeStep = 10000
+        dispatchMotionEvents(
+                getMotionEvent(
+                        x = targetCenterX,
+                        y = 0,
+                        action = MotionEvent.ACTION_DOWN),
+                getMotionEvent(
+                        x = targetCenterX,
+                        y = targetCenterY / 2),
+                getMotionEvent(
+                        x = targetCenterX,
+                        y = targetCenterY - magneticFieldRadius * 2,
+                        action = MotionEvent.ACTION_UP))
+
+        // No sticking should have occurred.
+        verifyNoMoreInteractions(magnetListener)
+    }
+
+    @Test
+    fun testFlingTowardsTarget_missTarget() {
+        timeStep = 10
+        // Forcefully fling the object down, but not towards the target.
+        dispatchMotionEvents(
+                getMotionEvent(
+                        x = 0,
+                        y = 0,
+                        action = MotionEvent.ACTION_DOWN),
+                getMotionEvent(
+                        x = 0,
+                        y = targetCenterY / 2),
+                getMotionEvent(
+                        x = 0,
+                        y = targetCenterY - magneticFieldRadius * 2,
+                        action = MotionEvent.ACTION_UP))
+
+        verifyNoMoreInteractions(magnetListener)
+    }
+
+    @Test
+    fun testMagnetAnimation() {
+        // Make sure the object starts at (0, 0).
+        assertEquals(0f, objectX)
+        assertEquals(0f, objectY)
+
+        // Trigger the magnet animation, and block the test until it ends.
+        PhysicsAnimatorTestUtils.setAllAnimationsBlock(true)
+        magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
+                x = targetCenterX - 250,
+                y = targetCenterY - 250,
+                action = MotionEvent.ACTION_DOWN))
+
+        magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
+                x = targetCenterX,
+                y = targetCenterY))
+
+        // The object's (top-left) position should now position it centered over the target.
+        assertEquals(targetCenterX - objectSize / 2, objectX)
+        assertEquals(targetCenterY - objectSize / 2, objectY)
+    }
+
+    @Test
+    fun testMultipleTargets() {
+        val secondMagneticTarget = getSecondMagneticTarget()
+
+        // Drag into the second target.
+        dispatchMotionEvents(
+                getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN),
+                getMotionEvent(x = 100, y = 900))
+
+        // Verify that we received an onStuck for the second target, and no others.
+        verify(magnetListener).onStuckToTarget(secondMagneticTarget)
+        verifyNoMoreInteractions(magnetListener)
+
+        // Drag into the original target.
+        dispatchMotionEvents(
+                getMotionEvent(x = 0, y = 0),
+                getMotionEvent(x = 500, y = 900))
+
+        // We should have unstuck from the second one and stuck into the original one.
+        verify(magnetListener).onUnstuckFromTarget(
+                eq(secondMagneticTarget), anyFloat(), anyFloat(), eq(false))
+        verify(magnetListener).onStuckToTarget(magneticTarget)
+        verifyNoMoreInteractions(magnetListener)
+    }
+
+    @Test
+    fun testMultipleTargets_flingIntoSecond() {
+        val secondMagneticTarget = getSecondMagneticTarget()
+
+        timeStep = 10
+
+        // Fling towards the second target.
+        dispatchMotionEvents(
+                getMotionEvent(x = 100, y = 0, action = MotionEvent.ACTION_DOWN),
+                getMotionEvent(x = 100, y = 350),
+                getMotionEvent(x = 100, y = 650, action = MotionEvent.ACTION_UP))
+
+        // Verify that we received an onStuck for the second target.
+        verify(magnetListener).onStuckToTarget(secondMagneticTarget)
+
+        // Fling towards the first target.
+        dispatchMotionEvents(
+                getMotionEvent(x = 300, y = 0, action = MotionEvent.ACTION_DOWN),
+                getMotionEvent(x = 400, y = 350),
+                getMotionEvent(x = 500, y = 650, action = MotionEvent.ACTION_UP))
+
+        // Verify that we received onStuck for the original target.
+        verify(magnetListener).onStuckToTarget(magneticTarget)
+    }
+
+    private fun getSecondMagneticTarget(): MagnetizedObject.MagneticTarget {
+        // The first target view is at bounds (400, 800, 600, 1000) and it has a center of
+        // (500, 900). We'll add a second one at bounds (0, 800, 200, 1000) with center (100, 900).
+        val secondTargetView = mock(View::class.java)
+        var secondTargetCenterX = 100
+        var secondTargetCenterY = 900
+
+        `when`(secondTargetView.context).thenReturn(context)
+        `when`(secondTargetView.width).thenReturn(targetSize)  // width = 200
+        `when`(secondTargetView.height).thenReturn(targetSize) // height = 200
+        doAnswer { invocation ->
+            (invocation.arguments[0] as Runnable).run()
+            true
+        }.`when`(secondTargetView).post(ArgumentMatchers.any())
+        doAnswer { invocation ->
+            (invocation.arguments[0] as IntArray).also { location ->
+                // Return the top left of the target.
+                location[0] = secondTargetCenterX - targetSize / 2 // x = 0
+                location[1] = secondTargetCenterY - targetSize / 2 // y = 800
+            }
+        }.`when`(secondTargetView).getLocationOnScreen(ArgumentMatchers.any())
+
+        return magnetizedObject.addTarget(secondTargetView, magneticFieldRadius)
+    }
+
+    /**
+     * Return a MotionEvent at the given coordinates, with the given action (or MOVE by default).
+     * The event's time fields will be incremented by 10ms each time this is called, so tha
+     * VelocityTracker works.
+     */
+    private fun getMotionEvent(
+        x: Int,
+        y: Int,
+        action: Int = MotionEvent.ACTION_MOVE
+    ): MotionEvent {
+        return MotionEvent.obtain(time, time, action, x.toFloat(), y.toFloat(), 0)
+                .also { time += timeStep }
+    }
+
+    /** Dispatch all of the provided events to the target view. */
+    private fun dispatchMotionEvents(vararg events: MotionEvent) {
+        events.forEach { magnetizedObject.maybeConsumeMotionEvent(it) }
+    }
+
+    /** Prevents Kotlin from being mad that eq() is nullable. */
+    private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
+}
\ No newline at end of file
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipAnimationControllerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipAnimationControllerTest.java
new file mode 100644
index 0000000..7d51886
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipAnimationControllerTest.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (C) 2020 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.systemui.pip;
+
+import static com.android.wm.shell.pip.PipAnimationController.TRANSITION_DIRECTION_LEAVE_PIP;
+import static com.android.wm.shell.pip.PipAnimationController.TRANSITION_DIRECTION_TO_PIP;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.view.SurfaceControl;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.wm.shell.pip.PipAnimationController;
+import com.android.wm.shell.pip.PipSurfaceTransactionHelper;
+import com.android.wm.shell.pip.PipTestCase;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Unit tests against {@link PipAnimationController} to ensure that it sends the right callbacks
+ * depending on the various interactions.
+ */
+@RunWith(AndroidTestingRunner.class)
+@SmallTest
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+public class PipAnimationControllerTest extends PipTestCase {
+
+    private PipAnimationController mPipAnimationController;
+
+    private SurfaceControl mLeash;
+
+    @Mock
+    private PipAnimationController.PipAnimationCallback mPipAnimationCallback;
+
+    @Before
+    public void setUp() throws Exception {
+        mPipAnimationController = new PipAnimationController(
+                new PipSurfaceTransactionHelper(mContext));
+        mLeash = new SurfaceControl.Builder()
+                .setContainerLayer()
+                .setName("FakeLeash")
+                .build();
+        MockitoAnnotations.initMocks(this);
+    }
+
+    @Test
+    public void getAnimator_withAlpha_returnFloatAnimator() {
+        final PipAnimationController.PipTransitionAnimator animator = mPipAnimationController
+                .getAnimator(mLeash, new Rect(), 0f, 1f);
+
+        assertEquals("Expect ANIM_TYPE_ALPHA animation",
+                animator.getAnimationType(), PipAnimationController.ANIM_TYPE_ALPHA);
+    }
+
+    @Test
+    public void getAnimator_withBounds_returnBoundsAnimator() {
+        final PipAnimationController.PipTransitionAnimator animator = mPipAnimationController
+                .getAnimator(mLeash, new Rect(), new Rect(), null, TRANSITION_DIRECTION_TO_PIP);
+
+        assertEquals("Expect ANIM_TYPE_BOUNDS animation",
+                animator.getAnimationType(), PipAnimationController.ANIM_TYPE_BOUNDS);
+    }
+
+    @Test
+    public void getAnimator_whenSameTypeRunning_updateExistingAnimator() {
+        final Rect startValue = new Rect(0, 0, 100, 100);
+        final Rect endValue1 = new Rect(100, 100, 200, 200);
+        final Rect endValue2 = new Rect(200, 200, 300, 300);
+        final PipAnimationController.PipTransitionAnimator oldAnimator = mPipAnimationController
+                .getAnimator(mLeash, startValue, endValue1, null, TRANSITION_DIRECTION_TO_PIP);
+        oldAnimator.setSurfaceControlTransactionFactory(DummySurfaceControlTx::new);
+        oldAnimator.start();
+
+        final PipAnimationController.PipTransitionAnimator newAnimator = mPipAnimationController
+                .getAnimator(mLeash, startValue, endValue2, null, TRANSITION_DIRECTION_TO_PIP);
+
+        assertEquals("getAnimator with same type returns same animator",
+                oldAnimator, newAnimator);
+        assertEquals("getAnimator with same type updates end value",
+                endValue2, newAnimator.getEndValue());
+    }
+
+    @Test
+    public void getAnimator_setTransitionDirection() {
+        PipAnimationController.PipTransitionAnimator animator = mPipAnimationController
+                .getAnimator(mLeash, new Rect(), 0f, 1f)
+                .setTransitionDirection(TRANSITION_DIRECTION_TO_PIP);
+        assertEquals("Transition to PiP mode",
+                animator.getTransitionDirection(), TRANSITION_DIRECTION_TO_PIP);
+
+        animator = mPipAnimationController
+                .getAnimator(mLeash, new Rect(), 0f, 1f)
+                .setTransitionDirection(TRANSITION_DIRECTION_LEAVE_PIP);
+        assertEquals("Transition to fullscreen mode",
+                animator.getTransitionDirection(), TRANSITION_DIRECTION_LEAVE_PIP);
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void pipTransitionAnimator_updateEndValue() {
+        final Rect startValue = new Rect(0, 0, 100, 100);
+        final Rect endValue1 = new Rect(100, 100, 200, 200);
+        final Rect endValue2 = new Rect(200, 200, 300, 300);
+        final PipAnimationController.PipTransitionAnimator animator = mPipAnimationController
+                .getAnimator(mLeash, startValue, endValue1, null, TRANSITION_DIRECTION_TO_PIP);
+
+        animator.updateEndValue(endValue2);
+
+        assertEquals("updateEndValue updates end value", animator.getEndValue(), endValue2);
+    }
+
+    @Test
+    public void pipTransitionAnimator_setPipAnimationCallback() {
+        final Rect startValue = new Rect(0, 0, 100, 100);
+        final Rect endValue = new Rect(100, 100, 200, 200);
+        final PipAnimationController.PipTransitionAnimator animator = mPipAnimationController
+                .getAnimator(mLeash, startValue, endValue, null, TRANSITION_DIRECTION_TO_PIP);
+        animator.setSurfaceControlTransactionFactory(DummySurfaceControlTx::new);
+
+        animator.setPipAnimationCallback(mPipAnimationCallback);
+
+        // onAnimationStart triggers onPipAnimationStart
+        animator.onAnimationStart(animator);
+        verify(mPipAnimationCallback).onPipAnimationStart(animator);
+
+        // onAnimationCancel triggers onPipAnimationCancel
+        animator.onAnimationCancel(animator);
+        verify(mPipAnimationCallback).onPipAnimationCancel(animator);
+
+        // onAnimationEnd triggers onPipAnimationEnd
+        animator.onAnimationEnd(animator);
+        verify(mPipAnimationCallback).onPipAnimationEnd(any(SurfaceControl.Transaction.class),
+                eq(animator));
+    }
+
+    /**
+     * A dummy {@link SurfaceControl.Transaction} class.
+     * This is created as {@link Mock} does not support method chaining.
+     */
+    public static class DummySurfaceControlTx extends SurfaceControl.Transaction {
+        @Override
+        public SurfaceControl.Transaction setAlpha(SurfaceControl leash, float alpha) {
+            return this;
+        }
+
+        @Override
+        public SurfaceControl.Transaction setPosition(SurfaceControl leash, float x, float y) {
+            return this;
+        }
+
+        @Override
+        public SurfaceControl.Transaction setWindowCrop(SurfaceControl leash, int w, int h) {
+            return this;
+        }
+
+        @Override
+        public SurfaceControl.Transaction setCornerRadius(SurfaceControl leash, float radius) {
+            return this;
+        }
+
+        @Override
+        public SurfaceControl.Transaction setMatrix(SurfaceControl leash, Matrix matrix,
+                float[] float9) {
+            return this;
+        }
+
+        @Override
+        public void apply() {}
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipBoundsHandlerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipBoundsHandlerTest.java
new file mode 100644
index 0000000..f514b0b
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipBoundsHandlerTest.java
@@ -0,0 +1,348 @@
+/*
+ * Copyright (C) 2020 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.systemui.pip;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.content.ComponentName;
+import android.graphics.Rect;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.testing.TestableResources;
+import android.util.Size;
+import android.view.DisplayInfo;
+import android.view.Gravity;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.wm.shell.pip.PipBoundsHandler;
+import com.android.wm.shell.pip.PipTestCase;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Unit tests against {@link PipBoundsHandler}, including but not limited to:
+ * - default/movement bounds
+ * - save/restore PiP position on application lifecycle
+ * - save/restore PiP position on screen rotation
+ */
+@RunWith(AndroidTestingRunner.class)
+@SmallTest
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+public class PipBoundsHandlerTest extends PipTestCase {
+    private static final int ROUNDING_ERROR_MARGIN = 16;
+    private static final float ASPECT_RATIO_ERROR_MARGIN = 0.01f;
+    private static final float DEFAULT_ASPECT_RATIO = 1f;
+    private static final float MIN_ASPECT_RATIO = 0.5f;
+    private static final float MAX_ASPECT_RATIO = 2f;
+    private static final Rect EMPTY_CURRENT_BOUNDS = null;
+    private static final Size EMPTY_MINIMAL_SIZE = null;
+
+    private PipBoundsHandler mPipBoundsHandler;
+    private DisplayInfo mDefaultDisplayInfo;
+    private ComponentName mTestComponentName1;
+    private ComponentName mTestComponentName2;
+
+    @Before
+    public void setUp() throws Exception {
+        initializeMockResources();
+        mPipBoundsHandler = new PipBoundsHandler(mContext);
+        mTestComponentName1 = new ComponentName(mContext, "component1");
+        mTestComponentName2 = new ComponentName(mContext, "component2");
+
+        mPipBoundsHandler.onDisplayInfoChanged(mDefaultDisplayInfo);
+    }
+
+    private void initializeMockResources() {
+        final TestableResources res = mContext.getOrCreateTestableResources();
+        res.addOverride(
+                com.android.internal.R.dimen.config_pictureInPictureDefaultAspectRatio,
+                DEFAULT_ASPECT_RATIO);
+        res.addOverride(
+                com.android.internal.R.integer.config_defaultPictureInPictureGravity,
+                Gravity.END | Gravity.BOTTOM);
+        res.addOverride(
+                com.android.internal.R.dimen.default_minimal_size_pip_resizable_task, 100);
+        res.addOverride(
+                com.android.internal.R.string.config_defaultPictureInPictureScreenEdgeInsets,
+                "16x16");
+        res.addOverride(
+                com.android.internal.R.dimen.config_pictureInPictureMinAspectRatio,
+                MIN_ASPECT_RATIO);
+        res.addOverride(
+                com.android.internal.R.dimen.config_pictureInPictureMaxAspectRatio,
+                MAX_ASPECT_RATIO);
+
+        mDefaultDisplayInfo = new DisplayInfo();
+        mDefaultDisplayInfo.displayId = 1;
+        mDefaultDisplayInfo.logicalWidth = 1000;
+        mDefaultDisplayInfo.logicalHeight = 1500;
+    }
+
+    @Test
+    public void getDefaultAspectRatio() {
+        assertEquals("Default aspect ratio matches resources",
+                DEFAULT_ASPECT_RATIO, mPipBoundsHandler.getDefaultAspectRatio(),
+                ASPECT_RATIO_ERROR_MARGIN);
+    }
+
+    @Test
+    public void onConfigurationChanged_reloadResources() {
+        final float newDefaultAspectRatio = (DEFAULT_ASPECT_RATIO + MAX_ASPECT_RATIO) / 2;
+        final TestableResources res = mContext.getOrCreateTestableResources();
+        res.addOverride(com.android.internal.R.dimen.config_pictureInPictureDefaultAspectRatio,
+                newDefaultAspectRatio);
+
+        mPipBoundsHandler.onConfigurationChanged(mContext);
+
+        assertEquals("Default aspect ratio should be reloaded",
+                mPipBoundsHandler.getDefaultAspectRatio(), newDefaultAspectRatio,
+                ASPECT_RATIO_ERROR_MARGIN);
+    }
+
+    @Test
+    public void getDestinationBounds_returnBoundsMatchesAspectRatio() {
+        final float[] aspectRatios = new float[] {
+                (MIN_ASPECT_RATIO + DEFAULT_ASPECT_RATIO) / 2,
+                DEFAULT_ASPECT_RATIO,
+                (MAX_ASPECT_RATIO + DEFAULT_ASPECT_RATIO) / 2
+        };
+        for (float aspectRatio : aspectRatios) {
+            final Rect destinationBounds = mPipBoundsHandler.getDestinationBounds(
+                    mTestComponentName1, aspectRatio, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+            final float actualAspectRatio =
+                    destinationBounds.width() / (destinationBounds.height() * 1f);
+            assertEquals("Destination bounds matches the given aspect ratio",
+                    aspectRatio, actualAspectRatio, ASPECT_RATIO_ERROR_MARGIN);
+        }
+    }
+
+    @Test
+    public void getDestinationBounds_invalidAspectRatio_returnsDefaultAspectRatio() {
+        final float[] invalidAspectRatios = new float[] {
+                MIN_ASPECT_RATIO / 2,
+                MAX_ASPECT_RATIO * 2
+        };
+        for (float aspectRatio : invalidAspectRatios) {
+            final Rect destinationBounds = mPipBoundsHandler.getDestinationBounds(
+                    mTestComponentName1, aspectRatio, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+            final float actualAspectRatio =
+                    destinationBounds.width() / (destinationBounds.height() * 1f);
+            assertEquals("Destination bounds fallbacks to default aspect ratio",
+                    mPipBoundsHandler.getDefaultAspectRatio(), actualAspectRatio,
+                    ASPECT_RATIO_ERROR_MARGIN);
+        }
+    }
+
+    @Test
+    public void  getDestinationBounds_withCurrentBounds_returnBoundsMatchesAspectRatio() {
+        final float aspectRatio = (DEFAULT_ASPECT_RATIO + MAX_ASPECT_RATIO) / 2;
+        final Rect currentBounds = new Rect(0, 0, 0, 100);
+        currentBounds.right = (int) (currentBounds.height() * aspectRatio) + currentBounds.left;
+
+        final Rect destinationBounds = mPipBoundsHandler.getDestinationBounds(
+                mTestComponentName1, aspectRatio, currentBounds, EMPTY_MINIMAL_SIZE);
+
+        final float actualAspectRatio =
+                destinationBounds.width() / (destinationBounds.height() * 1f);
+        assertEquals("Destination bounds matches the given aspect ratio",
+                aspectRatio, actualAspectRatio, ASPECT_RATIO_ERROR_MARGIN);
+    }
+
+    @Test
+    public void getDestinationBounds_withMinSize_returnMinBounds() {
+        final float[] aspectRatios = new float[] {
+                (MIN_ASPECT_RATIO + DEFAULT_ASPECT_RATIO) / 2,
+                DEFAULT_ASPECT_RATIO,
+                (MAX_ASPECT_RATIO + DEFAULT_ASPECT_RATIO) / 2
+        };
+        final Size[] minimalSizes = new Size[] {
+                new Size((int) (100 * aspectRatios[0]), 100),
+                new Size((int) (100 * aspectRatios[1]), 100),
+                new Size((int) (100 * aspectRatios[2]), 100)
+        };
+        for (int i = 0; i < aspectRatios.length; i++) {
+            final float aspectRatio = aspectRatios[i];
+            final Size minimalSize = minimalSizes[i];
+            final Rect destinationBounds = mPipBoundsHandler.getDestinationBounds(
+                    mTestComponentName1, aspectRatio, EMPTY_CURRENT_BOUNDS, minimalSize);
+            assertTrue("Destination bounds is no smaller than minimal requirement",
+                    (destinationBounds.width() == minimalSize.getWidth()
+                            && destinationBounds.height() >= minimalSize.getHeight())
+                            || (destinationBounds.height() == minimalSize.getHeight()
+                            && destinationBounds.width() >= minimalSize.getWidth()));
+            final float actualAspectRatio =
+                    destinationBounds.width() / (destinationBounds.height() * 1f);
+            assertEquals("Destination bounds matches the given aspect ratio",
+                    aspectRatio, actualAspectRatio, ASPECT_RATIO_ERROR_MARGIN);
+        }
+    }
+
+    @Test
+    public void getDestinationBounds_withCurrentBounds_ignoreMinBounds() {
+        final float aspectRatio = (DEFAULT_ASPECT_RATIO + MAX_ASPECT_RATIO) / 2;
+        final Rect currentBounds = new Rect(0, 0, 0, 100);
+        currentBounds.right = (int) (currentBounds.height() * aspectRatio) + currentBounds.left;
+        final Size minSize = new Size(currentBounds.width() / 2, currentBounds.height() / 2);
+
+        final Rect destinationBounds = mPipBoundsHandler.getDestinationBounds(
+                mTestComponentName1, aspectRatio, currentBounds, minSize);
+
+        assertTrue("Destination bounds ignores minimal size",
+                destinationBounds.width() > minSize.getWidth()
+                        && destinationBounds.height() > minSize.getHeight());
+    }
+
+    @Test
+    public void getDestinationBounds_withDifferentComponentName_ignoreLastPosition() {
+        final Rect oldPosition = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        oldPosition.offset(0, -100);
+        mPipBoundsHandler.onSaveReentryBounds(mTestComponentName1, oldPosition);
+
+        final Rect newPosition = mPipBoundsHandler.getDestinationBounds(mTestComponentName2,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        assertNonBoundsInclusionWithMargin("ignore saved bounds", oldPosition, newPosition);
+    }
+
+    @Test
+    public void setShelfHeight_offsetBounds() {
+        final int shelfHeight = 100;
+        final Rect oldPosition = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        mPipBoundsHandler.setShelfHeight(true, shelfHeight);
+        final Rect newPosition = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        oldPosition.offset(0, -shelfHeight);
+        assertBoundsInclusionWithMargin("offsetBounds by shelf", oldPosition, newPosition);
+    }
+
+    @Test
+    public void onImeVisibilityChanged_offsetBounds() {
+        final int imeHeight = 100;
+        final Rect oldPosition = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        mPipBoundsHandler.onImeVisibilityChanged(true, imeHeight);
+        final Rect newPosition = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        oldPosition.offset(0, -imeHeight);
+        assertBoundsInclusionWithMargin("offsetBounds by IME", oldPosition, newPosition);
+    }
+
+    @Test
+    public void onSaveReentryBounds_restoreLastPosition() {
+        final Rect oldPosition = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        oldPosition.offset(0, -100);
+        mPipBoundsHandler.onSaveReentryBounds(mTestComponentName1, oldPosition);
+
+        final Rect newPosition = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        assertBoundsInclusionWithMargin("restoreLastPosition", oldPosition, newPosition);
+    }
+
+    @Test
+    public void onSaveReentryBounds_restoreLastSize() {
+        final Rect oldSize = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        oldSize.scale(1.25f);
+        mPipBoundsHandler.onSaveReentryBounds(mTestComponentName1, oldSize);
+
+        final Rect newSize = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        assertEquals(oldSize.width(), newSize.width());
+        assertEquals(oldSize.height(), newSize.height());
+    }
+
+    @Test
+    public void onResetReentryBounds_useDefaultBounds() {
+        final Rect defaultBounds = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+        final Rect newBounds = new Rect(defaultBounds);
+        newBounds.offset(0, -100);
+        mPipBoundsHandler.onSaveReentryBounds(mTestComponentName1, newBounds);
+
+        mPipBoundsHandler.onResetReentryBounds(mTestComponentName1);
+        final Rect actualBounds = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        assertBoundsInclusionWithMargin("useDefaultBounds", defaultBounds, actualBounds);
+    }
+
+    @Test
+    public void onResetReentryBounds_componentMismatch_restoreLastPosition() {
+        final Rect defaultBounds = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+        final Rect newBounds = new Rect(defaultBounds);
+        newBounds.offset(0, -100);
+        mPipBoundsHandler.onSaveReentryBounds(mTestComponentName1, newBounds);
+
+        mPipBoundsHandler.onResetReentryBounds(mTestComponentName2);
+        final Rect actualBounds = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        assertBoundsInclusionWithMargin("restoreLastPosition", newBounds, actualBounds);
+    }
+
+    @Test
+    public void onSaveReentryBounds_componentMismatch_restoreLastSize() {
+        final Rect oldSize = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        oldSize.scale(1.25f);
+        mPipBoundsHandler.onSaveReentryBounds(mTestComponentName1, oldSize);
+
+        mPipBoundsHandler.onResetReentryBounds(mTestComponentName2);
+        final Rect newSize = mPipBoundsHandler.getDestinationBounds(mTestComponentName1,
+                DEFAULT_ASPECT_RATIO, EMPTY_CURRENT_BOUNDS, EMPTY_MINIMAL_SIZE);
+
+        assertEquals(oldSize.width(), newSize.width());
+        assertEquals(oldSize.height(), newSize.height());
+    }
+
+    private void assertBoundsInclusionWithMargin(String from, Rect expected, Rect actual) {
+        final Rect expectedWithMargin = new Rect(expected);
+        expectedWithMargin.inset(-ROUNDING_ERROR_MARGIN, -ROUNDING_ERROR_MARGIN);
+        assertTrue(from + ": expect " + expected
+                + " contains " + actual
+                + " with error margin " + ROUNDING_ERROR_MARGIN,
+                expectedWithMargin.contains(actual));
+    }
+
+    private void assertNonBoundsInclusionWithMargin(String from, Rect expected, Rect actual) {
+        final Rect expectedWithMargin = new Rect(expected);
+        expectedWithMargin.inset(-ROUNDING_ERROR_MARGIN, -ROUNDING_ERROR_MARGIN);
+        assertFalse(from + ": expect " + expected
+                        + " not contains " + actual
+                        + " with error margin " + ROUNDING_ERROR_MARGIN,
+                expectedWithMargin.contains(actual));
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipTestCase.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipTestCase.java
new file mode 100644
index 0000000..fdebe4e
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/PipTestCase.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2020 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.wm.shell.pip;
+
+import static android.view.Display.DEFAULT_DISPLAY;
+
+import android.content.Context;
+import android.hardware.display.DisplayManager;
+import android.testing.TestableContext;
+
+import androidx.test.InstrumentationRegistry;
+
+import org.junit.Before;
+
+/**
+ * Base class that does One Handed specific setup.
+ */
+public abstract class PipTestCase {
+
+    protected TestableContext mContext;
+
+    @Before
+    public void setup() {
+        final Context context =
+                InstrumentationRegistry.getInstrumentation().getTargetContext();
+        final DisplayManager dm = context.getSystemService(DisplayManager.class);
+        mContext = new TestableContext(
+                context.createDisplayContext(dm.getDisplay(DEFAULT_DISPLAY)));
+
+        InstrumentationRegistry
+                .getInstrumentation()
+                .getUiAutomation()
+                .adoptShellPermissionIdentity();
+    }
+
+    protected Context getContext() {
+        return mContext;
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipControllerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipControllerTest.java
new file mode 100644
index 0000000..d305c64
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipControllerTest.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2020 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.systemui.pip.phone;
+
+import static android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.pm.PackageManager;
+import android.os.RemoteException;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableContext;
+import android.testing.TestableLooper;
+
+import com.android.wm.shell.WindowManagerShellWrapper;
+import com.android.wm.shell.common.DisplayController;
+import com.android.wm.shell.pip.PipBoundsHandler;
+import com.android.wm.shell.pip.PipTaskOrganizer;
+import com.android.wm.shell.pip.PipTestCase;
+import com.android.wm.shell.pip.phone.PipAppOpsListener;
+import com.android.wm.shell.pip.phone.PipController;
+import com.android.wm.shell.pip.phone.PipMediaController;
+import com.android.wm.shell.pip.phone.PipTouchHandler;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Unit tests for {@link PipController}
+ */
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper
+public class PipControllerTest extends PipTestCase {
+    private com.android.wm.shell.pip.phone.PipController mPipController;
+    private TestableContext mSpiedContext;
+
+    @Mock private DisplayController mMockdDisplayController;
+    @Mock private PackageManager mPackageManager;
+    @Mock private com.android.wm.shell.pip.phone.PipMenuActivityController
+            mMockPipMenuActivityController;
+    @Mock private PipAppOpsListener mMockPipAppOpsListener;
+    @Mock private PipBoundsHandler mMockPipBoundsHandler;
+    @Mock private PipMediaController mMockPipMediaController;
+    @Mock private PipTaskOrganizer mMockPipTaskOrganizer;
+    @Mock private PipTouchHandler mMockPipTouchHandler;
+    @Mock private WindowManagerShellWrapper mMockWindowManagerShellWrapper;
+
+    @Before
+    public void setUp() throws RemoteException {
+        MockitoAnnotations.initMocks(this);
+
+        mSpiedContext = spy(mContext);
+
+        when(mPackageManager.hasSystemFeature(FEATURE_PICTURE_IN_PICTURE)).thenReturn(false);
+        when(mSpiedContext.getPackageManager()).thenReturn(mPackageManager);
+
+        mPipController = new PipController(mSpiedContext, mMockdDisplayController,
+                mMockPipAppOpsListener, mMockPipBoundsHandler, mMockPipMediaController,
+                mMockPipMenuActivityController, mMockPipTaskOrganizer, mMockPipTouchHandler,
+                mMockWindowManagerShellWrapper);
+    }
+
+    @Test
+    public void testNonPipDevice_shouldNotRegisterPipTransitionCallback() {
+        verify(mMockPipTaskOrganizer, never()).registerPipTransitionCallback(any());
+    }
+
+    @Test
+    public void testNonPipDevice_shouldNotAddDisplayChangingController() {
+        verify(mMockdDisplayController, never()).addDisplayChangingController(any());
+    }
+
+    @Test
+    public void testNonPipDevice_shouldNotAddDisplayWindowListener() {
+        verify(mMockdDisplayController, never()).addDisplayWindowListener(any());
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTaskOrganizerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTaskOrganizerTest.java
new file mode 100644
index 0000000..663169f
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTaskOrganizerTest.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2020 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.systemui.pip.phone;
+
+import static android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.pm.PackageManager;
+import android.os.RemoteException;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableContext;
+import android.testing.TestableLooper;
+
+import com.android.wm.shell.ShellTaskOrganizer;
+import com.android.wm.shell.common.DisplayController;
+import com.android.wm.shell.pip.PipBoundsHandler;
+import com.android.wm.shell.pip.PipSurfaceTransactionHelper;
+import com.android.wm.shell.pip.PipTaskOrganizer;
+import com.android.wm.shell.pip.PipTestCase;
+import com.android.wm.shell.pip.PipUiEventLogger;
+import com.android.wm.shell.splitscreen.SplitScreen;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Optional;
+
+/**
+ * Unit tests for {@link PipTaskOrganizer}
+ */
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper
+public class PipTaskOrganizerTest extends PipTestCase {
+    private PipTaskOrganizer mSpiedPipTaskOrganizer;
+    private TestableContext mSpiedContext;
+
+    @Mock private DisplayController mMockdDisplayController;
+    @Mock private PackageManager mPackageManager;
+    @Mock private PipBoundsHandler mMockPipBoundsHandler;
+    @Mock private PipSurfaceTransactionHelper mMockPipSurfaceTransactionHelper;
+    @Mock private PipUiEventLogger mMockPipUiEventLogger;
+    @Mock private Optional<SplitScreen> mMockOptionalSplitScreen;
+    @Mock private ShellTaskOrganizer mMockShellTaskOrganizer;
+
+    @Before
+    public void setUp() throws RemoteException {
+        MockitoAnnotations.initMocks(this);
+
+        mSpiedContext = spy(mContext);
+
+        when(mPackageManager.hasSystemFeature(FEATURE_PICTURE_IN_PICTURE)).thenReturn(false);
+        when(mSpiedContext.getPackageManager()).thenReturn(mPackageManager);
+
+        mSpiedPipTaskOrganizer = spy(new PipTaskOrganizer(mSpiedContext, mMockPipBoundsHandler,
+                mMockPipSurfaceTransactionHelper, mMockOptionalSplitScreen, mMockdDisplayController,
+                mMockPipUiEventLogger, mMockShellTaskOrganizer));
+    }
+
+    @Test
+    public void testNonPipDevice_shellTaskOrganizer_shouldNotAddListener() {
+        verify(mMockShellTaskOrganizer, never()).addListener(any(), anyInt());
+    }
+
+    @Test
+    public void testNonPipDevice_displayController_shouldNotAddDisplayWindowListener() {
+        verify(mMockdDisplayController, never()).addDisplayWindowListener(any());
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchHandlerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchHandlerTest.java
new file mode 100644
index 0000000..c96cb20
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchHandlerTest.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright (C) 2020 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.systemui.pip.phone;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.util.Size;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.wm.shell.R;
+import com.android.wm.shell.common.FloatingContentCoordinator;
+import com.android.wm.shell.pip.PipBoundsHandler;
+import com.android.wm.shell.pip.PipSnapAlgorithm;
+import com.android.wm.shell.pip.PipTaskOrganizer;
+import com.android.wm.shell.pip.PipTestCase;
+import com.android.wm.shell.pip.PipUiEventLogger;
+import com.android.wm.shell.pip.phone.PipMenuActivityController;
+import com.android.wm.shell.pip.phone.PipMotionHelper;
+import com.android.wm.shell.pip.phone.PipResizeGestureHandler;
+import com.android.wm.shell.pip.phone.PipTouchHandler;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Unit tests against {@link PipTouchHandler}, including but not limited to:
+ * - Update movement bounds based on new bounds
+ * - Update movement bounds based on IME/shelf
+ * - Update movement bounds to PipResizeHandler
+ */
+@RunWith(AndroidTestingRunner.class)
+@SmallTest
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+public class PipTouchHandlerTest extends PipTestCase {
+
+    private PipTouchHandler mPipTouchHandler;
+
+    @Mock
+    private PipMenuActivityController mPipMenuActivityController;
+
+    @Mock
+    private PipTaskOrganizer mPipTaskOrganizer;
+
+    @Mock
+    private FloatingContentCoordinator mFloatingContentCoordinator;
+
+    @Mock
+    private PipUiEventLogger mPipUiEventLogger;
+
+    private PipBoundsHandler mPipBoundsHandler;
+    private PipSnapAlgorithm mPipSnapAlgorithm;
+    private PipMotionHelper mMotionHelper;
+    private PipResizeGestureHandler mPipResizeGestureHandler;
+
+    private Rect mInsetBounds;
+    private Rect mMinBounds;
+    private Rect mCurBounds;
+    private boolean mFromImeAdjustment;
+    private boolean mFromShelfAdjustment;
+    private int mDisplayRotation;
+    private int mImeHeight;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        mPipBoundsHandler = new PipBoundsHandler(mContext);
+        mPipSnapAlgorithm = mPipBoundsHandler.getSnapAlgorithm();
+        mPipSnapAlgorithm = new PipSnapAlgorithm(mContext);
+        mPipTouchHandler = new PipTouchHandler(mContext, mPipMenuActivityController,
+                mPipBoundsHandler, mPipTaskOrganizer, mFloatingContentCoordinator,
+                mPipUiEventLogger);
+        mMotionHelper = Mockito.spy(mPipTouchHandler.getMotionHelper());
+        mPipResizeGestureHandler = Mockito.spy(mPipTouchHandler.getPipResizeGestureHandler());
+        mPipTouchHandler.setPipMotionHelper(mMotionHelper);
+        mPipTouchHandler.setPipResizeGestureHandler(mPipResizeGestureHandler);
+
+        // Assume a display of 1000 x 1000
+        // inset of 10
+        mInsetBounds = new Rect(10, 10, 990, 990);
+        // minBounds of 100x100 bottom right corner
+        mMinBounds = new Rect(890, 890, 990, 990);
+        mCurBounds = new Rect(mMinBounds);
+        mFromImeAdjustment = false;
+        mFromShelfAdjustment = false;
+        mDisplayRotation = 0;
+        mImeHeight = 100;
+    }
+
+    @Test
+    public void updateMovementBounds_minBounds() {
+        Rect expectedMinMovementBounds = new Rect();
+        mPipSnapAlgorithm.getMovementBounds(mMinBounds, mInsetBounds, expectedMinMovementBounds, 0);
+
+        mPipTouchHandler.onMovementBoundsChanged(mInsetBounds, mMinBounds, mCurBounds,
+                mFromImeAdjustment, mFromShelfAdjustment, mDisplayRotation);
+
+        assertEquals(expectedMinMovementBounds, mPipTouchHandler.mNormalMovementBounds);
+        verify(mPipResizeGestureHandler, times(1))
+                .updateMinSize(mMinBounds.width(), mMinBounds.height());
+    }
+
+    @Test
+    public void updateMovementBounds_maxBounds() {
+        Point displaySize = new Point();
+        mContext.getDisplay().getRealSize(displaySize);
+        Size maxSize = mPipSnapAlgorithm.getSizeForAspectRatio(1,
+                mContext.getResources().getDimensionPixelSize(
+                        R.dimen.pip_expanded_shortest_edge_size), displaySize.x, displaySize.y);
+        Rect maxBounds = new Rect(0, 0, maxSize.getWidth(), maxSize.getHeight());
+        Rect expectedMaxMovementBounds = new Rect();
+        mPipSnapAlgorithm.getMovementBounds(maxBounds, mInsetBounds, expectedMaxMovementBounds, 0);
+
+        mPipTouchHandler.onMovementBoundsChanged(mInsetBounds, mMinBounds, mCurBounds,
+                mFromImeAdjustment, mFromShelfAdjustment, mDisplayRotation);
+
+        assertEquals(expectedMaxMovementBounds, mPipTouchHandler.mExpandedMovementBounds);
+        verify(mPipResizeGestureHandler, times(1))
+                .updateMaxSize(maxBounds.width(), maxBounds.height());
+    }
+
+    @Test
+    public void updateMovementBounds_withImeAdjustment_movesPip() {
+        mFromImeAdjustment = true;
+        mPipTouchHandler.onImeVisibilityChanged(true /* imeVisible */, mImeHeight);
+
+        mPipTouchHandler.onMovementBoundsChanged(mInsetBounds, mMinBounds, mCurBounds,
+                mFromImeAdjustment, mFromShelfAdjustment, mDisplayRotation);
+
+        verify(mMotionHelper, times(1)).animateToOffset(any(), anyInt());
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchStateTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchStateTest.java
new file mode 100644
index 0000000..2702130
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/phone/PipTouchStateTest.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright (C) 2017 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.systemui.pip.phone;
+
+import static android.view.MotionEvent.ACTION_BUTTON_PRESS;
+import static android.view.MotionEvent.ACTION_DOWN;
+import static android.view.MotionEvent.ACTION_MOVE;
+import static android.view.MotionEvent.ACTION_UP;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.testing.TestableLooper.RunWithLooper;
+import android.view.MotionEvent;
+import android.view.ViewConfiguration;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.wm.shell.pip.PipTestCase;
+import com.android.wm.shell.pip.phone.PipTouchState;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.concurrent.CountDownLatch;
+
+@RunWith(AndroidTestingRunner.class)
+@SmallTest
+@RunWithLooper
+public class PipTouchStateTest extends PipTestCase {
+
+    private PipTouchState mTouchState;
+    private CountDownLatch mDoubleTapCallbackTriggeredLatch;
+    private CountDownLatch mHoverExitCallbackTriggeredLatch;
+
+    @Before
+    public void setUp() throws Exception {
+        mDoubleTapCallbackTriggeredLatch = new CountDownLatch(1);
+        mHoverExitCallbackTriggeredLatch = new CountDownLatch(1);
+        mTouchState = new PipTouchState(ViewConfiguration.get(getContext()),
+                Handler.createAsync(Looper.myLooper()), () -> {
+            mDoubleTapCallbackTriggeredLatch.countDown();
+        }, () -> {
+            mHoverExitCallbackTriggeredLatch.countDown();
+        });
+        assertFalse(mTouchState.isDoubleTap());
+        assertFalse(mTouchState.isWaitingForDoubleTap());
+    }
+
+    @Test
+    public void testDoubleTapLongSingleTap_notDoubleTapAndNotWaiting() {
+        final long currentTime = SystemClock.uptimeMillis();
+
+        mTouchState.onTouchEvent(createMotionEvent(ACTION_DOWN, currentTime, 0, 0));
+        mTouchState.onTouchEvent(createMotionEvent(ACTION_UP,
+                currentTime + PipTouchState.DOUBLE_TAP_TIMEOUT + 10, 0, 0));
+        assertFalse(mTouchState.isDoubleTap());
+        assertFalse(mTouchState.isWaitingForDoubleTap());
+        assertTrue(mTouchState.getDoubleTapTimeoutCallbackDelay() == -1);
+    }
+
+    @Test
+    public void testDoubleTapTimeout_timeoutCallbackCalled() throws Exception {
+        final long currentTime = SystemClock.uptimeMillis();
+
+        mTouchState.onTouchEvent(createMotionEvent(ACTION_DOWN, currentTime, 0, 0));
+        mTouchState.onTouchEvent(createMotionEvent(ACTION_UP,
+                currentTime + PipTouchState.DOUBLE_TAP_TIMEOUT - 10, 0, 0));
+        assertFalse(mTouchState.isDoubleTap());
+        assertTrue(mTouchState.isWaitingForDoubleTap());
+
+        assertTrue(mTouchState.getDoubleTapTimeoutCallbackDelay() == 10);
+        mTouchState.scheduleDoubleTapTimeoutCallback();
+
+        // TODO: Remove this sleep. Its only being added because it speeds up this test a bit.
+        Thread.sleep(15);
+        TestableLooper.get(this).processAllMessages();
+        assertTrue(mDoubleTapCallbackTriggeredLatch.getCount() == 0);
+    }
+
+    @Test
+    public void testDoubleTapDrag_doubleTapCanceled() {
+        final long currentTime = SystemClock.uptimeMillis();
+
+        mTouchState.onTouchEvent(createMotionEvent(ACTION_DOWN, currentTime, 0, 0));
+        mTouchState.onTouchEvent(createMotionEvent(ACTION_MOVE, currentTime + 10, 500, 500));
+        mTouchState.onTouchEvent(createMotionEvent(ACTION_UP, currentTime + 20, 500, 500));
+        assertTrue(mTouchState.isDragging());
+        assertFalse(mTouchState.isDoubleTap());
+        assertFalse(mTouchState.isWaitingForDoubleTap());
+        assertTrue(mTouchState.getDoubleTapTimeoutCallbackDelay() == -1);
+    }
+
+    @Test
+    public void testDoubleTap_doubleTapRegistered() {
+        final long currentTime = SystemClock.uptimeMillis();
+
+        mTouchState.onTouchEvent(createMotionEvent(ACTION_DOWN, currentTime, 0, 0));
+        mTouchState.onTouchEvent(createMotionEvent(ACTION_UP, currentTime + 10, 0, 0));
+        mTouchState.onTouchEvent(createMotionEvent(ACTION_DOWN,
+                currentTime + PipTouchState.DOUBLE_TAP_TIMEOUT - 20, 0, 0));
+        mTouchState.onTouchEvent(createMotionEvent(ACTION_UP,
+                currentTime + PipTouchState.DOUBLE_TAP_TIMEOUT - 10, 0, 0));
+        assertTrue(mTouchState.isDoubleTap());
+        assertFalse(mTouchState.isWaitingForDoubleTap());
+        assertTrue(mTouchState.getDoubleTapTimeoutCallbackDelay() == -1);
+    }
+
+    @Test
+    public void testHoverExitTimeout_timeoutCallbackCalled() throws Exception {
+        mTouchState.scheduleHoverExitTimeoutCallback();
+
+        // TODO: Remove this sleep. Its only being added because it speeds up this test a bit.
+        Thread.sleep(50);
+        TestableLooper.get(this).processAllMessages();
+        assertTrue(mHoverExitCallbackTriggeredLatch.getCount() == 0);
+    }
+
+    @Test
+    public void testHoverExitTimeout_timeoutCallbackNotCalled() throws Exception {
+        mTouchState.scheduleHoverExitTimeoutCallback();
+        TestableLooper.get(this).processAllMessages();
+        assertTrue(mHoverExitCallbackTriggeredLatch.getCount() == 1);
+    }
+
+    @Test
+    public void testHoverExitTimeout_timeoutCallbackNotCalled_ifButtonPress() throws Exception {
+        mTouchState.scheduleHoverExitTimeoutCallback();
+        mTouchState.onTouchEvent(createMotionEvent(ACTION_BUTTON_PRESS, SystemClock.uptimeMillis(),
+                0, 0));
+
+        // TODO: Remove this sleep. Its only being added because it speeds up this test a bit.
+        Thread.sleep(50);
+        TestableLooper.get(this).processAllMessages();
+        assertTrue(mHoverExitCallbackTriggeredLatch.getCount() == 1);
+    }
+
+    private MotionEvent createMotionEvent(int action, long eventTime, float x, float y) {
+        return MotionEvent.obtain(0, eventTime, action, x, y, 0);
+    }
+}