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 
155     /**
156      * Select a foreground color depending on whether the background color is dark or light
157      * @param backgroundColor Background color to coordinate with
158      * @param palette Artwork palette, should be obtained from {@link generateArtworkPaletteBuilder}
159      * @return foreground color
160      */
selectForegroundColor(int backgroundColor, Palette palette)161     public static int selectForegroundColor(int backgroundColor, Palette palette) {
162         if (ContrastColorUtil.isColorLight(backgroundColor)) {
163             return selectForegroundColorForSwatches(palette.getDarkVibrantSwatch(),
164                     palette.getVibrantSwatch(),
165                     palette.getDarkMutedSwatch(),
166                     palette.getMutedSwatch(),
167                     palette.getDominantSwatch(),
168                     Color.BLACK);
169         } else {
170             return selectForegroundColorForSwatches(palette.getLightVibrantSwatch(),
171                     palette.getVibrantSwatch(),
172                     palette.getLightMutedSwatch(),
173                     palette.getMutedSwatch(),
174                     palette.getDominantSwatch(),
175                     Color.WHITE);
176         }
177     }
178 
selectForegroundColorForSwatches(Palette.Swatch moreVibrant, Palette.Swatch vibrant, Palette.Swatch moreMutedSwatch, Palette.Swatch mutedSwatch, Palette.Swatch dominantSwatch, int fallbackColor)179     private static int selectForegroundColorForSwatches(Palette.Swatch moreVibrant,
180             Palette.Swatch vibrant, Palette.Swatch moreMutedSwatch, Palette.Swatch mutedSwatch,
181             Palette.Swatch dominantSwatch, int fallbackColor) {
182         Palette.Swatch coloredCandidate = selectVibrantCandidate(moreVibrant, vibrant);
183         if (coloredCandidate == null) {
184             coloredCandidate = selectMutedCandidate(mutedSwatch, moreMutedSwatch);
185         }
186         if (coloredCandidate != null) {
187             if (dominantSwatch == coloredCandidate) {
188                 return coloredCandidate.getRgb();
189             } else if ((float) coloredCandidate.getPopulation() / dominantSwatch.getPopulation()
190                     < POPULATION_FRACTION_FOR_DOMINANT
191                     && dominantSwatch.getHsl()[1] > MIN_SATURATION_WHEN_DECIDING) {
192                 return dominantSwatch.getRgb();
193             } else {
194                 return coloredCandidate.getRgb();
195             }
196         } else if (hasEnoughPopulation(dominantSwatch)) {
197             return dominantSwatch.getRgb();
198         } else {
199             return fallbackColor;
200         }
201     }
202 
selectMutedCandidate(Palette.Swatch first, Palette.Swatch second)203     private static Palette.Swatch selectMutedCandidate(Palette.Swatch first,
204             Palette.Swatch second) {
205         boolean firstValid = hasEnoughPopulation(first);
206         boolean secondValid = hasEnoughPopulation(second);
207         if (firstValid && secondValid) {
208             float firstSaturation = first.getHsl()[1];
209             float secondSaturation = second.getHsl()[1];
210             float populationFraction = first.getPopulation() / (float) second.getPopulation();
211             if (firstSaturation * populationFraction > secondSaturation) {
212                 return first;
213             } else {
214                 return second;
215             }
216         } else if (firstValid) {
217             return first;
218         } else if (secondValid) {
219             return second;
220         }
221         return null;
222     }
223 
selectVibrantCandidate(Palette.Swatch first, Palette.Swatch second)224     private static Palette.Swatch selectVibrantCandidate(Palette.Swatch first,
225             Palette.Swatch second) {
226         boolean firstValid = hasEnoughPopulation(first);
227         boolean secondValid = hasEnoughPopulation(second);
228         if (firstValid && secondValid) {
229             int firstPopulation = first.getPopulation();
230             int secondPopulation = second.getPopulation();
231             if (firstPopulation / (float) secondPopulation
232                     < POPULATION_FRACTION_FOR_MORE_VIBRANT) {
233                 return second;
234             } else {
235                 return first;
236             }
237         } else if (firstValid) {
238             return first;
239         } else if (secondValid) {
240             return second;
241         }
242         return null;
243     }
244 
hasEnoughPopulation(Palette.Swatch swatch)245     private static boolean hasEnoughPopulation(Palette.Swatch swatch) {
246         // We want a fraction that is at least 1% of the image
247         return swatch != null
248                 && (swatch.getPopulation() / (float) RESIZE_BITMAP_AREA > MINIMUM_IMAGE_FRACTION);
249     }
250 
251     /**
252      * Finds an appropriate background swatch from media artwork.
253      *
254      * @param artwork Media artwork
255      * @return Swatch that should be used as the background of the media notification.
256      */
findBackgroundSwatch(Bitmap artwork)257     public static Palette.Swatch findBackgroundSwatch(Bitmap artwork) {
258         return findBackgroundSwatch(generateArtworkPaletteBuilder(artwork).generate());
259     }
260 
261     /**
262      * Finds an appropriate background swatch from the palette of media artwork.
263      *
264      * @param palette Artwork palette, should be obtained from {@link generateArtworkPaletteBuilder}
265      * @return Swatch that should be used as the background of the media notification.
266      */
findBackgroundSwatch(Palette palette)267     public static Palette.Swatch findBackgroundSwatch(Palette palette) {
268         // by default we use the dominant palette
269         Palette.Swatch dominantSwatch = palette.getDominantSwatch();
270         if (dominantSwatch == null) {
271             return new Palette.Swatch(Color.WHITE, 100);
272         }
273 
274         if (!isWhiteOrBlack(dominantSwatch.getHsl())) {
275             return dominantSwatch;
276         }
277         // Oh well, we selected black or white. Lets look at the second color!
278         List<Palette.Swatch> swatches = palette.getSwatches();
279         float highestNonWhitePopulation = -1;
280         Palette.Swatch second = null;
281         for (Palette.Swatch swatch: swatches) {
282             if (swatch != dominantSwatch
283                     && swatch.getPopulation() > highestNonWhitePopulation
284                     && !isWhiteOrBlack(swatch.getHsl())) {
285                 second = swatch;
286                 highestNonWhitePopulation = swatch.getPopulation();
287             }
288         }
289         if (second == null) {
290             return dominantSwatch;
291         }
292         if (dominantSwatch.getPopulation() / highestNonWhitePopulation
293                 > POPULATION_FRACTION_FOR_WHITE_OR_BLACK) {
294             // The dominant swatch is very dominant, lets take it!
295             // We're not filtering on white or black
296             return dominantSwatch;
297         } else {
298             return second;
299         }
300     }
301 
302     /**
303      * Generate a palette builder for media artwork.
304      *
305      * For producing a smooth background transition, the palette is extracted from only the left
306      * side of the artwork.
307      *
308      * @param artwork Media artwork
309      * @return Builder that generates the {@link Palette} for the media artwork.
310      */
generateArtworkPaletteBuilder(Bitmap artwork)311     public static Palette.Builder generateArtworkPaletteBuilder(Bitmap artwork) {
312         // for the background we only take the left side of the image to ensure
313         // a smooth transition
314         return Palette.from(artwork)
315                 .setRegion(0, 0, artwork.getWidth() / 2, artwork.getHeight())
316                 .clearFilters() // we want all colors, red / white / black ones too!
317                 .resizeBitmapArea(RESIZE_BITMAP_AREA);
318     }
319 
isWhiteOrBlack(float[] hsl)320     private static boolean isWhiteOrBlack(float[] hsl) {
321         return isBlack(hsl) || isWhite(hsl);
322     }
323 
324     /**
325      * @return true if the color represents a color which is close to black.
326      */
isBlack(float[] hslColor)327     private static boolean isBlack(float[] hslColor) {
328         return hslColor[2] <= BLACK_MAX_LIGHTNESS;
329     }
330 
331     /**
332      * @return true if the color represents a color which is close to white.
333      */
isWhite(float[] hslColor)334     private static boolean isWhite(float[] hslColor) {
335         return hslColor[2] >= WHITE_MIN_LIGHTNESS;
336     }
337 }
338