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