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 import com.android.launcher3.LauncherAppState; 36 import com.android.launcher3.Utilities; 37 import java.io.File; 38 import java.io.FileOutputStream; 39 import java.nio.ByteBuffer; 40 import java.util.Random; 41 42 public class IconNormalizer { 43 44 private static final String TAG = "IconNormalizer"; 45 private static final boolean DEBUG = false; 46 // Ratio of icon visible area to full icon size for a square shaped icon 47 private static final float MAX_SQUARE_AREA_FACTOR = 375.0f / 576; 48 // Ratio of icon visible area to full icon size for a circular shaped icon 49 private static final float MAX_CIRCLE_AREA_FACTOR = 380.0f / 576; 50 51 private static final float CIRCLE_AREA_BY_RECT = (float) Math.PI / 4; 52 53 // Slope used to calculate icon visible area to full icon size for any generic shaped icon. 54 private static final float LINEAR_SCALE_SLOPE = 55 (MAX_CIRCLE_AREA_FACTOR - MAX_SQUARE_AREA_FACTOR) / (1 - CIRCLE_AREA_BY_RECT); 56 57 private static final int MIN_VISIBLE_ALPHA = 40; 58 59 // Shape detection related constants 60 private static final float BOUND_RATIO_MARGIN = .05f; 61 private static final float PIXEL_DIFF_PERCENTAGE_THRESHOLD = 0.005f; 62 private static final float SCALE_NOT_INITIALIZED = 0; 63 64 private static final Object LOCK = new Object(); 65 private static IconNormalizer sIconNormalizer; 66 67 private final int mMaxSize; 68 private final Bitmap mBitmap; 69 private final Bitmap mBitmapARGB; 70 private final Canvas mCanvas; 71 private final Paint mPaintMaskShape; 72 private final Paint mPaintMaskShapeOutline; 73 private final byte[] mPixels; 74 private final int[] mPixelsARGB; 75 76 private final Rect mAdaptiveIconBounds; 77 private float mAdaptiveIconScale; 78 79 // for each y, stores the position of the leftmost x and the rightmost x 80 private final float[] mLeftBorder; 81 private final float[] mRightBorder; 82 private final Rect mBounds; 83 private final Matrix mMatrix; 84 85 private final Paint mPaintIcon; 86 private final Canvas mCanvasARGB; 87 88 private final File mDir; 89 private int mFileId; 90 private final Random mRandom; 91 92 private IconNormalizer(Context context) { 93 // Use twice the icon size as maximum size to avoid scaling down twice. 94 mMaxSize = LauncherAppState.getIDP(context).iconBitmapSize * 2; 95 mBitmap = Bitmap.createBitmap(mMaxSize, mMaxSize, Bitmap.Config.ALPHA_8); 96 mCanvas = new Canvas(mBitmap); 97 mPixels = new byte[mMaxSize * mMaxSize]; 98 mPixelsARGB = new int[mMaxSize * mMaxSize]; 99 mLeftBorder = new float[mMaxSize]; 100 mRightBorder = new float[mMaxSize]; 101 mBounds = new Rect(); 102 mAdaptiveIconBounds = new Rect(); 103 104 // Needed for isShape() method 105 mBitmapARGB = Bitmap.createBitmap(mMaxSize, mMaxSize, Bitmap.Config.ARGB_8888); 106 mCanvasARGB = new Canvas(mBitmapARGB); 107 108 mPaintIcon = new Paint(); 109 mPaintIcon.setColor(Color.WHITE); 110 111 mPaintMaskShape = new Paint(); 112 mPaintMaskShape.setColor(Color.RED); 113 mPaintMaskShape.setStyle(Paint.Style.FILL); 114 mPaintMaskShape.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.XOR)); 115 116 mPaintMaskShapeOutline = new Paint(); 117 mPaintMaskShapeOutline.setStrokeWidth(2 * context.getResources().getDisplayMetrics().density); 118 mPaintMaskShapeOutline.setStyle(Paint.Style.STROKE); 119 mPaintMaskShapeOutline.setColor(Color.BLACK); 120 mPaintMaskShapeOutline.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); 121 122 mMatrix = new Matrix(); 123 mAdaptiveIconScale = SCALE_NOT_INITIALIZED; 124 125 mDir = context.getExternalFilesDir(null); 126 mRandom = new Random(); 127 } 128 129 /** 130 * Returns if the shape of the icon is same as the path. 131 * For this method to work, the shape path bounds should be in [0,1]x[0,1] bounds. 132 */ 133 private boolean isShape(Path maskPath) { 134 // Condition1: 135 // If width and height of the path not close to a square, then the icon shape is 136 // not same as the mask shape. 137 float iconRatio = ((float) mBounds.width()) / mBounds.height(); 138 if (Math.abs(iconRatio - 1) > BOUND_RATIO_MARGIN) { 139 if (DEBUG) { 140 Log.d(TAG, "Not same as mask shape because width != height. " + iconRatio); 141 } 142 return false; 143 } 144 145 // Condition 2: 146 // Actual icon (white) and the fitted shape (e.g., circle)(red) XOR operation 147 // should generate transparent image, if the actual icon is equivalent to the shape. 148 mFileId = mRandom.nextInt(); 149 mBitmapARGB.eraseColor(Color.TRANSPARENT); 150 mCanvasARGB.drawBitmap(mBitmap, 0, 0, mPaintIcon); 151 152 if (DEBUG) { 153 final File beforeFile = new File(mDir, "isShape" + mFileId + "_before.png"); 154 try { 155 mBitmapARGB.compress(Bitmap.CompressFormat.PNG, 100, 156 new FileOutputStream(beforeFile)); 157 } catch (Exception e) {} 158 } 159 160 // Fit the shape within the icon's bounding box 161 mMatrix.reset(); 162 mMatrix.setScale(mBounds.width(), mBounds.height()); 163 mMatrix.postTranslate(mBounds.left, mBounds.top); 164 maskPath.transform(mMatrix); 165 166 // XOR operation 167 mCanvasARGB.drawPath(maskPath, mPaintMaskShape); 168 169 // DST_OUT operation around the mask path outline 170 mCanvasARGB.drawPath(maskPath, mPaintMaskShapeOutline); 171 172 boolean isTrans = isTransparentBitmap(mBitmapARGB); 173 if (DEBUG) { 174 final File afterFile = new File(mDir, 175 "isShape" + mFileId + "_after_" + isTrans + ".png"); 176 try { 177 mBitmapARGB.compress(Bitmap.CompressFormat.PNG, 100, 178 new FileOutputStream(afterFile)); 179 } catch (Exception e) {} 180 } 181 182 // Check if the result is almost transparent 183 if (!isTrans) { 184 if (DEBUG) { 185 Log.d(TAG, "Not same as mask shape"); 186 } 187 return false; 188 } 189 return true; 190 } 191 192 /** 193 * Used to determine if certain the bitmap is transparent. 194 */ 195 private boolean isTransparentBitmap(Bitmap bitmap) { 196 int w = mBounds.width(); 197 int h = mBounds.height(); 198 bitmap.getPixels(mPixelsARGB, 0 /* the first index to write into the array */, 199 w /* stride */, 200 mBounds.left, mBounds.top, 201 w, h); 202 int sum = 0; 203 for (int i = 0; i < w * h; i++) { 204 if(Color.alpha(mPixelsARGB[i]) > MIN_VISIBLE_ALPHA) { 205 sum++; 206 } 207 } 208 float percentageDiffPixels = ((float) sum) / (mBounds.width() * mBounds.height()); 209 boolean transparentImage = percentageDiffPixels < PIXEL_DIFF_PERCENTAGE_THRESHOLD; 210 if (DEBUG) { 211 Log.d(TAG, 212 "Total # pixel that is different (id=" + mFileId + "):" + percentageDiffPixels 213 + "=" + 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.ATLEAST_OREO && 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.ATLEAST_OREO && 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