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