1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.wm.shell.common.pip; 18 19 import android.annotation.NonNull; 20 import android.annotation.Nullable; 21 import android.app.PictureInPictureParams; 22 import android.content.Context; 23 import android.content.pm.ActivityInfo; 24 import android.content.res.Resources; 25 import android.graphics.Rect; 26 import android.util.DisplayMetrics; 27 import android.util.Rational; 28 import android.util.Size; 29 import android.view.Gravity; 30 31 import com.android.internal.protolog.ProtoLog; 32 import com.android.wm.shell.R; 33 import com.android.wm.shell.protolog.ShellProtoLogGroup; 34 35 import java.io.PrintWriter; 36 37 /** 38 * Calculates the default, normal, entry, inset and movement bounds of the PIP. 39 */ 40 public class PipBoundsAlgorithm { 41 42 private static final String TAG = PipBoundsAlgorithm.class.getSimpleName(); 43 private static final float INVALID_SNAP_FRACTION = -1f; 44 45 @NonNull private final PipBoundsState mPipBoundsState; 46 @NonNull protected final PipDisplayLayoutState mPipDisplayLayoutState; 47 @NonNull protected final SizeSpecSource mSizeSpecSource; 48 private final PipSnapAlgorithm mSnapAlgorithm; 49 private final PipKeepClearAlgorithmInterface mPipKeepClearAlgorithm; 50 51 private float mDefaultAspectRatio; 52 private float mMinAspectRatio; 53 private float mMaxAspectRatio; 54 private int mDefaultStackGravity; 55 PipBoundsAlgorithm(Context context, @NonNull PipBoundsState pipBoundsState, @NonNull PipSnapAlgorithm pipSnapAlgorithm, @NonNull PipKeepClearAlgorithmInterface pipKeepClearAlgorithm, @NonNull PipDisplayLayoutState pipDisplayLayoutState, @NonNull SizeSpecSource sizeSpecSource)56 public PipBoundsAlgorithm(Context context, @NonNull PipBoundsState pipBoundsState, 57 @NonNull PipSnapAlgorithm pipSnapAlgorithm, 58 @NonNull PipKeepClearAlgorithmInterface pipKeepClearAlgorithm, 59 @NonNull PipDisplayLayoutState pipDisplayLayoutState, 60 @NonNull SizeSpecSource sizeSpecSource) { 61 mPipBoundsState = pipBoundsState; 62 mSnapAlgorithm = pipSnapAlgorithm; 63 mPipKeepClearAlgorithm = pipKeepClearAlgorithm; 64 mPipDisplayLayoutState = pipDisplayLayoutState; 65 mSizeSpecSource = sizeSpecSource; 66 reloadResources(context); 67 // Initialize the aspect ratio to the default aspect ratio. Don't do this in reload 68 // resources as it would clobber mAspectRatio when entering PiP from fullscreen which 69 // triggers a configuration change and the resources to be reloaded. 70 mPipBoundsState.setAspectRatio(mDefaultAspectRatio); 71 } 72 73 /** 74 * TODO: move the resources to SysUI package. 75 */ reloadResources(Context context)76 private void reloadResources(Context context) { 77 final Resources res = context.getResources(); 78 mDefaultAspectRatio = res.getFloat( 79 R.dimen.config_pictureInPictureDefaultAspectRatio); 80 mDefaultStackGravity = res.getInteger( 81 R.integer.config_defaultPictureInPictureGravity); 82 mMinAspectRatio = res.getFloat( 83 com.android.internal.R.dimen.config_pictureInPictureMinAspectRatio); 84 mMaxAspectRatio = res.getFloat( 85 com.android.internal.R.dimen.config_pictureInPictureMaxAspectRatio); 86 } 87 88 /** 89 * The {@link PipSnapAlgorithm} is couple on display bounds 90 * @return {@link PipSnapAlgorithm}. 91 */ getSnapAlgorithm()92 public PipSnapAlgorithm getSnapAlgorithm() { 93 return mSnapAlgorithm; 94 } 95 96 /** Responds to configuration change. */ onConfigurationChanged(Context context)97 public void onConfigurationChanged(Context context) { 98 reloadResources(context); 99 } 100 101 /** Returns the normal bounds (i.e. the default entry bounds). */ getNormalBounds()102 public Rect getNormalBounds() { 103 // The normal bounds are the default bounds adjusted to the current aspect ratio. 104 return transformBoundsToAspectRatioIfValid(getDefaultBounds(), 105 mPipBoundsState.getAspectRatio(), false /* useCurrentMinEdgeSize */, 106 false /* useCurrentSize */); 107 } 108 109 /** Returns the default bounds. */ getDefaultBounds()110 public Rect getDefaultBounds() { 111 return getDefaultBounds(INVALID_SNAP_FRACTION, null /* size */); 112 } 113 114 /** 115 * Returns the destination bounds to place the PIP window on entry. 116 * If there are any keep clear areas registered, the position will try to avoid occluding them. 117 */ getEntryDestinationBounds()118 public Rect getEntryDestinationBounds() { 119 Rect entryBounds = getEntryDestinationBoundsIgnoringKeepClearAreas(); 120 Rect insets = new Rect(); 121 getInsetBounds(insets); 122 return mPipKeepClearAlgorithm.findUnoccludedPosition(entryBounds, 123 mPipBoundsState.getRestrictedKeepClearAreas(), 124 mPipBoundsState.getUnrestrictedKeepClearAreas(), insets); 125 } 126 127 /** Returns the destination bounds to place the PIP window on entry. */ getEntryDestinationBoundsIgnoringKeepClearAreas()128 public Rect getEntryDestinationBoundsIgnoringKeepClearAreas() { 129 final PipBoundsState.PipReentryState reentryState = mPipBoundsState.getReentryState(); 130 131 final Rect destinationBounds = getDefaultBounds(); 132 if (reentryState != null) { 133 final Size scaledBounds = new Size( 134 Math.round(mPipBoundsState.getMaxSize().x * reentryState.getBoundsScale()), 135 Math.round(mPipBoundsState.getMaxSize().y * reentryState.getBoundsScale())); 136 destinationBounds.set(getDefaultBounds(reentryState.getSnapFraction(), scaledBounds)); 137 } 138 139 final boolean useCurrentSize = reentryState != null; 140 Rect aspectRatioBounds = transformBoundsToAspectRatioIfValid(destinationBounds, 141 mPipBoundsState.getAspectRatio(), false /* useCurrentMinEdgeSize */, 142 useCurrentSize); 143 return aspectRatioBounds; 144 } 145 146 /** Returns the current bounds adjusted to the new aspect ratio, if valid. */ getAdjustedDestinationBounds(Rect currentBounds, float newAspectRatio)147 public Rect getAdjustedDestinationBounds(Rect currentBounds, float newAspectRatio) { 148 return transformBoundsToAspectRatioIfValid(currentBounds, newAspectRatio, 149 true /* useCurrentMinEdgeSize */, false /* useCurrentSize */); 150 } 151 152 /** 153 * 154 * Get the smallest/most minimal size allowed. 155 */ getMinimalSize(ActivityInfo activityInfo)156 public Size getMinimalSize(ActivityInfo activityInfo) { 157 if (activityInfo == null || activityInfo.windowLayout == null) { 158 return null; 159 } 160 final ActivityInfo.WindowLayout windowLayout = activityInfo.windowLayout; 161 // -1 will be populated if an activity specifies defaultWidth/defaultHeight in <layout> 162 // without minWidth/minHeight 163 if (windowLayout.minWidth > 0 && windowLayout.minHeight > 0) { 164 // If either dimension is smaller than the allowed minimum, adjust them 165 // according to mOverridableMinSize 166 return new Size( 167 Math.max(windowLayout.minWidth, getOverrideMinEdgeSize()), 168 Math.max(windowLayout.minHeight, getOverrideMinEdgeSize())); 169 } 170 return null; 171 } 172 173 /** 174 * Returns the source hint rect if it is valid (if provided and is contained by the current 175 * task bounds). 176 */ getValidSourceHintRect(PictureInPictureParams params, Rect sourceBounds)177 public static Rect getValidSourceHintRect(PictureInPictureParams params, Rect sourceBounds) { 178 final Rect sourceHintRect = params != null && params.hasSourceBoundsHint() 179 ? params.getSourceRectHint() 180 : null; 181 if (sourceHintRect != null && sourceBounds.contains(sourceHintRect)) { 182 return sourceHintRect; 183 } 184 return null; 185 } 186 187 188 /** 189 * Returns the source hint rect if it is valid (if provided and is contained by the current 190 * task bounds, while not smaller than the destination bounds). 191 */ 192 @Nullable getValidSourceHintRect(PictureInPictureParams params, Rect sourceBounds, Rect destinationBounds)193 public static Rect getValidSourceHintRect(PictureInPictureParams params, Rect sourceBounds, 194 Rect destinationBounds) { 195 Rect sourceRectHint = getValidSourceHintRect(params, sourceBounds); 196 if (!isSourceRectHintValidForEnterPip(sourceRectHint, destinationBounds)) { 197 sourceRectHint = null; 198 } 199 return sourceRectHint; 200 } 201 202 /** 203 * This is a situation in which the source rect hint on at least one axis is smaller 204 * than the destination bounds, which represents a problem because we would have to scale 205 * up that axis to fit the bounds. So instead, just fallback to the non-source hint 206 * animation in this case. 207 * 208 * @return {@code false} if the given source is too small to use for the entering animation. 209 */ isSourceRectHintValidForEnterPip(Rect sourceRectHint, Rect destinationBounds)210 public static boolean isSourceRectHintValidForEnterPip(Rect sourceRectHint, 211 Rect destinationBounds) { 212 if (sourceRectHint == null || sourceRectHint.isEmpty()) { 213 ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, 214 "isSourceRectHintValidForEnterPip=false, empty hint"); 215 return false; 216 } 217 if (sourceRectHint.width() <= destinationBounds.width() 218 || sourceRectHint.height() <= destinationBounds.height()) { 219 ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, 220 "isSourceRectHintValidForEnterPip=false, hint(%s) is smaller" 221 + " than destination(%s)", sourceRectHint, destinationBounds); 222 return false; 223 } 224 // We use the aspect ratio of source rect hint to check against destination bounds 225 // here to avoid upscaling error. 226 final Rational srcAspectRatio = new Rational( 227 sourceRectHint.width(), sourceRectHint.height()); 228 if (!PictureInPictureParams.isSameAspectRatio(destinationBounds, srcAspectRatio)) { 229 ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, 230 "isSourceRectHintValidForEnterPip=false, hint(%s) does not match" 231 + " destination(%s) aspect ratio", sourceRectHint, destinationBounds); 232 return false; 233 } 234 return true; 235 } 236 getDefaultAspectRatio()237 public float getDefaultAspectRatio() { 238 return mDefaultAspectRatio; 239 } 240 241 /** 242 * 243 * Give the aspect ratio if the supplied PiP params have one, or else return default. 244 */ getAspectRatioOrDefault( @ndroid.annotation.Nullable PictureInPictureParams params)245 public float getAspectRatioOrDefault( 246 @android.annotation.Nullable PictureInPictureParams params) { 247 return params != null && params.hasSetAspectRatio() 248 ? params.getAspectRatioFloat() 249 : getDefaultAspectRatio(); 250 } 251 252 /** 253 * @return whether the given aspectRatio is valid. 254 */ isValidPictureInPictureAspectRatio(float aspectRatio)255 public boolean isValidPictureInPictureAspectRatio(float aspectRatio) { 256 return Float.compare(mMinAspectRatio, aspectRatio) <= 0 257 && Float.compare(aspectRatio, mMaxAspectRatio) <= 0; 258 } 259 transformBoundsToAspectRatioIfValid(Rect bounds, float aspectRatio, boolean useCurrentMinEdgeSize, boolean useCurrentSize)260 private Rect transformBoundsToAspectRatioIfValid(Rect bounds, float aspectRatio, 261 boolean useCurrentMinEdgeSize, boolean useCurrentSize) { 262 final Rect destinationBounds = new Rect(bounds); 263 if (isValidPictureInPictureAspectRatio(aspectRatio)) { 264 transformBoundsToAspectRatio(destinationBounds, aspectRatio, 265 useCurrentMinEdgeSize, useCurrentSize); 266 } 267 return destinationBounds; 268 } 269 270 /** 271 * Set the current bounds (or the default bounds if there are no current bounds) with the 272 * specified aspect ratio. 273 */ transformBoundsToAspectRatio(Rect stackBounds, float aspectRatio, boolean useCurrentMinEdgeSize, boolean useCurrentSize)274 public void transformBoundsToAspectRatio(Rect stackBounds, float aspectRatio, 275 boolean useCurrentMinEdgeSize, boolean useCurrentSize) { 276 // Save the snap fraction and adjust the size based on the new aspect ratio. 277 final float snapFraction = mSnapAlgorithm.getSnapFraction(stackBounds, 278 getMovementBounds(stackBounds), mPipBoundsState.getStashedState()); 279 280 final Size size; 281 if (useCurrentMinEdgeSize || useCurrentSize) { 282 // Use the existing size but adjusted to the new aspect ratio. 283 size = mSizeSpecSource.getSizeForAspectRatio( 284 new Size(stackBounds.width(), stackBounds.height()), aspectRatio); 285 } else { 286 size = mSizeSpecSource.getDefaultSize(aspectRatio); 287 } 288 289 final int left = (int) (stackBounds.centerX() - size.getWidth() / 2f); 290 final int top = (int) (stackBounds.centerY() - size.getHeight() / 2f); 291 stackBounds.set(left, top, left + size.getWidth(), top + size.getHeight()); 292 mSnapAlgorithm.applySnapFraction(stackBounds, getMovementBounds(stackBounds), snapFraction); 293 } 294 295 /** 296 * @return the default bounds to show the PIP, if a {@param snapFraction} and {@param size} are 297 * provided, then it will apply the default bounds to the provided snap fraction and size. 298 */ getDefaultBounds(float snapFraction, Size size)299 private Rect getDefaultBounds(float snapFraction, Size size) { 300 final Rect defaultBounds = new Rect(); 301 if (snapFraction != INVALID_SNAP_FRACTION && size != null) { 302 // The default bounds are the given size positioned at the given snap fraction. 303 defaultBounds.set(0, 0, size.getWidth(), size.getHeight()); 304 final Rect movementBounds = getMovementBounds(defaultBounds); 305 mSnapAlgorithm.applySnapFraction(defaultBounds, movementBounds, snapFraction); 306 return defaultBounds; 307 } 308 309 // Calculate the default size. 310 final Size defaultSize; 311 final Rect insetBounds = new Rect(); 312 getInsetBounds(insetBounds); 313 314 // Calculate the default size 315 defaultSize = mSizeSpecSource.getDefaultSize(mDefaultAspectRatio); 316 317 // Now that we have the default size, apply the snap fraction if valid or position the 318 // bounds using the default gravity. 319 if (snapFraction != INVALID_SNAP_FRACTION) { 320 defaultBounds.set(0, 0, defaultSize.getWidth(), defaultSize.getHeight()); 321 final Rect movementBounds = getMovementBounds(defaultBounds); 322 mSnapAlgorithm.applySnapFraction(defaultBounds, movementBounds, snapFraction); 323 } else { 324 Gravity.apply(mDefaultStackGravity, defaultSize.getWidth(), defaultSize.getHeight(), 325 insetBounds, 0, Math.max( 326 mPipBoundsState.isImeShowing() ? mPipBoundsState.getImeHeight() : 0, 327 mPipBoundsState.isShelfShowing() 328 ? mPipBoundsState.getShelfHeight() : 0), defaultBounds); 329 } 330 return defaultBounds; 331 } 332 333 /** 334 * Populates the bounds on the screen that the PIP can be visible in. 335 */ getInsetBounds(Rect outRect)336 public void getInsetBounds(Rect outRect) { 337 outRect.set(mPipDisplayLayoutState.getInsetBounds()); 338 } 339 getOverrideMinEdgeSize()340 private int getOverrideMinEdgeSize() { 341 return mSizeSpecSource.getOverrideMinEdgeSize(); 342 } 343 344 /** 345 * @return the movement bounds for the given stackBounds and the current state of the 346 * controller. 347 */ getMovementBounds(Rect stackBounds)348 public Rect getMovementBounds(Rect stackBounds) { 349 return getMovementBounds(stackBounds, true /* adjustForIme */); 350 } 351 352 /** 353 * @return the movement bounds for the given stackBounds and the current state of the 354 * controller. 355 */ getMovementBounds(Rect stackBounds, boolean adjustForIme)356 public Rect getMovementBounds(Rect stackBounds, boolean adjustForIme) { 357 final Rect movementBounds = new Rect(); 358 getInsetBounds(movementBounds); 359 360 // Apply the movement bounds adjustments based on the current state. 361 getMovementBounds(stackBounds, movementBounds, movementBounds, 362 (adjustForIme && mPipBoundsState.isImeShowing()) 363 ? mPipBoundsState.getImeHeight() : 0); 364 365 return movementBounds; 366 } 367 368 /** 369 * Adjusts movementBoundsOut so that it is the movement bounds for the given stackBounds. 370 */ getMovementBounds(Rect stackBounds, Rect insetBounds, Rect movementBoundsOut, int bottomOffset)371 public void getMovementBounds(Rect stackBounds, Rect insetBounds, Rect movementBoundsOut, 372 int bottomOffset) { 373 // Adjust the right/bottom to ensure the stack bounds never goes offscreen 374 movementBoundsOut.set(insetBounds); 375 movementBoundsOut.right = Math.max(insetBounds.left, insetBounds.right 376 - stackBounds.width()); 377 movementBoundsOut.bottom = Math.max(insetBounds.top, insetBounds.bottom 378 - stackBounds.height()); 379 movementBoundsOut.bottom -= bottomOffset; 380 } 381 382 /** 383 * @return the default snap fraction to apply instead of the default gravity when calculating 384 * the default stack bounds when first entering PiP. 385 */ getSnapFraction(Rect stackBounds)386 public float getSnapFraction(Rect stackBounds) { 387 return getSnapFraction(stackBounds, getMovementBounds(stackBounds)); 388 } 389 390 /** 391 * @return the default snap fraction to apply instead of the default gravity when calculating 392 * the default stack bounds when first entering PiP. 393 */ getSnapFraction(Rect stackBounds, Rect movementBounds)394 public float getSnapFraction(Rect stackBounds, Rect movementBounds) { 395 return mSnapAlgorithm.getSnapFraction(stackBounds, movementBounds); 396 } 397 398 /** 399 * Applies the given snap fraction to the given stack bounds. 400 */ applySnapFraction(Rect stackBounds, float snapFraction)401 public void applySnapFraction(Rect stackBounds, float snapFraction) { 402 final Rect movementBounds = getMovementBounds(stackBounds); 403 mSnapAlgorithm.applySnapFraction(stackBounds, movementBounds, snapFraction); 404 } 405 406 /** 407 * @return the pixels for a given dp value. 408 */ dpToPx(float dpValue, DisplayMetrics dm)409 private int dpToPx(float dpValue, DisplayMetrics dm) { 410 return PipUtils.dpToPx(dpValue, dm); 411 } 412 413 /** 414 * @return the normal bounds adjusted so that they fit the menu actions. 415 */ adjustNormalBoundsToFitMenu(@onNull Rect normalBounds, @Nullable Size minMenuSize)416 public Rect adjustNormalBoundsToFitMenu(@NonNull Rect normalBounds, 417 @Nullable Size minMenuSize) { 418 if (minMenuSize == null) { 419 return normalBounds; 420 } 421 if (normalBounds.width() >= minMenuSize.getWidth() 422 && normalBounds.height() >= minMenuSize.getHeight()) { 423 // The normal bounds can fit the menu as is, no need to adjust the bounds. 424 return normalBounds; 425 } 426 final Rect adjustedNormalBounds = new Rect(); 427 final boolean needsWidthAdj = minMenuSize.getWidth() > normalBounds.width(); 428 final boolean needsHeightAdj = minMenuSize.getHeight() > normalBounds.height(); 429 final int adjWidth; 430 final int adjHeight; 431 if (needsWidthAdj && needsHeightAdj) { 432 // Both the width and the height are too small - find the edge that needs the larger 433 // adjustment and scale that edge. The other edge will scale beyond the minMenuSize 434 // when the aspect ratio is applied. 435 final float widthScaleFactor = 436 ((float) (minMenuSize.getWidth())) / ((float) (normalBounds.width())); 437 final float heightScaleFactor = 438 ((float) (minMenuSize.getHeight())) / ((float) (normalBounds.height())); 439 if (widthScaleFactor > heightScaleFactor) { 440 adjWidth = minMenuSize.getWidth(); 441 adjHeight = Math.round(adjWidth / mPipBoundsState.getAspectRatio()); 442 } else { 443 adjHeight = minMenuSize.getHeight(); 444 adjWidth = Math.round(adjHeight * mPipBoundsState.getAspectRatio()); 445 } 446 } else if (needsWidthAdj) { 447 // Width is too small - use the min menu size width instead. 448 adjWidth = minMenuSize.getWidth(); 449 adjHeight = Math.round(adjWidth / mPipBoundsState.getAspectRatio()); 450 } else { 451 // Height is too small - use the min menu size height instead. 452 adjHeight = minMenuSize.getHeight(); 453 adjWidth = Math.round(adjHeight * mPipBoundsState.getAspectRatio()); 454 } 455 adjustedNormalBounds.set(0, 0, adjWidth, adjHeight); 456 // Make sure the bounds conform to the aspect ratio and min edge size. 457 transformBoundsToAspectRatio(adjustedNormalBounds, 458 mPipBoundsState.getAspectRatio(), true /* useCurrentMinEdgeSize */, 459 true /* useCurrentSize */); 460 return adjustedNormalBounds; 461 } 462 463 /** 464 * Dumps internal states. 465 */ dump(PrintWriter pw, String prefix)466 public void dump(PrintWriter pw, String prefix) { 467 final String innerPrefix = prefix + " "; 468 pw.println(prefix + TAG); 469 pw.println(innerPrefix + "mDefaultAspectRatio=" + mDefaultAspectRatio); 470 pw.println(innerPrefix + "mMinAspectRatio=" + mMinAspectRatio); 471 pw.println(innerPrefix + "mMaxAspectRatio=" + mMaxAspectRatio); 472 pw.println(innerPrefix + "mDefaultStackGravity=" + mDefaultStackGravity); 473 pw.println(innerPrefix + "mSnapAlgorithm" + mSnapAlgorithm); 474 } 475 } 476