/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.view; import static android.view.Gravity.BOTTOM; import static android.view.Gravity.LEFT; import static android.view.Gravity.RIGHT; import static android.view.Gravity.TOP; import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE; import android.annotation.NonNull; import android.annotation.Nullable; import android.graphics.Insets; import android.graphics.Matrix; import android.graphics.Path; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.Region; import android.text.TextUtils; import android.util.Log; import android.util.PathParser; import com.android.internal.annotations.VisibleForTesting; import java.util.Locale; import java.util.Objects; /** * In order to accept the cutout specification for all of edges in devices, the specification * parsing method is extracted from * {@link android.view.DisplayCutout#fromResourcesRectApproximation(Resources, int, int)} to be * the specified class for parsing the specification. * BNF definition: * * * * * @hide */ @VisibleForTesting(visibility = PACKAGE) public class CutoutSpecification { private static final String TAG = "CutoutSpecification"; private static final boolean DEBUG = false; private static final int MINIMAL_ACCEPTABLE_PATH_LENGTH = "H1V1Z".length(); private static final char MARKER_START_CHAR = '@'; private static final String DP_MARKER = MARKER_START_CHAR + "dp"; private static final String BOTTOM_MARKER = MARKER_START_CHAR + "bottom"; private static final String RIGHT_MARKER = MARKER_START_CHAR + "right"; private static final String LEFT_MARKER = MARKER_START_CHAR + "left"; private static final String CUTOUT_MARKER = MARKER_START_CHAR + "cutout"; private static final String CENTER_VERTICAL_MARKER = MARKER_START_CHAR + "center_vertical"; /* By default, it's top bound cutout. That's why TOP_BOUND_CUTOUT_MARKER is not defined */ private static final String BIND_RIGHT_CUTOUT_MARKER = MARKER_START_CHAR + "bind_right_cutout"; private static final String BIND_LEFT_CUTOUT_MARKER = MARKER_START_CHAR + "bind_left_cutout"; private final Path mPath; private final Rect mLeftBound; private final Rect mTopBound; private final Rect mRightBound; private final Rect mBottomBound; private Insets mInsets; private CutoutSpecification(@NonNull Parser parser) { mPath = parser.mPath; mLeftBound = parser.mLeftBound; mTopBound = parser.mTopBound; mRightBound = parser.mRightBound; mBottomBound = parser.mBottomBound; mInsets = parser.mInsets; applyPhysicalPixelDisplaySizeRatio(parser.mPhysicalPixelDisplaySizeRatio); if (DEBUG) { Log.d(TAG, String.format(Locale.ENGLISH, "left cutout = %s, top cutout = %s, right cutout = %s, bottom cutout = %s", mLeftBound != null ? mLeftBound.toString() : "", mTopBound != null ? mTopBound.toString() : "", mRightBound != null ? mRightBound.toString() : "", mBottomBound != null ? mBottomBound.toString() : "")); } } private void applyPhysicalPixelDisplaySizeRatio(float physicalPixelDisplaySizeRatio) { if (physicalPixelDisplaySizeRatio == 1f) { return; } if (mPath != null && !mPath.isEmpty()) { final Matrix matrix = new Matrix(); matrix.postScale(physicalPixelDisplaySizeRatio, physicalPixelDisplaySizeRatio); mPath.transform(matrix); } scaleBounds(mLeftBound, physicalPixelDisplaySizeRatio); scaleBounds(mTopBound, physicalPixelDisplaySizeRatio); scaleBounds(mRightBound, physicalPixelDisplaySizeRatio); scaleBounds(mBottomBound, physicalPixelDisplaySizeRatio); mInsets = scaleInsets(mInsets, physicalPixelDisplaySizeRatio); } private void scaleBounds(Rect r, float ratio) { if (r != null && !r.isEmpty()) { r.scale(ratio); } } private Insets scaleInsets(Insets insets, float ratio) { return Insets.of( (int) (insets.left * ratio + 0.5f), (int) (insets.top * ratio + 0.5f), (int) (insets.right * ratio + 0.5f), (int) (insets.bottom * ratio + 0.5f)); } @VisibleForTesting(visibility = PACKAGE) @Nullable public Path getPath() { return mPath; } @VisibleForTesting(visibility = PACKAGE) @Nullable public Rect getLeftBound() { return mLeftBound; } @VisibleForTesting(visibility = PACKAGE) @Nullable public Rect getTopBound() { return mTopBound; } @VisibleForTesting(visibility = PACKAGE) @Nullable public Rect getRightBound() { return mRightBound; } @VisibleForTesting(visibility = PACKAGE) @Nullable public Rect getBottomBound() { return mBottomBound; } /** * To count the safe inset according to the cutout bounds and waterfall inset. * * @return the safe inset. */ @VisibleForTesting(visibility = PACKAGE) @NonNull public Rect getSafeInset() { return mInsets.toRect(); } private static int decideWhichEdge(boolean isTopEdgeShortEdge, boolean isShortEdge, boolean isStart) { return (isTopEdgeShortEdge) ? ((isShortEdge) ? (isStart ? TOP : BOTTOM) : (isStart ? LEFT : RIGHT)) : ((isShortEdge) ? (isStart ? LEFT : RIGHT) : (isStart ? TOP : BOTTOM)); } /** * The CutoutSpecification Parser. */ @VisibleForTesting(visibility = PACKAGE) public static class Parser { private final boolean mIsShortEdgeOnTop; private final float mStableDensity; private final int mPhysicalDisplayWidth; private final int mPhysicalDisplayHeight; private final float mPhysicalPixelDisplaySizeRatio; private final Matrix mMatrix; private Insets mInsets; private int mSafeInsetLeft; private int mSafeInsetTop; private int mSafeInsetRight; private int mSafeInsetBottom; private final Rect mTmpRect = new Rect(); private final RectF mTmpRectF = new RectF(); private boolean mInDp; private Path mPath; private Rect mLeftBound; private Rect mTopBound; private Rect mRightBound; private Rect mBottomBound; private boolean mPositionFromLeft = false; private boolean mPositionFromRight = false; private boolean mPositionFromBottom = false; private boolean mPositionFromCenterVertical = false; private boolean mBindLeftCutout = false; private boolean mBindRightCutout = false; private boolean mBindBottomCutout = false; private boolean mIsTouchShortEdgeStart; private boolean mIsTouchShortEdgeEnd; private boolean mIsCloserToStartSide; @VisibleForTesting(visibility = PACKAGE) public Parser(float stableDensity, int physicalDisplayWidth, int physicalDisplayHeight) { this(stableDensity, physicalDisplayWidth, physicalDisplayHeight, 1f); } /** * The constructor of the CutoutSpecification parser to parse the specification of cutout. * @param stableDensity the display density. * @param physicalDisplayWidth the display width. * @param physicalDisplayHeight the display height. * @param physicalPixelDisplaySizeRatio the display size ratio based on stable display size. */ Parser(float stableDensity, int physicalDisplayWidth, int physicalDisplayHeight, float physicalPixelDisplaySizeRatio) { mStableDensity = stableDensity; mPhysicalDisplayWidth = physicalDisplayWidth; mPhysicalDisplayHeight = physicalDisplayHeight; mPhysicalPixelDisplaySizeRatio = physicalPixelDisplaySizeRatio; mMatrix = new Matrix(); mIsShortEdgeOnTop = mPhysicalDisplayWidth < mPhysicalDisplayHeight; } private void computeBoundsRectAndAddToRegion(Path p, Region inoutRegion, Rect inoutRect) { mTmpRectF.setEmpty(); p.computeBounds(mTmpRectF, false /* unused */); mTmpRectF.round(inoutRect); inoutRegion.op(inoutRect, Region.Op.UNION); } private void resetStatus(StringBuilder sb) { sb.setLength(0); mPositionFromBottom = false; mPositionFromLeft = false; mPositionFromRight = false; mPositionFromCenterVertical = false; mBindLeftCutout = false; mBindRightCutout = false; mBindBottomCutout = false; } private void translateMatrix() { final float offsetX; if (mPositionFromRight) { offsetX = mPhysicalDisplayWidth; } else if (mPositionFromLeft) { offsetX = 0; } else { offsetX = mPhysicalDisplayWidth / 2f; } final float offsetY; if (mPositionFromBottom) { offsetY = mPhysicalDisplayHeight; } else if (mPositionFromCenterVertical) { offsetY = mPhysicalDisplayHeight / 2f; } else { offsetY = 0; } mMatrix.reset(); if (mInDp) { mMatrix.postScale(mStableDensity, mStableDensity); } mMatrix.postTranslate(offsetX, offsetY); } private int computeSafeInsets(int gravity, Rect rect) { if (gravity == LEFT && rect.right > 0 && rect.right < mPhysicalDisplayWidth) { return rect.right; } else if (gravity == TOP && rect.bottom > 0 && rect.bottom < mPhysicalDisplayHeight) { return rect.bottom; } else if (gravity == RIGHT && rect.left > 0 && rect.left < mPhysicalDisplayWidth) { return mPhysicalDisplayWidth - rect.left; } else if (gravity == BOTTOM && rect.top > 0 && rect.top < mPhysicalDisplayHeight) { return mPhysicalDisplayHeight - rect.top; } return 0; } private void setSafeInset(int gravity, int inset) { if (gravity == LEFT) { mSafeInsetLeft = inset; } else if (gravity == TOP) { mSafeInsetTop = inset; } else if (gravity == RIGHT) { mSafeInsetRight = inset; } else if (gravity == BOTTOM) { mSafeInsetBottom = inset; } } private int getSafeInset(int gravity) { if (gravity == LEFT) { return mSafeInsetLeft; } else if (gravity == TOP) { return mSafeInsetTop; } else if (gravity == RIGHT) { return mSafeInsetRight; } else if (gravity == BOTTOM) { return mSafeInsetBottom; } return 0; } @NonNull private Rect onSetEdgeCutout(boolean isStart, boolean isShortEdge, @NonNull Rect rect) { final int gravity; if (isShortEdge) { gravity = decideWhichEdge(mIsShortEdgeOnTop, true, isStart); } else { if (mIsTouchShortEdgeStart && mIsTouchShortEdgeEnd) { gravity = decideWhichEdge(mIsShortEdgeOnTop, false, isStart); } else if (mIsTouchShortEdgeStart || mIsTouchShortEdgeEnd) { gravity = decideWhichEdge(mIsShortEdgeOnTop, true, mIsCloserToStartSide); } else { gravity = decideWhichEdge(mIsShortEdgeOnTop, isShortEdge, isStart); } } int oldSafeInset = getSafeInset(gravity); int newSafeInset = computeSafeInsets(gravity, rect); if (oldSafeInset < newSafeInset) { setSafeInset(gravity, newSafeInset); } return new Rect(rect); } private void setEdgeCutout(@NonNull Path newPath) { if (mBindRightCutout && mRightBound == null) { mRightBound = onSetEdgeCutout(false, !mIsShortEdgeOnTop, mTmpRect); } else if (mBindLeftCutout && mLeftBound == null) { mLeftBound = onSetEdgeCutout(true, !mIsShortEdgeOnTop, mTmpRect); } else if (mBindBottomCutout && mBottomBound == null) { mBottomBound = onSetEdgeCutout(false, mIsShortEdgeOnTop, mTmpRect); } else if (!(mBindBottomCutout || mBindLeftCutout || mBindRightCutout) && mTopBound == null) { mTopBound = onSetEdgeCutout(true, mIsShortEdgeOnTop, mTmpRect); } else { return; } if (mPath != null) { mPath.addPath(newPath); } else { mPath = newPath; } } private void parseSvgPathSpec(Region region, String spec) { if (TextUtils.length(spec) < MINIMAL_ACCEPTABLE_PATH_LENGTH) { Log.e(TAG, "According to SVG definition, it shouldn't happen"); return; } translateMatrix(); final Path newPath = PathParser.createPathFromPathData(spec); newPath.transform(mMatrix); computeBoundsRectAndAddToRegion(newPath, region, mTmpRect); if (DEBUG) { Log.d(TAG, String.format(Locale.ENGLISH, "hasLeft = %b, hasRight = %b, hasBottom = %b, hasCenterVertical = %b", mPositionFromLeft, mPositionFromRight, mPositionFromBottom, mPositionFromCenterVertical)); Log.d(TAG, "region = " + region); Log.d(TAG, "spec = \"" + spec + "\" rect = " + mTmpRect + " newPath = " + newPath); } if (mTmpRect.isEmpty()) { return; } if (mIsShortEdgeOnTop) { mIsTouchShortEdgeStart = mTmpRect.top <= 0; mIsTouchShortEdgeEnd = mTmpRect.bottom >= mPhysicalDisplayHeight; mIsCloserToStartSide = mTmpRect.centerY() < mPhysicalDisplayHeight / 2; } else { mIsTouchShortEdgeStart = mTmpRect.left <= 0; mIsTouchShortEdgeEnd = mTmpRect.right >= mPhysicalDisplayWidth; mIsCloserToStartSide = mTmpRect.centerX() < mPhysicalDisplayWidth / 2; } setEdgeCutout(newPath); } private void parseSpecWithoutDp(@NonNull String specWithoutDp) { Region region = Region.obtain(); StringBuilder sb = null; int currentIndex = 0; int lastIndex = 0; while ((currentIndex = specWithoutDp.indexOf(MARKER_START_CHAR, lastIndex)) != -1) { if (sb == null) { sb = new StringBuilder(specWithoutDp.length()); } sb.append(specWithoutDp, lastIndex, currentIndex); if (specWithoutDp.startsWith(LEFT_MARKER, currentIndex)) { if (!mPositionFromRight) { mPositionFromLeft = true; } currentIndex += LEFT_MARKER.length(); } else if (specWithoutDp.startsWith(RIGHT_MARKER, currentIndex)) { if (!mPositionFromLeft) { mPositionFromRight = true; } currentIndex += RIGHT_MARKER.length(); } else if (specWithoutDp.startsWith(BOTTOM_MARKER, currentIndex)) { parseSvgPathSpec(region, sb.toString()); currentIndex += BOTTOM_MARKER.length(); /* prepare to parse the rest path */ resetStatus(sb); mBindBottomCutout = true; mPositionFromBottom = true; } else if (specWithoutDp.startsWith(CENTER_VERTICAL_MARKER, currentIndex)) { parseSvgPathSpec(region, sb.toString()); currentIndex += CENTER_VERTICAL_MARKER.length(); /* prepare to parse the rest path */ resetStatus(sb); mPositionFromCenterVertical = true; } else if (specWithoutDp.startsWith(CUTOUT_MARKER, currentIndex)) { parseSvgPathSpec(region, sb.toString()); currentIndex += CUTOUT_MARKER.length(); /* prepare to parse the rest path */ resetStatus(sb); } else if (specWithoutDp.startsWith(BIND_LEFT_CUTOUT_MARKER, currentIndex)) { mBindBottomCutout = false; mBindRightCutout = false; mBindLeftCutout = true; currentIndex += BIND_LEFT_CUTOUT_MARKER.length(); } else if (specWithoutDp.startsWith(BIND_RIGHT_CUTOUT_MARKER, currentIndex)) { mBindBottomCutout = false; mBindLeftCutout = false; mBindRightCutout = true; currentIndex += BIND_RIGHT_CUTOUT_MARKER.length(); } else { currentIndex += 1; } lastIndex = currentIndex; } if (sb == null) { parseSvgPathSpec(region, specWithoutDp); } else { sb.append(specWithoutDp, lastIndex, specWithoutDp.length()); parseSvgPathSpec(region, sb.toString()); } region.recycle(); } /** * To parse specification string as the CutoutSpecification. * * @param originalSpec the specification string * @return the CutoutSpecification instance */ @VisibleForTesting(visibility = PACKAGE) public CutoutSpecification parse(@NonNull String originalSpec) { Objects.requireNonNull(originalSpec); int dpIndex = originalSpec.lastIndexOf(DP_MARKER); mInDp = (dpIndex != -1); final String spec; if (dpIndex != -1) { spec = originalSpec.substring(0, dpIndex) + originalSpec.substring(dpIndex + DP_MARKER.length()); } else { spec = originalSpec; } parseSpecWithoutDp(spec); mInsets = Insets.of(mSafeInsetLeft, mSafeInsetTop, mSafeInsetRight, mSafeInsetBottom); return new CutoutSpecification(this); } } }