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