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 com.android.systemui.statusbar.notification;
18 
19 import android.app.Notification;
20 import android.content.Context;
21 import android.graphics.Bitmap;
22 import android.graphics.Canvas;
23 import android.graphics.Color;
24 import android.graphics.drawable.Drawable;
25 import android.graphics.drawable.Icon;
26 import android.util.LayoutDirection;
27 
28 import androidx.annotation.VisibleForTesting;
29 import androidx.palette.graphics.Palette;
30 
31 import com.android.internal.util.ContrastColorUtil;
32 import com.android.systemui.R;
33 
34 import java.util.List;
35 
36 /**
37  * A class the processes media notifications and extracts the right text and background colors.
38  */
39 public class MediaNotificationProcessor {
40 
41     /**
42      * The fraction below which we select the vibrant instead of the light/dark vibrant color
43      */
44     private static final float POPULATION_FRACTION_FOR_MORE_VIBRANT = 1.0f;
45 
46     /**
47      * Minimum saturation that a muted color must have if there exists if deciding between two
48      * colors
49      */
50     private static final float MIN_SATURATION_WHEN_DECIDING = 0.19f;
51 
52     /**
53      * Minimum fraction that any color must have to be picked up as a text color
54      */
55     private static final double MINIMUM_IMAGE_FRACTION = 0.002;
56 
57     /**
58      * The population fraction to select the dominant color as the text color over a the colored
59      * ones.
60      */
61     private static final float POPULATION_FRACTION_FOR_DOMINANT = 0.01f;
62 
63     /**
64      * The population fraction to select a white or black color as the background over a color.
65      */
66     private static final float POPULATION_FRACTION_FOR_WHITE_OR_BLACK = 2.5f;
67     private static final float BLACK_MAX_LIGHTNESS = 0.08f;
68     private static final float WHITE_MIN_LIGHTNESS = 0.90f;
69     private static final int RESIZE_BITMAP_AREA = 150 * 150;
70     private final ImageGradientColorizer mColorizer;
71     private final Context mContext;
72     private final Palette.Filter mBlackWhiteFilter = (rgb, hsl) -> !isWhiteOrBlack(hsl);
73 
74     /**
75      * The context of the notification. This is the app context of the package posting the
76      * notification.
77      */
78     private final Context mPackageContext;
79 
MediaNotificationProcessor(Context context, Context packageContext)80     public MediaNotificationProcessor(Context context, Context packageContext) {
81         this(context, packageContext, new ImageGradientColorizer());
82     }
83 
84     @VisibleForTesting
MediaNotificationProcessor(Context context, Context packageContext, ImageGradientColorizer colorizer)85     MediaNotificationProcessor(Context context, Context packageContext,
86             ImageGradientColorizer colorizer) {
87         mContext = context;
88         mPackageContext = packageContext;
89         mColorizer = colorizer;
90     }
91 
92     /**
93      * Processes a builder of a media notification and calculates the appropriate colors that should
94      * be used.
95      *
96      * @param notification the notification that is being processed
97      * @param builder the recovered builder for the notification. this will be modified
98      */
processNotification(Notification notification, Notification.Builder builder)99     public void processNotification(Notification notification, Notification.Builder builder) {
100         Icon largeIcon = notification.getLargeIcon();
101         Bitmap bitmap = null;
102         Drawable drawable = null;
103         if (largeIcon != null) {
104             // We're transforming the builder, let's make sure all baked in RemoteViews are
105             // rebuilt!
106             builder.setRebuildStyledRemoteViews(true);
107             drawable = largeIcon.loadDrawable(mPackageContext);
108             int backgroundColor = 0;
109             if (notification.isColorizedMedia()) {
110                 int width = drawable.getIntrinsicWidth();
111                 int height = drawable.getIntrinsicHeight();
112                 int area = width * height;
113                 if (area > RESIZE_BITMAP_AREA) {
114                     double factor = Math.sqrt((float) RESIZE_BITMAP_AREA / area);
115                     width = (int) (factor * width);
116                     height = (int) (factor * height);
117                 }
118                 bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
119                 Canvas canvas = new Canvas(bitmap);
120                 drawable.setBounds(0, 0, width, height);
121                 drawable.draw(canvas);
122 
123                 Palette.Builder paletteBuilder = generateArtworkPaletteBuilder(bitmap);
124                 Palette palette = paletteBuilder.generate();
125                 Palette.Swatch backgroundSwatch = findBackgroundSwatch(palette);
126                 backgroundColor = backgroundSwatch.getRgb();
127                 // we want most of the full region again, slightly shifted to the right
128                 float textColorStartWidthFraction = 0.4f;
129                 paletteBuilder.setRegion((int) (bitmap.getWidth() * textColorStartWidthFraction), 0,
130                         bitmap.getWidth(),
131                         bitmap.getHeight());
132                 // We're not filtering on white or black
133                 if (!isWhiteOrBlack(backgroundSwatch.getHsl())) {
134                     final float backgroundHue = backgroundSwatch.getHsl()[0];
135                     paletteBuilder.addFilter((rgb, hsl) -> {
136                         // at least 10 degrees hue difference
137                         float diff = Math.abs(hsl[0] - backgroundHue);
138                         return diff > 10 && diff < 350;
139                     });
140                 }
141                 paletteBuilder.addFilter(mBlackWhiteFilter);
142                 palette = paletteBuilder.generate();
143                 int foregroundColor = selectForegroundColor(backgroundColor, palette);
144                 builder.setColorPalette(backgroundColor, foregroundColor);
145             } else {
146                 backgroundColor = mContext.getColor(R.color.notification_material_background_color);
147             }
148             Bitmap colorized = mColorizer.colorize(drawable, backgroundColor,
149                     mContext.getResources().getConfiguration().getLayoutDirection() ==
150                             LayoutDirection.RTL);
151             builder.setLargeIcon(Icon.createWithBitmap(colorized));
152         }
153     }
154 
selectForegroundColor(int backgroundColor, Palette palette)155     private int selectForegroundColor(int backgroundColor, Palette palette) {
156         if (ContrastColorUtil.isColorLight(backgroundColor)) {
157             return selectForegroundColorForSwatches(palette.getDarkVibrantSwatch(),
158                     palette.getVibrantSwatch(),
159                     palette.getDarkMutedSwatch(),
160                     palette.getMutedSwatch(),
161                     palette.getDominantSwatch(),
162                     Color.BLACK);
163         } else {
164             return selectForegroundColorForSwatches(palette.getLightVibrantSwatch(),
165                     palette.getVibrantSwatch(),
166                     palette.getLightMutedSwatch(),
167                     palette.getMutedSwatch(),
168                     palette.getDominantSwatch(),
169                     Color.WHITE);
170         }
171     }
172 
selectForegroundColorForSwatches(Palette.Swatch moreVibrant, Palette.Swatch vibrant, Palette.Swatch moreMutedSwatch, Palette.Swatch mutedSwatch, Palette.Swatch dominantSwatch, int fallbackColor)173     private int selectForegroundColorForSwatches(Palette.Swatch moreVibrant,
174             Palette.Swatch vibrant, Palette.Swatch moreMutedSwatch, Palette.Swatch mutedSwatch,
175             Palette.Swatch dominantSwatch, int fallbackColor) {
176         Palette.Swatch coloredCandidate = selectVibrantCandidate(moreVibrant, vibrant);
177         if (coloredCandidate == null) {
178             coloredCandidate = selectMutedCandidate(mutedSwatch, moreMutedSwatch);
179         }
180         if (coloredCandidate != null) {
181             if (dominantSwatch == coloredCandidate) {
182                 return coloredCandidate.getRgb();
183             } else if ((float) coloredCandidate.getPopulation() / dominantSwatch.getPopulation()
184                     < POPULATION_FRACTION_FOR_DOMINANT
185                     && dominantSwatch.getHsl()[1] > MIN_SATURATION_WHEN_DECIDING) {
186                 return dominantSwatch.getRgb();
187             } else {
188                 return coloredCandidate.getRgb();
189             }
190         } else if (hasEnoughPopulation(dominantSwatch)) {
191             return dominantSwatch.getRgb();
192         } else {
193             return fallbackColor;
194         }
195     }
196 
selectMutedCandidate(Palette.Swatch first, Palette.Swatch second)197     private Palette.Swatch selectMutedCandidate(Palette.Swatch first,
198             Palette.Swatch second) {
199         boolean firstValid = hasEnoughPopulation(first);
200         boolean secondValid = hasEnoughPopulation(second);
201         if (firstValid && secondValid) {
202             float firstSaturation = first.getHsl()[1];
203             float secondSaturation = second.getHsl()[1];
204             float populationFraction = first.getPopulation() / (float) second.getPopulation();
205             if (firstSaturation * populationFraction > secondSaturation) {
206                 return first;
207             } else {
208                 return second;
209             }
210         } else if (firstValid) {
211             return first;
212         } else if (secondValid) {
213             return second;
214         }
215         return null;
216     }
217 
selectVibrantCandidate(Palette.Swatch first, Palette.Swatch second)218     private Palette.Swatch selectVibrantCandidate(Palette.Swatch first, Palette.Swatch second) {
219         boolean firstValid = hasEnoughPopulation(first);
220         boolean secondValid = hasEnoughPopulation(second);
221         if (firstValid && secondValid) {
222             int firstPopulation = first.getPopulation();
223             int secondPopulation = second.getPopulation();
224             if (firstPopulation / (float) secondPopulation
225                     < POPULATION_FRACTION_FOR_MORE_VIBRANT) {
226                 return second;
227             } else {
228                 return first;
229             }
230         } else if (firstValid) {
231             return first;
232         } else if (secondValid) {
233             return second;
234         }
235         return null;
236     }
237 
hasEnoughPopulation(Palette.Swatch swatch)238     private boolean hasEnoughPopulation(Palette.Swatch swatch) {
239         // We want a fraction that is at least 1% of the image
240         return swatch != null
241                 && (swatch.getPopulation() / (float) RESIZE_BITMAP_AREA > MINIMUM_IMAGE_FRACTION);
242     }
243 
244     /**
245      * Finds an appropriate background swatch from media artwork.
246      *
247      * @param artwork Media artwork
248      * @return Swatch that should be used as the background of the media notification.
249      */
findBackgroundSwatch(Bitmap artwork)250     public static Palette.Swatch findBackgroundSwatch(Bitmap artwork) {
251         return findBackgroundSwatch(generateArtworkPaletteBuilder(artwork).generate());
252     }
253 
254     /**
255      * Finds an appropriate background swatch from the palette of media artwork.
256      *
257      * @param palette Artwork palette, should be obtained from {@link generateArtworkPaletteBuilder}
258      * @return Swatch that should be used as the background of the media notification.
259      */
findBackgroundSwatch(Palette palette)260     private static Palette.Swatch findBackgroundSwatch(Palette palette) {
261         // by default we use the dominant palette
262         Palette.Swatch dominantSwatch = palette.getDominantSwatch();
263         if (dominantSwatch == null) {
264             return new Palette.Swatch(Color.WHITE, 100);
265         }
266 
267         if (!isWhiteOrBlack(dominantSwatch.getHsl())) {
268             return dominantSwatch;
269         }
270         // Oh well, we selected black or white. Lets look at the second color!
271         List<Palette.Swatch> swatches = palette.getSwatches();
272         float highestNonWhitePopulation = -1;
273         Palette.Swatch second = null;
274         for (Palette.Swatch swatch: swatches) {
275             if (swatch != dominantSwatch
276                     && swatch.getPopulation() > highestNonWhitePopulation
277                     && !isWhiteOrBlack(swatch.getHsl())) {
278                 second = swatch;
279                 highestNonWhitePopulation = swatch.getPopulation();
280             }
281         }
282         if (second == null) {
283             return dominantSwatch;
284         }
285         if (dominantSwatch.getPopulation() / highestNonWhitePopulation
286                 > POPULATION_FRACTION_FOR_WHITE_OR_BLACK) {
287             // The dominant swatch is very dominant, lets take it!
288             // We're not filtering on white or black
289             return dominantSwatch;
290         } else {
291             return second;
292         }
293     }
294 
295     /**
296      * Generate a palette builder for media artwork.
297      *
298      * For producing a smooth background transition, the palette is extracted from only the left
299      * side of the artwork.
300      *
301      * @param artwork Media artwork
302      * @return Builder that generates the {@link Palette} for the media artwork.
303      */
generateArtworkPaletteBuilder(Bitmap artwork)304     private static Palette.Builder generateArtworkPaletteBuilder(Bitmap artwork) {
305         // for the background we only take the left side of the image to ensure
306         // a smooth transition
307         return Palette.from(artwork)
308                 .setRegion(0, 0, artwork.getWidth() / 2, artwork.getHeight())
309                 .clearFilters() // we want all colors, red / white / black ones too!
310                 .resizeBitmapArea(RESIZE_BITMAP_AREA);
311     }
312 
isWhiteOrBlack(float[] hsl)313     private static boolean isWhiteOrBlack(float[] hsl) {
314         return isBlack(hsl) || isWhite(hsl);
315     }
316 
317     /**
318      * @return true if the color represents a color which is close to black.
319      */
isBlack(float[] hslColor)320     private static boolean isBlack(float[] hslColor) {
321         return hslColor[2] <= BLACK_MAX_LIGHTNESS;
322     }
323 
324     /**
325      * @return true if the color represents a color which is close to white.
326      */
isWhite(float[] hslColor)327     private static boolean isWhite(float[] hslColor) {
328         return hslColor[2] >= WHITE_MIN_LIGHTNESS;
329     }
330 }
331