1 /*
2  * Copyright (C) 2020 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 android.view;
18 
19 import static android.view.Gravity.BOTTOM;
20 import static android.view.Gravity.LEFT;
21 import static android.view.Gravity.RIGHT;
22 import static android.view.Gravity.TOP;
23 
24 import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE;
25 
26 import android.annotation.NonNull;
27 import android.annotation.Nullable;
28 import android.graphics.Insets;
29 import android.graphics.Matrix;
30 import android.graphics.Path;
31 import android.graphics.Rect;
32 import android.graphics.RectF;
33 import android.graphics.Region;
34 import android.text.TextUtils;
35 import android.util.Log;
36 import android.util.PathParser;
37 
38 import com.android.internal.annotations.VisibleForTesting;
39 
40 import java.util.Locale;
41 import java.util.Objects;
42 
43 /**
44  * In order to accept the cutout specification for all of edges in devices, the specification
45  * parsing method is extracted from
46  * {@link android.view.DisplayCutout#fromResourcesRectApproximation(Resources, int, int)} to be
47  * the specified class for parsing the specification.
48  * BNF definition:
49  * <ul>
50  *      <li>Cutouts Specification = ([Cutout Delimiter],Cutout Specification) {...}, [Dp] ; </li>
51  *      <li>Cutout Specification  = [Vertical Position], (SVG Path Element), [Horizontal Position]
52  *                                  [Bind Cutout] ;</li>
53  *      <li>Vertical Position     = "@bottom" | "@center_vertical" ;</li>
54  *      <li>Horizontal Position   = "@left" | "@right" ;</li>
55  *      <li>Bind Cutout           = "@bind_left_cutout" | "@bind_right_cutout" ;</li>
56  *      <li>Cutout Delimiter      = "@cutout" ;</li>
57  *      <li>Dp                    = "@dp"</li>
58  * </ul>
59  *
60  * <ul>
61  *     <li>Vertical position is top by default if there is neither "@bottom" nor "@center_vertical"
62  *     </li>
63  *     <li>Horizontal position is center horizontal by default if there is neither "@left" nor
64  *     "@right".</li>
65  *     <li>@bottom make the cutout piece bind to bottom edge.</li>
66  *     <li>both of @bind_left_cutout and @bind_right_cutout are use to claim the cutout belong to
67  *     left or right edge cutout.</li>
68  * </ul>
69  *
70  * @hide
71  */
72 @VisibleForTesting(visibility = PACKAGE)
73 public class CutoutSpecification {
74     private static final String TAG = "CutoutSpecification";
75     private static final boolean DEBUG = false;
76 
77     private static final int MINIMAL_ACCEPTABLE_PATH_LENGTH = "H1V1Z".length();
78 
79     private static final char MARKER_START_CHAR = '@';
80     private static final String DP_MARKER = MARKER_START_CHAR + "dp";
81 
82     private static final String BOTTOM_MARKER = MARKER_START_CHAR + "bottom";
83     private static final String RIGHT_MARKER = MARKER_START_CHAR + "right";
84     private static final String LEFT_MARKER = MARKER_START_CHAR + "left";
85     private static final String CUTOUT_MARKER = MARKER_START_CHAR + "cutout";
86     private static final String CENTER_VERTICAL_MARKER = MARKER_START_CHAR + "center_vertical";
87 
88     /* By default, it's top bound cutout. That's why TOP_BOUND_CUTOUT_MARKER is not defined */
89     private static final String BIND_RIGHT_CUTOUT_MARKER = MARKER_START_CHAR + "bind_right_cutout";
90     private static final String BIND_LEFT_CUTOUT_MARKER = MARKER_START_CHAR + "bind_left_cutout";
91 
92     private final Path mPath;
93     private final Rect mLeftBound;
94     private final Rect mTopBound;
95     private final Rect mRightBound;
96     private final Rect mBottomBound;
97     private final Insets mInsets;
98 
CutoutSpecification(@onNull Parser parser)99     private CutoutSpecification(@NonNull Parser parser) {
100         mPath = parser.mPath;
101         mLeftBound = parser.mLeftBound;
102         mTopBound = parser.mTopBound;
103         mRightBound = parser.mRightBound;
104         mBottomBound = parser.mBottomBound;
105         mInsets = parser.mInsets;
106 
107         if (DEBUG) {
108             Log.d(TAG, String.format(Locale.ENGLISH,
109                     "left cutout = %s, top cutout = %s, right cutout = %s, bottom cutout = %s",
110                     mLeftBound != null ? mLeftBound.toString() : "",
111                     mTopBound != null ? mTopBound.toString() : "",
112                     mRightBound != null ? mRightBound.toString() : "",
113                     mBottomBound != null ? mBottomBound.toString() : ""));
114         }
115     }
116 
117     @VisibleForTesting(visibility = PACKAGE)
118     @Nullable
getPath()119     public Path getPath() {
120         return mPath;
121     }
122 
123     @VisibleForTesting(visibility = PACKAGE)
124     @Nullable
getLeftBound()125     public Rect getLeftBound() {
126         return mLeftBound;
127     }
128 
129     @VisibleForTesting(visibility = PACKAGE)
130     @Nullable
getTopBound()131     public Rect getTopBound() {
132         return mTopBound;
133     }
134 
135     @VisibleForTesting(visibility = PACKAGE)
136     @Nullable
getRightBound()137     public Rect getRightBound() {
138         return mRightBound;
139     }
140 
141     @VisibleForTesting(visibility = PACKAGE)
142     @Nullable
getBottomBound()143     public Rect getBottomBound() {
144         return mBottomBound;
145     }
146 
147     /**
148      * To count the safe inset according to the cutout bounds and waterfall inset.
149      *
150      * @return the safe inset.
151      */
152     @VisibleForTesting(visibility = PACKAGE)
153     @NonNull
getSafeInset()154     public Rect getSafeInset() {
155         return mInsets.toRect();
156     }
157 
decideWhichEdge(boolean isTopEdgeShortEdge, boolean isShortEdge, boolean isStart)158     private static int decideWhichEdge(boolean isTopEdgeShortEdge,
159             boolean isShortEdge, boolean isStart) {
160         return (isTopEdgeShortEdge)
161                 ? ((isShortEdge) ? (isStart ? TOP : BOTTOM) : (isStart ? LEFT : RIGHT))
162                 : ((isShortEdge) ? (isStart ? LEFT : RIGHT) : (isStart ? TOP : BOTTOM));
163     }
164 
165     /**
166      * The CutoutSpecification Parser.
167      */
168     @VisibleForTesting(visibility = PACKAGE)
169     public static class Parser {
170         private final boolean mIsShortEdgeOnTop;
171         private final float mDensity;
172         private final int mDisplayWidth;
173         private final int mDisplayHeight;
174         private final Matrix mMatrix;
175         private Insets mInsets;
176         private int mSafeInsetLeft;
177         private int mSafeInsetTop;
178         private int mSafeInsetRight;
179         private int mSafeInsetBottom;
180 
181         private final Rect mTmpRect = new Rect();
182         private final RectF mTmpRectF = new RectF();
183 
184         private boolean mInDp;
185 
186         private Path mPath;
187         private Rect mLeftBound;
188         private Rect mTopBound;
189         private Rect mRightBound;
190         private Rect mBottomBound;
191 
192         private boolean mPositionFromLeft = false;
193         private boolean mPositionFromRight = false;
194         private boolean mPositionFromBottom = false;
195         private boolean mPositionFromCenterVertical = false;
196 
197         private boolean mBindLeftCutout = false;
198         private boolean mBindRightCutout = false;
199         private boolean mBindBottomCutout = false;
200 
201         private boolean mIsTouchShortEdgeStart;
202         private boolean mIsTouchShortEdgeEnd;
203         private boolean mIsCloserToStartSide;
204 
205         /**
206          * The constructor of the CutoutSpecification parser to parse the specification of cutout.
207          * @param density the display density.
208          * @param displayWidth the display width.
209          * @param displayHeight the display height.
210          */
211         @VisibleForTesting(visibility = PACKAGE)
Parser(float density, int displayWidth, int displayHeight)212         public Parser(float density, int displayWidth, int displayHeight) {
213             mDensity = density;
214             mDisplayWidth = displayWidth;
215             mDisplayHeight = displayHeight;
216             mMatrix = new Matrix();
217             mIsShortEdgeOnTop = mDisplayWidth < mDisplayHeight;
218         }
219 
220         private void computeBoundsRectAndAddToRegion(Path p, Region inoutRegion, Rect inoutRect) {
221             mTmpRectF.setEmpty();
222             p.computeBounds(mTmpRectF, false /* unused */);
223             mTmpRectF.round(inoutRect);
224             inoutRegion.op(inoutRect, Region.Op.UNION);
225         }
226 
227         private void resetStatus(StringBuilder sb) {
228             sb.setLength(0);
229             mPositionFromBottom = false;
230             mPositionFromLeft = false;
231             mPositionFromRight = false;
232             mPositionFromCenterVertical = false;
233 
234             mBindLeftCutout = false;
235             mBindRightCutout = false;
236             mBindBottomCutout = false;
237         }
238 
239         private void translateMatrix() {
240             final float offsetX;
241             if (mPositionFromRight) {
242                 offsetX = mDisplayWidth;
243             } else if (mPositionFromLeft) {
244                 offsetX = 0;
245             } else {
246                 offsetX = mDisplayWidth / 2f;
247             }
248 
249             final float offsetY;
250             if (mPositionFromBottom) {
251                 offsetY = mDisplayHeight;
252             } else if (mPositionFromCenterVertical) {
253                 offsetY = mDisplayHeight / 2f;
254             } else {
255                 offsetY = 0;
256             }
257 
258             mMatrix.reset();
259             if (mInDp) {
260                 mMatrix.postScale(mDensity, mDensity);
261             }
262             mMatrix.postTranslate(offsetX, offsetY);
263         }
264 
265         private int computeSafeInsets(int gravity, Rect rect) {
266             if (gravity == LEFT && rect.right > 0 && rect.right < mDisplayWidth) {
267                 return rect.right;
268             } else if (gravity == TOP && rect.bottom > 0 && rect.bottom < mDisplayHeight) {
269                 return rect.bottom;
270             } else if (gravity == RIGHT && rect.left > 0 && rect.left < mDisplayWidth) {
271                 return mDisplayWidth - rect.left;
272             } else if (gravity == BOTTOM && rect.top > 0 && rect.top < mDisplayHeight) {
273                 return mDisplayHeight - rect.top;
274             }
275             return 0;
276         }
277 
278         private void setSafeInset(int gravity, int inset) {
279             if (gravity == LEFT) {
280                 mSafeInsetLeft = inset;
281             } else if (gravity == TOP) {
282                 mSafeInsetTop = inset;
283             } else if (gravity == RIGHT) {
284                 mSafeInsetRight = inset;
285             } else if (gravity == BOTTOM) {
286                 mSafeInsetBottom = inset;
287             }
288         }
289 
290         private int getSafeInset(int gravity) {
291             if (gravity == LEFT) {
292                 return mSafeInsetLeft;
293             } else if (gravity == TOP) {
294                 return mSafeInsetTop;
295             } else if (gravity == RIGHT) {
296                 return mSafeInsetRight;
297             } else if (gravity == BOTTOM) {
298                 return mSafeInsetBottom;
299             }
300             return 0;
301         }
302 
303         @NonNull
304         private Rect onSetEdgeCutout(boolean isStart, boolean isShortEdge, @NonNull Rect rect) {
305             final int gravity;
306             if (isShortEdge) {
307                 gravity = decideWhichEdge(mIsShortEdgeOnTop, true, isStart);
308             } else {
309                 if (mIsTouchShortEdgeStart && mIsTouchShortEdgeEnd) {
310                     gravity = decideWhichEdge(mIsShortEdgeOnTop, false, isStart);
311                 } else if (mIsTouchShortEdgeStart || mIsTouchShortEdgeEnd) {
312                     gravity = decideWhichEdge(mIsShortEdgeOnTop, true,
313                             mIsCloserToStartSide);
314                 } else {
315                     gravity = decideWhichEdge(mIsShortEdgeOnTop, isShortEdge, isStart);
316                 }
317             }
318 
319             int oldSafeInset = getSafeInset(gravity);
320             int newSafeInset = computeSafeInsets(gravity, rect);
321             if (oldSafeInset < newSafeInset) {
322                 setSafeInset(gravity, newSafeInset);
323             }
324 
325             return new Rect(rect);
326         }
327 
328         private void setEdgeCutout(@NonNull Path newPath) {
329             if (mBindRightCutout && mRightBound == null) {
330                 mRightBound = onSetEdgeCutout(false, !mIsShortEdgeOnTop, mTmpRect);
331             } else if (mBindLeftCutout && mLeftBound == null) {
332                 mLeftBound = onSetEdgeCutout(true, !mIsShortEdgeOnTop, mTmpRect);
333             } else if (mBindBottomCutout && mBottomBound == null) {
334                 mBottomBound = onSetEdgeCutout(false, mIsShortEdgeOnTop, mTmpRect);
335             } else if (!(mBindBottomCutout || mBindLeftCutout || mBindRightCutout)
336                     && mTopBound == null) {
337                 mTopBound = onSetEdgeCutout(true, mIsShortEdgeOnTop, mTmpRect);
338             } else {
339                 return;
340             }
341 
342             if (mPath != null) {
343                 mPath.addPath(newPath);
344             } else {
345                 mPath = newPath;
346             }
347         }
348 
349         private void parseSvgPathSpec(Region region, String spec) {
350             if (TextUtils.length(spec) < MINIMAL_ACCEPTABLE_PATH_LENGTH) {
351                 Log.e(TAG, "According to SVG definition, it shouldn't happen");
352                 return;
353             }
354             spec.trim();
355             translateMatrix();
356 
357             final Path newPath = PathParser.createPathFromPathData(spec);
358             newPath.transform(mMatrix);
359             computeBoundsRectAndAddToRegion(newPath, region, mTmpRect);
360 
361             if (DEBUG) {
362                 Log.d(TAG, String.format(Locale.ENGLISH,
363                         "hasLeft = %b, hasRight = %b, hasBottom = %b, hasCenterVertical = %b",
364                         mPositionFromLeft, mPositionFromRight, mPositionFromBottom,
365                         mPositionFromCenterVertical));
366                 Log.d(TAG, "region = " + region);
367                 Log.d(TAG, "spec = \"" + spec + "\" rect = " + mTmpRect + " newPath = " + newPath);
368             }
369 
370             if (mTmpRect.isEmpty()) {
371                 return;
372             }
373 
374             if (mIsShortEdgeOnTop) {
375                 mIsTouchShortEdgeStart = mTmpRect.top <= 0;
376                 mIsTouchShortEdgeEnd = mTmpRect.bottom >= mDisplayHeight;
377                 mIsCloserToStartSide = mTmpRect.centerY() < mDisplayHeight / 2;
378             } else {
379                 mIsTouchShortEdgeStart = mTmpRect.left <= 0;
380                 mIsTouchShortEdgeEnd = mTmpRect.right >= mDisplayWidth;
381                 mIsCloserToStartSide = mTmpRect.centerX() < mDisplayWidth / 2;
382             }
383 
384             setEdgeCutout(newPath);
385         }
386 
387         private void parseSpecWithoutDp(@NonNull String specWithoutDp) {
388             Region region = Region.obtain();
389             StringBuilder sb = null;
390             int currentIndex = 0;
391             int lastIndex = 0;
392             while ((currentIndex = specWithoutDp.indexOf(MARKER_START_CHAR, lastIndex)) != -1) {
393                 if (sb == null) {
394                     sb = new StringBuilder(specWithoutDp.length());
395                 }
396                 sb.append(specWithoutDp, lastIndex, currentIndex);
397 
398                 if (specWithoutDp.startsWith(LEFT_MARKER, currentIndex)) {
399                     if (!mPositionFromRight) {
400                         mPositionFromLeft = true;
401                     }
402                     currentIndex += LEFT_MARKER.length();
403                 } else if (specWithoutDp.startsWith(RIGHT_MARKER, currentIndex)) {
404                     if (!mPositionFromLeft) {
405                         mPositionFromRight = true;
406                     }
407                     currentIndex += RIGHT_MARKER.length();
408                 } else if (specWithoutDp.startsWith(BOTTOM_MARKER, currentIndex)) {
409                     parseSvgPathSpec(region, sb.toString());
410                     currentIndex += BOTTOM_MARKER.length();
411 
412                     /* prepare to parse the rest path */
413                     resetStatus(sb);
414                     mBindBottomCutout = true;
415                     mPositionFromBottom = true;
416                 } else if (specWithoutDp.startsWith(CENTER_VERTICAL_MARKER, currentIndex)) {
417                     parseSvgPathSpec(region, sb.toString());
418                     currentIndex += CENTER_VERTICAL_MARKER.length();
419 
420                     /* prepare to parse the rest path */
421                     resetStatus(sb);
422                     mPositionFromCenterVertical = true;
423                 } else if (specWithoutDp.startsWith(CUTOUT_MARKER, currentIndex)) {
424                     parseSvgPathSpec(region, sb.toString());
425                     currentIndex += CUTOUT_MARKER.length();
426 
427                     /* prepare to parse the rest path */
428                     resetStatus(sb);
429                 } else if (specWithoutDp.startsWith(BIND_LEFT_CUTOUT_MARKER, currentIndex)) {
430                     mBindBottomCutout = false;
431                     mBindRightCutout = false;
432                     mBindLeftCutout = true;
433 
434                     currentIndex += BIND_LEFT_CUTOUT_MARKER.length();
435                 } else if (specWithoutDp.startsWith(BIND_RIGHT_CUTOUT_MARKER, currentIndex)) {
436                     mBindBottomCutout = false;
437                     mBindLeftCutout = false;
438                     mBindRightCutout = true;
439 
440                     currentIndex += BIND_RIGHT_CUTOUT_MARKER.length();
441                 } else {
442                     currentIndex += 1;
443                 }
444 
445                 lastIndex = currentIndex;
446             }
447 
448             if (sb == null) {
449                 parseSvgPathSpec(region, specWithoutDp);
450             } else {
451                 sb.append(specWithoutDp, lastIndex, specWithoutDp.length());
452                 parseSvgPathSpec(region, sb.toString());
453             }
454 
455             region.recycle();
456         }
457 
458         /**
459          * To parse specification string as the CutoutSpecification.
460          *
461          * @param originalSpec the specification string
462          * @return the CutoutSpecification instance
463          */
464         @VisibleForTesting(visibility = PACKAGE)
465         public CutoutSpecification parse(@NonNull String originalSpec) {
466             Objects.requireNonNull(originalSpec);
467 
468             int dpIndex = originalSpec.lastIndexOf(DP_MARKER);
469             mInDp = (dpIndex != -1);
470             final String spec;
471             if (dpIndex != -1) {
472                 spec = originalSpec.substring(0, dpIndex)
473                         + originalSpec.substring(dpIndex + DP_MARKER.length());
474             } else {
475                 spec = originalSpec;
476             }
477 
478             parseSpecWithoutDp(spec);
479 
480             mInsets = Insets.of(mSafeInsetLeft, mSafeInsetTop, mSafeInsetRight, mSafeInsetBottom);
481             return new CutoutSpecification(this);
482         }
483     }
484 }
485