blob: 6765d0b67c31081541658f6418b0a545e8fb44e2 [file] [log] [blame]
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package android.app;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.SystemApi;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Parcel;
import android.os.Parcelable;
import android.util.Log;
import android.util.Size;
import com.android.internal.graphics.ColorUtils;
import com.android.internal.graphics.palette.Palette;
import com.android.internal.graphics.palette.VariationalKMeansQuantizer;
import com.android.internal.util.ContrastColorUtil;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Provides information about the colors of a wallpaper.
* <p>
* Exposes the 3 most visually representative colors of a wallpaper. Can be either
* {@link WallpaperColors#getPrimaryColor()}, {@link WallpaperColors#getSecondaryColor()}
* or {@link WallpaperColors#getTertiaryColor()}.
*/
public final class WallpaperColors implements Parcelable {
private static final boolean DEBUG_DARK_PIXELS = false;
/**
* Specifies that dark text is preferred over the current wallpaper for best presentation.
* <p>
* eg. A launcher may set its text color to black if this flag is specified.
* @hide
*/
@SystemApi
public static final int HINT_SUPPORTS_DARK_TEXT = 1 << 0;
/**
* Specifies that dark theme is preferred over the current wallpaper for best presentation.
* <p>
* eg. A launcher may set its drawer color to black if this flag is specified.
* @hide
*/
@SystemApi
public static final int HINT_SUPPORTS_DARK_THEME = 1 << 1;
/**
* Specifies that this object was generated by extracting colors from a bitmap.
* @hide
*/
public static final int HINT_FROM_BITMAP = 1 << 2;
// Maximum size that a bitmap can have to keep our calculations sane
private static final int MAX_BITMAP_SIZE = 112;
// Even though we have a maximum size, we'll mainly match bitmap sizes
// using the area instead. This way our comparisons are aspect ratio independent.
private static final int MAX_WALLPAPER_EXTRACTION_AREA = MAX_BITMAP_SIZE * MAX_BITMAP_SIZE;
// When extracting the main colors, only consider colors
// present in at least MIN_COLOR_OCCURRENCE of the image
private static final float MIN_COLOR_OCCURRENCE = 0.05f;
// Decides when dark theme is optimal for this wallpaper
private static final float DARK_THEME_MEAN_LUMINANCE = 0.25f;
// Minimum mean luminosity that an image needs to have to support dark text
private static final float BRIGHT_IMAGE_MEAN_LUMINANCE = 0.75f;
// We also check if the image has dark pixels in it,
// to avoid bright images with some dark spots.
private static final float DARK_PIXEL_CONTRAST = 6f;
private static final float MAX_DARK_AREA = 0.025f;
private final ArrayList<Color> mMainColors;
private int mColorHints;
public WallpaperColors(Parcel parcel) {
mMainColors = new ArrayList<>();
final int count = parcel.readInt();
for (int i = 0; i < count; i++) {
final int colorInt = parcel.readInt();
Color color = Color.valueOf(colorInt);
mMainColors.add(color);
}
mColorHints = parcel.readInt();
}
/**
* Constructs {@link WallpaperColors} from a drawable.
* <p>
* Main colors will be extracted from the drawable.
*
* @param drawable Source where to extract from.
*/
public static WallpaperColors fromDrawable(Drawable drawable) {
if (drawable == null) {
throw new IllegalArgumentException("Drawable cannot be null");
}
Rect initialBounds = drawable.copyBounds();
int width = drawable.getIntrinsicWidth();
int height = drawable.getIntrinsicHeight();
// Some drawables do not have intrinsic dimensions
if (width <= 0 || height <= 0) {
width = MAX_BITMAP_SIZE;
height = MAX_BITMAP_SIZE;
}
Size optimalSize = calculateOptimalSize(width, height);
Bitmap bitmap = Bitmap.createBitmap(optimalSize.getWidth(), optimalSize.getHeight(),
Bitmap.Config.ARGB_8888);
final Canvas bmpCanvas = new Canvas(bitmap);
drawable.setBounds(0, 0, bitmap.getWidth(), bitmap.getHeight());
drawable.draw(bmpCanvas);
final WallpaperColors colors = WallpaperColors.fromBitmap(bitmap);
bitmap.recycle();
drawable.setBounds(initialBounds);
return colors;
}
/**
* Constructs {@link WallpaperColors} from a bitmap.
* <p>
* Main colors will be extracted from the bitmap.
*
* @param bitmap Source where to extract from.
*/
public static WallpaperColors fromBitmap(@NonNull Bitmap bitmap) {
if (bitmap == null) {
throw new IllegalArgumentException("Bitmap can't be null");
}
final int bitmapArea = bitmap.getWidth() * bitmap.getHeight();
boolean shouldRecycle = false;
if (bitmapArea > MAX_WALLPAPER_EXTRACTION_AREA) {
shouldRecycle = true;
Size optimalSize = calculateOptimalSize(bitmap.getWidth(), bitmap.getHeight());
bitmap = Bitmap.createScaledBitmap(bitmap, optimalSize.getWidth(),
optimalSize.getHeight(), true /* filter */);
}
final Palette palette = Palette
.from(bitmap)
.setQuantizer(new VariationalKMeansQuantizer())
.maximumColorCount(5)
.clearFilters()
.resizeBitmapArea(MAX_WALLPAPER_EXTRACTION_AREA)
.generate();
// Remove insignificant colors and sort swatches by population
final ArrayList<Palette.Swatch> swatches = new ArrayList<>(palette.getSwatches());
final float minColorArea = bitmap.getWidth() * bitmap.getHeight() * MIN_COLOR_OCCURRENCE;
swatches.removeIf(s -> s.getPopulation() < minColorArea);
swatches.sort((a, b) -> b.getPopulation() - a.getPopulation());
final int swatchesSize = swatches.size();
Color primary = null, secondary = null, tertiary = null;
swatchLoop:
for (int i = 0; i < swatchesSize; i++) {
Color color = Color.valueOf(swatches.get(i).getRgb());
switch (i) {
case 0:
primary = color;
break;
case 1:
secondary = color;
break;
case 2:
tertiary = color;
break;
default:
// out of bounds
break swatchLoop;
}
}
int hints = calculateDarkHints(bitmap);
if (shouldRecycle) {
bitmap.recycle();
}
return new WallpaperColors(primary, secondary, tertiary, HINT_FROM_BITMAP | hints);
}
/**
* Constructs a new object from three colors.
*
* @param primaryColor Primary color.
* @param secondaryColor Secondary color.
* @param tertiaryColor Tertiary color.
* @see WallpaperColors#fromBitmap(Bitmap)
* @see WallpaperColors#fromDrawable(Drawable)
*/
public WallpaperColors(@NonNull Color primaryColor, @Nullable Color secondaryColor,
@Nullable Color tertiaryColor) {
this(primaryColor, secondaryColor, tertiaryColor, 0);
}
/**
* Constructs a new object from three colors, where hints can be specified.
*
* @param primaryColor Primary color.
* @param secondaryColor Secondary color.
* @param tertiaryColor Tertiary color.
* @param colorHints A combination of WallpaperColor hints.
* @see WallpaperColors#HINT_SUPPORTS_DARK_TEXT
* @see WallpaperColors#fromBitmap(Bitmap)
* @see WallpaperColors#fromDrawable(Drawable)
* @hide
*/
@SystemApi
public WallpaperColors(@NonNull Color primaryColor, @Nullable Color secondaryColor,
@Nullable Color tertiaryColor, int colorHints) {
if (primaryColor == null) {
throw new IllegalArgumentException("Primary color should never be null.");
}
mMainColors = new ArrayList<>(3);
mMainColors.add(primaryColor);
if (secondaryColor != null) {
mMainColors.add(secondaryColor);
}
if (tertiaryColor != null) {
if (secondaryColor == null) {
throw new IllegalArgumentException("tertiaryColor can't be specified when "
+ "secondaryColor is null");
}
mMainColors.add(tertiaryColor);
}
mColorHints = colorHints;
}
public static final @android.annotation.NonNull Creator<WallpaperColors> CREATOR = new Creator<WallpaperColors>() {
@Override
public WallpaperColors createFromParcel(Parcel in) {
return new WallpaperColors(in);
}
@Override
public WallpaperColors[] newArray(int size) {
return new WallpaperColors[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
List<Color> mainColors = getMainColors();
int count = mainColors.size();
dest.writeInt(count);
for (int i = 0; i < count; i++) {
Color color = mainColors.get(i);
dest.writeInt(color.toArgb());
}
dest.writeInt(mColorHints);
}
/**
* Gets the most visually representative color of the wallpaper.
* "Visually representative" means easily noticeable in the image,
* probably happening at high frequency.
*
* @return A color.
*/
public @NonNull Color getPrimaryColor() {
return mMainColors.get(0);
}
/**
* Gets the second most preeminent color of the wallpaper. Can be null.
*
* @return A color, may be null.
*/
public @Nullable Color getSecondaryColor() {
return mMainColors.size() < 2 ? null : mMainColors.get(1);
}
/**
* Gets the third most preeminent color of the wallpaper. Can be null.
*
* @return A color, may be null.
*/
public @Nullable Color getTertiaryColor() {
return mMainColors.size() < 3 ? null : mMainColors.get(2);
}
/**
* List of most preeminent colors, sorted by importance.
*
* @return List of colors.
* @hide
*/
public @NonNull List<Color> getMainColors() {
return Collections.unmodifiableList(mMainColors);
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) {
return false;
}
WallpaperColors other = (WallpaperColors) o;
return mMainColors.equals(other.mMainColors)
&& mColorHints == other.mColorHints;
}
@Override
public int hashCode() {
return 31 * mMainColors.hashCode() + mColorHints;
}
/**
* Combination of WallpaperColor hints.
*
* @see WallpaperColors#HINT_SUPPORTS_DARK_TEXT
* @return True if dark text is supported.
* @hide
*/
@SystemApi
public int getColorHints() {
return mColorHints;
}
/**
* @param colorHints Combination of WallpaperColors hints.
* @see WallpaperColors#HINT_SUPPORTS_DARK_TEXT
* @hide
*/
public void setColorHints(int colorHints) {
mColorHints = colorHints;
}
/**
* Checks if image is bright and clean enough to support light text.
*
* @param source What to read.
* @return Whether image supports dark text or not.
*/
private static int calculateDarkHints(Bitmap source) {
if (source == null) {
return 0;
}
int[] pixels = new int[source.getWidth() * source.getHeight()];
double totalLuminance = 0;
final int maxDarkPixels = (int) (pixels.length * MAX_DARK_AREA);
int darkPixels = 0;
source.getPixels(pixels, 0 /* offset */, source.getWidth(), 0 /* x */, 0 /* y */,
source.getWidth(), source.getHeight());
// This bitmap was already resized to fit the maximum allowed area.
// Let's just loop through the pixels, no sweat!
float[] tmpHsl = new float[3];
for (int i = 0; i < pixels.length; i++) {
ColorUtils.colorToHSL(pixels[i], tmpHsl);
final float luminance = tmpHsl[2];
final int alpha = Color.alpha(pixels[i]);
// Make sure we don't have a dark pixel mass that will
// make text illegible.
final boolean satisfiesTextContrast = ContrastColorUtil
.calculateContrast(pixels[i], Color.BLACK) > DARK_PIXEL_CONTRAST;
if (!satisfiesTextContrast && alpha != 0) {
darkPixels++;
if (DEBUG_DARK_PIXELS) {
pixels[i] = Color.RED;
}
}
totalLuminance += luminance;
}
int hints = 0;
double meanLuminance = totalLuminance / pixels.length;
if (meanLuminance > BRIGHT_IMAGE_MEAN_LUMINANCE && darkPixels < maxDarkPixels) {
hints |= HINT_SUPPORTS_DARK_TEXT;
}
if (meanLuminance < DARK_THEME_MEAN_LUMINANCE) {
hints |= HINT_SUPPORTS_DARK_THEME;
}
if (DEBUG_DARK_PIXELS) {
try (FileOutputStream out = new FileOutputStream("/data/pixels.png")) {
source.setPixels(pixels, 0, source.getWidth(), 0, 0, source.getWidth(),
source.getHeight());
source.compress(Bitmap.CompressFormat.PNG, 100, out);
} catch (Exception e) {
e.printStackTrace();
}
Log.d("WallpaperColors", "l: " + meanLuminance + ", d: " + darkPixels +
" maxD: " + maxDarkPixels + " numPixels: " + pixels.length);
}
return hints;
}
private static Size calculateOptimalSize(int width, int height) {
// Calculate how big the bitmap needs to be.
// This avoids unnecessary processing and allocation inside Palette.
final int requestedArea = width * height;
double scale = 1;
if (requestedArea > MAX_WALLPAPER_EXTRACTION_AREA) {
scale = Math.sqrt(MAX_WALLPAPER_EXTRACTION_AREA / (double) requestedArea);
}
int newWidth = (int) (width * scale);
int newHeight = (int) (height * scale);
// Dealing with edge cases of the drawable being too wide or too tall.
// Width or height would end up being 0, in this case we'll set it to 1.
if (newWidth == 0) {
newWidth = 1;
}
if (newHeight == 0) {
newHeight = 1;
}
return new Size(newWidth, newHeight);
}
@Override
public String toString() {
final StringBuilder colors = new StringBuilder();
for (int i = 0; i < mMainColors.size(); i++) {
colors.append(Integer.toHexString(mMainColors.get(i).toArgb())).append(" ");
}
return "[WallpaperColors: " + colors.toString() + "h: " + mColorHints + "]";
}
}