1 /*
2  * Copyright (C) 2015 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.launcher3.icons;
18 
19 import android.annotation.TargetApi;
20 import android.content.Context;
21 import android.content.res.Resources;
22 import android.graphics.Bitmap;
23 import android.graphics.Canvas;
24 import android.graphics.Color;
25 import android.graphics.Matrix;
26 import android.graphics.Paint;
27 import android.graphics.Path;
28 import android.graphics.PorterDuff;
29 import android.graphics.PorterDuffXfermode;
30 import android.graphics.Rect;
31 import android.graphics.RectF;
32 import android.graphics.Region;
33 import android.graphics.drawable.AdaptiveIconDrawable;
34 import android.graphics.drawable.Drawable;
35 import android.os.Build;
36 import android.util.Log;
37 
38 import java.nio.ByteBuffer;
39 
40 import androidx.annotation.NonNull;
41 import androidx.annotation.Nullable;
42 
43 public class IconNormalizer {
44 
45     private static final String TAG = "IconNormalizer";
46     private static final boolean DEBUG = false;
47     // Ratio of icon visible area to full icon size for a square shaped icon
48     private static final float MAX_SQUARE_AREA_FACTOR = 375.0f / 576;
49     // Ratio of icon visible area to full icon size for a circular shaped icon
50     private static final float MAX_CIRCLE_AREA_FACTOR = 380.0f / 576;
51 
52     private static final float CIRCLE_AREA_BY_RECT = (float) Math.PI / 4;
53 
54     // Slope used to calculate icon visible area to full icon size for any generic shaped icon.
55     private static final float LINEAR_SCALE_SLOPE =
56             (MAX_CIRCLE_AREA_FACTOR - MAX_SQUARE_AREA_FACTOR) / (1 - CIRCLE_AREA_BY_RECT);
57 
58     private static final int MIN_VISIBLE_ALPHA = 40;
59 
60     // Shape detection related constants
61     private static final float BOUND_RATIO_MARGIN = .05f;
62     private static final float PIXEL_DIFF_PERCENTAGE_THRESHOLD = 0.005f;
63     private static final float SCALE_NOT_INITIALIZED = 0;
64 
65     // Ratio of the diameter of an normalized circular icon to the actual icon size.
66     public static final float ICON_VISIBLE_AREA_FACTOR = 0.92f;
67 
68     private final int mMaxSize;
69     private final Bitmap mBitmap;
70     private final Canvas mCanvas;
71     private final Paint mPaintMaskShape;
72     private final Paint mPaintMaskShapeOutline;
73     private final byte[] mPixels;
74 
75     private final RectF mAdaptiveIconBounds;
76     private float mAdaptiveIconScale;
77 
78     private boolean mEnableShapeDetection;
79 
80     // for each y, stores the position of the leftmost x and the rightmost x
81     private final float[] mLeftBorder;
82     private final float[] mRightBorder;
83     private final Rect mBounds;
84     private final Path mShapePath;
85     private final Matrix mMatrix;
86 
87     /** package private **/
IconNormalizer(Context context, int iconBitmapSize, boolean shapeDetection)88     IconNormalizer(Context context, int iconBitmapSize, boolean shapeDetection) {
89         // Use twice the icon size as maximum size to avoid scaling down twice.
90         mMaxSize = iconBitmapSize * 2;
91         mBitmap = Bitmap.createBitmap(mMaxSize, mMaxSize, Bitmap.Config.ALPHA_8);
92         mCanvas = new Canvas(mBitmap);
93         mPixels = new byte[mMaxSize * mMaxSize];
94         mLeftBorder = new float[mMaxSize];
95         mRightBorder = new float[mMaxSize];
96         mBounds = new Rect();
97         mAdaptiveIconBounds = new RectF();
98 
99         mPaintMaskShape = new Paint();
100         mPaintMaskShape.setColor(Color.RED);
101         mPaintMaskShape.setStyle(Paint.Style.FILL);
102         mPaintMaskShape.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.XOR));
103 
104         mPaintMaskShapeOutline = new Paint();
105         mPaintMaskShapeOutline.setStrokeWidth(
106                 2 * context.getResources().getDisplayMetrics().density);
107         mPaintMaskShapeOutline.setStyle(Paint.Style.STROKE);
108         mPaintMaskShapeOutline.setColor(Color.BLACK);
109         mPaintMaskShapeOutline.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
110 
111         mShapePath = new Path();
112         mMatrix = new Matrix();
113         mAdaptiveIconScale = SCALE_NOT_INITIALIZED;
114         mEnableShapeDetection = shapeDetection;
115     }
116 
getScale(float hullArea, float boundingArea, float fullArea)117     private static float getScale(float hullArea, float boundingArea, float fullArea) {
118         float hullByRect = hullArea / boundingArea;
119         float scaleRequired;
120         if (hullByRect < CIRCLE_AREA_BY_RECT) {
121             scaleRequired = MAX_CIRCLE_AREA_FACTOR;
122         } else {
123             scaleRequired = MAX_SQUARE_AREA_FACTOR + LINEAR_SCALE_SLOPE * (1 - hullByRect);
124         }
125 
126         float areaScale = hullArea / fullArea;
127         // Use sqrt of the final ratio as the images is scaled across both width and height.
128         return areaScale > scaleRequired ? (float) Math.sqrt(scaleRequired / areaScale) : 1;
129     }
130 
131     /**
132      * @param d Should be AdaptiveIconDrawable
133      * @param size Canvas size to use
134      */
135     @TargetApi(Build.VERSION_CODES.O)
normalizeAdaptiveIcon(Drawable d, int size, @Nullable RectF outBounds)136     public static float normalizeAdaptiveIcon(Drawable d, int size, @Nullable RectF outBounds) {
137         Rect tmpBounds = new Rect(d.getBounds());
138         d.setBounds(0, 0, size, size);
139 
140         Path path = ((AdaptiveIconDrawable) d).getIconMask();
141         Region region = new Region();
142         region.setPath(path, new Region(0, 0, size, size));
143 
144         Rect hullBounds = region.getBounds();
145         int hullArea = GraphicsUtils.getArea(region);
146 
147         if (outBounds != null) {
148             float sizeF = size;
149             outBounds.set(
150                     hullBounds.left / sizeF,
151                     hullBounds.top / sizeF,
152                     1 - (hullBounds.right / sizeF),
153                     1 - (hullBounds.bottom / sizeF));
154         }
155         d.setBounds(tmpBounds);
156         return getScale(hullArea, hullArea, size * size);
157     }
158 
159     /**
160      * Returns if the shape of the icon is same as the path.
161      * For this method to work, the shape path bounds should be in [0,1]x[0,1] bounds.
162      */
isShape(Path maskPath)163     private boolean isShape(Path maskPath) {
164         // Condition1:
165         // If width and height of the path not close to a square, then the icon shape is
166         // not same as the mask shape.
167         float iconRatio = ((float) mBounds.width()) / mBounds.height();
168         if (Math.abs(iconRatio - 1) > BOUND_RATIO_MARGIN) {
169             if (DEBUG) {
170                 Log.d(TAG, "Not same as mask shape because width != height. " + iconRatio);
171             }
172             return false;
173         }
174 
175         // Condition 2:
176         // Actual icon (white) and the fitted shape (e.g., circle)(red) XOR operation
177         // should generate transparent image, if the actual icon is equivalent to the shape.
178 
179         // Fit the shape within the icon's bounding box
180         mMatrix.reset();
181         mMatrix.setScale(mBounds.width(), mBounds.height());
182         mMatrix.postTranslate(mBounds.left, mBounds.top);
183         maskPath.transform(mMatrix, mShapePath);
184 
185         // XOR operation
186         mCanvas.drawPath(mShapePath, mPaintMaskShape);
187 
188         // DST_OUT operation around the mask path outline
189         mCanvas.drawPath(mShapePath, mPaintMaskShapeOutline);
190 
191         // Check if the result is almost transparent
192         return isTransparentBitmap();
193     }
194 
195     /**
196      * Used to determine if certain the bitmap is transparent.
197      */
isTransparentBitmap()198     private boolean isTransparentBitmap() {
199         ByteBuffer buffer = ByteBuffer.wrap(mPixels);
200         buffer.rewind();
201         mBitmap.copyPixelsToBuffer(buffer);
202 
203         int y = mBounds.top;
204         // buffer position
205         int index = y * mMaxSize;
206         // buffer shift after every row, width of buffer = mMaxSize
207         int rowSizeDiff = mMaxSize - mBounds.right;
208 
209         int sum = 0;
210         for (; y < mBounds.bottom; y++) {
211             index += mBounds.left;
212             for (int x = mBounds.left; x < mBounds.right; x++) {
213                 if ((mPixels[index] & 0xFF) > MIN_VISIBLE_ALPHA) {
214                     sum++;
215                 }
216                 index++;
217             }
218             index += rowSizeDiff;
219         }
220 
221         float percentageDiffPixels = ((float) sum) / (mBounds.width() * mBounds.height());
222         return percentageDiffPixels < PIXEL_DIFF_PERCENTAGE_THRESHOLD;
223     }
224 
225     /**
226      * Returns the amount by which the {@param d} should be scaled (in both dimensions) so that it
227      * matches the design guidelines for a launcher icon.
228      *
229      * We first calculate the convex hull of the visible portion of the icon.
230      * This hull then compared with the bounding rectangle of the hull to find how closely it
231      * resembles a circle and a square, by comparing the ratio of the areas. Note that this is not an
232      * ideal solution but it gives satisfactory result without affecting the performance.
233      *
234      * This closeness is used to determine the ratio of hull area to the full icon size.
235      * Refer {@link #MAX_CIRCLE_AREA_FACTOR} and {@link #MAX_SQUARE_AREA_FACTOR}
236      *
237      * @param outBounds optional rect to receive the fraction distance from each edge.
238      */
getScale(@onNull Drawable d, @Nullable RectF outBounds, @Nullable Path path, @Nullable boolean[] outMaskShape)239     public synchronized float getScale(@NonNull Drawable d, @Nullable RectF outBounds,
240             @Nullable Path path, @Nullable boolean[] outMaskShape) {
241         if (BaseIconFactory.ATLEAST_OREO && d instanceof AdaptiveIconDrawable) {
242             if (mAdaptiveIconScale == SCALE_NOT_INITIALIZED) {
243                 mAdaptiveIconScale = normalizeAdaptiveIcon(d, mMaxSize, mAdaptiveIconBounds);
244             }
245             if (outBounds != null) {
246                 outBounds.set(mAdaptiveIconBounds);
247             }
248             return mAdaptiveIconScale;
249         }
250         int width = d.getIntrinsicWidth();
251         int height = d.getIntrinsicHeight();
252         if (width <= 0 || height <= 0) {
253             width = width <= 0 || width > mMaxSize ? mMaxSize : width;
254             height = height <= 0 || height > mMaxSize ? mMaxSize : height;
255         } else if (width > mMaxSize || height > mMaxSize) {
256             int max = Math.max(width, height);
257             width = mMaxSize * width / max;
258             height = mMaxSize * height / max;
259         }
260 
261         mBitmap.eraseColor(Color.TRANSPARENT);
262         d.setBounds(0, 0, width, height);
263         d.draw(mCanvas);
264 
265         ByteBuffer buffer = ByteBuffer.wrap(mPixels);
266         buffer.rewind();
267         mBitmap.copyPixelsToBuffer(buffer);
268 
269         // Overall bounds of the visible icon.
270         int topY = -1;
271         int bottomY = -1;
272         int leftX = mMaxSize + 1;
273         int rightX = -1;
274 
275         // Create border by going through all pixels one row at a time and for each row find
276         // the first and the last non-transparent pixel. Set those values to mLeftBorder and
277         // mRightBorder and use -1 if there are no visible pixel in the row.
278 
279         // buffer position
280         int index = 0;
281         // buffer shift after every row, width of buffer = mMaxSize
282         int rowSizeDiff = mMaxSize - width;
283         // first and last position for any row.
284         int firstX, lastX;
285 
286         for (int y = 0; y < height; y++) {
287             firstX = lastX = -1;
288             for (int x = 0; x < width; x++) {
289                 if ((mPixels[index] & 0xFF) > MIN_VISIBLE_ALPHA) {
290                     if (firstX == -1) {
291                         firstX = x;
292                     }
293                     lastX = x;
294                 }
295                 index++;
296             }
297             index += rowSizeDiff;
298 
299             mLeftBorder[y] = firstX;
300             mRightBorder[y] = lastX;
301 
302             // If there is at least one visible pixel, update the overall bounds.
303             if (firstX != -1) {
304                 bottomY = y;
305                 if (topY == -1) {
306                     topY = y;
307                 }
308 
309                 leftX = Math.min(leftX, firstX);
310                 rightX = Math.max(rightX, lastX);
311             }
312         }
313 
314         if (topY == -1 || rightX == -1) {
315             // No valid pixels found. Do not scale.
316             return 1;
317         }
318 
319         convertToConvexArray(mLeftBorder, 1, topY, bottomY);
320         convertToConvexArray(mRightBorder, -1, topY, bottomY);
321 
322         // Area of the convex hull
323         float area = 0;
324         for (int y = 0; y < height; y++) {
325             if (mLeftBorder[y] <= -1) {
326                 continue;
327             }
328             area += mRightBorder[y] - mLeftBorder[y] + 1;
329         }
330 
331         mBounds.left = leftX;
332         mBounds.right = rightX;
333 
334         mBounds.top = topY;
335         mBounds.bottom = bottomY;
336 
337         if (outBounds != null) {
338             outBounds.set(((float) mBounds.left) / width, ((float) mBounds.top) / height,
339                     1 - ((float) mBounds.right) / width,
340                     1 - ((float) mBounds.bottom) / height);
341         }
342         if (outMaskShape != null && mEnableShapeDetection && outMaskShape.length > 0) {
343             outMaskShape[0] = isShape(path);
344         }
345         // Area of the rectangle required to fit the convex hull
346         float rectArea = (bottomY + 1 - topY) * (rightX + 1 - leftX);
347         return getScale(area, rectArea, width * height);
348     }
349 
350     /**
351      * Modifies {@param xCoordinates} to represent a convex border. Fills in all missing values
352      * (except on either ends) with appropriate values.
353      * @param xCoordinates map of x coordinate per y.
354      * @param direction 1 for left border and -1 for right border.
355      * @param topY the first Y position (inclusive) with a valid value.
356      * @param bottomY the last Y position (inclusive) with a valid value.
357      */
convertToConvexArray( float[] xCoordinates, int direction, int topY, int bottomY)358     private static void convertToConvexArray(
359             float[] xCoordinates, int direction, int topY, int bottomY) {
360         int total = xCoordinates.length;
361         // The tangent at each pixel.
362         float[] angles = new float[total - 1];
363 
364         int first = topY; // First valid y coordinate
365         int last = -1;    // Last valid y coordinate which didn't have a missing value
366 
367         float lastAngle = Float.MAX_VALUE;
368 
369         for (int i = topY + 1; i <= bottomY; i++) {
370             if (xCoordinates[i] <= -1) {
371                 continue;
372             }
373             int start;
374 
375             if (lastAngle == Float.MAX_VALUE) {
376                 start = first;
377             } else {
378                 float currentAngle = (xCoordinates[i] - xCoordinates[last]) / (i - last);
379                 start = last;
380                 // If this position creates a concave angle, keep moving up until we find a
381                 // position which creates a convex angle.
382                 if ((currentAngle - lastAngle) * direction < 0) {
383                     while (start > first) {
384                         start --;
385                         currentAngle = (xCoordinates[i] - xCoordinates[start]) / (i - start);
386                         if ((currentAngle - angles[start]) * direction >= 0) {
387                             break;
388                         }
389                     }
390                 }
391             }
392 
393             // Reset from last check
394             lastAngle = (xCoordinates[i] - xCoordinates[start]) / (i - start);
395             // Update all the points from start.
396             for (int j = start; j < i; j++) {
397                 angles[j] = lastAngle;
398                 xCoordinates[j] = xCoordinates[start] + lastAngle * (j - start);
399             }
400             last = i;
401         }
402     }
403 
404     /**
405      * @return The diameter of the normalized circle that fits inside of the square (size x size).
406      */
getNormalizedCircleSize(int size)407     public static int getNormalizedCircleSize(int size) {
408         float area = size * size * MAX_CIRCLE_AREA_FACTOR;
409         return (int) Math.round(Math.sqrt((4 * area) / Math.PI));
410     }
411 }
412