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<? super T>. For example, if this is a
+ * PhysicsAnimator<FrameLayout>, you can use a FloatPropertyCompat<FrameLayout>, as
+ * well as a FloatPropertyCompat<ViewGroup>, 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);
+ }
+}