WallpaperColors.java revision b5e5053ebc442ced1ad702f551919bc533bee164
1/*
2 * Copyright (C) 2017 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
17package android.app;
18
19import android.annotation.NonNull;
20import android.annotation.Nullable;
21import android.graphics.Bitmap;
22import android.graphics.Canvas;
23import android.graphics.Color;
24import android.graphics.drawable.Drawable;
25import android.os.Parcel;
26import android.os.Parcelable;
27import android.util.Size;
28
29import com.android.internal.graphics.ColorUtils;
30import com.android.internal.graphics.palette.Palette;
31import com.android.internal.graphics.palette.VariationalKMeansQuantizer;
32
33import java.util.ArrayList;
34import java.util.Collections;
35import java.util.List;
36
37/**
38 * Provides information about the colors of a wallpaper.
39 * <p>
40 * Exposes the 3 most visually representative colors of a wallpaper. Can be either
41 * {@link WallpaperColors#getPrimaryColor()}, {@link WallpaperColors#getSecondaryColor()}
42 * or {@link WallpaperColors#getTertiaryColor()}.
43 */
44public final class WallpaperColors implements Parcelable {
45
46    /**
47     * Specifies that dark text is preferred over the current wallpaper for best presentation.
48     * <p>
49     * eg. A launcher may set its text color to black if this flag is specified.
50     * @hide
51     */
52    public static final int HINT_SUPPORTS_DARK_TEXT = 1 << 0;
53
54    /**
55     * Specifies that dark theme is preferred over the current wallpaper for best presentation.
56     * <p>
57     * eg. A launcher may set its drawer color to black if this flag is specified.
58     * @hide
59     */
60    public static final int HINT_SUPPORTS_DARK_THEME = 1 << 1;
61
62    /**
63     * Specifies that this object was generated by extracting colors from a bitmap.
64     * @hide
65     */
66    public static final int HINT_FROM_BITMAP = 1 << 2;
67
68    // Maximum size that a bitmap can have to keep our calculations sane
69    private static final int MAX_BITMAP_SIZE = 112;
70
71    // Even though we have a maximum size, we'll mainly match bitmap sizes
72    // using the area instead. This way our comparisons are aspect ratio independent.
73    private static final int MAX_WALLPAPER_EXTRACTION_AREA = MAX_BITMAP_SIZE * MAX_BITMAP_SIZE;
74
75    // When extracting the main colors, only consider colors
76    // present in at least MIN_COLOR_OCCURRENCE of the image
77    private static final float MIN_COLOR_OCCURRENCE = 0.05f;
78
79    // Decides when dark theme is optimal for this wallpaper
80    private static final float DARK_THEME_MEAN_LUMINANCE = 0.25f;
81    // Minimum mean luminosity that an image needs to have to support dark text
82    private static final float BRIGHT_IMAGE_MEAN_LUMINANCE = 0.75f;
83    // We also check if the image has dark pixels in it,
84    // to avoid bright images with some dark spots.
85    private static final float DARK_PIXEL_LUMINANCE = 0.45f;
86    private static final float MAX_DARK_AREA = 0.05f;
87
88    private final ArrayList<Color> mMainColors;
89    private int mColorHints;
90
91    public WallpaperColors(Parcel parcel) {
92        mMainColors = new ArrayList<>();
93        final int count = parcel.readInt();
94        for (int i = 0; i < count; i++) {
95            final int colorInt = parcel.readInt();
96            Color color = Color.valueOf(colorInt);
97            mMainColors.add(color);
98        }
99        mColorHints = parcel.readInt();
100    }
101
102    /**
103     * Constructs {@link WallpaperColors} from a drawable.
104     * <p>
105     * Main colors will be extracted from the drawable.
106     *
107     * @param drawable Source where to extract from.
108     */
109    public static WallpaperColors fromDrawable(Drawable drawable) {
110        int width = drawable.getIntrinsicWidth();
111        int height = drawable.getIntrinsicHeight();
112
113        // Some drawables do not have intrinsic dimensions
114        if (width <= 0 || height <= 0) {
115            width = MAX_BITMAP_SIZE;
116            height = MAX_BITMAP_SIZE;
117        }
118
119        Size optimalSize = calculateOptimalSize(width, height);
120        Bitmap bitmap = Bitmap.createBitmap(optimalSize.getWidth(), optimalSize.getHeight(),
121                Bitmap.Config.ARGB_8888);
122        final Canvas bmpCanvas = new Canvas(bitmap);
123        drawable.setBounds(0, 0, bitmap.getWidth(), bitmap.getHeight());
124        drawable.draw(bmpCanvas);
125
126        final WallpaperColors colors = WallpaperColors.fromBitmap(bitmap);
127        bitmap.recycle();
128
129        return colors;
130    }
131
132    /**
133     * Constructs {@link WallpaperColors} from a bitmap.
134     * <p>
135     * Main colors will be extracted from the bitmap.
136     *
137     * @param bitmap Source where to extract from.
138     */
139    public static WallpaperColors fromBitmap(@NonNull Bitmap bitmap) {
140        if (bitmap == null) {
141            throw new IllegalArgumentException("Bitmap can't be null");
142        }
143
144        final int bitmapArea = bitmap.getWidth() * bitmap.getHeight();
145        boolean shouldRecycle = false;
146        if (bitmapArea > MAX_WALLPAPER_EXTRACTION_AREA) {
147            shouldRecycle = true;
148            Size optimalSize = calculateOptimalSize(bitmap.getWidth(), bitmap.getHeight());
149            bitmap = Bitmap.createScaledBitmap(bitmap, optimalSize.getWidth(),
150                    optimalSize.getHeight(), true /* filter */);
151        }
152
153        final Palette palette = Palette
154                .from(bitmap)
155                .setQuantizer(new VariationalKMeansQuantizer())
156                .maximumColorCount(5)
157                .clearFilters()
158                .resizeBitmapArea(MAX_WALLPAPER_EXTRACTION_AREA)
159                .generate();
160
161        // Remove insignificant colors and sort swatches by population
162        final ArrayList<Palette.Swatch> swatches = new ArrayList<>(palette.getSwatches());
163        final float minColorArea = bitmap.getWidth() * bitmap.getHeight() * MIN_COLOR_OCCURRENCE;
164        swatches.removeIf(s -> s.getPopulation() < minColorArea);
165        swatches.sort((a, b) -> b.getPopulation() - a.getPopulation());
166
167        final int swatchesSize = swatches.size();
168        Color primary = null, secondary = null, tertiary = null;
169
170        swatchLoop:
171        for (int i = 0; i < swatchesSize; i++) {
172            Color color = Color.valueOf(swatches.get(i).getRgb());
173            switch (i) {
174                case 0:
175                    primary = color;
176                    break;
177                case 1:
178                    secondary = color;
179                    break;
180                case 2:
181                    tertiary = color;
182                    break;
183                default:
184                    // out of bounds
185                    break swatchLoop;
186            }
187        }
188
189        int hints = calculateDarkHints(bitmap);
190
191        if (shouldRecycle) {
192            bitmap.recycle();
193        }
194
195        return new WallpaperColors(primary, secondary, tertiary, HINT_FROM_BITMAP | hints);
196    }
197
198    /**
199     * Constructs a new object from three colors.
200     *
201     * @param primaryColor Primary color.
202     * @param secondaryColor Secondary color.
203     * @param tertiaryColor Tertiary color.
204     * @see WallpaperColors#fromBitmap(Bitmap)
205     * @see WallpaperColors#fromDrawable(Drawable)
206     */
207    public WallpaperColors(@NonNull Color primaryColor, @Nullable Color secondaryColor,
208            @Nullable Color tertiaryColor) {
209        this(primaryColor, secondaryColor, tertiaryColor, 0);
210    }
211
212    /**
213     * Constructs a new object from three colors, where hints can be specified.
214     *
215     * @param primaryColor Primary color.
216     * @param secondaryColor Secondary color.
217     * @param tertiaryColor Tertiary color.
218     * @param colorHints A combination of WallpaperColor hints.
219     * @see WallpaperColors#HINT_SUPPORTS_DARK_TEXT
220     * @see WallpaperColors#fromBitmap(Bitmap)
221     * @see WallpaperColors#fromDrawable(Drawable)
222     * @hide
223     */
224    public WallpaperColors(@NonNull Color primaryColor, @Nullable Color secondaryColor,
225            @Nullable Color tertiaryColor, int colorHints) {
226
227        if (primaryColor == null) {
228            throw new IllegalArgumentException("Primary color should never be null.");
229        }
230
231        mMainColors = new ArrayList<>(3);
232        mMainColors.add(primaryColor);
233        if (secondaryColor != null) {
234            mMainColors.add(secondaryColor);
235        }
236        if (tertiaryColor != null) {
237            if (secondaryColor == null) {
238                throw new IllegalArgumentException("tertiaryColor can't be specified when "
239                        + "secondaryColor is null");
240            }
241            mMainColors.add(tertiaryColor);
242        }
243
244        mColorHints = colorHints;
245    }
246
247    public static final Creator<WallpaperColors> CREATOR = new Creator<WallpaperColors>() {
248        @Override
249        public WallpaperColors createFromParcel(Parcel in) {
250            return new WallpaperColors(in);
251        }
252
253        @Override
254        public WallpaperColors[] newArray(int size) {
255            return new WallpaperColors[size];
256        }
257    };
258
259    @Override
260    public int describeContents() {
261        return 0;
262    }
263
264    @Override
265    public void writeToParcel(Parcel dest, int flags) {
266        List<Color> mainColors = getMainColors();
267        int count = mainColors.size();
268        dest.writeInt(count);
269        for (int i = 0; i < count; i++) {
270            Color color = mainColors.get(i);
271            dest.writeInt(color.toArgb());
272        }
273        dest.writeInt(mColorHints);
274    }
275
276    /**
277     * Gets the most visually representative color of the wallpaper.
278     * "Visually representative" means easily noticeable in the image,
279     * probably happening at high frequency.
280     *
281     * @return A color.
282     */
283    public @NonNull Color getPrimaryColor() {
284        return mMainColors.get(0);
285    }
286
287    /**
288     * Gets the second most preeminent color of the wallpaper. Can be null.
289     *
290     * @return A color, may be null.
291     */
292    public @Nullable Color getSecondaryColor() {
293        return mMainColors.size() < 2 ? null : mMainColors.get(1);
294    }
295
296    /**
297     * Gets the third most preeminent color of the wallpaper. Can be null.
298     *
299     * @return A color, may be null.
300     */
301    public @Nullable Color getTertiaryColor() {
302        return mMainColors.size() < 3 ? null : mMainColors.get(2);
303    }
304
305    /**
306     * List of most preeminent colors, sorted by importance.
307     *
308     * @return List of colors.
309     * @hide
310     */
311    public @NonNull List<Color> getMainColors() {
312        return Collections.unmodifiableList(mMainColors);
313    }
314
315    @Override
316    public boolean equals(Object o) {
317        if (o == null || getClass() != o.getClass()) {
318            return false;
319        }
320
321        WallpaperColors other = (WallpaperColors) o;
322        return mMainColors.equals(other.mMainColors)
323                && mColorHints == other.mColorHints;
324    }
325
326    @Override
327    public int hashCode() {
328        return 31 * mMainColors.hashCode() + mColorHints;
329    }
330
331    /**
332     * Combination of WallpaperColor hints.
333     *
334     * @see WallpaperColors#HINT_SUPPORTS_DARK_TEXT
335     * @return True if dark text is supported.
336     * @hide
337     */
338    public int getColorHints() {
339        return mColorHints;
340    }
341
342    /**
343     * @param colorHints Combination of WallpaperColors hints.
344     * @see WallpaperColors#HINT_SUPPORTS_DARK_TEXT
345     * @hide
346     */
347    public void setColorHints(int colorHints) {
348        mColorHints = colorHints;
349    }
350
351    /**
352     * Checks if image is bright and clean enough to support light text.
353     *
354     * @param source What to read.
355     * @return Whether image supports dark text or not.
356     */
357    private static int calculateDarkHints(Bitmap source) {
358        if (source == null) {
359            return 0;
360        }
361
362        int[] pixels = new int[source.getWidth() * source.getHeight()];
363        double totalLuminance = 0;
364        final int maxDarkPixels = (int) (pixels.length * MAX_DARK_AREA);
365        int darkPixels = 0;
366        source.getPixels(pixels, 0 /* offset */, source.getWidth(), 0 /* x */, 0 /* y */,
367                source.getWidth(), source.getHeight());
368
369        // This bitmap was already resized to fit the maximum allowed area.
370        // Let's just loop through the pixels, no sweat!
371        float[] tmpHsl = new float[3];
372        for (int i = 0; i < pixels.length; i++) {
373            ColorUtils.colorToHSL(pixels[i], tmpHsl);
374            final float luminance = tmpHsl[2];
375            final int alpha = Color.alpha(pixels[i]);
376            // Make sure we don't have a dark pixel mass that will
377            // make text illegible.
378            if (luminance < DARK_PIXEL_LUMINANCE && alpha != 0) {
379                darkPixels++;
380            }
381            totalLuminance += luminance;
382        }
383
384        int hints = 0;
385        double meanLuminance = totalLuminance / pixels.length;
386        if (meanLuminance > BRIGHT_IMAGE_MEAN_LUMINANCE && darkPixels < maxDarkPixels) {
387            hints |= HINT_SUPPORTS_DARK_TEXT;
388        }
389        if (meanLuminance < DARK_THEME_MEAN_LUMINANCE) {
390            hints |= HINT_SUPPORTS_DARK_THEME;
391        }
392
393        return hints;
394    }
395
396    private static Size calculateOptimalSize(int width, int height) {
397        // Calculate how big the bitmap needs to be.
398        // This avoids unnecessary processing and allocation inside Palette.
399        final int requestedArea = width * height;
400        double scale = 1;
401        if (requestedArea > MAX_WALLPAPER_EXTRACTION_AREA) {
402            scale = Math.sqrt(MAX_WALLPAPER_EXTRACTION_AREA / (double) requestedArea);
403        }
404        int newWidth = (int) (width * scale);
405        int newHeight = (int) (height * scale);
406        // Dealing with edge cases of the drawable being too wide or too tall.
407        // Width or height would end up being 0, in this case we'll set it to 1.
408        if (newWidth == 0) {
409            newWidth = 1;
410        }
411        if (newHeight == 0) {
412            newHeight = 1;
413        }
414
415        return new Size(newWidth, newHeight);
416    }
417
418    @Override
419    public String toString() {
420        final StringBuilder colors = new StringBuilder();
421        for (int i = 0; i < mMainColors.size(); i++) {
422            colors.append(Integer.toHexString(mMainColors.get(i).toArgb())).append(" ");
423        }
424        return "[WallpaperColors: " + colors.toString() + "h: " + mColorHints + "]";
425    }
426}
427