Move TV PiP away from keep clear areas

The PiP is placed in a way to avoid keep clear areas.
If no areas overlapping the default PiP anchor position are defined,
the PiP will stay at its anchor position.
Otherwise, it will try to move to a free location closest to its anchor
position. Unrestricted areas can cause the PiP to move across the entire
screen, but restricted areas have limited influence and can only move
the PiP a short distance.
If no free position is found, the PiP will be stashed for some time.
To find the position to move to once the PiP unstashes, a relaxed search
is performed, excluding a restricted keep clear area. If this still does
not yield a position to move to, only unrestricted areas will be taken
into account. The PiP is stashed along the edge closest to the unstash
position.

Bug: 218416347
Bug: 218494300
Test: atest TvPipKeepClearAlgorithmTest
Change-Id: I76c6527320fc403bb604b188f38eed505eeaab89
This commit is contained in:
Robert Horvath
2022-02-08 16:03:55 +01:00
parent 22c3bb0c72
commit c4f571a27e
11 changed files with 1451 additions and 120 deletions

View File

@@ -33,4 +33,10 @@
<!-- The default gravity for the picture-in-picture window.
Currently, this maps to Gravity.BOTTOM | Gravity.RIGHT -->
<integer name="config_defaultPictureInPictureGravity">0x55</integer>
<!-- Fraction of screen width/height restricted keep clear areas can move the PiP. -->
<fraction name="config_pipMaxRestrictedMoveDistance">15%</fraction>
<!-- Duration (in milliseconds) the PiP stays stashed before automatically unstashing. -->
<integer name="config_pipStashDuration">5000</integer>
</resources>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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.
-->
<!-- These resources are around just to allow their values to be customized
for TV products. Do not translate. -->
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<!-- Padding between PIP and keep clear areas that caused it to move. -->
<dimen name="pip_keep_clear_area_padding">16dp</dimen>
</resources>

View File

@@ -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

View File

@@ -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);

View File

@@ -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 {}

View File

@@ -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<Rect> restrictedKeepClearAreas = mTvPipBoundsState.getRestrictedKeepClearAreas();
Set<Rect> 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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<Rect> 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();
}
}

View File

@@ -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<Rect> = 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<Rect>,
unrestrictedAreas: Set<Rect>
): 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<Rect>): Set<Rect> {
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<Rect>,
unrestrictedAreas: Set<Rect>
): 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<Rect>,
unrestrictedAreas: Set<Rect>
): 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<Rect>,
unrestrictedAreas: Set<Rect>
): Rect? {
if (depth == 0) {
return findFreeMovePosition(pipAnchorBounds, restrictedAreas, unrestrictedAreas)
}
val candidates = mutableListOf<Rect>()
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<Rect>,
unrestrictedAreas: Set<Rect>
): Rect? {
val movementBounds = transformedMovementBounds
val candidateEdgeRects = mutableListOf<Rect>()
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<Rect>()
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>): 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<Rect>,
unrestrictedAreas: Set<Rect>
): SweepLineEvent {
val events = mutableListOf<SweepLineEvent>()
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<Rect>,
unrestrictedAreas: Set<Rect>
): SweepLineEvent {
val events = mutableListOf<SweepLineEvent>()
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<SweepLineEvent>,
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
}

View File

@@ -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();

View File

@@ -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<Rect>()
private var unrestrictedAreas = mutableSetOf<Rect>()
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)
}
}