• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
17 package android.app;
18 
19 import android.annotation.IntDef;
20 import android.annotation.NonNull;
21 import android.annotation.Nullable;
22 import android.graphics.Bitmap;
23 import android.graphics.Canvas;
24 import android.graphics.Color;
25 import android.graphics.Rect;
26 import android.graphics.drawable.Drawable;
27 import android.os.Parcel;
28 import android.os.Parcelable;
29 import android.util.Log;
30 import android.util.Size;
31 
32 import com.android.internal.graphics.ColorUtils;
33 import com.android.internal.graphics.cam.Cam;
34 import com.android.internal.graphics.palette.CelebiQuantizer;
35 import com.android.internal.graphics.palette.Palette;
36 import com.android.internal.graphics.palette.VariationalKMeansQuantizer;
37 import com.android.internal.util.ContrastColorUtil;
38 
39 import java.io.FileOutputStream;
40 import java.lang.annotation.Retention;
41 import java.lang.annotation.RetentionPolicy;
42 import java.util.ArrayList;
43 import java.util.Collections;
44 import java.util.HashMap;
45 import java.util.List;
46 import java.util.Map;
47 import java.util.Set;
48 
49 /**
50  * Provides information about the colors of a wallpaper.
51  * <p>
52  * Exposes the 3 most visually representative colors of a wallpaper. Can be either
53  * {@link WallpaperColors#getPrimaryColor()}, {@link WallpaperColors#getSecondaryColor()}
54  * or {@link WallpaperColors#getTertiaryColor()}.
55  */
56 public final class WallpaperColors implements Parcelable {
57     /**
58      * @hide
59      */
60     @IntDef(prefix = "HINT_", value = {HINT_SUPPORTS_DARK_TEXT, HINT_SUPPORTS_DARK_THEME},
61             flag = true)
62     @Retention(RetentionPolicy.SOURCE)
63     public @interface ColorsHints {}
64 
65     private static final boolean DEBUG_DARK_PIXELS = false;
66 
67     /**
68      * Specifies that dark text is preferred over the current wallpaper for best presentation.
69      * <p>
70      * eg. A launcher may set its text color to black if this flag is specified.
71      */
72     public static final int HINT_SUPPORTS_DARK_TEXT = 1 << 0;
73 
74     /**
75      * Specifies that dark theme is preferred over the current wallpaper for best presentation.
76      * <p>
77      * eg. A launcher may set its drawer color to black if this flag is specified.
78      */
79     public static final int HINT_SUPPORTS_DARK_THEME = 1 << 1;
80 
81     /**
82      * Specifies that this object was generated by extracting colors from a bitmap.
83      * @hide
84      */
85     public static final int HINT_FROM_BITMAP = 1 << 2;
86 
87     // Maximum size that a bitmap can have to keep our calculations valid
88     private static final int MAX_BITMAP_SIZE = 112;
89 
90     // Even though we have a maximum size, we'll mainly match bitmap sizes
91     // using the area instead. This way our comparisons are aspect ratio independent.
92     private static final int MAX_WALLPAPER_EXTRACTION_AREA = MAX_BITMAP_SIZE * MAX_BITMAP_SIZE;
93 
94     // When extracting the main colors, only consider colors
95     // present in at least MIN_COLOR_OCCURRENCE of the image
96     private static final float MIN_COLOR_OCCURRENCE = 0.05f;
97 
98     // Decides when dark theme is optimal for this wallpaper
99     private static final float DARK_THEME_MEAN_LUMINANCE = 0.3f;
100     // Minimum mean luminosity that an image needs to have to support dark text
101     private static final float BRIGHT_IMAGE_MEAN_LUMINANCE = 0.7f;
102     // We also check if the image has dark pixels in it,
103     // to avoid bright images with some dark spots.
104     private static final float DARK_PIXEL_CONTRAST = 5.5f;
105     private static final float MAX_DARK_AREA = 0.05f;
106 
107     private final List<Color> mMainColors;
108     private final Map<Integer, Integer> mAllColors;
109     private int mColorHints;
110 
WallpaperColors(Parcel parcel)111     public WallpaperColors(Parcel parcel) {
112         mMainColors = new ArrayList<>();
113         mAllColors = new HashMap<>();
114         int count = parcel.readInt();
115         for (int i = 0; i < count; i++) {
116             final int colorInt = parcel.readInt();
117             Color color = Color.valueOf(colorInt);
118             mMainColors.add(color);
119         }
120         count = parcel.readInt();
121         for (int i = 0; i < count; i++) {
122             final int colorInt = parcel.readInt();
123             final int population = parcel.readInt();
124             mAllColors.put(colorInt, population);
125         }
126         mColorHints = parcel.readInt();
127     }
128 
129     /**
130      * Constructs {@link WallpaperColors} from a drawable.
131      * <p>
132      * Main colors will be extracted from the drawable.
133      *
134      * @param drawable Source where to extract from.
135      */
fromDrawable(Drawable drawable)136     public static WallpaperColors fromDrawable(Drawable drawable) {
137         if (drawable == null) {
138             throw new IllegalArgumentException("Drawable cannot be null");
139         }
140 
141         Rect initialBounds = drawable.copyBounds();
142         int width = drawable.getIntrinsicWidth();
143         int height = drawable.getIntrinsicHeight();
144 
145         // Some drawables do not have intrinsic dimensions
146         if (width <= 0 || height <= 0) {
147             width = MAX_BITMAP_SIZE;
148             height = MAX_BITMAP_SIZE;
149         }
150 
151         Size optimalSize = calculateOptimalSize(width, height);
152         Bitmap bitmap = Bitmap.createBitmap(optimalSize.getWidth(), optimalSize.getHeight(),
153                 Bitmap.Config.ARGB_8888);
154         final Canvas bmpCanvas = new Canvas(bitmap);
155         drawable.setBounds(0, 0, bitmap.getWidth(), bitmap.getHeight());
156         drawable.draw(bmpCanvas);
157 
158         final WallpaperColors colors = WallpaperColors.fromBitmap(bitmap);
159         bitmap.recycle();
160 
161         drawable.setBounds(initialBounds);
162         return colors;
163     }
164 
165     /**
166      * Constructs {@link WallpaperColors} from a bitmap.
167      * <p>
168      * Main colors will be extracted from the bitmap.
169      *
170      * @param bitmap Source where to extract from.
171      */
fromBitmap(@onNull Bitmap bitmap)172     public static WallpaperColors fromBitmap(@NonNull Bitmap bitmap) {
173         if (bitmap == null) {
174             throw new IllegalArgumentException("Bitmap can't be null");
175         }
176 
177         final int bitmapArea = bitmap.getWidth() * bitmap.getHeight();
178         boolean shouldRecycle = false;
179         if (bitmapArea > MAX_WALLPAPER_EXTRACTION_AREA) {
180             shouldRecycle = true;
181             Size optimalSize = calculateOptimalSize(bitmap.getWidth(), bitmap.getHeight());
182             bitmap = Bitmap.createScaledBitmap(bitmap, optimalSize.getWidth(),
183                     optimalSize.getHeight(), false /* filter */);
184         }
185 
186         final Palette palette;
187         if (ActivityManager.isLowRamDeviceStatic()) {
188             palette = Palette
189                     .from(bitmap, new VariationalKMeansQuantizer())
190                     .maximumColorCount(5)
191                     .resizeBitmapArea(MAX_WALLPAPER_EXTRACTION_AREA)
192                     .generate();
193         } else {
194             palette = Palette
195                     .from(bitmap, new CelebiQuantizer())
196                     .maximumColorCount(128)
197                     .resizeBitmapArea(MAX_WALLPAPER_EXTRACTION_AREA)
198                     .generate();
199         }
200         // Remove insignificant colors and sort swatches by population
201         final ArrayList<Palette.Swatch> swatches = new ArrayList<>(palette.getSwatches());
202         swatches.sort((a, b) -> b.getPopulation() - a.getPopulation());
203 
204         final int swatchesSize = swatches.size();
205 
206         final Map<Integer, Integer> populationByColor = new HashMap<>();
207         for (int i = 0; i < swatchesSize; i++) {
208             Palette.Swatch swatch = swatches.get(i);
209             int colorInt = swatch.getInt();
210             populationByColor.put(colorInt, swatch.getPopulation());
211 
212         }
213 
214         int hints = calculateDarkHints(bitmap);
215 
216         if (shouldRecycle) {
217             bitmap.recycle();
218         }
219 
220         return new WallpaperColors(populationByColor, HINT_FROM_BITMAP | hints);
221     }
222 
223     /**
224      * Constructs a new object from three colors.
225      *
226      * @param primaryColor Primary color.
227      * @param secondaryColor Secondary color.
228      * @param tertiaryColor Tertiary color.
229      * @see WallpaperColors#fromBitmap(Bitmap)
230      * @see WallpaperColors#fromDrawable(Drawable)
231      */
WallpaperColors(@onNull Color primaryColor, @Nullable Color secondaryColor, @Nullable Color tertiaryColor)232     public WallpaperColors(@NonNull Color primaryColor, @Nullable Color secondaryColor,
233             @Nullable Color tertiaryColor) {
234         this(primaryColor, secondaryColor, tertiaryColor, 0);
235 
236         // Calculate dark theme support based on primary color.
237         final float[] tmpHsl = new float[3];
238         ColorUtils.colorToHSL(primaryColor.toArgb(), tmpHsl);
239         final float luminance = tmpHsl[2];
240         if (luminance < DARK_THEME_MEAN_LUMINANCE) {
241             mColorHints |= HINT_SUPPORTS_DARK_THEME;
242         }
243     }
244 
245     /**
246      * Constructs a new object from three colors, where hints can be specified.
247      *
248      * @param primaryColor Primary color.
249      * @param secondaryColor Secondary color.
250      * @param tertiaryColor Tertiary color.
251      * @param colorHints A combination of color hints.
252      * @see WallpaperColors#fromBitmap(Bitmap)
253      * @see WallpaperColors#fromDrawable(Drawable)
254      */
WallpaperColors(@onNull Color primaryColor, @Nullable Color secondaryColor, @Nullable Color tertiaryColor, @ColorsHints int colorHints)255     public WallpaperColors(@NonNull Color primaryColor, @Nullable Color secondaryColor,
256             @Nullable Color tertiaryColor, @ColorsHints int colorHints) {
257 
258         if (primaryColor == null) {
259             throw new IllegalArgumentException("Primary color should never be null.");
260         }
261 
262         mMainColors = new ArrayList<>(3);
263         mAllColors = new HashMap<>();
264 
265         mMainColors.add(primaryColor);
266         mAllColors.put(primaryColor.toArgb(), 0);
267         if (secondaryColor != null) {
268             mMainColors.add(secondaryColor);
269             mAllColors.put(secondaryColor.toArgb(), 0);
270         }
271         if (tertiaryColor != null) {
272             if (secondaryColor == null) {
273                 throw new IllegalArgumentException("tertiaryColor can't be specified when "
274                         + "secondaryColor is null");
275             }
276             mMainColors.add(tertiaryColor);
277             mAllColors.put(tertiaryColor.toArgb(), 0);
278         }
279         mColorHints = colorHints;
280     }
281 
282     /**
283      * Constructs a new object from a set of colors, where hints can be specified.
284      *
285      * @param colorToPopulation Map with keys of colors, and value representing the number of
286      *                          occurrences of color in the wallpaper.
287      * @param colorHints        A combination of color hints.
288      * @hide
289      * @see WallpaperColors#HINT_SUPPORTS_DARK_TEXT
290      * @see WallpaperColors#fromBitmap(Bitmap)
291      * @see WallpaperColors#fromDrawable(Drawable)
292      */
WallpaperColors(@onNull Map<Integer, Integer> colorToPopulation, @ColorsHints int colorHints)293     public WallpaperColors(@NonNull Map<Integer, Integer> colorToPopulation,
294             @ColorsHints int colorHints) {
295         mAllColors = colorToPopulation;
296 
297         final Map<Integer, Cam> colorToCam = new HashMap<>();
298         for (int color : colorToPopulation.keySet()) {
299             colorToCam.put(color, Cam.fromInt(color));
300         }
301         final double[] hueProportions = hueProportions(colorToCam, colorToPopulation);
302         final Map<Integer, Double> colorToHueProportion = colorToHueProportion(
303                 colorToPopulation.keySet(), colorToCam, hueProportions);
304 
305         final Map<Integer, Double> colorToScore = new HashMap<>();
306         for (Map.Entry<Integer, Double> mapEntry : colorToHueProportion.entrySet()) {
307             int color = mapEntry.getKey();
308             double proportion = mapEntry.getValue();
309             double score = score(colorToCam.get(color), proportion);
310             colorToScore.put(color, score);
311         }
312         ArrayList<Map.Entry<Integer, Double>> mapEntries = new ArrayList(colorToScore.entrySet());
313         mapEntries.sort((a, b) -> b.getValue().compareTo(a.getValue()));
314 
315         List<Integer> colorsByScoreDescending = new ArrayList<>();
316         for (Map.Entry<Integer, Double> colorToScoreEntry : mapEntries) {
317             colorsByScoreDescending.add(colorToScoreEntry.getKey());
318         }
319 
320         List<Integer> mainColorInts = new ArrayList<>();
321         findSeedColorLoop:
322         for (int color : colorsByScoreDescending) {
323             Cam cam = colorToCam.get(color);
324             for (int otherColor : mainColorInts) {
325                 Cam otherCam = colorToCam.get(otherColor);
326                 if (hueDiff(cam, otherCam) < 15) {
327                     continue findSeedColorLoop;
328                 }
329             }
330             mainColorInts.add(color);
331         }
332         List<Color> mainColors = new ArrayList<>();
333         for (int colorInt : mainColorInts) {
334             mainColors.add(Color.valueOf(colorInt));
335         }
336         mMainColors = mainColors;
337         mColorHints = colorHints;
338     }
339 
hueDiff(Cam a, Cam b)340     private static double hueDiff(Cam a, Cam b) {
341         return (180f - Math.abs(Math.abs(a.getHue() - b.getHue()) - 180f));
342     }
343 
score(Cam cam, double proportion)344     private static double score(Cam cam, double proportion) {
345         return cam.getChroma() + (proportion * 100);
346     }
347 
colorToHueProportion(Set<Integer> colors, Map<Integer, Cam> colorToCam, double[] hueProportions)348     private static Map<Integer, Double> colorToHueProportion(Set<Integer> colors,
349             Map<Integer, Cam> colorToCam, double[] hueProportions) {
350         Map<Integer, Double> colorToHueProportion = new HashMap<>();
351         for (int color : colors) {
352             final int hue = wrapDegrees(Math.round(colorToCam.get(color).getHue()));
353             double proportion = 0.0;
354             for (int i = hue - 15; i < hue + 15; i++) {
355                 proportion += hueProportions[wrapDegrees(i)];
356             }
357             colorToHueProportion.put(color, proportion);
358         }
359         return colorToHueProportion;
360     }
361 
wrapDegrees(int degrees)362     private static int wrapDegrees(int degrees) {
363         if (degrees < 0) {
364             return (degrees % 360) + 360;
365         } else if (degrees >= 360) {
366             return degrees % 360;
367         } else {
368             return degrees;
369         }
370     }
371 
hueProportions(@onNull Map<Integer, Cam> colorToCam, Map<Integer, Integer> colorToPopulation)372     private static double[] hueProportions(@NonNull Map<Integer, Cam> colorToCam,
373             Map<Integer, Integer> colorToPopulation) {
374         final double[] proportions = new double[360];
375 
376         double totalPopulation = 0;
377         for (Map.Entry<Integer, Integer> entry : colorToPopulation.entrySet()) {
378             totalPopulation += entry.getValue();
379         }
380 
381         for (Map.Entry<Integer, Integer> entry : colorToPopulation.entrySet()) {
382             final int color = (int) entry.getKey();
383             final int population = colorToPopulation.get(color);
384             final Cam cam = colorToCam.get(color);
385             final int hue = wrapDegrees(Math.round(cam.getHue()));
386             proportions[hue] = proportions[hue] + ((double) population / totalPopulation);
387         }
388 
389         return proportions;
390     }
391 
392     public static final @android.annotation.NonNull Creator<WallpaperColors> CREATOR = new Creator<WallpaperColors>() {
393         @Override
394         public WallpaperColors createFromParcel(Parcel in) {
395             return new WallpaperColors(in);
396         }
397 
398         @Override
399         public WallpaperColors[] newArray(int size) {
400             return new WallpaperColors[size];
401         }
402     };
403 
404     @Override
describeContents()405     public int describeContents() {
406         return 0;
407     }
408 
409     @Override
writeToParcel(Parcel dest, int flags)410     public void writeToParcel(Parcel dest, int flags) {
411         List<Color> mainColors = getMainColors();
412         int count = mainColors.size();
413         dest.writeInt(count);
414         for (int i = 0; i < count; i++) {
415             Color color = mainColors.get(i);
416             dest.writeInt(color.toArgb());
417         }
418         count = mAllColors.size();
419         dest.writeInt(count);
420         for (Map.Entry<Integer, Integer> colorEntry : mAllColors.entrySet()) {
421             if (colorEntry.getKey() != null) {
422                 dest.writeInt(colorEntry.getKey());
423                 Integer population = colorEntry.getValue();
424                 int populationInt = (population != null) ? population : 0;
425                 dest.writeInt(populationInt);
426             }
427         }
428         dest.writeInt(mColorHints);
429     }
430 
431     /**
432      * Gets the most visually representative color of the wallpaper.
433      * "Visually representative" means easily noticeable in the image,
434      * probably happening at high frequency.
435      *
436      * @return A color.
437      */
getPrimaryColor()438     public @NonNull Color getPrimaryColor() {
439         return mMainColors.get(0);
440     }
441 
442     /**
443      * Gets the second most preeminent color of the wallpaper. Can be null.
444      *
445      * @return A color, may be null.
446      */
getSecondaryColor()447     public @Nullable Color getSecondaryColor() {
448         return mMainColors.size() < 2 ? null : mMainColors.get(1);
449     }
450 
451     /**
452      * Gets the third most preeminent color of the wallpaper. Can be null.
453      *
454      * @return A color, may be null.
455      */
getTertiaryColor()456     public @Nullable Color getTertiaryColor() {
457         return mMainColors.size() < 3 ? null : mMainColors.get(2);
458     }
459 
460     /**
461      * List of most preeminent colors, sorted by importance.
462      *
463      * @return List of colors.
464      * @hide
465      */
getMainColors()466     public @NonNull List<Color> getMainColors() {
467         return Collections.unmodifiableList(mMainColors);
468     }
469 
470     /**
471      * Map of all colors. Key is rgb integer, value is importance of color.
472      *
473      * @return List of colors.
474      * @hide
475      */
getAllColors()476     public @NonNull Map<Integer, Integer> getAllColors() {
477         return Collections.unmodifiableMap(mAllColors);
478     }
479 
480 
481     @Override
equals(@ullable Object o)482     public boolean equals(@Nullable Object o) {
483         if (o == null || getClass() != o.getClass()) {
484             return false;
485         }
486 
487         WallpaperColors other = (WallpaperColors) o;
488         return mMainColors.equals(other.mMainColors)
489                 && mAllColors.equals(other.mAllColors)
490                 && mColorHints == other.mColorHints;
491     }
492 
493     @Override
hashCode()494     public int hashCode() {
495         return (31 * mMainColors.hashCode() * mAllColors.hashCode()) + mColorHints;
496     }
497 
498     /**
499      * Returns the color hints for this instance.
500      * @return The color hints.
501      */
getColorHints()502     public @ColorsHints int getColorHints() {
503         return mColorHints;
504     }
505 
506     /**
507      * Checks if image is bright and clean enough to support light text.
508      *
509      * @param source What to read.
510      * @return Whether image supports dark text or not.
511      */
calculateDarkHints(Bitmap source)512     private static int calculateDarkHints(Bitmap source) {
513         if (source == null) {
514             return 0;
515         }
516 
517         int[] pixels = new int[source.getWidth() * source.getHeight()];
518         double totalLuminance = 0;
519         final int maxDarkPixels = (int) (pixels.length * MAX_DARK_AREA);
520         int darkPixels = 0;
521         source.getPixels(pixels, 0 /* offset */, source.getWidth(), 0 /* x */, 0 /* y */,
522                 source.getWidth(), source.getHeight());
523 
524         // This bitmap was already resized to fit the maximum allowed area.
525         // Let's just loop through the pixels, no sweat!
526         float[] tmpHsl = new float[3];
527         for (int i = 0; i < pixels.length; i++) {
528             ColorUtils.colorToHSL(pixels[i], tmpHsl);
529             final float luminance = tmpHsl[2];
530             final int alpha = Color.alpha(pixels[i]);
531             // Make sure we don't have a dark pixel mass that will
532             // make text illegible.
533             final boolean satisfiesTextContrast = ContrastColorUtil
534                     .calculateContrast(pixels[i], Color.BLACK) > DARK_PIXEL_CONTRAST;
535             if (!satisfiesTextContrast && alpha != 0) {
536                 darkPixels++;
537                 if (DEBUG_DARK_PIXELS) {
538                     pixels[i] = Color.RED;
539                 }
540             }
541             totalLuminance += luminance;
542         }
543 
544         int hints = 0;
545         double meanLuminance = totalLuminance / pixels.length;
546         if (meanLuminance > BRIGHT_IMAGE_MEAN_LUMINANCE && darkPixels < maxDarkPixels) {
547             hints |= HINT_SUPPORTS_DARK_TEXT;
548         }
549         if (meanLuminance < DARK_THEME_MEAN_LUMINANCE) {
550             hints |= HINT_SUPPORTS_DARK_THEME;
551         }
552 
553         if (DEBUG_DARK_PIXELS) {
554             try (FileOutputStream out = new FileOutputStream("/data/pixels.png")) {
555                 source.setPixels(pixels, 0, source.getWidth(), 0, 0, source.getWidth(),
556                         source.getHeight());
557                 source.compress(Bitmap.CompressFormat.PNG, 100, out);
558             } catch (Exception e) {
559                 e.printStackTrace();
560             }
561             Log.d("WallpaperColors", "l: " + meanLuminance + ", d: " + darkPixels +
562                     " maxD: " + maxDarkPixels + " numPixels: " + pixels.length);
563         }
564 
565         return hints;
566     }
567 
calculateOptimalSize(int width, int height)568     private static Size calculateOptimalSize(int width, int height) {
569         // Calculate how big the bitmap needs to be.
570         // This avoids unnecessary processing and allocation inside Palette.
571         final int requestedArea = width * height;
572         double scale = 1;
573         if (requestedArea > MAX_WALLPAPER_EXTRACTION_AREA) {
574             scale = Math.sqrt(MAX_WALLPAPER_EXTRACTION_AREA / (double) requestedArea);
575         }
576         int newWidth = (int) (width * scale);
577         int newHeight = (int) (height * scale);
578         // Dealing with edge cases of the drawable being too wide or too tall.
579         // Width or height would end up being 0, in this case we'll set it to 1.
580         if (newWidth == 0) {
581             newWidth = 1;
582         }
583         if (newHeight == 0) {
584             newHeight = 1;
585         }
586 
587         return new Size(newWidth, newHeight);
588     }
589 
590     @Override
toString()591     public String toString() {
592         final StringBuilder colors = new StringBuilder();
593         for (int i = 0; i < mMainColors.size(); i++) {
594             colors.append(Integer.toHexString(mMainColors.get(i).toArgb())).append(" ");
595         }
596         return "[WallpaperColors: " + colors.toString() + "h: " + mColorHints + "]";
597     }
598 }
599