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.Size; 28 import android.view.Gravity; 29 30 import com.android.internal.protolog.common.ProtoLog; 31 import com.android.wm.shell.R; 32 import com.android.wm.shell.protolog.ShellProtoLogGroup; 33 34 import java.io.PrintWriter; 35 36 /** 37 * Calculates the default, normal, entry, inset and movement bounds of the PIP. 38 */ 39 public class PipBoundsAlgorithm { 40 41 private static final String TAG = PipBoundsAlgorithm.class.getSimpleName(); 42 private static final float INVALID_SNAP_FRACTION = -1f; 43 44 // The same value (with the same name) is used in Launcher. 45 private static final float PIP_ASPECT_RATIO_MISMATCH_THRESHOLD = 0.01f; 46 47 @NonNull private final PipBoundsState mPipBoundsState; 48 @NonNull protected final PipDisplayLayoutState mPipDisplayLayoutState; 49 @NonNull protected final SizeSpecSource mSizeSpecSource; 50 private final PipSnapAlgorithm mSnapAlgorithm; 51 private final PipKeepClearAlgorithmInterface mPipKeepClearAlgorithm; 52 53 private float mDefaultAspectRatio; 54 private float mMinAspectRatio; 55 private float mMaxAspectRatio; 56 private int mDefaultStackGravity; 57 PipBoundsAlgorithm(Context context, @NonNull PipBoundsState pipBoundsState, @NonNull PipSnapAlgorithm pipSnapAlgorithm, @NonNull PipKeepClearAlgorithmInterface pipKeepClearAlgorithm, @NonNull PipDisplayLayoutState pipDisplayLayoutState, @NonNull SizeSpecSource sizeSpecSource)58 public PipBoundsAlgorithm(Context context, @NonNull PipBoundsState pipBoundsState, 59 @NonNull PipSnapAlgorithm pipSnapAlgorithm, 60 @NonNull PipKeepClearAlgorithmInterface pipKeepClearAlgorithm, 61 @NonNull PipDisplayLayoutState pipDisplayLayoutState, 62 @NonNull SizeSpecSource sizeSpecSource) { 63 mPipBoundsState = pipBoundsState; 64 mSnapAlgorithm = pipSnapAlgorithm; 65 mPipKeepClearAlgorithm = pipKeepClearAlgorithm; 66 mPipDisplayLayoutState = pipDisplayLayoutState; 67 mSizeSpecSource = sizeSpecSource; 68 reloadResources(context); 69 // Initialize the aspect ratio to the default aspect ratio. Don't do this in reload 70 // resources as it would clobber mAspectRatio when entering PiP from fullscreen which 71 // triggers a configuration change and the resources to be reloaded. 72 mPipBoundsState.setAspectRatio(mDefaultAspectRatio); 73 } 74 75 /** 76 * TODO: move the resources to SysUI package. 77 */ reloadResources(Context context)78 private void reloadResources(Context context) { 79 final Resources res = context.getResources(); 80 mDefaultAspectRatio = res.getFloat( 81 R.dimen.config_pictureInPictureDefaultAspectRatio); 82 mDefaultStackGravity = res.getInteger( 83 R.integer.config_defaultPictureInPictureGravity); 84 mMinAspectRatio = res.getFloat( 85 com.android.internal.R.dimen.config_pictureInPictureMinAspectRatio); 86 mMaxAspectRatio = res.getFloat( 87 com.android.internal.R.dimen.config_pictureInPictureMaxAspectRatio); 88 } 89 90 /** 91 * The {@link PipSnapAlgorithm} is couple on display bounds 92 * @return {@link PipSnapAlgorithm}. 93 */ getSnapAlgorithm()94 public PipSnapAlgorithm getSnapAlgorithm() { 95 return mSnapAlgorithm; 96 } 97 98 /** Responds to configuration change. */ onConfigurationChanged(Context context)99 public void onConfigurationChanged(Context context) { 100 reloadResources(context); 101 } 102 103 /** Returns the normal bounds (i.e. the default entry bounds). */ getNormalBounds()104 public Rect getNormalBounds() { 105 // The normal bounds are the default bounds adjusted to the current aspect ratio. 106 return transformBoundsToAspectRatioIfValid(getDefaultBounds(), 107 mPipBoundsState.getAspectRatio(), false /* useCurrentMinEdgeSize */, 108 false /* useCurrentSize */); 109 } 110 111 /** Returns the default bounds. */ getDefaultBounds()112 public Rect getDefaultBounds() { 113 return getDefaultBounds(INVALID_SNAP_FRACTION, null /* size */); 114 } 115 116 /** 117 * Returns the destination bounds to place the PIP window on entry. 118 * If there are any keep clear areas registered, the position will try to avoid occluding them. 119 */ getEntryDestinationBounds()120 public Rect getEntryDestinationBounds() { 121 Rect entryBounds = getEntryDestinationBoundsIgnoringKeepClearAreas(); 122 Rect insets = new Rect(); 123 getInsetBounds(insets); 124 return mPipKeepClearAlgorithm.findUnoccludedPosition(entryBounds, 125 mPipBoundsState.getRestrictedKeepClearAreas(), 126 mPipBoundsState.getUnrestrictedKeepClearAreas(), insets); 127 } 128 129 /** Returns the destination bounds to place the PIP window on entry. */ getEntryDestinationBoundsIgnoringKeepClearAreas()130 public Rect getEntryDestinationBoundsIgnoringKeepClearAreas() { 131 final PipBoundsState.PipReentryState reentryState = mPipBoundsState.getReentryState(); 132 133 final Rect destinationBounds = getDefaultBounds(); 134 if (reentryState != null) { 135 final Size scaledBounds = new Size( 136 Math.round(mPipBoundsState.getMaxSize().x * reentryState.getBoundsScale()), 137 Math.round(mPipBoundsState.getMaxSize().y * reentryState.getBoundsScale())); 138 destinationBounds.set(getDefaultBounds(reentryState.getSnapFraction(), scaledBounds)); 139 } 140 141 final boolean useCurrentSize = reentryState != null; 142 Rect aspectRatioBounds = transformBoundsToAspectRatioIfValid(destinationBounds, 143 mPipBoundsState.getAspectRatio(), false /* useCurrentMinEdgeSize */, 144 useCurrentSize); 145 return aspectRatioBounds; 146 } 147 148 /** Returns the current bounds adjusted to the new aspect ratio, if valid. */ getAdjustedDestinationBounds(Rect currentBounds, float newAspectRatio)149 public Rect getAdjustedDestinationBounds(Rect currentBounds, float newAspectRatio) { 150 return transformBoundsToAspectRatioIfValid(currentBounds, newAspectRatio, 151 true /* useCurrentMinEdgeSize */, false /* useCurrentSize */); 152 } 153 154 /** 155 * 156 * Get the smallest/most minimal size allowed. 157 */ getMinimalSize(ActivityInfo activityInfo)158 public Size getMinimalSize(ActivityInfo activityInfo) { 159 if (activityInfo == null || activityInfo.windowLayout == null) { 160 return null; 161 } 162 final ActivityInfo.WindowLayout windowLayout = activityInfo.windowLayout; 163 // -1 will be populated if an activity specifies defaultWidth/defaultHeight in <layout> 164 // without minWidth/minHeight 165 if (windowLayout.minWidth > 0 && windowLayout.minHeight > 0) { 166 // If either dimension is smaller than the allowed minimum, adjust them 167 // according to mOverridableMinSize 168 return new Size( 169 Math.max(windowLayout.minWidth, getOverrideMinEdgeSize()), 170 Math.max(windowLayout.minHeight, getOverrideMinEdgeSize())); 171 } 172 return null; 173 } 174 175 /** 176 * Returns the source hint rect if it is valid (if provided and is contained by the current 177 * task bounds). 178 */ getValidSourceHintRect(PictureInPictureParams params, Rect sourceBounds)179 public static Rect getValidSourceHintRect(PictureInPictureParams params, Rect sourceBounds) { 180 final Rect sourceHintRect = params != null && params.hasSourceBoundsHint() 181 ? params.getSourceRectHint() 182 : null; 183 if (sourceHintRect != null && sourceBounds.contains(sourceHintRect)) { 184 return sourceHintRect; 185 } 186 return null; 187 } 188 189 190 /** 191 * Returns the source hint rect if it is valid (if provided and is contained by the current 192 * task bounds, while not smaller than the destination bounds). 193 */ 194 @Nullable getValidSourceHintRect(PictureInPictureParams params, Rect sourceBounds, Rect destinationBounds)195 public static Rect getValidSourceHintRect(PictureInPictureParams params, Rect sourceBounds, 196 Rect destinationBounds) { 197 Rect sourceRectHint = getValidSourceHintRect(params, sourceBounds); 198 if (!isSourceRectHintValidForEnterPip(sourceRectHint, destinationBounds)) { 199 sourceRectHint = null; 200 } 201 return sourceRectHint; 202 } 203 204 /** 205 * This is a situation in which the source rect hint on at least one axis is smaller 206 * than the destination bounds, which represents a problem because we would have to scale 207 * up that axis to fit the bounds. So instead, just fallback to the non-source hint 208 * animation in this case. 209 * 210 * @return {@code false} if the given source is too small to use for the entering animation. 211 */ isSourceRectHintValidForEnterPip(Rect sourceRectHint, Rect destinationBounds)212 public static boolean isSourceRectHintValidForEnterPip(Rect sourceRectHint, 213 Rect destinationBounds) { 214 if (sourceRectHint == null || sourceRectHint.isEmpty()) { 215 ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, 216 "isSourceRectHintValidForEnterPip=false, empty hint"); 217 return false; 218 } 219 if (sourceRectHint.width() <= destinationBounds.width() 220 || sourceRectHint.height() <= destinationBounds.height()) { 221 ProtoLog.d(ShellProtoLogGroup.WM_SHELL_PICTURE_IN_PICTURE, 222 "isSourceRectHintValidForEnterPip=false, hint(%s) is smaller" 223 + " than destination(%s)", sourceRectHint, destinationBounds); 224 return false; 225 } 226 final float reportedRatio = destinationBounds.width() / (float) destinationBounds.height(); 227 final float inferredRatio = sourceRectHint.width() / (float) sourceRectHint.height(); 228 if (Math.abs(reportedRatio - inferredRatio) > PIP_ASPECT_RATIO_MISMATCH_THRESHOLD) { 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