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 491dff08187f4..92501f312661d 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 @@ -70,7 +70,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, @@ -84,7 +85,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 210ea54baf588..17515883b4ad8 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 @@ -54,11 +54,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) + } +}