// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.components.webapps;
import android.app.ActivityManager;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.AdaptiveIconDrawable;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.os.Build;
import androidx.annotation.RequiresApi;
import org.jni_zero.CalledByNative;
import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.components.browser_ui.widget.RoundedIconGenerator;
import org.chromium.ui.base.ViewUtils;
import org.chromium.url.GURL;
/**
* This class contains functions related to adding shortcuts to the Android Home screen. These
* shortcuts are used to either open a page in the main browser or open a web app.
*/
public class WebappsIconUtils {
private static final String TAG = "WebappsIconUtils";
// These sizes are from the Material spec for icons:
// https://www.google.com/design/spec/style/icons.html#icons-product-icons
private static final float MAX_INNER_SIZE_RATIO = 1.25f;
private static final float ICON_PADDING_RATIO = 2.0f / 44.0f;
private static final float ICON_CORNER_RADIUS_RATIO = 1.0f / 16.0f;
private static final float GENERATED_ICON_PADDING_RATIO = 1.0f / 12.0f;
private static final float GENERATED_ICON_FONT_SIZE_RATIO = 1.0f / 3.0f;
// Constants for figuring out the amount of padding required to transform a web manifest
// maskable icon to an Android adaptive icon.
//
// The web standard for maskable icons specifies a larger safe zone inside the icon
// than Android adaptive icons define. Therefore we need to pad the image so that
// the maskable icon's safe zone is reduced to the dimensions expected by Android. See
// https://github.com/w3c/manifest/issues/555#issuecomment-404097653.
//
// The *_RATIO variables give the diameter of the safe zone divided by the width of the icon.
// Sources:
// - https://www.w3.org/TR/appmanifest/#icon-masks
// - https://medium.com/google-design/designing-adaptive-icons-515af294c783
//
// We subtract 1 from the scaling factor to give the amount we need to increase by, then divide
// it by two to get the amount of padding that we will add to both sides.
private static final float MASKABLE_SAFE_ZONE_RATIO = 4.0f / 5.0f;
private static final float ADAPTIVE_SAFE_ZONE_RATIO = 66.0f / 108.0f;
private static final float MASKABLE_TO_ADAPTIVE_SCALING_FACTOR =
MASKABLE_SAFE_ZONE_RATIO / ADAPTIVE_SAFE_ZONE_RATIO;
private static final float MASKABLE_ICON_PADDING_RATIO =
(MASKABLE_TO_ADAPTIVE_SCALING_FACTOR - 1.0f) / 2.0f;
private static final float SHORTCUT_ICON_IDEAL_SIZE_DP = 48;
@RequiresApi(Build.VERSION_CODES.O)
@CalledByNative
public static Bitmap generateAdaptiveIconBitmap(Bitmap bitmap) {
Bitmap padded = createHomeScreenIconFromWebIcon(bitmap, true);
Icon adaptiveIcon = Icon.createWithAdaptiveBitmap(padded);
AdaptiveIconDrawable adaptiveIconDrawable =
(AdaptiveIconDrawable)
adaptiveIcon.loadDrawable(ContextUtils.getApplicationContext());
Bitmap result =
Bitmap.createBitmap(
adaptiveIconDrawable.getIntrinsicWidth(),
adaptiveIconDrawable.getIntrinsicHeight(),
Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(result);
adaptiveIconDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
adaptiveIconDrawable.draw(canvas);
return result;
}
/**
* Adapts a website's icon (e.g. favicon or touch icon) to make it suitable for the home screen.
* This involves adding padding if the icon is a full sized square.
*
* @param webIcon The website's favicon or touch icon.
* @param maskable Whether the icon is suitable for creating an adaptive icon.
* @return Bitmap Either the touch-icon or the newly created favicon.
*/
public static Bitmap createHomeScreenIconFromWebIcon(Bitmap webIcon, boolean maskable) {
// getLauncherLargeIconSize() is just a guess at the launcher icon size, and is often
// wrong -- the launcher can show icons at any size it pleases. Instead of resizing the
// icon to the supposed launcher size and then having the launcher resize the icon again,
// just leave the icon at its original size and let the launcher do a single rescaling.
// Unless the icon is much too big; then scale it down here too.
Context context = ContextUtils.getApplicationContext();
ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
int maxInnerSize = Math.round(am.getLauncherLargeIconSize() * MAX_INNER_SIZE_RATIO);
int innerSize = Math.min(maxInnerSize, Math.max(webIcon.getWidth(), webIcon.getHeight()));
Rect innerBounds = new Rect(0, 0, innerSize, innerSize);
int padding = 0;
if (maskable) {
// See comments for MASKABLE_ICON_PADDING_RATIO.
padding = Math.round(MASKABLE_ICON_PADDING_RATIO * innerSize);
} else if (shouldPadIcon(webIcon)) {
// Draw the icon with padding around it if all four corners are not transparent.
padding = Math.round(ICON_PADDING_RATIO * innerSize);
}
int outerSize = 2 * padding + innerSize;
innerBounds.offset(padding, padding);
Bitmap bitmap;
try {
bitmap = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888);
} catch (OutOfMemoryError e) {
Log.e(TAG, "OutOfMemoryError while creating bitmap for home screen icon.");
return webIcon;
}
Canvas canvas = new Canvas(bitmap);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setFilterBitmap(true);
canvas.drawBitmap(webIcon, null, innerBounds, paint);
return bitmap;
}
/**
* Returns the ideal size for an icon representing a web app. This size is used on app banners,
* the Android Home screen, and in Android's recent tasks list, among other places.
*
* @param context Context to pull resources from.
* @return the dimensions in pixels which the icon should have.
*/
public static int getIdealHomescreenIconSizeInPx(Context context) {
return getSizeFromResourceInPx(context, R.dimen.webapp_home_screen_icon_size);
}
/**
* Returns the minimum size for an icon representing a web app. This size is used on app
* banners, the Android Home screen, and in Android's recent tasks list, among other places.
*
* @param context Context to pull resources from.
* @return the lower bound of the size which the icon should have in pixels.
*/
public static int getMinimumHomescreenIconSizeInPx(Context context) {
float sizeInPx = context.getResources().getDimension(R.dimen.webapp_home_screen_icon_size);
float density = context.getResources().getDisplayMetrics().density;
float idealIconSizeInDp = sizeInPx / density;
return Math.round(idealIconSizeInDp * (density - 1));
}
/**
* Returns the ideal size for an image displayed on a web app's splash screen.
*
* @param context Context to pull resources from.
* @return the dimensions in pixels which the image should have.
*/
public static int getIdealSplashImageSizeInPx(Context context) {
return getSizeFromResourceInPx(context, R.dimen.webapp_splash_image_size_ideal);
}
/**
* Returns the minimum size for an image displayed on a web app's splash screen.
*
* @param context Context to pull resources from.
* @return the lower bound of the size which the image should have in pixels.
*/
public static int getMinimumSplashImageSizeInPx(Context context) {
return getSizeFromResourceInPx(context, R.dimen.webapp_splash_image_size_minimum);
}
/**
* Returns the ideal size for a monochrome icon of a WebAPK.
*
* @param context Context to pull resources from.
* @return the dimensions in pixels which the monochrome icon should have.
*/
public static int getIdealMonochromeIconSizeInPx(Context context) {
return getSizeFromResourceInPx(context, R.dimen.webapk_monochrome_icon_size);
}
/**
* Returns the ideal size for an adaptive launcher icon of a WebAPK.
*
* @param context Context to pull resources from.
* @return the dimensions in pixels which the adaptive launcher icon should have.
*/
public static int getIdealAdaptiveLauncherIconSizeInPx(Context context) {
return getSizeFromResourceInPx(context, R.dimen.webapk_adaptive_icon_size);
}
/**
* Returns the ideal size for prompt UI icon corner radius.
*
* @return the dimensions in pixels which the prompt UI should use as the corner radius.
*/
@CalledByNative
public static int getIdealIconCornerRadiusPxForPromptUI() {
Context context = ContextUtils.getApplicationContext();
return context.getResources().getDimensionPixelSize(R.dimen.webapk_prompt_ui_icon_radius);
}
/** Check the running Android version supports adaptive icon (i.e. API level >= 26) */
public static boolean doesAndroidSupportMaskableIcons() {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O;
}
/**
* Returns whether the given icon matches the size requirements to be used on the home screen.
*
* @param width Icon width, in pixels.
* @param height Icon height, in pixels.
* @return whether the given icon matches the size requirements to be used on the home screen.
*/
@CalledByNative
public static boolean isIconLargeEnoughForLauncher(int width, int height) {
Context context = ContextUtils.getApplicationContext();
ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
final int minimalSize = am.getLauncherLargeIconSize() / 2;
return width >= minimalSize && height >= minimalSize;
}
/**
* Generates a generic icon to be used in the launcher. This is just a rounded rectangle with a
* letter in the middle taken from the website's domain name.
*
* @param url URL of the shortcut.
* @param red Red component of the dominant icon color.
* @param green Green component of the dominant icon color.
* @param blue Blue component of the dominant icon color.
* @return Bitmap Either the touch-icon or the newly created favicon.
*/
@CalledByNative
public static Bitmap generateHomeScreenIcon(GURL url, int red, int green, int blue) {
Context context = ContextUtils.getApplicationContext();
ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
final int outerSize = am.getLauncherLargeIconSize();
final int iconDensity = am.getLauncherLargeIconDensity();
Bitmap bitmap = null;
try {
bitmap = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888);
} catch (OutOfMemoryError e) {
Log.w(TAG, "OutOfMemoryError while trying to draw bitmap on canvas.");
return null;
}
Canvas canvas = new Canvas(bitmap);
// Draw the drop shadow.
int padding = (int) (GENERATED_ICON_PADDING_RATIO * outerSize);
Rect outerBounds = new Rect(0, 0, outerSize, outerSize);
Bitmap iconShadow =
getBitmapFromResourceId(context, R.mipmap.shortcut_icon_shadow, iconDensity);
Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG);
canvas.drawBitmap(iconShadow, null, outerBounds, paint);
// Draw the rounded rectangle and letter.
int innerSize = outerSize - 2 * padding;
int cornerRadius = Math.round(ICON_CORNER_RADIUS_RATIO * outerSize);
int fontSize = Math.round(GENERATED_ICON_FONT_SIZE_RATIO * outerSize);
int color = Color.rgb(red, green, blue);
RoundedIconGenerator generator =
new RoundedIconGenerator(innerSize, innerSize, cornerRadius, color, fontSize);
Bitmap icon = generator.generateIconForUrl(url);
if (icon == null) return null; // Bookmark URL does not have a domain.
canvas.drawBitmap(icon, padding, padding, null);
return bitmap;
}
/**
* Returns an array of sizes which describe the ideal size and minimum size of the Home screen
* icon and the ideal and minimum sizes of the splash screen image in that order.
*/
@CalledByNative
private static int[] getIconSizes() {
Context context = ContextUtils.getApplicationContext();
// This ordering must be kept up to date with the C++ WebappsIconUtils.
return new int[] {
getIdealHomescreenIconSizeInPx(context),
getMinimumHomescreenIconSizeInPx(context),
getIdealSplashImageSizeInPx(context),
getMinimumSplashImageSizeInPx(context),
getIdealMonochromeIconSizeInPx(context),
getIdealAdaptiveLauncherIconSizeInPx(context),
ViewUtils.dpToPx(context, SHORTCUT_ICON_IDEAL_SIZE_DP)
};
}
/**
* Returns true if we should add padding to this icon. We use a heuristic that if the pixels in
* all four corners of the icon are not transparent, we assume the icon is square and maximally
* sized, i.e. in need of padding. Otherwise, no padding is added.
*/
private static boolean shouldPadIcon(Bitmap icon) {
int maxX = icon.getWidth() - 1;
int maxY = icon.getHeight() - 1;
if ((Color.alpha(icon.getPixel(0, 0)) != 0)
&& (Color.alpha(icon.getPixel(maxX, maxY)) != 0)
&& (Color.alpha(icon.getPixel(0, maxY)) != 0)
&& (Color.alpha(icon.getPixel(maxX, 0)) != 0)) {
return true;
}
return false;
}
private static int getSizeFromResourceInPx(Context context, int resource) {
return Math.round(context.getResources().getDimension(resource));
}
private static Bitmap getBitmapFromResourceId(Context context, int id, int density) {
Drawable drawable =
ApiCompatibilityUtils.getDrawableForDensity(context.getResources(), id, density);
if (drawable instanceof BitmapDrawable) {
BitmapDrawable bd = (BitmapDrawable) drawable;
return bd.getBitmap();
}
assert false : "The drawable was not a bitmap drawable as expected";
return null;
}
}