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