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