Merge "Move TV PiP away from keep clear areas" into tm-dev

This commit is contained in:
Robert Horvath
2022-03-09 09:14:54 +00:00
committed by Android (Google) Code Review
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

@@ -69,7 +69,8 @@ public abstract class TvPipModule {
TaskStackListenerImpl taskStackListener,
DisplayController displayController,
WindowManagerShellWrapper windowManagerShellWrapper,
@ShellMainThread ShellExecutor mainExecutor) {
@ShellMainThread ShellExecutor mainExecutor,
@ShellMainThread Handler mainHandler) {
return Optional.of(
TvPipController.create(
context,
@@ -83,7 +84,8 @@ public abstract class TvPipModule {
taskStackListener,
displayController,
windowManagerShellWrapper,
mainExecutor));
mainExecutor,
mainHandler));
}
@WMSingleton

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

@@ -55,11 +55,15 @@ public class PipBoundsState {
public static final int STASH_TYPE_NONE = 0;
public static final int STASH_TYPE_LEFT = 1;
public static final int STASH_TYPE_RIGHT = 2;
public static final int STASH_TYPE_BOTTOM = 3;
public static final int STASH_TYPE_TOP = 4;
@IntDef(prefix = { "STASH_TYPE_" }, value = {
STASH_TYPE_NONE,
STASH_TYPE_LEFT,
STASH_TYPE_RIGHT
STASH_TYPE_RIGHT,
STASH_TYPE_BOTTOM,
STASH_TYPE_TOP
})
@Retention(RetentionPolicy.SOURCE)
public @interface StashType {}

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)
}
}