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.support.annotation.VisibleForTesting; 27 import android.support.v7.graphics.Palette; 28 import android.util.LayoutDirection; 29 30 import com.android.internal.util.NotificationColorUtil; 31 import com.android.systemui.R; 32 33 import java.util.List; 34 35 /** 36 * A class the processes media notifications and extracts the right text and background colors. 37 */ 38 public class MediaNotificationProcessor { 39 40 /** 41 * The fraction below which we select the vibrant instead of the light/dark vibrant color 42 */ 43 private static final float POPULATION_FRACTION_FOR_MORE_VIBRANT = 1.0f; 44 45 /** 46 * Minimum saturation that a muted color must have if there exists if deciding between two 47 * colors 48 */ 49 private static final float MIN_SATURATION_WHEN_DECIDING = 0.19f; 50 51 /** 52 * Minimum fraction that any color must have to be picked up as a text color 53 */ 54 private static final double MINIMUM_IMAGE_FRACTION = 0.002; 55 56 /** 57 * The population fraction to select the dominant color as the text color over a the colored 58 * ones. 59 */ 60 private static final float POPULATION_FRACTION_FOR_DOMINANT = 0.01f; 61 62 /** 63 * The population fraction to select a white or black color as the background over a color. 64 */ 65 private static final float POPULATION_FRACTION_FOR_WHITE_OR_BLACK = 2.5f; 66 private static final float BLACK_MAX_LIGHTNESS = 0.08f; 67 private static final float WHITE_MIN_LIGHTNESS = 0.90f; 68 private static final int RESIZE_BITMAP_AREA = 150 * 150; 69 private final ImageGradientColorizer mColorizer; 70 private final Context mContext; 71 private float[] mFilteredBackgroundHsl = null; 72 private 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 80 public MediaNotificationProcessor(Context context, Context packageContext) { 81 this(context, packageContext, new ImageGradientColorizer()); 82 } 83 84 @VisibleForTesting 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 */ 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 // for the background we only take the left side of the image to ensure 124 // a smooth transition 125 Palette.Builder paletteBuilder = Palette.from(bitmap) 126 .setRegion(0, 0, bitmap.getWidth() / 2, bitmap.getHeight()) 127 .clearFilters() // we want all colors, red / white / black ones too! 128 .resizeBitmapArea(RESIZE_BITMAP_AREA); 129 Palette palette = paletteBuilder.generate(); 130 backgroundColor = findBackgroundColorAndFilter(palette); 131 // we want most of the full region again, slightly shifted to the right 132 float textColorStartWidthFraction = 0.4f; 133 paletteBuilder.setRegion((int) (bitmap.getWidth() * textColorStartWidthFraction), 0, 134 bitmap.getWidth(), 135 bitmap.getHeight()); 136 if (mFilteredBackgroundHsl != null) { 137 paletteBuilder.addFilter((rgb, hsl) -> { 138 // at least 10 degrees hue difference 139 float diff = Math.abs(hsl[0] - mFilteredBackgroundHsl[0]); 140 return diff > 10 && diff < 350; 141 }); 142 } 143 paletteBuilder.addFilter(mBlackWhiteFilter); 144 palette = paletteBuilder.generate(); 145 int foregroundColor = selectForegroundColor(backgroundColor, palette); 146 builder.setColorPalette(backgroundColor, foregroundColor); 147 } else { 148 backgroundColor = mContext.getColor(R.color.notification_material_background_color); 149 } 150 Bitmap colorized = mColorizer.colorize(drawable, backgroundColor, 151 mContext.getResources().getConfiguration().getLayoutDirection() == 152 LayoutDirection.RTL); 153 builder.setLargeIcon(Icon.createWithBitmap(colorized)); 154 } 155 } 156 157 private int selectForegroundColor(int backgroundColor, Palette palette) { 158 if (NotificationColorUtil.isColorLight(backgroundColor)) { 159 return selectForegroundColorForSwatches(palette.getDarkVibrantSwatch(), 160 palette.getVibrantSwatch(), 161 palette.getDarkMutedSwatch(), 162 palette.getMutedSwatch(), 163 palette.getDominantSwatch(), 164 Color.BLACK); 165 } else { 166 return selectForegroundColorForSwatches(palette.getLightVibrantSwatch(), 167 palette.getVibrantSwatch(), 168 palette.getLightMutedSwatch(), 169 palette.getMutedSwatch(), 170 palette.getDominantSwatch(), 171 Color.WHITE); 172 } 173 } 174 175 private int selectForegroundColorForSwatches(Palette.Swatch moreVibrant, 176 Palette.Swatch vibrant, Palette.Swatch moreMutedSwatch, Palette.Swatch mutedSwatch, 177 Palette.Swatch dominantSwatch, int fallbackColor) { 178 Palette.Swatch coloredCandidate = selectVibrantCandidate(moreVibrant, vibrant); 179 if (coloredCandidate == null) { 180 coloredCandidate = selectMutedCandidate(mutedSwatch, moreMutedSwatch); 181 } 182 if (coloredCandidate != null) { 183 if (dominantSwatch == coloredCandidate) { 184 return coloredCandidate.getRgb(); 185 } else if ((float) coloredCandidate.getPopulation() / dominantSwatch.getPopulation() 186 < POPULATION_FRACTION_FOR_DOMINANT 187 && dominantSwatch.getHsl()[1] > MIN_SATURATION_WHEN_DECIDING) { 188 return dominantSwatch.getRgb(); 189 } else { 190 return coloredCandidate.getRgb(); 191 } 192 } else if (hasEnoughPopulation(dominantSwatch)) { 193 return dominantSwatch.getRgb(); 194 } else { 195 return fallbackColor; 196 } 197 } 198 199 private Palette.Swatch selectMutedCandidate(Palette.Swatch first, 200 Palette.Swatch second) { 201 boolean firstValid = hasEnoughPopulation(first); 202 boolean secondValid = hasEnoughPopulation(second); 203 if (firstValid && secondValid) { 204 float firstSaturation = first.getHsl()[1]; 205 float secondSaturation = second.getHsl()[1]; 206 float populationFraction = first.getPopulation() / (float) second.getPopulation(); 207 if (firstSaturation * populationFraction > secondSaturation) { 208 return first; 209 } else { 210 return second; 211 } 212 } else if (firstValid) { 213 return first; 214 } else if (secondValid) { 215 return second; 216 } 217 return null; 218 } 219 220 private Palette.Swatch selectVibrantCandidate(Palette.Swatch first, Palette.Swatch second) { 221 boolean firstValid = hasEnoughPopulation(first); 222 boolean secondValid = hasEnoughPopulation(second); 223 if (firstValid && secondValid) { 224 int firstPopulation = first.getPopulation(); 225 int secondPopulation = second.getPopulation(); 226 if (firstPopulation / (float) secondPopulation 227 < POPULATION_FRACTION_FOR_MORE_VIBRANT) { 228 return second; 229 } else { 230 return first; 231 } 232 } else if (firstValid) { 233 return first; 234 } else if (secondValid) { 235 return second; 236 } 237 return null; 238 } 239 240 private boolean hasEnoughPopulation(Palette.Swatch swatch) { 241 // We want a fraction that is at least 1% of the image 242 return swatch != null 243 && (swatch.getPopulation() / (float) RESIZE_BITMAP_AREA > MINIMUM_IMAGE_FRACTION); 244 } 245 246 private int findBackgroundColorAndFilter(Palette palette) { 247 // by default we use the dominant palette 248 Palette.Swatch dominantSwatch = palette.getDominantSwatch(); 249 if (dominantSwatch == null) { 250 // We're not filtering on white or black 251 mFilteredBackgroundHsl = null; 252 return Color.WHITE; 253 } 254 255 if (!isWhiteOrBlack(dominantSwatch.getHsl())) { 256 mFilteredBackgroundHsl = dominantSwatch.getHsl(); 257 return dominantSwatch.getRgb(); 258 } 259 // Oh well, we selected black or white. Lets look at the second color! 260 List<Palette.Swatch> swatches = palette.getSwatches(); 261 float highestNonWhitePopulation = -1; 262 Palette.Swatch second = null; 263 for (Palette.Swatch swatch: swatches) { 264 if (swatch != dominantSwatch 265 && swatch.getPopulation() > highestNonWhitePopulation 266 && !isWhiteOrBlack(swatch.getHsl())) { 267 second = swatch; 268 highestNonWhitePopulation = swatch.getPopulation(); 269 } 270 } 271 if (second == null) { 272 // We're not filtering on white or black 273 mFilteredBackgroundHsl = null; 274 return dominantSwatch.getRgb(); 275 } 276 if (dominantSwatch.getPopulation() / highestNonWhitePopulation 277 > POPULATION_FRACTION_FOR_WHITE_OR_BLACK) { 278 // The dominant swatch is very dominant, lets take it! 279 // We're not filtering on white or black 280 mFilteredBackgroundHsl = null; 281 return dominantSwatch.getRgb(); 282 } else { 283 mFilteredBackgroundHsl = second.getHsl(); 284 return second.getRgb(); 285 } 286 } 287 288 private boolean isWhiteOrBlack(float[] hsl) { 289 return isBlack(hsl) || isWhite(hsl); 290 } 291 292 293 /** 294 * @return true if the color represents a color which is close to black. 295 */ 296 private boolean isBlack(float[] hslColor) { 297 return hslColor[2] <= BLACK_MAX_LIGHTNESS; 298 } 299 300 /** 301 * @return true if the color represents a color which is close to white. 302 */ 303 private boolean isWhite(float[] hslColor) { 304 return hslColor[2] >= WHITE_MIN_LIGHTNESS; 305 } 306 } 307