diff --git a/libs/WindowManager/Shell/res/values-television/config.xml b/libs/WindowManager/Shell/res/values-television/config.xml
index 552048e8be9ab..dcb4c10260626 100644
--- a/libs/WindowManager/Shell/res/values-television/config.xml
+++ b/libs/WindowManager/Shell/res/values-television/config.xml
@@ -33,4 +33,10 @@
0x55
+
+
+ 15%
+
+
+ 5000
diff --git a/libs/WindowManager/Shell/res/values-television/dimen.xml b/libs/WindowManager/Shell/res/values-television/dimen.xml
new file mode 100644
index 0000000000000..14e89f8b08df7
--- /dev/null
+++ b/libs/WindowManager/Shell/res/values-television/dimen.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+ 16dp
+
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/TvPipModule.java b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/TvPipModule.java
index 1e934c5c6e228..1dd5ebcd993ef 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/TvPipModule.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/TvPipModule.java
@@ -69,7 +69,8 @@ public abstract class TvPipModule {
TaskStackListenerImpl taskStackListener,
DisplayController displayController,
WindowManagerShellWrapper windowManagerShellWrapper,
- @ShellMainThread ShellExecutor mainExecutor) {
+ @ShellMainThread ShellExecutor mainExecutor,
+ @ShellMainThread Handler mainHandler) {
return Optional.of(
TvPipController.create(
context,
@@ -83,7 +84,8 @@ public abstract class TvPipModule {
taskStackListener,
displayController,
windowManagerShellWrapper,
- mainExecutor));
+ mainExecutor,
+ mainHandler));
}
@WMSingleton
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipBoundsAlgorithm.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipBoundsAlgorithm.java
index 797df413d2623..c2d582354b13c 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipBoundsAlgorithm.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipBoundsAlgorithm.java
@@ -74,7 +74,7 @@ public class PipBoundsAlgorithm {
/**
* TODO: move the resources to SysUI package.
*/
- protected void reloadResources(Context context) {
+ private void reloadResources(Context context) {
final Resources res = context.getResources();
mDefaultAspectRatio = res.getFloat(
R.dimen.config_pictureInPictureDefaultAspectRatio);
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipBoundsState.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipBoundsState.java
index ddcd4bd3a8e3d..17d7f5d0d5673 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipBoundsState.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/PipBoundsState.java
@@ -55,11 +55,15 @@ public class PipBoundsState {
public static final int STASH_TYPE_NONE = 0;
public static final int STASH_TYPE_LEFT = 1;
public static final int STASH_TYPE_RIGHT = 2;
+ public static final int STASH_TYPE_BOTTOM = 3;
+ public static final int STASH_TYPE_TOP = 4;
@IntDef(prefix = { "STASH_TYPE_" }, value = {
STASH_TYPE_NONE,
STASH_TYPE_LEFT,
- STASH_TYPE_RIGHT
+ STASH_TYPE_RIGHT,
+ STASH_TYPE_BOTTOM,
+ STASH_TYPE_TOP
})
@Retention(RetentionPolicy.SOURCE)
public @interface StashType {}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipBoundsAlgorithm.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipBoundsAlgorithm.java
index 8ab78e64e2f6d..72b934805c953 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipBoundsAlgorithm.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipBoundsAlgorithm.java
@@ -28,15 +28,21 @@ import static com.android.wm.shell.pip.tv.TvPipBoundsState.ORIENTATION_VERTICAL;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Rect;
+import android.os.SystemClock;
+import android.util.ArraySet;
import android.util.Log;
import android.util.Size;
import android.view.Gravity;
import androidx.annotation.NonNull;
+import com.android.wm.shell.R;
import com.android.wm.shell.common.DisplayLayout;
import com.android.wm.shell.pip.PipBoundsAlgorithm;
import com.android.wm.shell.pip.PipSnapAlgorithm;
+import com.android.wm.shell.pip.tv.TvPipKeepClearAlgorithm.Placement;
+
+import java.util.Set;
/**
* Contains pip bounds calculations that are specific to TV.
@@ -46,91 +52,129 @@ public class TvPipBoundsAlgorithm extends PipBoundsAlgorithm {
private static final String TAG = TvPipBoundsAlgorithm.class.getSimpleName();
private static final boolean DEBUG = TvPipController.DEBUG;
- private final @android.annotation.NonNull TvPipBoundsState mTvPipBoundsState;
+ private final @NonNull TvPipBoundsState mTvPipBoundsState;
private int mFixedExpandedHeightInPx;
private int mFixedExpandedWidthInPx;
+ private final TvPipKeepClearAlgorithm mKeepClearAlgorithm;
+
public TvPipBoundsAlgorithm(Context context,
@NonNull TvPipBoundsState tvPipBoundsState,
@NonNull PipSnapAlgorithm pipSnapAlgorithm) {
super(context, tvPipBoundsState, pipSnapAlgorithm);
this.mTvPipBoundsState = tvPipBoundsState;
+ this.mKeepClearAlgorithm = new TvPipKeepClearAlgorithm(SystemClock::uptimeMillis);
+ reloadResources(context);
}
- @Override
- protected void reloadResources(Context context) {
- super.reloadResources(context);
+ private void reloadResources(Context context) {
final Resources res = context.getResources();
mFixedExpandedHeightInPx = res.getDimensionPixelSize(
com.android.internal.R.dimen.config_pictureInPictureExpandedHorizontalHeight);
mFixedExpandedWidthInPx = res.getDimensionPixelSize(
com.android.internal.R.dimen.config_pictureInPictureExpandedVerticalWidth);
+ mKeepClearAlgorithm.setPipAreaPadding(
+ res.getDimensionPixelSize(R.dimen.pip_keep_clear_area_padding));
+ mKeepClearAlgorithm.setMaxRestrictedDistanceFraction(
+ res.getFraction(R.fraction.config_pipMaxRestrictedMoveDistance, 1, 1));
+ mKeepClearAlgorithm.setStashDuration(res.getInteger(R.integer.config_pipStashDuration));
+ }
+
+ @Override
+ public void onConfigurationChanged(Context context) {
+ super.onConfigurationChanged(context);
+ reloadResources(context);
}
/** Returns the destination bounds to place the PIP window on entry. */
@Override
public Rect getEntryDestinationBounds() {
if (DEBUG) Log.d(TAG, "getEntryDestinationBounds()");
- if (mTvPipBoundsState.getTvExpandedAspectRatio() != 0
+ if (mTvPipBoundsState.isTvExpandedPipSupported()
+ && mTvPipBoundsState.getDesiredTvExpandedAspectRatio() != 0
&& !mTvPipBoundsState.isTvPipManuallyCollapsed()) {
- updatePositionOnExpandToggled(Gravity.NO_GRAVITY, true);
+ updateExpandedPipSize();
+ updateGravityOnExpandToggled(Gravity.NO_GRAVITY, true);
+ mTvPipBoundsState.setTvPipExpanded(true);
}
- return getTvPipBounds(true);
+ return getTvPipBounds().getBounds();
}
/** Returns the current bounds adjusted to the new aspect ratio, if valid. */
@Override
public Rect getAdjustedDestinationBounds(Rect currentBounds, float newAspectRatio) {
if (DEBUG) Log.d(TAG, "getAdjustedDestinationBounds: " + newAspectRatio);
- return getTvPipBounds(mTvPipBoundsState.isTvPipExpanded());
+ return getTvPipBounds().getBounds();
}
/**
- * The normal bounds at a different position on the screen.
+ * Calculates the PiP bounds.
*/
- public Rect getTvNormalBounds() {
- Rect normalBounds = getNormalBounds();
- Rect insetBounds = new Rect();
+ public Placement getTvPipBounds() {
+ final Size pipSize = getPipSize();
+ final Rect displayBounds = mTvPipBoundsState.getDisplayBounds();
+ final Size screenSize = new Size(displayBounds.width(), displayBounds.height());
+ final Rect insetBounds = new Rect();
getInsetBounds(insetBounds);
+ Set restrictedKeepClearAreas = mTvPipBoundsState.getRestrictedKeepClearAreas();
+ Set unrestrictedKeepClearAreas = mTvPipBoundsState.getUnrestrictedKeepClearAreas();
+
if (mTvPipBoundsState.isImeShowing()) {
if (DEBUG) Log.d(TAG, "IME showing, height: " + mTvPipBoundsState.getImeHeight());
- insetBounds.bottom -= mTvPipBoundsState.getImeHeight();
+
+ final Rect imeBounds = new Rect(
+ 0,
+ insetBounds.bottom - mTvPipBoundsState.getImeHeight(),
+ insetBounds.right,
+ insetBounds.bottom);
+
+ unrestrictedKeepClearAreas = new ArraySet<>(unrestrictedKeepClearAreas);
+ unrestrictedKeepClearAreas.add(imeBounds);
}
- Rect result = new Rect();
- Gravity.apply(mTvPipBoundsState.getTvPipGravity(), normalBounds.width(),
- normalBounds.height(), insetBounds, result);
+ mKeepClearAlgorithm.setGravity(mTvPipBoundsState.getTvPipGravity());
+ mKeepClearAlgorithm.setScreenSize(screenSize);
+ mKeepClearAlgorithm.setMovementBounds(insetBounds);
+ mKeepClearAlgorithm.setStashOffset(mTvPipBoundsState.getStashOffset());
+
+ final Placement placement = mKeepClearAlgorithm.calculatePipPosition(
+ pipSize,
+ restrictedKeepClearAreas,
+ unrestrictedKeepClearAreas);
if (DEBUG) {
- Log.d(TAG, "normalBounds: " + normalBounds.toShortString());
+ Log.d(TAG, "pipSize: " + pipSize);
+ Log.d(TAG, "screenSize: " + screenSize);
+ Log.d(TAG, "stashOffset: " + mTvPipBoundsState.getStashOffset());
Log.d(TAG, "insetBounds: " + insetBounds.toShortString());
+ Log.d(TAG, "pipSize: " + pipSize);
Log.d(TAG, "gravity: " + Gravity.toString(mTvPipBoundsState.getTvPipGravity()));
- Log.d(TAG, "resultBounds: " + result.toShortString());
+ Log.d(TAG, "restrictedKeepClearAreas: " + restrictedKeepClearAreas);
+ Log.d(TAG, "unrestrictedKeepClearAreas: " + unrestrictedKeepClearAreas);
+ Log.d(TAG, "placement: " + placement);
}
- mTvPipBoundsState.setTvPipExpanded(false);
-
- return result;
+ return placement;
}
/**
- * @return previous gravity if it is to be saved, or Gravity.NO_GRAVITY if not.
+ * @return previous gravity if it is to be saved, or {@link Gravity#NO_GRAVITY} if not.
*/
- int updatePositionOnExpandToggled(int previousGravity, boolean expanding) {
+ int updateGravityOnExpandToggled(int previousGravity, boolean expanding) {
if (DEBUG) {
- Log.d(TAG, "updatePositionOnExpandToggle(), expanding: " + expanding
+ Log.d(TAG, "updateGravityOnExpandToggled(), expanding: " + expanding
+ ", mOrientation: " + mTvPipBoundsState.getTvFixedPipOrientation()
+ ", previous gravity: " + Gravity.toString(previousGravity));
}
- if (!mTvPipBoundsState.isTvExpandedPipEnabled()) {
+ if (!mTvPipBoundsState.isTvExpandedPipSupported()) {
return Gravity.NO_GRAVITY;
}
if (expanding && mTvPipBoundsState.getTvFixedPipOrientation() == ORIENTATION_UNDETERMINED) {
- float expandedRatio = mTvPipBoundsState.getTvExpandedAspectRatio();
+ float expandedRatio = mTvPipBoundsState.getDesiredTvExpandedAspectRatio();
if (expandedRatio == 0) {
return Gravity.NO_GRAVITY;
}
@@ -139,7 +183,6 @@ public class TvPipBoundsAlgorithm extends PipBoundsAlgorithm {
} else {
mTvPipBoundsState.setTvFixedPipOrientation(ORIENTATION_HORIZONTAL);
}
-
}
int gravityToSave = Gravity.NO_GRAVITY;
@@ -181,10 +224,10 @@ public class TvPipBoundsAlgorithm extends PipBoundsAlgorithm {
}
/**
- * @return true if position changed
+ * @return true if gravity changed
*/
- boolean updatePosition(int keycode) {
- if (DEBUG) Log.d(TAG, "updatePosition, keycode: " + keycode);
+ boolean updateGravity(int keycode) {
+ if (DEBUG) Log.d(TAG, "updateGravity, keycode: " + keycode);
// Check if position change is valid
if (mTvPipBoundsState.isTvPipExpanded()) {
@@ -247,26 +290,31 @@ public class TvPipBoundsAlgorithm extends PipBoundsAlgorithm {
return false;
}
+ private Size getPipSize() {
+ final boolean isExpanded =
+ mTvPipBoundsState.isTvExpandedPipSupported() && mTvPipBoundsState.isTvPipExpanded()
+ && mTvPipBoundsState.getDesiredTvExpandedAspectRatio() != 0;
+ if (isExpanded) {
+ return mTvPipBoundsState.getTvExpandedSize();
+ } else {
+ final Rect normalBounds = getNormalBounds();
+ return new Size(normalBounds.width(), normalBounds.height());
+ }
+ }
+
/**
- * Calculates the PiP bounds.
+ * Updates {@link TvPipBoundsState#getTvExpandedSize()} based on
+ * {@link TvPipBoundsState#getDesiredTvExpandedAspectRatio()}, the screen size.
*/
- public Rect getTvPipBounds(boolean expandedIfPossible) {
- if (DEBUG) {
- Log.d(TAG, "getExpandedBoundsIfPossible with gravity "
- + Gravity.toString(mTvPipBoundsState.getTvPipGravity())
- + ", fixed orientation: " + mTvPipBoundsState.getTvFixedPipOrientation());
- }
+ void updateExpandedPipSize() {
+ final DisplayLayout displayLayout = mTvPipBoundsState.getDisplayLayout();
+ final float expandedRatio =
+ mTvPipBoundsState.getDesiredTvExpandedAspectRatio(); // width / height
- if (!mTvPipBoundsState.isTvExpandedPipEnabled() || !expandedIfPossible) {
- return getTvNormalBounds();
- }
-
- DisplayLayout displayLayout = mTvPipBoundsState.getDisplayLayout();
- float expandedRatio = mTvPipBoundsState.getTvExpandedAspectRatio(); // width / height
- Size expandedSize;
+ final Size expandedSize;
if (expandedRatio == 0) {
- Log.d(TAG, "Expanded mode not supported");
- return getTvNormalBounds();
+ Log.d(TAG, "updateExpandedPipSize(): Expanded mode aspect ratio of 0 not supported");
+ return;
} else if (expandedRatio < 1) {
// vertical
if (mTvPipBoundsState.getTvFixedPipOrientation() == ORIENTATION_HORIZONTAL) {
@@ -300,26 +348,14 @@ public class TvPipBoundsAlgorithm extends PipBoundsAlgorithm {
}
}
- if (expandedSize == null) {
- return getTvNormalBounds();
- }
-
- if (DEBUG) {
- Log.d(TAG, "expanded size, width: " + expandedSize.getWidth()
- + ", height: " + expandedSize.getHeight());
- }
-
- Rect insetBounds = new Rect();
- getInsetBounds(insetBounds);
-
- Rect expandedBounds = new Rect();
- Gravity.apply(mTvPipBoundsState.getTvPipGravity(), expandedSize.getWidth(),
- expandedSize.getHeight(), insetBounds, expandedBounds);
- if (DEBUG) Log.d(TAG, "expanded bounds: " + expandedBounds.toShortString());
-
mTvPipBoundsState.setTvExpandedSize(expandedSize);
- mTvPipBoundsState.setTvPipExpanded(true);
- return expandedBounds;
+ if (DEBUG) {
+ Log.d(TAG, "updateExpandedPipSize(): expanded size, width=" + expandedSize.getWidth()
+ + ", height=" + expandedSize.getHeight());
+ }
}
+ void keepUnstashedForCurrentKeepClearAreas() {
+ mKeepClearAlgorithm.keepUnstashedForCurrentKeepClearAreas();
+ }
}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipBoundsState.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipBoundsState.java
index 9370e33fce656..d880f821ee73f 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipBoundsState.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipBoundsState.java
@@ -53,10 +53,10 @@ public class TvPipBoundsState extends PipBoundsState {
public static final int DEFAULT_TV_GRAVITY = Gravity.BOTTOM | Gravity.RIGHT;
- private boolean mIsTvExpandedPipEnabled;
+ private final boolean mIsTvExpandedPipSupported;
private boolean mIsTvPipExpanded;
private boolean mTvPipManuallyCollapsed;
- private float mTvExpandedAspectRatio;
+ private float mDesiredTvExpandedAspectRatio;
private @Orientation int mTvFixedPipOrientation;
private int mTvPipGravity;
private @Nullable Size mTvExpandedSize;
@@ -64,8 +64,8 @@ public class TvPipBoundsState extends PipBoundsState {
public TvPipBoundsState(@NonNull Context context) {
super(context);
- setIsTvExpandedPipEnabled(context.getPackageManager().hasSystemFeature(
- PackageManager.FEATURE_EXPANDED_PICTURE_IN_PICTURE));
+ mIsTvExpandedPipSupported = context.getPackageManager().hasSystemFeature(
+ PackageManager.FEATURE_EXPANDED_PICTURE_IN_PICTURE);
}
/**
@@ -75,7 +75,7 @@ public class TvPipBoundsState extends PipBoundsState {
public void setBoundsStateForEntry(ComponentName componentName, ActivityInfo activityInfo,
PictureInPictureParams params, PipBoundsAlgorithm pipBoundsAlgorithm) {
super.setBoundsStateForEntry(componentName, activityInfo, params, pipBoundsAlgorithm);
- setTvExpandedAspectRatio(params.getExpandedAspectRatio(), true);
+ setDesiredTvExpandedAspectRatio(params.getExpandedAspectRatio(), true);
}
/** Resets the TV PiP state for a new activity. */
@@ -85,32 +85,32 @@ public class TvPipBoundsState extends PipBoundsState {
}
/** Set the tv expanded bounds of PIP */
- public void setTvExpandedSize(@Nullable Size bounds) {
- mTvExpandedSize = bounds;
+ public void setTvExpandedSize(@Nullable Size size) {
+ mTvExpandedSize = size;
}
- /** Get the PIP tv expanded bounds. */
+ /** Get the expanded size of the PiP. */
@Nullable
public Size getTvExpandedSize() {
return mTvExpandedSize;
}
/** Set the PIP aspect ratio for the expanded PIP (TV) that is desired by the app. */
- public void setTvExpandedAspectRatio(float aspectRatio, boolean override) {
+ public void setDesiredTvExpandedAspectRatio(float aspectRatio, boolean override) {
if (override || mTvFixedPipOrientation == ORIENTATION_UNDETERMINED || aspectRatio == 0) {
- mTvExpandedAspectRatio = aspectRatio;
+ mDesiredTvExpandedAspectRatio = aspectRatio;
resetTvPipState();
return;
}
if ((aspectRatio > 1 && mTvFixedPipOrientation == ORIENTATION_HORIZONTAL)
|| (aspectRatio <= 1 && mTvFixedPipOrientation == ORIENTATION_VERTICAL)) {
- mTvExpandedAspectRatio = aspectRatio;
+ mDesiredTvExpandedAspectRatio = aspectRatio;
}
}
/** Get the PIP aspect ratio for the expanded PIP (TV) that is desired by the app. */
- public float getTvExpandedAspectRatio() {
- return mTvExpandedAspectRatio;
+ public float getDesiredTvExpandedAspectRatio() {
+ return mDesiredTvExpandedAspectRatio;
}
/** Sets the orientation the expanded TV PiP activity has been fixed to. */
@@ -154,13 +154,9 @@ public class TvPipBoundsState extends PipBoundsState {
return mTvPipManuallyCollapsed;
}
- /** Sets whether expanded PiP is supported by the device. */
- public void setIsTvExpandedPipEnabled(boolean enabled) {
- mIsTvExpandedPipEnabled = enabled;
+ /** Returns whether expanded PiP is supported by the device. */
+ public boolean isTvExpandedPipSupported() {
+ return mIsTvExpandedPipSupported;
}
- /** Returns whether expanded PiP is supported by the device. */
- public boolean isTvExpandedPipEnabled() {
- return mIsTvExpandedPipEnabled;
- }
}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipController.java
index 3c830e0a0d62e..03c5e98de2988 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipController.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipController.java
@@ -30,9 +30,9 @@ import android.content.pm.ParceledListSlice;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Rect;
+import android.os.Handler;
import android.os.RemoteException;
import android.util.Log;
-import android.view.DisplayInfo;
import android.view.Gravity;
import com.android.wm.shell.R;
@@ -45,9 +45,11 @@ import com.android.wm.shell.common.TaskStackListenerImpl;
import com.android.wm.shell.pip.PinnedStackListenerForwarder;
import com.android.wm.shell.pip.Pip;
import com.android.wm.shell.pip.PipAnimationController;
+import com.android.wm.shell.pip.PipBoundsState;
import com.android.wm.shell.pip.PipMediaController;
import com.android.wm.shell.pip.PipTaskOrganizer;
import com.android.wm.shell.pip.PipTransitionController;
+import com.android.wm.shell.pip.tv.TvPipKeepClearAlgorithm.Placement;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@@ -62,6 +64,7 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
private static final String TAG = "TvPipController";
static final boolean DEBUG = false;
+ private static final double EPS = 1e-7;
private static final int NONEXISTENT_TASK_ID = -1;
@Retention(RetentionPolicy.SOURCE)
@@ -97,11 +100,13 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
private final TvPipNotificationController mPipNotificationController;
private final TvPipMenuController mTvPipMenuController;
private final ShellExecutor mMainExecutor;
+ private final Handler mMainHandler;
private final TvPipImpl mImpl = new TvPipImpl();
private @State int mState = STATE_NO_PIP;
private int mPreviousGravity = TvPipBoundsState.DEFAULT_TV_GRAVITY;
private int mPinnedTaskId = NONEXISTENT_TASK_ID;
+ private Runnable mUnstashRunnable;
private int mResizeAnimationDuration;
@@ -117,7 +122,8 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
TaskStackListenerImpl taskStackListener,
DisplayController displayController,
WindowManagerShellWrapper wmShell,
- ShellExecutor mainExecutor) {
+ ShellExecutor mainExecutor,
+ Handler mainHandler) {
return new TvPipController(
context,
tvPipBoundsState,
@@ -130,7 +136,8 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
taskStackListener,
displayController,
wmShell,
- mainExecutor).mImpl;
+ mainExecutor,
+ mainHandler).mImpl;
}
private TvPipController(
@@ -145,9 +152,11 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
TaskStackListenerImpl taskStackListener,
DisplayController displayController,
WindowManagerShellWrapper wmShell,
- ShellExecutor mainExecutor) {
+ ShellExecutor mainExecutor,
+ Handler mainHandler) {
mContext = context;
mMainExecutor = mainExecutor;
+ mMainHandler = mainHandler;
mTvPipBoundsState = tvPipBoundsState;
mTvPipBoundsState.setDisplayId(context.getDisplayId());
@@ -182,6 +191,7 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
loadConfigurations();
mPipNotificationController.onConfigurationChanged(mContext);
+ mTvPipBoundsAlgorithm.onConfigurationChanged(mContext);
}
/**
@@ -206,13 +216,20 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
}
setState(STATE_PIP_MENU);
- movePinnedStack();
+ updatePinnedStackBounds();
}
@Override
public void closeMenu() {
if (DEBUG) Log.d(TAG, "closeMenu(), state before=" + stateToName(mState));
setState(STATE_PIP);
+ mTvPipBoundsAlgorithm.keepUnstashedForCurrentKeepClearAreas();
+ updatePinnedStackBounds();
+ }
+
+ @Override
+ public void onInMoveModeChanged() {
+ updatePinnedStackBounds();
}
/**
@@ -231,21 +248,21 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
if (DEBUG) Log.d(TAG, "togglePipExpansion()");
boolean expanding = !mTvPipBoundsState.isTvPipExpanded();
int saveGravity = mTvPipBoundsAlgorithm
- .updatePositionOnExpandToggled(mPreviousGravity, expanding);
+ .updateGravityOnExpandToggled(mPreviousGravity, expanding);
if (saveGravity != Gravity.NO_GRAVITY) {
mPreviousGravity = saveGravity;
}
mTvPipBoundsState.setTvPipManuallyCollapsed(!expanding);
mTvPipBoundsState.setTvPipExpanded(expanding);
- movePinnedStack();
+ updatePinnedStackBounds();
}
@Override
public void movePip(int keycode) {
- if (mTvPipBoundsAlgorithm.updatePosition(keycode)) {
+ if (mTvPipBoundsAlgorithm.updateGravity(keycode)) {
mTvPipMenuController.updateGravity(mTvPipBoundsState.getTvPipGravity());
mPreviousGravity = Gravity.NO_GRAVITY;
- movePinnedStack();
+ updatePinnedStackBounds();
} else {
if (DEBUG) Log.d(TAG, "Position hasn't changed");
}
@@ -265,20 +282,48 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
Set unrestricted) {
if (mTvPipBoundsState.getDisplayId() == displayId) {
mTvPipBoundsState.setKeepClearAreas(restricted, unrestricted);
- movePinnedStack();
+ updatePinnedStackBounds();
}
}
/**
- * Animate to the updated position of the PiP based on the state and position of the PiP.
+ * Update the PiP bounds based on the state of the PiP and keep clear areas.
+ * Animates to the current PiP bounds, and schedules unstashing the PiP if necessary.
*/
- private void movePinnedStack() {
+ private void updatePinnedStackBounds() {
if (mState == STATE_NO_PIP) {
return;
}
- Rect bounds = mTvPipBoundsAlgorithm.getTvPipBounds(mTvPipBoundsState.isTvPipExpanded());
- if (DEBUG) Log.d(TAG, "movePinnedStack() - new pip bounds: " + bounds.toShortString());
+ final boolean stayAtAnchorPosition = mTvPipMenuController.isInMoveMode();
+ final boolean disallowStashing = mState == STATE_PIP_MENU || stayAtAnchorPosition;
+ final Placement placement = mTvPipBoundsAlgorithm.getTvPipBounds();
+
+ int stashType =
+ disallowStashing ? PipBoundsState.STASH_TYPE_NONE : placement.getStashType();
+ mTvPipBoundsState.setStashed(stashType);
+
+ if (stayAtAnchorPosition) {
+ movePinnedStackTo(placement.getAnchorBounds());
+ } else if (disallowStashing) {
+ movePinnedStackTo(placement.getUnstashedBounds());
+ } else {
+ movePinnedStackTo(placement.getBounds());
+ }
+
+ if (mUnstashRunnable != null) {
+ mMainHandler.removeCallbacks(mUnstashRunnable);
+ mUnstashRunnable = null;
+ }
+ if (!disallowStashing && placement.getUnstashDestinationBounds() != null) {
+ mUnstashRunnable = () -> movePinnedStackTo(placement.getUnstashDestinationBounds());
+ mMainHandler.postAtTime(mUnstashRunnable, placement.getUnstashTime());
+ }
+ }
+
+ /** Animates the PiP to the given bounds. */
+ private void movePinnedStackTo(Rect bounds) {
+ if (DEBUG) Log.d(TAG, "movePinnedStackTo() - new pip bounds: " + bounds.toShortString());
mPipTaskOrganizer.scheduleAnimateResizePip(bounds,
mResizeAnimationDuration, rect -> {
if (DEBUG) Log.d(TAG, "movePinnedStack() animation done");
@@ -359,6 +404,8 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
if (DEBUG) Log.d(TAG, " > show menu");
mTvPipMenuController.showMenu();
}
+
+ updatePinnedStackBounds();
}
private void loadConfigurations() {
@@ -366,12 +413,6 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
mResizeAnimationDuration = res.getInteger(R.integer.config_pipResizeAnimationDuration);
}
- private DisplayInfo getDisplayInfo() {
- final DisplayInfo displayInfo = new DisplayInfo();
- mContext.getDisplay().getDisplayInfo(displayInfo);
- return displayInfo;
- }
-
private void registerTaskStackListenerCallback(TaskStackListenerImpl taskStackListener) {
taskStackListener.addListener(new TaskStackListenerCallback() {
@Override
@@ -417,7 +458,7 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
mTvPipBoundsState.setImeVisibility(imeVisible, imeHeight);
if (mState != STATE_NO_PIP) {
- movePinnedStack();
+ updatePinnedStackBounds();
}
}
@@ -429,7 +470,7 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
mTvPipBoundsState.setAspectRatio(ratio);
if (!mTvPipBoundsState.isTvPipExpanded() && ratioChanged) {
- movePinnedStack();
+ updatePinnedStackBounds();
}
}
@@ -438,41 +479,45 @@ public class TvPipController implements PipTransitionController.PipTransitionCal
if (DEBUG) Log.d(TAG, "onExpandedAspectRatioChanged: " + ratio);
// 0) No update to the ratio --> don't do anything
- if (mTvPipBoundsState.getTvExpandedAspectRatio() == ratio) {
+
+ if (Math.abs(mTvPipBoundsState.getDesiredTvExpandedAspectRatio() - ratio)
+ < EPS) {
return;
}
- mTvPipBoundsState.setTvExpandedAspectRatio(ratio, false);
+ mTvPipBoundsState.setDesiredTvExpandedAspectRatio(ratio, false);
// 1) PiP is expanded and only aspect ratio changed, but wasn't disabled
// --> update bounds, but don't toggle
if (mTvPipBoundsState.isTvPipExpanded() && ratio != 0) {
- movePinnedStack();
+ mTvPipBoundsAlgorithm.updateExpandedPipSize();
+ updatePinnedStackBounds();
}
// 2) PiP is expanded, but expanded PiP was disabled
// --> collapse PiP
if (mTvPipBoundsState.isTvPipExpanded() && ratio == 0) {
int saveGravity = mTvPipBoundsAlgorithm
- .updatePositionOnExpandToggled(mPreviousGravity, false);
+ .updateGravityOnExpandToggled(mPreviousGravity, false);
if (saveGravity != Gravity.NO_GRAVITY) {
mPreviousGravity = saveGravity;
}
mTvPipBoundsState.setTvPipExpanded(false);
- movePinnedStack();
+ updatePinnedStackBounds();
}
// 3) PiP not expanded and not manually collapsed and expand was enabled
// --> expand to new ratio
if (!mTvPipBoundsState.isTvPipExpanded() && ratio != 0
&& !mTvPipBoundsState.isTvPipManuallyCollapsed()) {
+ mTvPipBoundsAlgorithm.updateExpandedPipSize();
int saveGravity = mTvPipBoundsAlgorithm
- .updatePositionOnExpandToggled(mPreviousGravity, true);
+ .updateGravityOnExpandToggled(mPreviousGravity, true);
if (saveGravity != Gravity.NO_GRAVITY) {
mPreviousGravity = saveGravity;
}
mTvPipBoundsState.setTvPipExpanded(true);
- movePinnedStack();
+ updatePinnedStackBounds();
}
}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipKeepClearAlgorithm.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipKeepClearAlgorithm.kt
new file mode 100644
index 0000000000000..5ac7a7200494d
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipKeepClearAlgorithm.kt
@@ -0,0 +1,741 @@
+/*
+ * Copyright (C) 2022 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.graphics.Point
+import android.graphics.Rect
+import android.util.Size
+import android.view.Gravity
+import com.android.wm.shell.pip.PipBoundsState
+import com.android.wm.shell.pip.PipBoundsState.STASH_TYPE_BOTTOM
+import com.android.wm.shell.pip.PipBoundsState.STASH_TYPE_LEFT
+import com.android.wm.shell.pip.PipBoundsState.STASH_TYPE_NONE
+import com.android.wm.shell.pip.PipBoundsState.STASH_TYPE_RIGHT
+import com.android.wm.shell.pip.PipBoundsState.STASH_TYPE_TOP
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.roundToInt
+
+private const val DEFAULT_PIP_MARGINS = 48
+private const val DEFAULT_STASH_DURATION = 5000L
+private const val RELAX_DEPTH = 1
+private const val DEFAULT_MAX_RESTRICTED_DISTANCE_FRACTION = 0.15
+
+/**
+ * This class calculates an appropriate position for a Picture-In-Picture (PiP) window, taking
+ * into account app defined keep clear areas.
+ *
+ * @param clock A function returning a current timestamp (in milliseconds)
+ */
+class TvPipKeepClearAlgorithm(private val clock: () -> Long) {
+ /**
+ * Result of the positioning algorithm.
+ *
+ * @param bounds The bounds the PiP should be placed at
+ * @param anchorBounds The bounds of the PiP anchor position
+ * (where the PiP would be placed if there were no keep clear areas)
+ * @param stashType Where the PiP has been stashed, if at all
+ * @param unstashDestinationBounds If stashed, the PiP should move to this position after
+ * [stashDuration] has passed.
+ * @param unstashTime If stashed, the time at which the PiP should move
+ * to [unstashDestinationBounds]
+ */
+ data class Placement(
+ val bounds: Rect,
+ val anchorBounds: Rect,
+ @PipBoundsState.StashType val stashType: Int = STASH_TYPE_NONE,
+ val unstashDestinationBounds: Rect? = null,
+ val unstashTime: Long = 0L
+ ) {
+ /** Bounds to use if the PiP should not be stashed. */
+ fun getUnstashedBounds() = unstashDestinationBounds ?: bounds
+ }
+
+ /** The size of the screen */
+ private var screenSize = Size(0, 0)
+
+ /** The bounds the PiP is allowed to move in */
+ private var movementBounds = Rect()
+
+ /** Padding to add between a keep clear area that caused the PiP to move and the PiP */
+ var pipAreaPadding = DEFAULT_PIP_MARGINS
+
+ /** The distance the PiP peeks into the screen when stashed */
+ var stashOffset = DEFAULT_PIP_MARGINS
+
+ /**
+ * How long (in milliseconds) the PiP should stay stashed for after the last time the
+ * keep clear areas causing the PiP to stash have changed.
+ */
+ var stashDuration = DEFAULT_STASH_DURATION
+
+ /** The fraction of screen width/height restricted keep clear areas can move the PiP */
+ var maxRestrictedDistanceFraction = DEFAULT_MAX_RESTRICTED_DISTANCE_FRACTION
+
+ private var pipGravity = Gravity.BOTTOM or Gravity.RIGHT
+ private var transformedScreenBounds = Rect()
+ private var transformedMovementBounds = Rect()
+
+ private var lastAreasOverlappingUnstashPosition: Set = emptySet()
+ private var lastStashTime: Long = Long.MIN_VALUE
+
+ /**
+ * Calculates the position the PiP should be placed at, taking into consideration the
+ * given keep clear areas.
+ *
+ * Restricted keep clear areas can move the PiP only by a limited amount, and may be ignored
+ * if there is no space for the PiP to move to.
+ * Apps holding the permission [android.Manifest.permission.USE_UNRESTRICTED_KEEP_CLEAR_AREAS]
+ * can declare unrestricted keep clear areas, which can move the PiP farther and placement will
+ * always try to respect these areas.
+ *
+ * If no free space the PiP is allowed to move to can be found, a stashed position is returned
+ * as [Placement.bounds], along with a position to move to once [Placement.unstashTime] has
+ * passed as [Placement.unstashDestinationBounds].
+ *
+ * @param pipSize The size of the PiP window
+ * @param restrictedAreas The restricted keep clear areas
+ * @param unrestrictedAreas The unrestricted keep clear areas
+ *
+ */
+ fun calculatePipPosition(
+ pipSize: Size,
+ restrictedAreas: Set,
+ unrestrictedAreas: Set
+ ): Placement {
+ val transformedRestrictedAreas = transformAndFilterAreas(restrictedAreas)
+ val transformedUnrestrictedAreas = transformAndFilterAreas(unrestrictedAreas)
+ val pipAnchorBounds = getNormalPipAnchorBounds(pipSize, transformedMovementBounds)
+
+ val result = calculatePipPositionTransformed(
+ pipAnchorBounds,
+ transformedRestrictedAreas,
+ transformedUnrestrictedAreas
+ )
+
+ val screenSpaceBounds = fromTransformedSpace(result.bounds)
+ return Placement(
+ screenSpaceBounds,
+ fromTransformedSpace(result.anchorBounds),
+ getStashType(screenSpaceBounds, movementBounds),
+ result.unstashDestinationBounds?.let { fromTransformedSpace(it) },
+ result.unstashTime
+ )
+ }
+
+ /**
+ * Filters out areas that encompass the entire movement bounds and returns them mapped to
+ * the base case space.
+ *
+ * Areas encompassing the entire movement bounds can occur when a full-screen View gets focused,
+ * but we don't want this to cause the PiP to get stashed.
+ */
+ private fun transformAndFilterAreas(areas: Set): Set {
+ return areas.mapNotNullTo(mutableSetOf()) {
+ when {
+ it.contains(movementBounds) -> null
+ else -> toTransformedSpace(it)
+ }
+ }
+ }
+
+ /**
+ * Calculates the position the PiP should be placed at, taking into consideration the
+ * given keep clear areas.
+ * All parameters are transformed from screen space to the base case space, where the PiP
+ * anchor is in the bottom right corner / on the right side.
+ *
+ * @see [calculatePipPosition]
+ */
+ private fun calculatePipPositionTransformed(
+ pipAnchorBounds: Rect,
+ restrictedAreas: Set,
+ unrestrictedAreas: Set
+ ): Placement {
+ if (restrictedAreas.isEmpty() && unrestrictedAreas.isEmpty()) {
+ return Placement(pipAnchorBounds, pipAnchorBounds)
+ }
+
+ // First try to find a free position to move to
+ val freeMovePos = findFreeMovePosition(pipAnchorBounds, restrictedAreas, unrestrictedAreas)
+ if (freeMovePos != null) {
+ lastAreasOverlappingUnstashPosition = emptySet()
+ return Placement(freeMovePos, pipAnchorBounds)
+ }
+
+ // If no free position is found, we have to stash the PiP.
+ // Find the position the PiP should return to once it unstashes by doing a relaxed
+ // search, or ignoring restricted areas, or returning to the anchor position
+ val unstashBounds =
+ findRelaxedMovePosition(pipAnchorBounds, restrictedAreas, unrestrictedAreas)
+ ?: findFreeMovePosition(pipAnchorBounds, emptySet(), unrestrictedAreas)
+ ?: pipAnchorBounds
+
+ val keepClearAreas = restrictedAreas + unrestrictedAreas
+ val areasOverlappingUnstashPosition =
+ keepClearAreas.filter { Rect.intersects(it, unstashBounds) }.toSet()
+ val areasOverlappingUnstashPositionChanged =
+ !lastAreasOverlappingUnstashPosition.containsAll(areasOverlappingUnstashPosition)
+ lastAreasOverlappingUnstashPosition = areasOverlappingUnstashPosition
+
+ val now = clock()
+ if (areasOverlappingUnstashPositionChanged) {
+ lastStashTime = now
+ }
+
+ // If overlapping areas haven't changed and the stash duration has passed, we can
+ // place the PiP at the unstash position
+ val unstashTime = lastStashTime + stashDuration
+ if (now >= unstashTime) {
+ return Placement(unstashBounds, pipAnchorBounds)
+ }
+
+ // Otherwise, we'll stash it close to the unstash position
+ val stashedBounds = getNearbyStashedPosition(unstashBounds, keepClearAreas)
+ return Placement(
+ stashedBounds,
+ pipAnchorBounds,
+ getStashType(stashedBounds, transformedMovementBounds),
+ unstashBounds,
+ unstashTime
+ )
+ }
+
+ @PipBoundsState.StashType
+ private fun getStashType(stashedBounds: Rect, movementBounds: Rect): Int {
+ return when {
+ stashedBounds.left < movementBounds.left -> STASH_TYPE_LEFT
+ stashedBounds.right > movementBounds.right -> STASH_TYPE_RIGHT
+ stashedBounds.top < movementBounds.top -> STASH_TYPE_TOP
+ stashedBounds.bottom > movementBounds.bottom -> STASH_TYPE_BOTTOM
+ else -> STASH_TYPE_NONE
+ }
+ }
+
+ private fun findRelaxedMovePosition(
+ pipAnchorBounds: Rect,
+ restrictedAreas: Set,
+ unrestrictedAreas: Set
+ ): Rect? {
+ if (RELAX_DEPTH <= 0) {
+ // relaxed search disabled
+ return null
+ }
+
+ return findRelaxedMovePosition(
+ RELAX_DEPTH,
+ pipAnchorBounds,
+ restrictedAreas.toMutableSet(),
+ unrestrictedAreas
+ )
+ }
+
+ private fun findRelaxedMovePosition(
+ depth: Int,
+ pipAnchorBounds: Rect,
+ restrictedAreas: MutableSet,
+ unrestrictedAreas: Set
+ ): Rect? {
+ if (depth == 0) {
+ return findFreeMovePosition(pipAnchorBounds, restrictedAreas, unrestrictedAreas)
+ }
+
+ val candidates = mutableListOf()
+ val areasToExclude = restrictedAreas.toList()
+ for (area in areasToExclude) {
+ restrictedAreas.remove(area)
+ val candidate = findRelaxedMovePosition(
+ depth - 1,
+ pipAnchorBounds,
+ restrictedAreas,
+ unrestrictedAreas
+ )
+ restrictedAreas.add(area)
+
+ if (candidate != null) {
+ candidates.add(candidate)
+ }
+ }
+ return candidates.minByOrNull { candidateCost(it, pipAnchorBounds) }
+ }
+
+ /** Cost function to evaluate candidate bounds */
+ private fun candidateCost(candidateBounds: Rect, pipAnchorBounds: Rect): Int {
+ // squared euclidean distance of corresponding rect corners
+ val dx = candidateBounds.left - pipAnchorBounds.left
+ val dy = candidateBounds.top - pipAnchorBounds.top
+ return dx * dx + dy * dy
+ }
+
+ private fun findFreeMovePosition(
+ pipAnchorBounds: Rect,
+ restrictedAreas: Set,
+ unrestrictedAreas: Set
+ ): Rect? {
+ val movementBounds = transformedMovementBounds
+ val candidateEdgeRects = mutableListOf()
+ val minRestrictedLeft =
+ pipAnchorBounds.right - screenSize.width * maxRestrictedDistanceFraction
+
+ candidateEdgeRects.add(
+ movementBounds.offsetCopy(movementBounds.width() + pipAreaPadding, 0)
+ )
+ candidateEdgeRects.addAll(unrestrictedAreas)
+ candidateEdgeRects.addAll(restrictedAreas.filter { it.left >= minRestrictedLeft })
+
+ // throw out edges that are too close to the left screen edge to fit the PiP
+ val minLeft = movementBounds.left + pipAnchorBounds.width()
+ candidateEdgeRects.retainAll { it.left - pipAreaPadding > minLeft }
+ candidateEdgeRects.sortBy { -it.left }
+
+ val maxRestrictedDY = (screenSize.height * maxRestrictedDistanceFraction).roundToInt()
+
+ val candidateBounds = mutableListOf()
+ for (edgeRect in candidateEdgeRects) {
+ val edge = edgeRect.left - pipAreaPadding
+ val dx = (edge - pipAnchorBounds.width()) - pipAnchorBounds.left
+ val candidatePipBounds = pipAnchorBounds.offsetCopy(dx, 0)
+ val searchUp = true
+ val searchDown = !isPipAnchoredToCorner()
+
+ if (searchUp) {
+ val event = findMinMoveUp(candidatePipBounds, restrictedAreas, unrestrictedAreas)
+ val padding = if (event.start) 0 else pipAreaPadding
+ val dy = event.pos - pipAnchorBounds.bottom - padding
+ val maxDY = if (event.unrestricted) movementBounds.height() else maxRestrictedDY
+ val candidate = pipAnchorBounds.offsetCopy(dx, dy)
+ val isOnScreen = candidate.top > movementBounds.top
+ val hangingMidAir = !candidate.intersectsY(edgeRect)
+ if (isOnScreen && abs(dy) <= maxDY && !hangingMidAir) {
+ candidateBounds.add(candidate)
+ }
+ }
+
+ if (searchDown) {
+ val event = findMinMoveDown(candidatePipBounds, restrictedAreas, unrestrictedAreas)
+ val padding = if (event.start) 0 else pipAreaPadding
+ val dy = event.pos - pipAnchorBounds.top + padding
+ val maxDY = if (event.unrestricted) movementBounds.height() else maxRestrictedDY
+ val candidate = pipAnchorBounds.offsetCopy(dx, dy)
+ val isOnScreen = candidate.bottom < movementBounds.bottom
+ val hangingMidAir = !candidate.intersectsY(edgeRect)
+ if (isOnScreen && abs(dy) <= maxDY && !hangingMidAir) {
+ candidateBounds.add(candidate)
+ }
+ }
+ }
+
+ candidateBounds.sortBy { candidateCost(it, pipAnchorBounds) }
+ return candidateBounds.firstOrNull()
+ }
+
+ private fun getNearbyStashedPosition(bounds: Rect, keepClearAreas: Set): Rect {
+ val screenBounds = transformedScreenBounds
+ val stashCandidates = Array(2) { Rect(bounds) }
+ val areasOverlappingPipX = keepClearAreas.filter { it.intersectsX(bounds) }
+ val areasOverlappingPipY = keepClearAreas.filter { it.intersectsY(bounds) }
+
+ if (screenBounds.bottom - bounds.bottom <= bounds.top - screenBounds.top) {
+ // bottom is closer than top, stash downwards
+ val fullStashTop = screenBounds.bottom - stashOffset
+
+ val maxBottom = areasOverlappingPipX.maxByOrNull { it.bottom }!!.bottom
+ val partialStashTop = maxBottom + pipAreaPadding
+
+ val downPosition = stashCandidates[0]
+ downPosition.offsetTo(bounds.left, min(fullStashTop, partialStashTop))
+ } else {
+ // top is closer than bottom, stash upwards
+ val fullStashY = screenBounds.top - bounds.height() + stashOffset
+
+ val minTop = areasOverlappingPipX.minByOrNull { it.top }!!.top
+ val partialStashY = minTop - bounds.height() - pipAreaPadding
+
+ val upPosition = stashCandidates[0]
+ upPosition.offsetTo(bounds.left, max(fullStashY, partialStashY))
+ }
+
+ if (screenBounds.right - bounds.right <= bounds.left - screenBounds.left) {
+ // right is closer than left, stash rightwards
+ val fullStashLeft = screenBounds.right - stashOffset
+
+ val maxRight = areasOverlappingPipY.maxByOrNull { it.right }!!.right
+ val partialStashLeft = maxRight + pipAreaPadding
+
+ val rightPosition = stashCandidates[1]
+ rightPosition.offsetTo(min(fullStashLeft, partialStashLeft), bounds.top)
+ } else {
+ // left is closer than right, stash leftwards
+ val fullStashLeft = screenBounds.left - bounds.width() + stashOffset
+
+ val minLeft = areasOverlappingPipY.minByOrNull { it.left }!!.left
+ val partialStashLeft = minLeft - bounds.width() - pipAreaPadding
+
+ val rightPosition = stashCandidates[1]
+ rightPosition.offsetTo(max(fullStashLeft, partialStashLeft), bounds.top)
+ }
+
+ return stashCandidates.minByOrNull {
+ val dx = abs(it.left - bounds.left)
+ val dy = abs(it.top - bounds.top)
+ dx * bounds.height() + dy * bounds.width()
+ }!!
+ }
+
+ /**
+ * Prevents the PiP from being stashed for the current set of keep clear areas.
+ * The PiP may stash again if keep clear areas change.
+ */
+ fun keepUnstashedForCurrentKeepClearAreas() {
+ lastStashTime = Long.MIN_VALUE
+ }
+
+ /**
+ * Updates the size of the screen.
+ *
+ * @param size The new size of the screen
+ */
+ fun setScreenSize(size: Size) {
+ if (screenSize == size) {
+ return
+ }
+
+ screenSize = size
+ transformedScreenBounds =
+ toTransformedSpace(Rect(0, 0, screenSize.width, screenSize.height))
+ transformedMovementBounds = toTransformedSpace(transformedMovementBounds)
+ }
+
+ /**
+ * Updates the bounds within which the PiP is allowed to move.
+ *
+ * @param bounds The new movement bounds
+ */
+ fun setMovementBounds(bounds: Rect) {
+ if (movementBounds == bounds) {
+ return
+ }
+
+ movementBounds.set(bounds)
+ transformedMovementBounds = toTransformedSpace(movementBounds)
+ }
+
+ /**
+ * Sets the corner/side of the PiP's home position.
+ */
+ fun setGravity(gravity: Int) {
+ if (pipGravity == gravity) return
+
+ pipGravity = gravity
+ transformedScreenBounds =
+ toTransformedSpace(Rect(0, 0, screenSize.width, screenSize.height))
+ transformedMovementBounds = toTransformedSpace(movementBounds)
+ }
+
+ /**
+ * @param open Whether this event marks the opening of an occupied segment
+ * @param pos The coordinate of this event
+ * @param unrestricted Whether this event was generated by an unrestricted keep clear area
+ * @param start Marks the special start event. Earlier events are skipped when sweeping
+ */
+ data class SweepLineEvent(
+ val open: Boolean,
+ val pos: Int,
+ val unrestricted: Boolean,
+ val start: Boolean = false
+ )
+
+ /**
+ * Returns a [SweepLineEvent] representing the minimal move up from [pipBounds] that clears
+ * the given keep clear areas.
+ */
+ private fun findMinMoveUp(
+ pipBounds: Rect,
+ restrictedAreas: Set,
+ unrestrictedAreas: Set
+ ): SweepLineEvent {
+ val events = mutableListOf()
+ val generateEvents: (Boolean) -> (Rect) -> Unit = { unrestricted ->
+ { area ->
+ if (pipBounds.intersectsX(area)) {
+ events.add(SweepLineEvent(true, area.bottom, unrestricted))
+ events.add(SweepLineEvent(false, area.top, unrestricted))
+ }
+ }
+ }
+
+ restrictedAreas.forEach(generateEvents(false))
+ unrestrictedAreas.forEach(generateEvents(true))
+
+ return sweepLineFindEarliestGap(
+ events,
+ pipBounds.height() + pipAreaPadding,
+ pipBounds.bottom,
+ pipBounds.height()
+ )
+ }
+
+ /**
+ * Returns a [SweepLineEvent] representing the minimal move down from [pipBounds] that clears
+ * the given keep clear areas.
+ */
+ private fun findMinMoveDown(
+ pipBounds: Rect,
+ restrictedAreas: Set,
+ unrestrictedAreas: Set
+ ): SweepLineEvent {
+ val events = mutableListOf()
+ val generateEvents: (Boolean) -> (Rect) -> Unit = { unrestricted ->
+ { area ->
+ if (pipBounds.intersectsX(area)) {
+ events.add(SweepLineEvent(true, -area.top, unrestricted))
+ events.add(SweepLineEvent(false, -area.bottom, unrestricted))
+ }
+ }
+ }
+
+ restrictedAreas.forEach(generateEvents(false))
+ unrestrictedAreas.forEach(generateEvents(true))
+
+ val earliestEvent = sweepLineFindEarliestGap(
+ events,
+ pipBounds.height() + pipAreaPadding,
+ -pipBounds.top,
+ pipBounds.height()
+ )
+
+ return earliestEvent.copy(pos = -earliestEvent.pos)
+ }
+
+ /**
+ * Takes a list of events representing the starts & ends of occupied segments, and
+ * returns the earliest event whose position is unoccupied and has [gapSize] distance to the
+ * next event.
+ *
+ * @param events List of [SweepLineEvent] representing occupied segments
+ * @param gapSize Size of the gap to search for
+ * @param startPos The position to start the search on.
+ * Inserts a special event marked with [SweepLineEvent.start].
+ * @param startGapSize Used instead of [gapSize] for the start event
+ */
+ private fun sweepLineFindEarliestGap(
+ events: MutableList,
+ gapSize: Int,
+ startPos: Int,
+ startGapSize: Int
+ ): SweepLineEvent {
+ events.add(
+ SweepLineEvent(
+ open = false,
+ pos = startPos,
+ unrestricted = true,
+ start = true
+ )
+ )
+ events.sortBy { -it.pos }
+
+ // sweep
+ var openCount = 0
+ var i = 0
+ while (i < events.size) {
+ val event = events[i]
+ if (!event.start) {
+ if (event.open) {
+ openCount++
+ } else {
+ openCount--
+ }
+ }
+
+ if (openCount == 0) {
+ // check if placement is possible
+ val candidate = event.pos
+ if (candidate > startPos) {
+ i++
+ continue
+ }
+
+ val eventGapSize = if (event.start) startGapSize else gapSize
+ val nextEvent = events.getOrNull(i + 1)
+ if (nextEvent == null || nextEvent.pos < candidate - eventGapSize) {
+ return event
+ }
+ }
+ i++
+ }
+
+ return events.last()
+ }
+
+ private fun shouldTransformFlipX(): Boolean {
+ return when (pipGravity) {
+ (Gravity.TOP), (Gravity.TOP or Gravity.CENTER_HORIZONTAL) -> true
+ (Gravity.TOP or Gravity.LEFT) -> true
+ (Gravity.LEFT), (Gravity.LEFT or Gravity.CENTER_VERTICAL) -> true
+ (Gravity.BOTTOM or Gravity.LEFT) -> true
+ else -> false
+ }
+ }
+
+ private fun shouldTransformFlipY(): Boolean {
+ return when (pipGravity) {
+ (Gravity.TOP or Gravity.LEFT) -> true
+ (Gravity.TOP or Gravity.RIGHT) -> true
+ else -> false
+ }
+ }
+
+ private fun shouldTransformRotate(): Boolean {
+ val horizontalGravity = pipGravity and Gravity.HORIZONTAL_GRAVITY_MASK
+ val leftOrRight = horizontalGravity == Gravity.LEFT || horizontalGravity == Gravity.RIGHT
+
+ if (leftOrRight) return false
+ return when (pipGravity and Gravity.VERTICAL_GRAVITY_MASK) {
+ (Gravity.TOP) -> true
+ (Gravity.BOTTOM) -> true
+ else -> false
+ }
+ }
+
+ /**
+ * Transforms the given rect from screen space into the base case space, where the PiP
+ * anchor is positioned in the bottom right corner or on the right side (for expanded PiP).
+ *
+ * @see [fromTransformedSpace]
+ */
+ private fun toTransformedSpace(r: Rect): Rect {
+ var screenWidth = screenSize.width
+ var screenHeight = screenSize.height
+
+ val tl = Point(r.left, r.top)
+ val tr = Point(r.right, r.top)
+ val br = Point(r.right, r.bottom)
+ val bl = Point(r.left, r.bottom)
+ val corners = arrayOf(tl, tr, br, bl)
+
+ // rotate first (CW)
+ if (shouldTransformRotate()) {
+ corners.forEach { p ->
+ val px = p.x
+ val py = p.y
+ p.x = py
+ p.y = -px
+ p.y += screenWidth // shift back screen into positive quadrant
+ }
+ screenWidth = screenSize.height
+ screenHeight = screenSize.width
+ }
+
+ // flip second
+ corners.forEach {
+ if (shouldTransformFlipX()) it.x = screenWidth - it.x
+ if (shouldTransformFlipY()) it.y = screenHeight - it.y
+ }
+
+ val top = corners.minByOrNull { it.y }!!.y
+ val right = corners.maxByOrNull { it.x }!!.x
+ val bottom = corners.maxByOrNull { it.y }!!.y
+ val left = corners.minByOrNull { it.x }!!.x
+
+ return Rect(left, top, right, bottom)
+ }
+
+ /**
+ * Transforms the given rect from the base case space, where the PiP anchor is positioned in
+ * the bottom right corner or on the right side, back into screen space.
+ *
+ * @see [toTransformedSpace]
+ */
+ private fun fromTransformedSpace(r: Rect): Rect {
+ val rotate = shouldTransformRotate()
+ val transformedScreenWidth = if (rotate) screenSize.height else screenSize.width
+ val transformedScreenHeight = if (rotate) screenSize.width else screenSize.height
+
+ val tl = Point(r.left, r.top)
+ val tr = Point(r.right, r.top)
+ val br = Point(r.right, r.bottom)
+ val bl = Point(r.left, r.bottom)
+ val corners = arrayOf(tl, tr, br, bl)
+
+ // flip first
+ corners.forEach {
+ if (shouldTransformFlipX()) it.x = transformedScreenWidth - it.x
+ if (shouldTransformFlipY()) it.y = transformedScreenHeight - it.y
+ }
+
+ // rotate second (CCW)
+ if (rotate) {
+ corners.forEach { p ->
+ p.y -= screenSize.width // undo shift back screen into positive quadrant
+ val px = p.x
+ val py = p.y
+ p.x = -py
+ p.y = px
+ }
+ }
+
+ val top = corners.minByOrNull { it.y }!!.y
+ val right = corners.maxByOrNull { it.x }!!.x
+ val bottom = corners.maxByOrNull { it.y }!!.y
+ val left = corners.minByOrNull { it.x }!!.x
+
+ return Rect(left, top, right, bottom)
+ }
+
+ /** PiP anchor bounds in base case for given gravity */
+ private fun getNormalPipAnchorBounds(pipSize: Size, movementBounds: Rect): Rect {
+ var size = pipSize
+ val rotateCW = shouldTransformRotate()
+ if (rotateCW) {
+ size = Size(pipSize.height, pipSize.width)
+ }
+
+ val pipBounds = Rect()
+ if (isPipAnchoredToCorner()) {
+ // bottom right
+ Gravity.apply(
+ Gravity.BOTTOM or Gravity.RIGHT,
+ size.width,
+ size.height,
+ movementBounds,
+ pipBounds
+ )
+ return pipBounds
+ } else {
+ // expanded, right side
+ Gravity.apply(Gravity.RIGHT, size.width, size.height, movementBounds, pipBounds)
+ return pipBounds
+ }
+ }
+
+ private fun isPipAnchoredToCorner(): Boolean {
+ val left = (pipGravity and Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.LEFT
+ val right = (pipGravity and Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.RIGHT
+ val top = (pipGravity and Gravity.VERTICAL_GRAVITY_MASK) == Gravity.TOP
+ val bottom = (pipGravity and Gravity.VERTICAL_GRAVITY_MASK) == Gravity.BOTTOM
+
+ val horizontal = left || right
+ val vertical = top || bottom
+
+ return horizontal && vertical
+ }
+
+ private fun Rect.offsetCopy(dx: Int, dy: Int) = Rect(this).apply { offset(dx, dy) }
+ private fun Rect.intersectsY(other: Rect) = bottom >= other.top && top <= other.bottom
+ private fun Rect.intersectsX(other: Rect) = right >= other.left && left <= other.right
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipMenuController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipMenuController.java
index 32ebe2d6aecf8..b3c230683cb30 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipMenuController.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/pip/tv/TvPipMenuController.java
@@ -174,8 +174,8 @@ public class TvPipMenuController implements PipMenuController, TvPipMenuView.Lis
}
void updateExpansionState() {
- mPipMenuView.setExpandedModeEnabled(mTvPipBoundsState.isTvExpandedPipEnabled()
- && mTvPipBoundsState.getTvExpandedAspectRatio() != 0);
+ mPipMenuView.setExpandedModeEnabled(mTvPipBoundsState.isTvExpandedPipSupported()
+ && mTvPipBoundsState.getDesiredTvExpandedAspectRatio() != 0);
mPipMenuView.setIsExpanded(mTvPipBoundsState.isTvPipExpanded());
}
@@ -202,12 +202,17 @@ public class TvPipMenuController implements PipMenuController, TvPipMenuView.Lis
}
}
+ boolean isInMoveMode() {
+ return mInMoveMode;
+ }
+
@Override
public void onEnterMoveMode() {
if (DEBUG) Log.d(TAG, "onEnterMoveMode - " + mInMoveMode);
mInMoveMode = true;
mPipMenuView.showMenuButtons(false);
mPipMenuView.showMovementHints(mDelegate.getPipGravity());
+ mDelegate.onInMoveModeChanged();
}
@Override
@@ -217,6 +222,7 @@ public class TvPipMenuController implements PipMenuController, TvPipMenuView.Lis
mInMoveMode = false;
mPipMenuView.showMenuButtons(true);
mPipMenuView.hideMovementHints();
+ mDelegate.onInMoveModeChanged();
return true;
}
return false;
@@ -447,6 +453,8 @@ public class TvPipMenuController implements PipMenuController, TvPipMenuView.Lis
void movePip(int keycode);
+ void onInMoveModeChanged();
+
int getPipGravity();
void togglePipExpansion();
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/tv/TvPipKeepClearAlgorithmTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/tv/TvPipKeepClearAlgorithmTest.kt
new file mode 100644
index 0000000000000..e6ba70e1b60e4
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/pip/tv/TvPipKeepClearAlgorithmTest.kt
@@ -0,0 +1,469 @@
+/*
+ * Copyright (C) 2022 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.graphics.Rect
+import android.testing.AndroidTestingRunner
+import android.util.Size
+import android.view.Gravity
+import org.junit.runner.RunWith
+import com.android.wm.shell.pip.PipBoundsState.STASH_TYPE_NONE
+import com.android.wm.shell.pip.PipBoundsState.STASH_TYPE_BOTTOM
+import com.android.wm.shell.pip.PipBoundsState.STASH_TYPE_RIGHT
+import com.android.wm.shell.pip.tv.TvPipKeepClearAlgorithm.Placement
+import org.junit.Before
+import org.junit.Test
+import junit.framework.Assert.assertEquals
+import junit.framework.Assert.assertNull
+
+@RunWith(AndroidTestingRunner::class)
+class TvPipKeepClearAlgorithmTest {
+ private val DEFAULT_PIP_SIZE = Size(384, 216)
+ private val EXPANDED_WIDE_PIP_SIZE = Size(384*2, 216)
+ private val DASHBOARD_WIDTH = 484
+ private val BOTTOM_SHEET_HEIGHT = 524
+ private val STASH_OFFSET = 64
+ private val PADDING = 16
+ private val SCREEN_SIZE = Size(1920, 1080)
+ private val SCREEN_EDGE_INSET = 50
+
+ private lateinit var pipSize: Size
+ private lateinit var movementBounds: Rect
+ private lateinit var algorithm: TvPipKeepClearAlgorithm
+ private var currentTime = 0L
+ private var restrictedAreas = mutableSetOf()
+ private var unrestrictedAreas = mutableSetOf()
+ private var gravity: Int = 0
+
+ @Before
+ fun setup() {
+ movementBounds = Rect(0, 0, SCREEN_SIZE.width, SCREEN_SIZE.height)
+ movementBounds.inset(SCREEN_EDGE_INSET, SCREEN_EDGE_INSET)
+
+ restrictedAreas.clear()
+ unrestrictedAreas.clear()
+ currentTime = 0L
+ pipSize = DEFAULT_PIP_SIZE
+ gravity = Gravity.BOTTOM or Gravity.RIGHT
+
+ algorithm = TvPipKeepClearAlgorithm({ currentTime })
+ algorithm.setScreenSize(SCREEN_SIZE)
+ algorithm.setMovementBounds(movementBounds)
+ algorithm.pipAreaPadding = PADDING
+ algorithm.stashOffset = STASH_OFFSET
+ algorithm.stashDuration = 5000L
+ algorithm.setGravity(gravity)
+ algorithm.maxRestrictedDistanceFraction = 0.3
+ }
+
+ @Test
+ fun testAnchorPosition_BottomRight() {
+ gravity = Gravity.BOTTOM or Gravity.RIGHT
+ testAnchorPosition()
+ }
+
+ @Test
+ fun testAnchorPosition_TopRight() {
+ gravity = Gravity.TOP or Gravity.RIGHT
+ testAnchorPosition()
+ }
+
+ @Test
+ fun testAnchorPosition_TopLeft() {
+ gravity = Gravity.TOP or Gravity.LEFT
+ testAnchorPosition()
+ }
+
+ @Test
+ fun testAnchorPosition_BottomLeft() {
+ gravity = Gravity.BOTTOM or Gravity.LEFT
+ testAnchorPosition()
+ }
+
+ @Test
+ fun testAnchorPosition_Right() {
+ gravity = Gravity.RIGHT
+ testAnchorPosition()
+ }
+
+ @Test
+ fun testAnchorPosition_Left() {
+ gravity = Gravity.LEFT
+ testAnchorPosition()
+ }
+
+ @Test
+ fun testAnchorPosition_Top() {
+ gravity = Gravity.TOP
+ testAnchorPosition()
+ }
+
+ @Test
+ fun testAnchorPosition_Bottom() {
+ gravity = Gravity.BOTTOM
+ testAnchorPosition()
+ }
+
+ @Test
+ fun testAnchorPosition_TopCenterHorizontal() {
+ gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
+ testAnchorPosition()
+ }
+
+ @Test
+ fun testAnchorPosition_BottomCenterHorizontal() {
+ gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
+ testAnchorPosition()
+ }
+
+ @Test
+ fun testAnchorPosition_RightCenterVertical() {
+ gravity = Gravity.RIGHT or Gravity.CENTER_VERTICAL
+ testAnchorPosition()
+ }
+
+ @Test
+ fun testAnchorPosition_LeftCenterVertical() {
+ gravity = Gravity.LEFT or Gravity.CENTER_VERTICAL
+ testAnchorPosition()
+ }
+
+ fun testAnchorPosition() {
+ val placement = getActualPlacement()
+
+ assertEquals(getExpectedAnchorBounds(), placement.bounds)
+ assertNotStashed(placement)
+ }
+
+ @Test
+ fun test_AnchorBottomRight_KeepClearNotObstructing_StayAtAnchor() {
+ gravity = Gravity.BOTTOM or Gravity.RIGHT
+
+ val sidebar = makeSideBar(DASHBOARD_WIDTH, Gravity.LEFT)
+ unrestrictedAreas.add(sidebar)
+
+ val expectedBounds = getExpectedAnchorBounds()
+
+ val placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertNotStashed(placement)
+ }
+
+ @Test
+ fun test_AnchorBottomRight_UnrestrictedRightSidebar_PushedLeft() {
+ gravity = Gravity.BOTTOM or Gravity.RIGHT
+
+ val sidebar = makeSideBar(DASHBOARD_WIDTH, Gravity.RIGHT)
+ unrestrictedAreas.add(sidebar)
+
+ val expectedBounds = anchorBoundsOffsetBy(SCREEN_EDGE_INSET - sidebar.width() - PADDING, 0)
+
+ val placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertNotStashed(placement)
+ }
+
+ @Test
+ fun test_AnchorTopRight_UnrestrictedRightSidebar_PushedLeft() {
+ gravity = Gravity.TOP or Gravity.RIGHT
+
+ val sidebar = makeSideBar(DASHBOARD_WIDTH, Gravity.RIGHT)
+ unrestrictedAreas.add(sidebar)
+
+ val expectedBounds = anchorBoundsOffsetBy(SCREEN_EDGE_INSET - sidebar.width() - PADDING, 0)
+
+ val placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertNotStashed(placement)
+ }
+
+ @Test
+ fun test_AnchorBottomLeft_UnrestrictedRightSidebar_StayAtAnchor() {
+ gravity = Gravity.BOTTOM or Gravity.LEFT
+
+ val sidebar = makeSideBar(DASHBOARD_WIDTH, Gravity.RIGHT)
+ unrestrictedAreas.add(sidebar)
+
+ val expectedBounds = anchorBoundsOffsetBy(0, 0)
+
+ val placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertNotStashed(placement)
+ }
+
+ @Test
+ fun test_AnchorBottom_UnrestrictedRightSidebar_StayAtAnchor() {
+ gravity = Gravity.BOTTOM
+
+ val sidebar = makeSideBar(DASHBOARD_WIDTH, Gravity.RIGHT)
+ unrestrictedAreas.add(sidebar)
+
+ val expectedBounds = anchorBoundsOffsetBy(0, 0)
+
+ val placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertNotStashed(placement)
+ }
+
+ @Test
+ fun testExpanded_AnchorBottom_UnrestrictedRightSidebar_StayAtAnchor() {
+ pipSize = EXPANDED_WIDE_PIP_SIZE
+ gravity = Gravity.BOTTOM
+
+ val sidebar = makeSideBar(DASHBOARD_WIDTH, Gravity.RIGHT)
+ unrestrictedAreas.add(sidebar)
+
+ val expectedBounds = anchorBoundsOffsetBy(0, 0)
+
+ val placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertNotStashed(placement)
+ }
+
+ @Test
+ fun test_AnchorBottomRight_RestrictedSmallBottomBar_PushedUp() {
+ gravity = Gravity.BOTTOM or Gravity.RIGHT
+
+ val bottomBar = makeBottomBar(96)
+ restrictedAreas.add(bottomBar)
+
+ val expectedBounds = anchorBoundsOffsetBy(0,
+ SCREEN_EDGE_INSET - bottomBar.height() - PADDING)
+
+ val placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertNotStashed(placement)
+ }
+
+ @Test
+ fun test_AnchorBottomRight_RestrictedBottomSheet_StashDownAtAnchor() {
+ gravity = Gravity.BOTTOM or Gravity.RIGHT
+
+ val bottomBar = makeBottomBar(BOTTOM_SHEET_HEIGHT)
+ restrictedAreas.add(bottomBar)
+
+ val expectedBounds = getExpectedAnchorBounds()
+ expectedBounds.offsetTo(expectedBounds.left, SCREEN_SIZE.height - STASH_OFFSET)
+
+ val placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertEquals(STASH_TYPE_BOTTOM, placement.stashType)
+ assertEquals(getExpectedAnchorBounds(), placement.unstashDestinationBounds)
+ assertEquals(algorithm.stashDuration, placement.unstashTime)
+ }
+
+ @Test
+ fun test_AnchorBottomRight_UnrestrictedBottomSheet_PushUp() {
+ gravity = Gravity.BOTTOM or Gravity.RIGHT
+
+ val bottomBar = makeBottomBar(BOTTOM_SHEET_HEIGHT)
+ unrestrictedAreas.add(bottomBar)
+
+ val expectedBounds = anchorBoundsOffsetBy(0,
+ SCREEN_EDGE_INSET - bottomBar.height() - PADDING)
+
+ val placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertNotStashed(placement)
+ }
+
+ @Test
+ fun test_AnchorBottomRight_UnrestrictedBottomSheet_RestrictedSidebar_StashAboveBottomSheet() {
+ gravity = Gravity.BOTTOM or Gravity.RIGHT
+
+ val bottomBar = makeBottomBar(BOTTOM_SHEET_HEIGHT)
+ unrestrictedAreas.add(bottomBar)
+
+ val maxRestrictedHorizontalPush =
+ (algorithm.maxRestrictedDistanceFraction * SCREEN_SIZE.width).toInt()
+ val sideBar = makeSideBar(maxRestrictedHorizontalPush + 100, Gravity.RIGHT)
+ restrictedAreas.add(sideBar)
+
+ val expectedUnstashBounds =
+ anchorBoundsOffsetBy(0, SCREEN_EDGE_INSET - bottomBar.height() - PADDING)
+
+ val expectedBounds = Rect(expectedUnstashBounds)
+ expectedBounds.offsetTo(SCREEN_SIZE.width - STASH_OFFSET, expectedBounds.top)
+
+ val placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertEquals(STASH_TYPE_RIGHT, placement.stashType)
+ assertEquals(expectedUnstashBounds, placement.unstashDestinationBounds)
+ assertEquals(algorithm.stashDuration, placement.unstashTime)
+ }
+
+ @Test
+ fun test_AnchorBottomRight_UnrestrictedBottomSheet_UnrestrictedSidebar_PushUpLeft() {
+ gravity = Gravity.BOTTOM or Gravity.RIGHT
+
+ val bottomBar = makeBottomBar(BOTTOM_SHEET_HEIGHT)
+ unrestrictedAreas.add(bottomBar)
+
+ val maxRestrictedHorizontalPush =
+ (algorithm.maxRestrictedDistanceFraction * SCREEN_SIZE.width).toInt()
+ val sideBar = makeSideBar(maxRestrictedHorizontalPush + 100, Gravity.RIGHT)
+ unrestrictedAreas.add(sideBar)
+
+ val expectedBounds = anchorBoundsOffsetBy(
+ SCREEN_EDGE_INSET - sideBar.width() - PADDING,
+ SCREEN_EDGE_INSET - bottomBar.height() - PADDING
+ )
+
+ val placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertNotStashed(placement)
+ }
+
+ @Test
+ fun test_Stashed_UnstashBoundsBecomeUnobstructed_Unstashes() {
+ gravity = Gravity.BOTTOM or Gravity.RIGHT
+
+ val bottomBar = makeBottomBar(BOTTOM_SHEET_HEIGHT)
+ unrestrictedAreas.add(bottomBar)
+
+ val maxRestrictedHorizontalPush =
+ (algorithm.maxRestrictedDistanceFraction * SCREEN_SIZE.width).toInt()
+ val sideBar = makeSideBar(maxRestrictedHorizontalPush + 100, Gravity.RIGHT)
+ restrictedAreas.add(sideBar)
+
+ val expectedUnstashBounds =
+ anchorBoundsOffsetBy(0, SCREEN_EDGE_INSET - bottomBar.height() - PADDING)
+
+ val expectedBounds = Rect(expectedUnstashBounds)
+ expectedBounds.offsetTo(SCREEN_SIZE.width - STASH_OFFSET, expectedBounds.top)
+
+ var placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertEquals(STASH_TYPE_RIGHT, placement.stashType)
+ assertEquals(expectedUnstashBounds, placement.unstashDestinationBounds)
+ assertEquals(algorithm.stashDuration, placement.unstashTime)
+
+ currentTime += 1000
+
+ restrictedAreas.remove(sideBar)
+ placement = getActualPlacement()
+ assertEquals(expectedUnstashBounds, placement.bounds)
+ assertNotStashed(placement)
+ }
+
+ @Test
+ fun test_Stashed_UnstashBoundsStaysObstructed_UnstashesAfterTimeout() {
+ gravity = Gravity.BOTTOM or Gravity.RIGHT
+
+ val bottomBar = makeBottomBar(BOTTOM_SHEET_HEIGHT)
+ unrestrictedAreas.add(bottomBar)
+
+ val maxRestrictedHorizontalPush =
+ (algorithm.maxRestrictedDistanceFraction * SCREEN_SIZE.width).toInt()
+ val sideBar = makeSideBar(maxRestrictedHorizontalPush + 100, Gravity.RIGHT)
+ restrictedAreas.add(sideBar)
+
+ val expectedUnstashBounds =
+ anchorBoundsOffsetBy(0, SCREEN_EDGE_INSET - bottomBar.height() - PADDING)
+
+ val expectedBounds = Rect(expectedUnstashBounds)
+ expectedBounds.offsetTo(SCREEN_SIZE.width - STASH_OFFSET, expectedBounds.top)
+
+ var placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertEquals(STASH_TYPE_RIGHT, placement.stashType)
+ assertEquals(expectedUnstashBounds, placement.unstashDestinationBounds)
+ assertEquals(algorithm.stashDuration, placement.unstashTime)
+
+ currentTime += algorithm.stashDuration
+
+ placement = getActualPlacement()
+ assertEquals(expectedUnstashBounds, placement.bounds)
+ assertNotStashed(placement)
+ }
+
+ @Test
+ fun test_Stashed_UnstashBoundsObstructionChanges_UnstashTimeExtended() {
+ gravity = Gravity.BOTTOM or Gravity.RIGHT
+
+ val bottomBar = makeBottomBar(BOTTOM_SHEET_HEIGHT)
+ unrestrictedAreas.add(bottomBar)
+
+ val maxRestrictedHorizontalPush =
+ (algorithm.maxRestrictedDistanceFraction * SCREEN_SIZE.width).toInt()
+ val sideBar = makeSideBar(maxRestrictedHorizontalPush + 100, Gravity.RIGHT)
+ restrictedAreas.add(sideBar)
+
+ val expectedUnstashBounds =
+ anchorBoundsOffsetBy(0, SCREEN_EDGE_INSET - bottomBar.height() - PADDING)
+
+ val expectedBounds = Rect(expectedUnstashBounds)
+ expectedBounds.offsetTo(SCREEN_SIZE.width - STASH_OFFSET, expectedBounds.top)
+
+ var placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertEquals(STASH_TYPE_RIGHT, placement.stashType)
+ assertEquals(expectedUnstashBounds, placement.unstashDestinationBounds)
+ assertEquals(algorithm.stashDuration, placement.unstashTime)
+
+ currentTime += 1000
+
+ val newObstruction = Rect(
+ 0,
+ expectedUnstashBounds.top,
+ expectedUnstashBounds.right,
+ expectedUnstashBounds.bottom
+ )
+ restrictedAreas.add(newObstruction)
+
+ placement = getActualPlacement()
+ assertEquals(expectedBounds, placement.bounds)
+ assertEquals(STASH_TYPE_RIGHT, placement.stashType)
+ assertEquals(expectedUnstashBounds, placement.unstashDestinationBounds)
+ assertEquals(currentTime + algorithm.stashDuration, placement.unstashTime)
+ }
+
+ private fun makeSideBar(width: Int, @Gravity.GravityFlags side: Int): Rect {
+ val sidebar = Rect(0, 0, width, SCREEN_SIZE.height)
+ if (side == Gravity.RIGHT) {
+ sidebar.offsetTo(SCREEN_SIZE.width - width, 0)
+ }
+ return sidebar
+ }
+
+ private fun makeBottomBar(height: Int): Rect {
+ return Rect(0, SCREEN_SIZE.height - height, SCREEN_SIZE.width, SCREEN_SIZE.height)
+ }
+
+ private fun getExpectedAnchorBounds(): Rect {
+ val expectedBounds = Rect()
+ Gravity.apply(gravity, pipSize.width, pipSize.height, movementBounds, expectedBounds)
+ return expectedBounds
+ }
+
+ private fun anchorBoundsOffsetBy(dx: Int, dy: Int): Rect {
+ val bounds = getExpectedAnchorBounds()
+ bounds.offset(dx, dy)
+ return bounds
+ }
+
+ private fun getActualPlacement(): Placement {
+ algorithm.setGravity(gravity)
+ return algorithm.calculatePipPosition(pipSize, restrictedAreas, unrestrictedAreas)
+ }
+
+ private fun assertNotStashed(actual: Placement) {
+ assertEquals(STASH_TYPE_NONE, actual.stashType)
+ assertNull(actual.unstashDestinationBounds)
+ assertEquals(0L, actual.unstashTime)
+ }
+}