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