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.split;
18 
19 import static android.view.WindowManager.DOCKED_INVALID;
20 import static android.view.WindowManager.DOCKED_LEFT;
21 import static android.view.WindowManager.DOCKED_RIGHT;
22 
23 import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_30_70;
24 import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_50_50;
25 import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_70_30;
26 import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_END_AND_DISMISS;
27 import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_MINIMIZE;
28 import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_NONE;
29 import static com.android.wm.shell.common.split.SplitScreenConstants.SNAP_TO_START_AND_DISMISS;
30 import static com.android.wm.shell.common.split.SplitScreenConstants.SnapPosition;
31 
32 import android.content.Context;
33 import android.content.res.Configuration;
34 import android.content.res.Resources;
35 import android.graphics.Rect;
36 import android.hardware.display.DisplayManager;
37 import android.view.Display;
38 import android.view.DisplayInfo;
39 
40 import androidx.annotation.Nullable;
41 
42 import java.util.ArrayList;
43 
44 /**
45  * Calculates the snap targets and the snap position given a position and a velocity. All positions
46  * here are to be interpreted as the left/top edge of the divider rectangle.
47  *
48  * @hide
49  */
50 public class DividerSnapAlgorithm {
51 
52     private static final int MIN_FLING_VELOCITY_DP_PER_SECOND = 400;
53     private static final int MIN_DISMISS_VELOCITY_DP_PER_SECOND = 600;
54 
55     /**
56      * 3 snap targets: left/top has 16:9 ratio (for videos), 1:1, and right/bottom has 16:9 ratio
57      */
58     private static final int SNAP_MODE_16_9 = 0;
59 
60     /**
61      * 3 snap targets: fixed ratio, 1:1, (1 - fixed ratio)
62      */
63     private static final int SNAP_FIXED_RATIO = 1;
64 
65     /**
66      * 1 snap target: 1:1
67      */
68     private static final int SNAP_ONLY_1_1 = 2;
69 
70     /**
71      * 1 snap target: minimized height, (1 - minimized height)
72      */
73     private static final int SNAP_MODE_MINIMIZED = 3;
74 
75     private final float mMinFlingVelocityPxPerSecond;
76     private final float mMinDismissVelocityPxPerSecond;
77     private final int mDisplayWidth;
78     private final int mDisplayHeight;
79     private final int mDividerSize;
80     private final ArrayList<SnapTarget> mTargets = new ArrayList<>();
81     private final Rect mInsets = new Rect();
82     private final int mSnapMode;
83     private final boolean mFreeSnapMode;
84     private final int mMinimalSizeResizableTask;
85     private final int mTaskHeightInMinimizedMode;
86     private final float mFixedRatio;
87     /** Allows split ratios to calculated dynamically instead of using {@link #mFixedRatio}. */
88     private final boolean mAllowFlexibleSplitRatios;
89     private boolean mIsHorizontalDivision;
90 
91     /** The first target which is still splitting the screen */
92     private final SnapTarget mFirstSplitTarget;
93 
94     /** The last target which is still splitting the screen */
95     private final SnapTarget mLastSplitTarget;
96 
97     private final SnapTarget mDismissStartTarget;
98     private final SnapTarget mDismissEndTarget;
99     private final SnapTarget mMiddleTarget;
100 
create(Context ctx, Rect insets)101     public static DividerSnapAlgorithm create(Context ctx, Rect insets) {
102         DisplayInfo displayInfo = new DisplayInfo();
103         ctx.getSystemService(DisplayManager.class).getDisplay(
104                 Display.DEFAULT_DISPLAY).getDisplayInfo(displayInfo);
105         int dividerWindowWidth = ctx.getResources().getDimensionPixelSize(
106                 com.android.internal.R.dimen.docked_stack_divider_thickness);
107         int dividerInsets = ctx.getResources().getDimensionPixelSize(
108                 com.android.internal.R.dimen.docked_stack_divider_insets);
109         return new DividerSnapAlgorithm(ctx.getResources(),
110                 displayInfo.logicalWidth, displayInfo.logicalHeight,
111                 dividerWindowWidth - 2 * dividerInsets,
112                 ctx.getApplicationContext().getResources().getConfiguration().orientation
113                         == Configuration.ORIENTATION_PORTRAIT,
114                 insets);
115     }
116 
DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize, boolean isHorizontalDivision, Rect insets)117     public DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize,
118             boolean isHorizontalDivision, Rect insets) {
119         this(res, displayWidth, displayHeight, dividerSize, isHorizontalDivision, insets,
120                 DOCKED_INVALID, false /* minimized */, true /* resizable */);
121     }
122 
DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize, boolean isHorizontalDivision, Rect insets, int dockSide)123     public DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize,
124         boolean isHorizontalDivision, Rect insets, int dockSide) {
125         this(res, displayWidth, displayHeight, dividerSize, isHorizontalDivision, insets,
126             dockSide, false /* minimized */, true /* resizable */);
127     }
128 
DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize, boolean isHorizontalDivision, Rect insets, int dockSide, boolean isMinimizedMode, boolean isHomeResizable)129     public DividerSnapAlgorithm(Resources res, int displayWidth, int displayHeight, int dividerSize,
130             boolean isHorizontalDivision, Rect insets, int dockSide, boolean isMinimizedMode,
131             boolean isHomeResizable) {
132         mMinFlingVelocityPxPerSecond =
133                 MIN_FLING_VELOCITY_DP_PER_SECOND * res.getDisplayMetrics().density;
134         mMinDismissVelocityPxPerSecond =
135                 MIN_DISMISS_VELOCITY_DP_PER_SECOND * res.getDisplayMetrics().density;
136         mDividerSize = dividerSize;
137         mDisplayWidth = displayWidth;
138         mDisplayHeight = displayHeight;
139         mIsHorizontalDivision = isHorizontalDivision;
140         mInsets.set(insets);
141         mSnapMode = isMinimizedMode ? SNAP_MODE_MINIMIZED :
142                 res.getInteger(com.android.internal.R.integer.config_dockedStackDividerSnapMode);
143         mFreeSnapMode = res.getBoolean(
144                 com.android.internal.R.bool.config_dockedStackDividerFreeSnapMode);
145         mFixedRatio = res.getFraction(
146                 com.android.internal.R.fraction.docked_stack_divider_fixed_ratio, 1, 1);
147         mMinimalSizeResizableTask = res.getDimensionPixelSize(
148                 com.android.internal.R.dimen.default_minimal_size_resizable_task);
149         mAllowFlexibleSplitRatios = res.getBoolean(
150                 com.android.internal.R.bool.config_flexibleSplitRatios);
151         mTaskHeightInMinimizedMode = isHomeResizable ? res.getDimensionPixelSize(
152                 com.android.internal.R.dimen.task_height_of_minimized_mode) : 0;
153         calculateTargets(isHorizontalDivision, dockSide);
154         mFirstSplitTarget = mTargets.get(1);
155         mLastSplitTarget = mTargets.get(mTargets.size() - 2);
156         mDismissStartTarget = mTargets.get(0);
157         mDismissEndTarget = mTargets.get(mTargets.size() - 1);
158         mMiddleTarget = mTargets.get(mTargets.size() / 2);
159         mMiddleTarget.isMiddleTarget = true;
160     }
161 
162     /**
163      * @return whether it's feasible to enable split screen in the current configuration, i.e. when
164      *         snapping in the middle both tasks are larger than the minimal task size.
165      */
isSplitScreenFeasible()166     public boolean isSplitScreenFeasible() {
167         int statusBarSize = mInsets.top;
168         int navBarSize = mIsHorizontalDivision ? mInsets.bottom : mInsets.right;
169         int size = mIsHorizontalDivision
170                 ? mDisplayHeight
171                 : mDisplayWidth;
172         int availableSpace = size - navBarSize - statusBarSize - mDividerSize;
173         return availableSpace / 2 >= mMinimalSizeResizableTask;
174     }
175 
calculateSnapTarget(int position, float velocity)176     public SnapTarget calculateSnapTarget(int position, float velocity) {
177         return calculateSnapTarget(position, velocity, true /* hardDismiss */);
178     }
179 
180     /**
181      * @param position the top/left position of the divider
182      * @param velocity current dragging velocity
183      * @param hardDismiss if set, make it a bit harder to get reach the dismiss targets
184      */
calculateSnapTarget(int position, float velocity, boolean hardDismiss)185     public SnapTarget calculateSnapTarget(int position, float velocity, boolean hardDismiss) {
186         if (position < mFirstSplitTarget.position && velocity < -mMinDismissVelocityPxPerSecond) {
187             return mDismissStartTarget;
188         }
189         if (position > mLastSplitTarget.position && velocity > mMinDismissVelocityPxPerSecond) {
190             return mDismissEndTarget;
191         }
192         if (Math.abs(velocity) < mMinFlingVelocityPxPerSecond) {
193             return snap(position, hardDismiss);
194         }
195         if (velocity < 0) {
196             return mFirstSplitTarget;
197         } else {
198             return mLastSplitTarget;
199         }
200     }
201 
calculateNonDismissingSnapTarget(int position)202     public SnapTarget calculateNonDismissingSnapTarget(int position) {
203         SnapTarget target = snap(position, false /* hardDismiss */);
204         if (target == mDismissStartTarget) {
205             return mFirstSplitTarget;
206         } else if (target == mDismissEndTarget) {
207             return mLastSplitTarget;
208         } else {
209             return target;
210         }
211     }
212 
213     /**
214      * Gets the SnapTarget corresponding to the given {@link SnapPosition}, or null if no such
215      * SnapTarget exists.
216      */
217     @Nullable
findSnapTarget(@napPosition int snapPosition)218     public SnapTarget findSnapTarget(@SnapPosition int snapPosition) {
219         for (SnapTarget t : mTargets) {
220             if (t.snapPosition == snapPosition) {
221                 return t;
222             }
223         }
224 
225         return null;
226     }
227 
calculateDismissingFraction(int position)228     public float calculateDismissingFraction(int position) {
229         if (position < mFirstSplitTarget.position) {
230             return 1f - (float) (position - getStartInset())
231                     / (mFirstSplitTarget.position - getStartInset());
232         } else if (position > mLastSplitTarget.position) {
233             return (float) (position - mLastSplitTarget.position)
234                     / (mDismissEndTarget.position - mLastSplitTarget.position - mDividerSize);
235         }
236         return 0f;
237     }
238 
getClosestDismissTarget(int position)239     public SnapTarget getClosestDismissTarget(int position) {
240         if (position < mFirstSplitTarget.position) {
241             return mDismissStartTarget;
242         } else if (position > mLastSplitTarget.position) {
243             return mDismissEndTarget;
244         } else if (position - mDismissStartTarget.position
245                 < mDismissEndTarget.position - position) {
246             return mDismissStartTarget;
247         } else {
248             return mDismissEndTarget;
249         }
250     }
251 
getFirstSplitTarget()252     public SnapTarget getFirstSplitTarget() {
253         return mFirstSplitTarget;
254     }
255 
getLastSplitTarget()256     public SnapTarget getLastSplitTarget() {
257         return mLastSplitTarget;
258     }
259 
getDismissStartTarget()260     public SnapTarget getDismissStartTarget() {
261         return mDismissStartTarget;
262     }
263 
getDismissEndTarget()264     public SnapTarget getDismissEndTarget() {
265         return mDismissEndTarget;
266     }
267 
getStartInset()268     private int getStartInset() {
269         if (mIsHorizontalDivision) {
270             return mInsets.top;
271         } else {
272             return mInsets.left;
273         }
274     }
275 
getEndInset()276     private int getEndInset() {
277         if (mIsHorizontalDivision) {
278             return mInsets.bottom;
279         } else {
280             return mInsets.right;
281         }
282     }
283 
shouldApplyFreeSnapMode(int position)284     private boolean shouldApplyFreeSnapMode(int position) {
285         if (!mFreeSnapMode) {
286             return false;
287         }
288         if (!isFirstSplitTargetAvailable() || !isLastSplitTargetAvailable()) {
289             return false;
290         }
291         return mFirstSplitTarget.position < position && position < mLastSplitTarget.position;
292     }
293 
snap(int position, boolean hardDismiss)294     private SnapTarget snap(int position, boolean hardDismiss) {
295         if (shouldApplyFreeSnapMode(position)) {
296             return new SnapTarget(position, position, SNAP_TO_NONE);
297         }
298         int minIndex = -1;
299         float minDistance = Float.MAX_VALUE;
300         int size = mTargets.size();
301         for (int i = 0; i < size; i++) {
302             SnapTarget target = mTargets.get(i);
303             float distance = Math.abs(position - target.position);
304             if (hardDismiss) {
305                 distance /= target.distanceMultiplier;
306             }
307             if (distance < minDistance) {
308                 minIndex = i;
309                 minDistance = distance;
310             }
311         }
312         return mTargets.get(minIndex);
313     }
314 
calculateTargets(boolean isHorizontalDivision, int dockedSide)315     private void calculateTargets(boolean isHorizontalDivision, int dockedSide) {
316         mTargets.clear();
317         int dividerMax = isHorizontalDivision
318                 ? mDisplayHeight
319                 : mDisplayWidth;
320         int startPos = -mDividerSize;
321         if (dockedSide == DOCKED_RIGHT) {
322             startPos += mInsets.left;
323         }
324         mTargets.add(new SnapTarget(startPos, startPos, SNAP_TO_START_AND_DISMISS, 0.35f));
325         switch (mSnapMode) {
326             case SNAP_MODE_16_9:
327                 addRatio16_9Targets(isHorizontalDivision, dividerMax);
328                 break;
329             case SNAP_FIXED_RATIO:
330                 addFixedDivisionTargets(isHorizontalDivision, dividerMax);
331                 break;
332             case SNAP_ONLY_1_1:
333                 addMiddleTarget(isHorizontalDivision);
334                 break;
335             case SNAP_MODE_MINIMIZED:
336                 addMinimizedTarget(isHorizontalDivision, dockedSide);
337                 break;
338         }
339         mTargets.add(new SnapTarget(dividerMax, dividerMax, SNAP_TO_END_AND_DISMISS, 0.35f));
340     }
341 
addNonDismissingTargets(boolean isHorizontalDivision, int topPosition, int bottomPosition, int dividerMax)342     private void addNonDismissingTargets(boolean isHorizontalDivision, int topPosition,
343             int bottomPosition, int dividerMax) {
344         maybeAddTarget(topPosition, topPosition - getStartInset(), SNAP_TO_30_70);
345         addMiddleTarget(isHorizontalDivision);
346         maybeAddTarget(bottomPosition,
347                 dividerMax - getEndInset() - (bottomPosition + mDividerSize), SNAP_TO_70_30);
348     }
349 
addFixedDivisionTargets(boolean isHorizontalDivision, int dividerMax)350     private void addFixedDivisionTargets(boolean isHorizontalDivision, int dividerMax) {
351         int start = isHorizontalDivision ? mInsets.top : mInsets.left;
352         int end = isHorizontalDivision
353                 ? mDisplayHeight - mInsets.bottom
354                 : mDisplayWidth - mInsets.right;
355         int size = (int) (mFixedRatio * (end - start)) - mDividerSize / 2;
356         if (mAllowFlexibleSplitRatios) {
357             size = Math.max(size, mMinimalSizeResizableTask);
358         }
359         int topPosition = start + size;
360         int bottomPosition = end - size - mDividerSize;
361         addNonDismissingTargets(isHorizontalDivision, topPosition, bottomPosition, dividerMax);
362     }
363 
addRatio16_9Targets(boolean isHorizontalDivision, int dividerMax)364     private void addRatio16_9Targets(boolean isHorizontalDivision, int dividerMax) {
365         int start = isHorizontalDivision ? mInsets.top : mInsets.left;
366         int end = isHorizontalDivision
367                 ? mDisplayHeight - mInsets.bottom
368                 : mDisplayWidth - mInsets.right;
369         int startOther = isHorizontalDivision ? mInsets.left : mInsets.top;
370         int endOther = isHorizontalDivision
371                 ? mDisplayWidth - mInsets.right
372                 : mDisplayHeight - mInsets.bottom;
373         float size = 9.0f / 16.0f * (endOther - startOther);
374         int sizeInt = (int) Math.floor(size);
375         int topPosition = start + sizeInt;
376         int bottomPosition = end - sizeInt - mDividerSize;
377         addNonDismissingTargets(isHorizontalDivision, topPosition, bottomPosition, dividerMax);
378     }
379 
380     /**
381      * Adds a target at {@param position} but only if the area with size of {@param smallerSize}
382      * meets the minimal size requirement.
383      */
maybeAddTarget(int position, int smallerSize, @SnapPosition int snapPosition)384     private void maybeAddTarget(int position, int smallerSize, @SnapPosition int snapPosition) {
385         if (smallerSize >= mMinimalSizeResizableTask) {
386             mTargets.add(new SnapTarget(position, position, snapPosition));
387         }
388     }
389 
addMiddleTarget(boolean isHorizontalDivision)390     private void addMiddleTarget(boolean isHorizontalDivision) {
391         int position = DockedDividerUtils.calculateMiddlePosition(isHorizontalDivision,
392                 mInsets, mDisplayWidth, mDisplayHeight, mDividerSize);
393         mTargets.add(new SnapTarget(position, position, SNAP_TO_50_50));
394     }
395 
addMinimizedTarget(boolean isHorizontalDivision, int dockedSide)396     private void addMinimizedTarget(boolean isHorizontalDivision, int dockedSide) {
397         // In portrait offset the position by the statusbar height, in landscape add the statusbar
398         // height as well to match portrait offset
399         int position = mTaskHeightInMinimizedMode + mInsets.top;
400         if (!isHorizontalDivision) {
401             if (dockedSide == DOCKED_LEFT) {
402                 position += mInsets.left;
403             } else if (dockedSide == DOCKED_RIGHT) {
404                 position = mDisplayWidth - position - mInsets.right - mDividerSize;
405             }
406         }
407         mTargets.add(new SnapTarget(position, position, SNAP_TO_MINIMIZE));
408     }
409 
getMiddleTarget()410     public SnapTarget getMiddleTarget() {
411         return mMiddleTarget;
412     }
413 
getNextTarget(SnapTarget snapTarget)414     public SnapTarget getNextTarget(SnapTarget snapTarget) {
415         int index = mTargets.indexOf(snapTarget);
416         if (index != -1 && index < mTargets.size() - 1) {
417             return mTargets.get(index + 1);
418         }
419         return snapTarget;
420     }
421 
getPreviousTarget(SnapTarget snapTarget)422     public SnapTarget getPreviousTarget(SnapTarget snapTarget) {
423         int index = mTargets.indexOf(snapTarget);
424         if (index != -1 && index > 0) {
425             return mTargets.get(index - 1);
426         }
427         return snapTarget;
428     }
429 
430     /**
431      * @return whether or not there are more than 1 split targets that do not include the two
432      * dismiss targets, used in deciding to display the middle target for accessibility
433      */
showMiddleSplitTargetForAccessibility()434     public boolean showMiddleSplitTargetForAccessibility() {
435         return (mTargets.size() - 2) > 1;
436     }
437 
isFirstSplitTargetAvailable()438     public boolean isFirstSplitTargetAvailable() {
439         return mFirstSplitTarget != mMiddleTarget;
440     }
441 
isLastSplitTargetAvailable()442     public boolean isLastSplitTargetAvailable() {
443         return mLastSplitTarget != mMiddleTarget;
444     }
445 
446     /**
447      * Finds the {@link SnapPosition} nearest to the given position.
448      */
calculateNearestSnapPosition(int currentPosition)449     public int calculateNearestSnapPosition(int currentPosition) {
450         return snap(currentPosition, /* hardDismiss */ true).snapPosition;
451     }
452 
453     /**
454      * Cycles through all non-dismiss targets with a stepping of {@param increment}. It moves left
455      * if {@param increment} is negative and moves right otherwise.
456      */
cycleNonDismissTarget(SnapTarget snapTarget, int increment)457     public SnapTarget cycleNonDismissTarget(SnapTarget snapTarget, int increment) {
458         int index = mTargets.indexOf(snapTarget);
459         if (index != -1) {
460             SnapTarget newTarget = mTargets.get((index + mTargets.size() + increment)
461                     % mTargets.size());
462             if (newTarget == mDismissStartTarget) {
463                 return mLastSplitTarget;
464             } else if (newTarget == mDismissEndTarget) {
465                 return mFirstSplitTarget;
466             } else {
467                 return newTarget;
468             }
469         }
470         return snapTarget;
471     }
472 
473     /**
474      * Represents a snap target for the divider.
475      */
476     public static class SnapTarget {
477         /** Position of this snap target. The right/bottom edge of the top/left task snaps here. */
478         public final int position;
479 
480         /**
481          * Like {@link #position}, but used to calculate the task bounds which might be different
482          * from the stack bounds.
483          */
484         public final int taskPosition;
485 
486         /**
487          * An int describing the placement of the divider in this snap target.
488          */
489         public final @SnapPosition int snapPosition;
490 
491         public boolean isMiddleTarget;
492 
493         /**
494          * Multiplier used to calculate distance to snap position. The lower this value, the harder
495          * it's to snap on this target
496          */
497         private final float distanceMultiplier;
498 
SnapTarget(int position, int taskPosition, @SnapPosition int snapPosition)499         public SnapTarget(int position, int taskPosition, @SnapPosition int snapPosition) {
500             this(position, taskPosition, snapPosition, 1f);
501         }
502 
SnapTarget(int position, int taskPosition, @SnapPosition int snapPosition, float distanceMultiplier)503         public SnapTarget(int position, int taskPosition, @SnapPosition int snapPosition,
504                 float distanceMultiplier) {
505             this.position = position;
506             this.taskPosition = taskPosition;
507             this.snapPosition = snapPosition;
508             this.distanceMultiplier = distanceMultiplier;
509         }
510     }
511 }
512