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