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