1/*
2 * Copyright (C) 2016 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
17package com.android.internal.policy;
18
19import android.content.Context;
20import android.content.res.Configuration;
21import android.content.res.Resources;
22import android.graphics.Point;
23import android.graphics.PointF;
24import android.graphics.Rect;
25import android.util.Size;
26import android.view.Gravity;
27import android.view.ViewConfiguration;
28import android.widget.Scroller;
29
30import java.io.PrintWriter;
31import java.util.ArrayList;
32
33/**
34 * Calculates the snap targets and the snap position for the PIP given a position and a velocity.
35 * All bounds are relative to the display top/left.
36 */
37public class PipSnapAlgorithm {
38
39    // The below SNAP_MODE_* constants correspond to the config resource value
40    // config_pictureInPictureSnapMode and should not be changed independently.
41    // Allows snapping to the four corners
42    private static final int SNAP_MODE_CORNERS_ONLY = 0;
43    // Allows snapping to the four corners and the mid-points on the long edge in each orientation
44    private static final int SNAP_MODE_CORNERS_AND_SIDES = 1;
45    // Allows snapping to anywhere along the edge of the screen
46    private static final int SNAP_MODE_EDGE = 2;
47    // Allows snapping anywhere along the edge of the screen and magnets towards corners
48    private static final int SNAP_MODE_EDGE_MAGNET_CORNERS = 3;
49    // Allows snapping on the long edge in each orientation and magnets towards corners
50    private static final int SNAP_MODE_LONG_EDGE_MAGNET_CORNERS = 4;
51
52    // The friction multiplier to control how slippery the PIP is when flung
53    private static final float SCROLL_FRICTION_MULTIPLIER = 8f;
54
55    // Threshold to magnet to a corner
56    private static final float CORNER_MAGNET_THRESHOLD = 0.3f;
57
58    private final Context mContext;
59
60    private final ArrayList<Integer> mSnapGravities = new ArrayList<>();
61    private final int mDefaultSnapMode = SNAP_MODE_EDGE_MAGNET_CORNERS;
62    private int mSnapMode = mDefaultSnapMode;
63
64    private final float mDefaultSizePercent;
65    private final float mMinAspectRatioForMinSize;
66    private final float mMaxAspectRatioForMinSize;
67
68    private Scroller mScroller;
69    private int mOrientation = Configuration.ORIENTATION_UNDEFINED;
70
71    private final int mMinimizedVisibleSize;
72    private boolean mIsMinimized;
73
74    public PipSnapAlgorithm(Context context) {
75        Resources res = context.getResources();
76        mContext = context;
77        mMinimizedVisibleSize = res.getDimensionPixelSize(
78                com.android.internal.R.dimen.pip_minimized_visible_size);
79        mDefaultSizePercent = res.getFloat(
80                com.android.internal.R.dimen.config_pictureInPictureDefaultSizePercent);
81        mMaxAspectRatioForMinSize = res.getFloat(
82                com.android.internal.R.dimen.config_pictureInPictureAspectRatioLimitForMinSize);
83        mMinAspectRatioForMinSize = 1f / mMaxAspectRatioForMinSize;
84        onConfigurationChanged();
85    }
86
87    /**
88     * Updates the snap algorithm when the configuration changes.
89     */
90    public void onConfigurationChanged() {
91        Resources res = mContext.getResources();
92        mOrientation = res.getConfiguration().orientation;
93        mSnapMode = res.getInteger(com.android.internal.R.integer.config_pictureInPictureSnapMode);
94        calculateSnapTargets();
95    }
96
97    /**
98     * Sets the PIP's minimized state.
99     */
100    public void setMinimized(boolean isMinimized) {
101        mIsMinimized = isMinimized;
102    }
103
104    /**
105     * @return the closest absolute snap stack bounds for the given {@param stackBounds} moving at
106     * the given {@param velocityX} and {@param velocityY}.  The {@param movementBounds} should be
107     * those for the given {@param stackBounds}.
108     */
109    public Rect findClosestSnapBounds(Rect movementBounds, Rect stackBounds, float velocityX,
110            float velocityY) {
111        final Rect finalStackBounds = new Rect(stackBounds);
112        if (mScroller == null) {
113            final ViewConfiguration viewConfig = ViewConfiguration.get(mContext);
114            mScroller = new Scroller(mContext);
115            mScroller.setFriction(viewConfig.getScrollFriction() * SCROLL_FRICTION_MULTIPLIER);
116        }
117        mScroller.fling(stackBounds.left, stackBounds.top,
118                (int) velocityX, (int) velocityY,
119                movementBounds.left, movementBounds.right,
120                movementBounds.top, movementBounds.bottom);
121        finalStackBounds.offsetTo(mScroller.getFinalX(), mScroller.getFinalY());
122        mScroller.abortAnimation();
123        return findClosestSnapBounds(movementBounds, finalStackBounds);
124    }
125
126    /**
127     * @return the closest absolute snap stack bounds for the given {@param stackBounds}.  The
128     * {@param movementBounds} should be those for the given {@param stackBounds}.
129     */
130    public Rect findClosestSnapBounds(Rect movementBounds, Rect stackBounds) {
131        final Rect pipBounds = new Rect(movementBounds.left, movementBounds.top,
132                movementBounds.right + stackBounds.width(),
133                movementBounds.bottom + stackBounds.height());
134        final Rect newBounds = new Rect(stackBounds);
135        if (mSnapMode == SNAP_MODE_LONG_EDGE_MAGNET_CORNERS
136                || mSnapMode == SNAP_MODE_EDGE_MAGNET_CORNERS) {
137            final Rect tmpBounds = new Rect();
138            final Point[] snapTargets = new Point[mSnapGravities.size()];
139            for (int i = 0; i < mSnapGravities.size(); i++) {
140                Gravity.apply(mSnapGravities.get(i), stackBounds.width(), stackBounds.height(),
141                        pipBounds, 0, 0, tmpBounds);
142                snapTargets[i] = new Point(tmpBounds.left, tmpBounds.top);
143            }
144            Point snapTarget = findClosestPoint(stackBounds.left, stackBounds.top, snapTargets);
145            float distance = distanceToPoint(snapTarget, stackBounds.left, stackBounds.top);
146            final float thresh = Math.max(stackBounds.width(), stackBounds.height())
147                    * CORNER_MAGNET_THRESHOLD;
148            if (distance < thresh) {
149                newBounds.offsetTo(snapTarget.x, snapTarget.y);
150            } else {
151                snapRectToClosestEdge(stackBounds, movementBounds, newBounds);
152            }
153        } else if (mSnapMode == SNAP_MODE_EDGE) {
154            // Find the closest edge to the given stack bounds and snap to it
155            snapRectToClosestEdge(stackBounds, movementBounds, newBounds);
156        } else {
157            // Find the closest snap point
158            final Rect tmpBounds = new Rect();
159            final Point[] snapTargets = new Point[mSnapGravities.size()];
160            for (int i = 0; i < mSnapGravities.size(); i++) {
161                Gravity.apply(mSnapGravities.get(i), stackBounds.width(), stackBounds.height(),
162                        pipBounds, 0, 0, tmpBounds);
163                snapTargets[i] = new Point(tmpBounds.left, tmpBounds.top);
164            }
165            Point snapTarget = findClosestPoint(stackBounds.left, stackBounds.top, snapTargets);
166            newBounds.offsetTo(snapTarget.x, snapTarget.y);
167        }
168        return newBounds;
169    }
170
171    /**
172     * Applies the offset to the {@param stackBounds} to adjust it to a minimized state.
173     */
174    public void applyMinimizedOffset(Rect stackBounds, Rect movementBounds, Point displaySize,
175            Rect stableInsets) {
176        if (stackBounds.left <= movementBounds.centerX()) {
177            stackBounds.offsetTo(stableInsets.left + mMinimizedVisibleSize - stackBounds.width(),
178                    stackBounds.top);
179        } else {
180            stackBounds.offsetTo(displaySize.x - stableInsets.right - mMinimizedVisibleSize,
181                    stackBounds.top);
182        }
183    }
184
185    /**
186     * @return returns a fraction that describes where along the {@param movementBounds} the
187     *         {@param stackBounds} are. If the {@param stackBounds} are not currently on the
188     *         {@param movementBounds} exactly, then they will be snapped to the movement bounds.
189     *
190     *         The fraction is defined in a clockwise fashion against the {@param movementBounds}:
191     *
192     *            0   1
193     *          4 +---+ 1
194     *            |   |
195     *          3 +---+ 2
196     *            3   2
197     */
198    public float getSnapFraction(Rect stackBounds, Rect movementBounds) {
199        final Rect tmpBounds = new Rect();
200        snapRectToClosestEdge(stackBounds, movementBounds, tmpBounds);
201        final float widthFraction = (float) (tmpBounds.left - movementBounds.left) /
202                movementBounds.width();
203        final float heightFraction = (float) (tmpBounds.top - movementBounds.top) /
204                movementBounds.height();
205        if (tmpBounds.top == movementBounds.top) {
206            return widthFraction;
207        } else if (tmpBounds.left == movementBounds.right) {
208            return 1f + heightFraction;
209        } else if (tmpBounds.top == movementBounds.bottom) {
210            return 2f + (1f - widthFraction);
211        } else {
212            return 3f + (1f - heightFraction);
213        }
214    }
215
216    /**
217     * Moves the {@param stackBounds} along the {@param movementBounds} to the given snap fraction.
218     * See {@link #getSnapFraction(Rect, Rect)}.
219     *
220     * The fraction is define in a clockwise fashion against the {@param movementBounds}:
221     *
222     *    0   1
223     *  4 +---+ 1
224     *    |   |
225     *  3 +---+ 2
226     *    3   2
227     */
228    public void applySnapFraction(Rect stackBounds, Rect movementBounds, float snapFraction) {
229        if (snapFraction < 1f) {
230            int offset = movementBounds.left + (int) (snapFraction * movementBounds.width());
231            stackBounds.offsetTo(offset, movementBounds.top);
232        } else if (snapFraction < 2f) {
233            snapFraction -= 1f;
234            int offset = movementBounds.top + (int) (snapFraction * movementBounds.height());
235            stackBounds.offsetTo(movementBounds.right, offset);
236        } else if (snapFraction < 3f) {
237            snapFraction -= 2f;
238            int offset = movementBounds.left + (int) ((1f - snapFraction) * movementBounds.width());
239            stackBounds.offsetTo(offset, movementBounds.bottom);
240        } else {
241            snapFraction -= 3f;
242            int offset = movementBounds.top + (int) ((1f - snapFraction) * movementBounds.height());
243            stackBounds.offsetTo(movementBounds.left, offset);
244        }
245    }
246
247    /**
248     * Adjusts {@param movementBoundsOut} so that it is the movement bounds for the given
249     * {@param stackBounds}.
250     */
251    public void getMovementBounds(Rect stackBounds, Rect insetBounds, Rect movementBoundsOut,
252            int imeHeight) {
253        // Adjust the right/bottom to ensure the stack bounds never goes offscreen
254        movementBoundsOut.set(insetBounds);
255        movementBoundsOut.right = Math.max(insetBounds.left, insetBounds.right -
256                stackBounds.width());
257        movementBoundsOut.bottom = Math.max(insetBounds.top, insetBounds.bottom -
258                stackBounds.height());
259        movementBoundsOut.bottom -= imeHeight;
260    }
261
262    /**
263     * @return the size of the PiP at the given {@param aspectRatio}, ensuring that the minimum edge
264     * is at least {@param minEdgeSize}.
265     */
266    public Size getSizeForAspectRatio(float aspectRatio, float minEdgeSize, int displayWidth,
267            int displayHeight) {
268        final int smallestDisplaySize = Math.min(displayWidth, displayHeight);
269        final int minSize = (int) Math.max(minEdgeSize, smallestDisplaySize * mDefaultSizePercent);
270
271        final int width;
272        final int height;
273        if (aspectRatio <= mMinAspectRatioForMinSize || aspectRatio > mMaxAspectRatioForMinSize) {
274            // Beyond these points, we can just use the min size as the shorter edge
275            if (aspectRatio <= 1) {
276                // Portrait, width is the minimum size
277                width = minSize;
278                height = Math.round(width / aspectRatio);
279            } else {
280                // Landscape, height is the minimum size
281                height = minSize;
282                width = Math.round(height * aspectRatio);
283            }
284        } else {
285            // Within these points, we ensure that the bounds fit within the radius of the limits
286            // at the points
287            final float widthAtMaxAspectRatioForMinSize = mMaxAspectRatioForMinSize * minSize;
288            final float radius = PointF.length(widthAtMaxAspectRatioForMinSize, minSize);
289            height = (int) Math.round(Math.sqrt((radius * radius) /
290                    (aspectRatio * aspectRatio + 1)));
291            width = Math.round(height * aspectRatio);
292        }
293        return new Size(width, height);
294    }
295
296    /**
297     * @return the closest point in {@param points} to the given {@param x} and {@param y}.
298     */
299    private Point findClosestPoint(int x, int y, Point[] points) {
300        Point closestPoint = null;
301        float minDistance = Float.MAX_VALUE;
302        for (Point p : points) {
303            float distance = distanceToPoint(p, x, y);
304            if (distance < minDistance) {
305                closestPoint = p;
306                minDistance = distance;
307            }
308        }
309        return closestPoint;
310    }
311
312    /**
313     * Snaps the {@param stackBounds} to the closest edge of the {@param movementBounds} and writes
314     * the new bounds out to {@param boundsOut}.
315     */
316    private void snapRectToClosestEdge(Rect stackBounds, Rect movementBounds, Rect boundsOut) {
317        // If the stackBounds are minimized, then it should only be snapped back horizontally
318        final int boundedLeft = Math.max(movementBounds.left, Math.min(movementBounds.right,
319                stackBounds.left));
320        final int boundedTop = Math.max(movementBounds.top, Math.min(movementBounds.bottom,
321                stackBounds.top));
322        boundsOut.set(stackBounds);
323        if (mIsMinimized) {
324            boundsOut.offsetTo(boundedLeft, boundedTop);
325            return;
326        }
327
328        // Otherwise, just find the closest edge
329        final int fromLeft = Math.abs(stackBounds.left - movementBounds.left);
330        final int fromTop = Math.abs(stackBounds.top - movementBounds.top);
331        final int fromRight = Math.abs(movementBounds.right - stackBounds.left);
332        final int fromBottom = Math.abs(movementBounds.bottom - stackBounds.top);
333        int shortest;
334        if (mSnapMode == SNAP_MODE_LONG_EDGE_MAGNET_CORNERS) {
335            // Only check longest edges
336            shortest = (mOrientation == Configuration.ORIENTATION_LANDSCAPE)
337                    ? Math.min(fromTop, fromBottom)
338                    : Math.min(fromLeft, fromRight);
339        } else {
340            shortest = Math.min(Math.min(fromLeft, fromRight), Math.min(fromTop, fromBottom));
341        }
342        if (shortest == fromLeft) {
343            boundsOut.offsetTo(movementBounds.left, boundedTop);
344        } else if (shortest == fromTop) {
345            boundsOut.offsetTo(boundedLeft, movementBounds.top);
346        } else if (shortest == fromRight) {
347            boundsOut.offsetTo(movementBounds.right, boundedTop);
348        } else {
349            boundsOut.offsetTo(boundedLeft, movementBounds.bottom);
350        }
351    }
352
353    /**
354     * @return the distance between point {@param p} and the given {@param x} and {@param y}.
355     */
356    private float distanceToPoint(Point p, int x, int y) {
357        return PointF.length(p.x - x, p.y - y);
358    }
359
360    /**
361     * Calculate the snap targets for the discrete snap modes.
362     */
363    private void calculateSnapTargets() {
364        mSnapGravities.clear();
365        switch (mSnapMode) {
366            case SNAP_MODE_CORNERS_AND_SIDES:
367                if (mOrientation == Configuration.ORIENTATION_LANDSCAPE) {
368                    mSnapGravities.add(Gravity.TOP | Gravity.CENTER_HORIZONTAL);
369                    mSnapGravities.add(Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL);
370                } else {
371                    mSnapGravities.add(Gravity.CENTER_VERTICAL | Gravity.LEFT);
372                    mSnapGravities.add(Gravity.CENTER_VERTICAL | Gravity.RIGHT);
373                }
374                // Fall through
375            case SNAP_MODE_CORNERS_ONLY:
376            case SNAP_MODE_EDGE_MAGNET_CORNERS:
377            case SNAP_MODE_LONG_EDGE_MAGNET_CORNERS:
378                mSnapGravities.add(Gravity.TOP | Gravity.LEFT);
379                mSnapGravities.add(Gravity.TOP | Gravity.RIGHT);
380                mSnapGravities.add(Gravity.BOTTOM | Gravity.LEFT);
381                mSnapGravities.add(Gravity.BOTTOM | Gravity.RIGHT);
382                break;
383            default:
384                // Skip otherwise
385                break;
386        }
387    }
388
389    public void dump(PrintWriter pw, String prefix) {
390        final String innerPrefix = prefix + "  ";
391        pw.println(prefix + PipSnapAlgorithm.class.getSimpleName());
392        pw.println(innerPrefix + "mSnapMode=" + mSnapMode);
393        pw.println(innerPrefix + "mOrientation=" + mOrientation);
394        pw.println(innerPrefix + "mMinimizedVisibleSize=" + mMinimizedVisibleSize);
395        pw.println(innerPrefix + "mIsMinimized=" + mIsMinimized);
396    }
397}
398