From cab7d8e0116066ad5f1d00e6a47ad1a65bf6dd80 Mon Sep 17 00:00:00 2001 From: Jeff DeCew Date: Wed, 19 May 2021 20:14:11 -0400 Subject: [PATCH] Resolve Notification.Colors once, instead of lazily. * Instead of resolving colors as-needed, resolve the entire palette potentially used by the notification at the start. * Centralize color logic and palette storage into Notification.Colors for easier unit testing. * Adds unit tests for the Notification.Colors, including validating color contrast ratios for various colors. * Fix a bug with ColorContrastUtil where the luminosity for dark/light font cutoff was 0.5, which resulted in a max contrast ratio of 1.9 for some colors. Now contrast ratios of 4.5:1 can be achieved for all colors. * Removes a few of the color accessors in Notification.Builder which were infrequently used, and replaces them with direct use of Notification.Colors. * Notification specific colors (primary, secondary, default) have only been used by colorized notifications for a few months. Remove the dark mode overrides of these, ensuring that colorized notifications have the same exact appearance in light and dark themes. * Replace the use of the "default" color with the "secondary text color"; this practically only affects the alerted bell icon, is more consistent, and reduces complexity. Fixes: 187881776 Test: manual Test: atest android.app.NotificationTest Change-Id: Ib8840d04093094c324d8bdce5b2e84c93a94db8d --- core/java/android/app/Notification.java | 454 +++++++++--------- .../internal/util/ContrastColorUtil.java | 5 +- core/res/res/values-night/colors.xml | 8 - core/tests/coretests/Android.bp | 1 + .../src/android/app/NotificationTest.java | 153 +++++- 5 files changed, 388 insertions(+), 233 deletions(-) diff --git a/core/java/android/app/Notification.java b/core/java/android/app/Notification.java index 18651411cf132..f18a88a24eef0 100644 --- a/core/java/android/app/Notification.java +++ b/core/java/android/app/Notification.java @@ -22,7 +22,6 @@ import static android.graphics.drawable.Icon.TYPE_URI_ADAPTIVE_BITMAP; import static java.util.Objects.requireNonNull; -import android.annotation.AttrRes; import android.annotation.ColorInt; import android.annotation.ColorRes; import android.annotation.DimenRes; @@ -36,6 +35,7 @@ import android.annotation.RequiresPermission; import android.annotation.SdkConstant; import android.annotation.SdkConstant.SdkConstantType; import android.annotation.StringRes; +import android.annotation.StyleableRes; import android.annotation.SuppressLint; import android.annotation.SystemApi; import android.annotation.TestApi; @@ -3749,20 +3749,12 @@ public class Notification implements Parcelable private boolean mIsLegacy; private boolean mIsLegacyInitialized; - /** - * Caches a contrast-enhanced version of {@link #mCachedContrastColorIsFor}. - */ - private int mCachedContrastColor = COLOR_INVALID; - private int mCachedContrastColorIsFor = COLOR_INVALID; - /** * Caches an instance of StandardTemplateParams. Note that this may have been used before, * so make sure to call {@link StandardTemplateParams#reset()} before using it. */ StandardTemplateParams mParams = new StandardTemplateParams(); - private int mTextColorsAreForBackground = COLOR_INVALID; - private int mPrimaryTextColor = COLOR_INVALID; - private int mSecondaryTextColor = COLOR_INVALID; + Colors mColors = new Colors(); private boolean mTintActionButtons; private boolean mInNightMode; @@ -5035,7 +5027,7 @@ public class Notification implements Parcelable contentView.setDrawableTint( R.id.phishing_alert, false /* targetBackground */, - getErrorColor(p), + getColors(p).getErrorColor(), PorterDuff.Mode.SRC_ATOP); } @@ -5082,8 +5074,8 @@ public class Notification implements Parcelable contentView.setDrawableTint( R.id.alerted_icon, false /* targetBackground */, - getHeaderIconColor(p), - PorterDuff.Mode.SRC_ATOP); + getColors(p).getSecondaryTextColor(), + PorterDuff.Mode.SRC_IN); } /** @@ -5211,8 +5203,7 @@ public class Notification implements Parcelable */ @VisibleForTesting public @ColorInt int getPrimaryTextColor(StandardTemplateParams p) { - ensureColors(p); - return mPrimaryTextColor; + return getColors(p).getPrimaryTextColor(); } /** @@ -5222,8 +5213,7 @@ public class Notification implements Parcelable */ @VisibleForTesting public @ColorInt int getSecondaryTextColor(StandardTemplateParams p) { - ensureColors(p); - return mSecondaryTextColor; + return getColors(p).getSecondaryTextColor(); } private void setTextViewColorSecondary(RemoteViews contentView, @IdRes int id, @@ -5231,29 +5221,9 @@ public class Notification implements Parcelable contentView.setTextColor(id, getSecondaryTextColor(p)); } - private void ensureColors(StandardTemplateParams p) { - int backgroundColor = getUnresolvedBackgroundColor(p); - if (mPrimaryTextColor == COLOR_INVALID - || mSecondaryTextColor == COLOR_INVALID - || mTextColorsAreForBackground != backgroundColor) { - mTextColorsAreForBackground = backgroundColor; - int defaultPrimaryTextColor = ContrastColorUtil.resolvePrimaryColor(mContext, - backgroundColor, mInNightMode); - int defaultSecondaryTextColor = ContrastColorUtil.resolveSecondaryColor(mContext, - backgroundColor, mInNightMode); - boolean colorized = backgroundColor != COLOR_DEFAULT; - if (colorized) { - mPrimaryTextColor = ContrastColorUtil.findAlphaToMeetContrast( - defaultPrimaryTextColor, backgroundColor, 4.5); - mSecondaryTextColor = ContrastColorUtil.findAlphaToMeetContrast( - defaultSecondaryTextColor, backgroundColor, 4.5); - } else { - mPrimaryTextColor = obtainThemeColor(R.attr.textColorPrimary, - defaultPrimaryTextColor); - mSecondaryTextColor = obtainThemeColor(R.attr.textColorSecondary, - defaultSecondaryTextColor); - } - } + private Colors getColors(StandardTemplateParams p) { + mColors.resolvePalette(mContext, mN.color, isBackgroundColorized(p), mInNightMode); + return mColors; } private void updateBackgroundColor(RemoteViews contentView, @@ -5278,7 +5248,7 @@ public class Notification implements Parcelable contentView.setProgressBar(R.id.progress, max, progress, ind); contentView.setProgressBackgroundTintList(R.id.progress, mContext.getColorStateList(R.color.notification_progress_background_color)); - ColorStateList progressTint = ColorStateList.valueOf(getAccentColor(p)); + ColorStateList progressTint = ColorStateList.valueOf(getPrimaryAccentColor(p)); contentView.setProgressTintList(R.id.progress, progressTint); contentView.setProgressIndeterminateTintList(R.id.progress, progressTint); return true; @@ -5412,13 +5382,13 @@ public class Notification implements Parcelable private void bindExpandButton(RemoteViews contentView, StandardTemplateParams p) { // set default colors int textColor = getPrimaryTextColor(p); - int pillColor = getProtectionColor(p); + int pillColor = getColors(p).getProtectionColor(); contentView.setInt(R.id.expand_button, "setDefaultTextColor", textColor); contentView.setInt(R.id.expand_button, "setDefaultPillColor", pillColor); // Use different highlighted colors for conversations' unread count if (p.mHighlightExpander) { textColor = getBackgroundColor(p); - pillColor = getAccentColor(p); + pillColor = getPrimaryAccentColor(p); } contentView.setInt(R.id.expand_button, "setHighlightTextColor", textColor); contentView.setInt(R.id.expand_button, "setHighlightPillColor", pillColor); @@ -5731,7 +5701,7 @@ public class Notification implements Parcelable showSpinner ? View.VISIBLE : View.GONE); big.setProgressIndeterminateTintList( R.id.notification_material_reply_progress, - ColorStateList.valueOf(getAccentColor(p))); + ColorStateList.valueOf(getPrimaryAccentColor(p))); if (replyText.length > 1 && !TextUtils.isEmpty(replyText[1].getText()) && p.maxRemoteInputHistory > 1) { @@ -6126,7 +6096,7 @@ public class Notification implements Parcelable // change the background bgColor CharSequence title = action.title; ColorStateList[] outResultColor = new ColorStateList[1]; - int background = getSecondaryAccentColor(p); + int background = getColors(p).getSecondaryAccentColor(); if (isLegacy()) { title = ContrastColorUtil.clearColorSpans(title); } else { @@ -6286,7 +6256,7 @@ public class Notification implements Parcelable if (largeIcon != null && isLegacy() && getColorUtil().isGrayscaleIcon(mContext, largeIcon)) { // resolve color will fall back to the default when legacy - int color = getContrastColor(p); + int color = getSmallIconColor(p); contentView.setInt(R.id.icon, "setOriginalIconColor", color); } } @@ -6302,14 +6272,7 @@ public class Notification implements Parcelable */ private @ColorInt int getStandardActionColor(Notification.StandardTemplateParams p) { return mTintActionButtons || isBackgroundColorized(p) - ? getAccentColor(p) : getNeutralColor(p); - } - - /** - * Gets a neutral color that can be used for icons or similar that should not stand out. - */ - private @ColorInt int getHeaderIconColor(StandardTemplateParams p) { - return isBackgroundColorized(p) ? getSecondaryTextColor(p) : getNeutralColor(p); + ? getPrimaryAccentColor(p) : getSecondaryTextColor(p); } /** @@ -6317,134 +6280,12 @@ public class Notification implements Parcelable * is the primary text color, otherwise it's the contrast-adjusted app-provided color. */ private @ColorInt int getSmallIconColor(StandardTemplateParams p) { - return getContrastColor(p); + return getColors(p).getContrastColor(); } - /** - * Gets the accent color for colored UI elements. If we're tinting with the theme - * accent, this is the theme accent color, otherwise this would be identical to - * {@link #getSmallIconColor(StandardTemplateParams)}. - */ - private @ColorInt int getAccentColor(StandardTemplateParams p) { - if (isBackgroundColorized(p)) { - return getPrimaryTextColor(p); - } - int color = obtainThemeColor(R.attr.colorAccent, COLOR_INVALID); - if (color != COLOR_INVALID) { - return color; - } - return getContrastColor(p); - } - - /** - * Gets the secondary accent color for colored UI elements. If we're tinting with the theme - * accent, this is the theme accent color, otherwise this would be identical to - * {@link #getSmallIconColor(StandardTemplateParams)}. - */ - private @ColorInt int getSecondaryAccentColor(StandardTemplateParams p) { - if (isBackgroundColorized(p)) { - return getSecondaryTextColor(p); - } - int color = obtainThemeColor(R.attr.colorAccentSecondary, COLOR_INVALID); - if (color != COLOR_INVALID) { - return color; - } - return getContrastColor(p); - } - - /** - * Gets the "surface protection" color from the theme, or a variant of the normal background - * color when colorized, or when not using theme color tints. - */ - private @ColorInt int getProtectionColor(StandardTemplateParams p) { - if (!isBackgroundColorized(p)) { - int color = obtainThemeColor(R.attr.colorBackgroundFloating, COLOR_INVALID); - if (color != COLOR_INVALID) { - return color; - } - } - // TODO(b/181048615): What color should we use for the expander pill when colorized - return ColorUtils.blendARGB(getPrimaryTextColor(p), getBackgroundColor(p), 0.8f); - } - - /** - * Gets the theme's error color, or the primary text color for colorized notifications. - */ - private @ColorInt int getErrorColor(StandardTemplateParams p) { - if (!isBackgroundColorized(p)) { - int color = obtainThemeColor(R.attr.colorError, COLOR_INVALID); - if (color != COLOR_INVALID) { - return color; - } - } - return getPrimaryTextColor(p); - } - - /** - * Gets the theme's background color - */ - private @ColorInt int getDefaultBackgroundColor() { - return obtainThemeColor(R.attr.colorSurface, - mInNightMode ? Color.BLACK : Color.WHITE); - } - - /** - * Gets the contrast-adjusted version of the color provided by the app. - */ - private @ColorInt int getContrastColor(StandardTemplateParams p) { - if (isBackgroundColorized(p)) { - return getPrimaryTextColor(p); - } - int rawColor = getRawColor(p); - if (mCachedContrastColorIsFor == rawColor && mCachedContrastColor != COLOR_INVALID) { - return mCachedContrastColor; - } - - int color; - // TODO: Maybe use getBackgroundColor(p) instead -- but doing so could break the cache - int background = getDefaultBackgroundColor(); - if (rawColor == COLOR_DEFAULT) { - ensureColors(p); - color = obtainThemeColor(R.attr.colorAccent, COLOR_INVALID); - if (color == COLOR_INVALID) { - color = ContrastColorUtil.resolveDefaultColor(mContext, background, - mInNightMode); - } - } else { - color = ContrastColorUtil.resolveContrastColor(mContext, rawColor, - background, mInNightMode); - } - if (Color.alpha(color) < 255) { - // alpha doesn't go well for color filters, so let's blend it manually - color = ContrastColorUtil.compositeColors(color, background); - } - mCachedContrastColorIsFor = rawColor; - return mCachedContrastColor = color; - } - - /** - * Return the raw color of this Notification, which doesn't necessarily satisfy contrast. - * - * @see #getContrastColor(StandardTemplateParams) for the contrasted color - * @param p the template params to inflate this with - */ - private @ColorInt int getRawColor(StandardTemplateParams p) { - return mN.color; - } - - /** - * Gets a neutral palette color; this is a contrast-satisfied version of the default color. - * @param p the template params to inflate this with - */ - private @ColorInt int getNeutralColor(StandardTemplateParams p) { - int background = getBackgroundColor(p); - int neutralColor = ContrastColorUtil.resolveDefaultColor(mContext, background, - mInNightMode); - if (Color.alpha(neutralColor) < 255) { - // alpha doesn't go well for color filters, so let's blend it manually - neutralColor = ContrastColorUtil.compositeColors(neutralColor, background); - } - return neutralColor; + /** @return the theme's accent color for colored UI elements. */ + private @ColorInt int getPrimaryAccentColor(StandardTemplateParams p) { + return getColors(p).getPrimaryAccentColor(); } /** @@ -6589,23 +6430,6 @@ public class Notification implements Parcelable return mN; } - /** - * Returns the color for the given Theme.DeviceDefault.DayNight attribute, or - * defValue if that could not be completed - */ - private @ColorInt int obtainThemeColor(@AttrRes int attrRes, @ColorInt int defaultColor) { - Resources.Theme theme = mContext.getTheme(); - if (theme == null) { - // Running unit tests with mocked context - return defaultColor; - } - theme = new ContextThemeWrapper(mContext, R.style.Theme_DeviceDefault_DayNight) - .getTheme(); - try (TypedArray ta = theme.obtainStyledAttributes(new int[]{attrRes})) { - return ta == null ? defaultColor : ta.getColor(0, defaultColor); - } - } - /** * Apply this Builder to an existing {@link Notification} object. * @@ -6716,24 +6540,8 @@ public class Notification implements Parcelable return R.layout.notification_material_action_tombstone; } - /** - * Gets the background color, with {@link #COLOR_DEFAULT} being a valid return value, - * which must be resolved by the caller before being used. - */ - private @ColorInt int getUnresolvedBackgroundColor(StandardTemplateParams p) { - return isBackgroundColorized(p) ? getRawColor(p) : COLOR_DEFAULT; - } - - /** - * Same as {@link #getUnresolvedBackgroundColor(StandardTemplateParams)} except that it - * also resolves the default color to the background. - */ private @ColorInt int getBackgroundColor(StandardTemplateParams p) { - int backgroundColor = getUnresolvedBackgroundColor(p); - if (backgroundColor == COLOR_DEFAULT) { - backgroundColor = getDefaultBackgroundColor(); - } - return backgroundColor; + return getColors(p).getBackgroundColor(); } private boolean textColorsNeedInversion() { @@ -9115,8 +8923,7 @@ public class Notification implements Parcelable container.setDrawableTint(buttonId, false, tintColor, PorterDuff.Mode.SRC_ATOP); - int rippleAlpha = Color.alpha(mBuilder.obtainThemeColor( - android.R.attr.colorControlHighlight, COLOR_DEFAULT)); + int rippleAlpha = mBuilder.getColors(p).getRippleAlpha(); int rippleColor = Color.argb(rippleAlpha, Color.red(tintColor), Color.green(tintColor), Color.blue(tintColor)); container.setRippleDrawableColor(buttonId, ColorStateList.valueOf(rippleColor)); @@ -12447,4 +12254,219 @@ public class Notification implements Parcelable return this; } } + + /** + * A utility which stores and calculates the palette of colors used to color notifications. + * @hide + */ + @VisibleForTesting + public static class Colors { + private int mPaletteIsForRawColor = COLOR_INVALID; + private boolean mPaletteIsForColorized = false; + private boolean mPaletteIsForNightMode = false; + // The following colors are the palette + private int mBackgroundColor = COLOR_INVALID; + private int mProtectionColor = COLOR_INVALID; + private int mPrimaryTextColor = COLOR_INVALID; + private int mSecondaryTextColor = COLOR_INVALID; + private int mPrimaryAccentColor = COLOR_INVALID; + private int mSecondaryAccentColor = COLOR_INVALID; + private int mErrorColor = COLOR_INVALID; + private int mContrastColor = COLOR_INVALID; + private int mRippleAlpha = 0x33; + + /** + * A utility for obtaining a TypedArray of the given DayNight-styled attributes, which + * returns null when the context is a mock with no theme. + * + * NOTE: Calling this method is expensive, as creating a new ContextThemeWrapper + * instances can allocate as much as 5MB of memory, so its important to call this method + * only when necessary, getting as many attributes as possible from each call. + * + * @see Resources.Theme#obtainStyledAttributes(int[]) + */ + @Nullable + private static TypedArray obtainDayNightAttributes(@NonNull Context ctx, + @NonNull @StyleableRes int[] attrs) { + // when testing, the mock context may have no theme + if (ctx.getTheme() == null) { + return null; + } + Resources.Theme theme = new ContextThemeWrapper(ctx, + R.style.Theme_DeviceDefault_DayNight).getTheme(); + return theme.obtainStyledAttributes(attrs); + } + + /** A null-safe wrapper of TypedArray.getColor because mocks return null */ + private static @ColorInt int getColor(@Nullable TypedArray ta, int index, + @ColorInt int defValue) { + return ta == null ? defValue : ta.getColor(index, defValue); + } + + /** + * Resolve the palette. If the inputs have not changed, this will be a no-op. + * This does not handle invalidating the resolved colors when the context itself changes, + * because that case does not happen in the current notification inflation pipeline; we will + * recreate a new builder (and thus a new palette) when reinflating notifications for a new + * theme (admittedly, we do the same for night mode, but that's easy to check). + * + * @param ctx the builder context. + * @param rawColor the notification's color; may be COLOR_DEFAULT, but may never have alpha. + * @param isColorized whether the notification is colorized. + * @param nightMode whether the UI is in night mode. + */ + public void resolvePalette(Context ctx, int rawColor, + boolean isColorized, boolean nightMode) { + if (mPaletteIsForRawColor == rawColor + && mPaletteIsForColorized == isColorized + && mPaletteIsForNightMode == nightMode) { + return; + } + mPaletteIsForRawColor = rawColor; + mPaletteIsForColorized = isColorized; + mPaletteIsForNightMode = nightMode; + + if (isColorized) { + if (rawColor == COLOR_DEFAULT) { + int[] attrs = {R.attr.colorAccentTertiary}; + try (TypedArray ta = obtainDayNightAttributes(ctx, attrs)) { + mBackgroundColor = getColor(ta, 0, Color.WHITE); + } + } else { + mBackgroundColor = rawColor; + } + mProtectionColor = COLOR_INVALID; // filled in at the end + mPrimaryTextColor = ContrastColorUtil.findAlphaToMeetContrast( + ContrastColorUtil.resolvePrimaryColor(ctx, mBackgroundColor, nightMode), + mBackgroundColor, 4.5); + mSecondaryTextColor = ContrastColorUtil.findAlphaToMeetContrast( + ContrastColorUtil.resolveSecondaryColor(ctx, mBackgroundColor, nightMode), + mBackgroundColor, 4.5); + mContrastColor = mPrimaryTextColor; + mPrimaryAccentColor = mPrimaryTextColor; + mSecondaryAccentColor = mSecondaryTextColor; + mErrorColor = mPrimaryTextColor; + mRippleAlpha = 0x33; + } else { + int[] attrs = { + R.attr.colorBackground, + R.attr.colorBackgroundFloating, + R.attr.textColorPrimary, + R.attr.textColorSecondary, + R.attr.colorAccent, + R.attr.colorAccentSecondary, + R.attr.colorError, + R.attr.colorControlHighlight + }; + try (TypedArray ta = obtainDayNightAttributes(ctx, attrs)) { + mBackgroundColor = getColor(ta, 0, nightMode ? Color.BLACK : Color.WHITE); + mProtectionColor = getColor(ta, 1, COLOR_INVALID); + mPrimaryTextColor = getColor(ta, 2, COLOR_INVALID); + mSecondaryTextColor = getColor(ta, 3, COLOR_INVALID); + mPrimaryAccentColor = getColor(ta, 4, COLOR_INVALID); + mSecondaryAccentColor = getColor(ta, 5, COLOR_INVALID); + mErrorColor = getColor(ta, 6, COLOR_INVALID); + mRippleAlpha = Color.alpha(getColor(ta, 7, 0x33ffffff)); + } + mContrastColor = calculateContrastColor(ctx, rawColor, mPrimaryAccentColor, + mBackgroundColor, nightMode); + + // make sure every color has a valid value + if (mPrimaryTextColor == COLOR_INVALID) { + mPrimaryTextColor = ContrastColorUtil.resolvePrimaryColor( + ctx, mBackgroundColor, nightMode); + } + if (mSecondaryTextColor == COLOR_INVALID) { + mSecondaryTextColor = ContrastColorUtil.resolveSecondaryColor( + ctx, mBackgroundColor, nightMode); + } + if (mPrimaryAccentColor == COLOR_INVALID) { + mPrimaryAccentColor = mContrastColor; + } + if (mSecondaryAccentColor == COLOR_INVALID) { + mSecondaryAccentColor = mContrastColor; + } + if (mErrorColor == COLOR_INVALID) { + mErrorColor = mPrimaryTextColor; + } + } + // make sure every color has a valid value + if (mProtectionColor == COLOR_INVALID) { + mProtectionColor = ColorUtils.blendARGB(mPrimaryTextColor, mBackgroundColor, 0.8f); + } + } + + /** calculates the contrast color for the non-colorized notifications */ + private static @ColorInt int calculateContrastColor(Context ctx, @ColorInt int rawColor, + @ColorInt int accentColor, @ColorInt int backgroundColor, boolean nightMode) { + int color; + if (rawColor == COLOR_DEFAULT) { + color = accentColor; + if (color == COLOR_INVALID) { + color = ContrastColorUtil.resolveDefaultColor(ctx, backgroundColor, nightMode); + } + } else { + color = ContrastColorUtil.resolveContrastColor(ctx, rawColor, backgroundColor, + nightMode); + } + return flattenAlpha(color, backgroundColor); + } + + /** remove any alpha by manually blending it with the given background. */ + private static @ColorInt int flattenAlpha(@ColorInt int color, @ColorInt int background) { + return Color.alpha(color) == 0xff ? color + : ContrastColorUtil.compositeColors(color, background); + } + + /** @return the notification's background color */ + public @ColorInt int getBackgroundColor() { + return mBackgroundColor; + } + + /** + * @return the "surface protection" color from the theme, + * or a variant of the normal background color when colorized. + */ + public @ColorInt int getProtectionColor() { + return mProtectionColor; + } + + /** @return the color for the most prominent text */ + public @ColorInt int getPrimaryTextColor() { + return mPrimaryTextColor; + } + + /** @return the color for less prominent text */ + public @ColorInt int getSecondaryTextColor() { + return mSecondaryTextColor; + } + + /** @return the theme's accent color for colored UI elements. */ + public @ColorInt int getPrimaryAccentColor() { + return mPrimaryAccentColor; + } + + /** @return the theme's secondary accent color for colored UI elements. */ + public @ColorInt int getSecondaryAccentColor() { + return mSecondaryAccentColor; + } + + /** + * @return the contrast-adjusted version of the color provided by the app, or the + * primary text color when colorized. + */ + public @ColorInt int getContrastColor() { + return mContrastColor; + } + + /** @return the theme's error color, or the primary text color when colorized. */ + public @ColorInt int getErrorColor() { + return mErrorColor; + } + + /** @return the alpha component of the current theme's control highlight color. */ + public int getRippleAlpha() { + return mRippleAlpha; + } + } } diff --git a/core/java/com/android/internal/util/ContrastColorUtil.java b/core/java/com/android/internal/util/ContrastColorUtil.java index 8508a8dd71a49..8b3c1337c0c8d 100644 --- a/core/java/com/android/internal/util/ContrastColorUtil.java +++ b/core/java/com/android/internal/util/ContrastColorUtil.java @@ -595,7 +595,9 @@ public class ContrastColorUtil { if (backgroundColor == Notification.COLOR_DEFAULT) { return !defaultBackgroundIsDark; } - return ColorUtilsFromCompat.calculateLuminance(backgroundColor) > 0.5; + // Color contrast ratio luminance midpoint, X: 1.05 / (X + 0.05) = (X + 0.05) / 0.05 + // Solved as X = sqrt(.05 * 1.05) - 0.05 = 0.17912878474 + return ColorUtilsFromCompat.calculateLuminance(backgroundColor) > 0.17912878474; } public static double calculateLuminance(int backgroundColor) { @@ -619,6 +621,7 @@ public class ContrastColorUtil { } public static boolean isColorLight(int backgroundColor) { + // TODO(b/188947832): Use 0.17912878474 instead of 0.5 to ensure better contrast return calculateLuminance(backgroundColor) > 0.5f; } diff --git a/core/res/res/values-night/colors.xml b/core/res/res/values-night/colors.xml index baffa5a82cdb1..816ddd41ccd36 100644 --- a/core/res/res/values-night/colors.xml +++ b/core/res/res/values-night/colors.xml @@ -17,14 +17,6 @@ NOTE: You might also want to edit: packages/SystemUI/res/values-night/colors.xml --> - - #ddffffff - - - #b2ffffff - - #ddffffff @color/notification_primary_text_color_dark @color/notification_secondary_text_color_dark diff --git a/core/tests/coretests/Android.bp b/core/tests/coretests/Android.bp index 415b1f27b5bb5..93e4a294a2cb2 100644 --- a/core/tests/coretests/Android.bp +++ b/core/tests/coretests/Android.bp @@ -41,6 +41,7 @@ android_test { "frameworks-core-util-lib", "mockwebserver", "guava", + "androidx.core_core", "androidx.test.espresso.core", "androidx.test.ext.junit", "androidx.test.runner", diff --git a/core/tests/coretests/src/android/app/NotificationTest.java b/core/tests/coretests/src/android/app/NotificationTest.java index 0f8c9e2de826e..cd07d464ee65d 100644 --- a/core/tests/coretests/src/android/app/NotificationTest.java +++ b/core/tests/coretests/src/android/app/NotificationTest.java @@ -16,6 +16,14 @@ package android.app; +import static androidx.core.graphics.ColorUtils.calculateContrast; + +import static com.android.compatibility.common.util.SystemUtil.runShellCommand; + +import static com.google.common.truth.Truth.assertThat; + +import static junit.framework.Assert.fail; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotSame; @@ -27,10 +35,10 @@ import android.annotation.Nullable; import android.content.Context; import android.content.Intent; import android.content.LocusId; +import android.content.res.Configuration; import android.graphics.BitmapFactory; import android.graphics.Color; import android.graphics.drawable.Icon; -import android.media.session.MediaSession; import android.os.Build; import android.os.Parcel; import android.os.Parcelable; @@ -325,13 +333,142 @@ public class NotificationTest { assertNull(clone.getLocusId()); } - private Notification.Builder getMediaNotification() { - MediaSession session = new MediaSession(mContext, "test"); - return new Notification.Builder(mContext, "color") - .setSmallIcon(com.android.internal.R.drawable.emergency_icon) - .setContentTitle("Title") - .setContentText("Text") - .setStyle(new Notification.MediaStyle().setMediaSession(session.getSessionToken())); + @Test + public void testColors_ensureColors_dayMode_producesValidPalette() { + Notification.Colors c = new Notification.Colors(); + boolean colorized = false; + boolean nightMode = false; + resolveColorsInNightMode(nightMode, c, Color.BLUE, colorized); + assertValid(c); + } + + @Test + public void testColors_ensureColors_nightMode_producesValidPalette() { + Notification.Colors c = new Notification.Colors(); + boolean colorized = false; + boolean nightMode = true; + resolveColorsInNightMode(nightMode, c, Color.BLUE, colorized); + assertValid(c); + } + + @Test + public void testColors_ensureColors_colorized_producesValidPalette_default() { + validateColorizedPaletteForColor(Notification.COLOR_DEFAULT); + } + + @Test + public void testColors_ensureColors_colorized_producesValidPalette_blue() { + validateColorizedPaletteForColor(Color.BLUE); + } + + @Test + public void testColors_ensureColors_colorized_producesValidPalette_red() { + validateColorizedPaletteForColor(Color.RED); + } + + @Test + public void testColors_ensureColors_colorized_producesValidPalette_white() { + validateColorizedPaletteForColor(Color.WHITE); + } + + @Test + public void testColors_ensureColors_colorized_producesValidPalette_black() { + validateColorizedPaletteForColor(Color.BLACK); + } + + public void validateColorizedPaletteForColor(int rawColor) { + Notification.Colors cDay = new Notification.Colors(); + Notification.Colors cNight = new Notification.Colors(); + boolean colorized = true; + + resolveColorsInNightMode(false, cDay, rawColor, colorized); + resolveColorsInNightMode(true, cNight, rawColor, colorized); + + if (rawColor != Notification.COLOR_DEFAULT) { + assertEquals(rawColor, cDay.getBackgroundColor()); + assertEquals(rawColor, cNight.getBackgroundColor()); + } + + assertValid(cDay); + assertValid(cNight); + + if (rawColor != Notification.COLOR_DEFAULT) { + // When a color is provided, night mode should have no effect on the notification + assertEquals(cDay.getBackgroundColor(), cNight.getBackgroundColor()); + assertEquals(cDay.getPrimaryTextColor(), cNight.getPrimaryTextColor()); + assertEquals(cDay.getSecondaryTextColor(), cNight.getSecondaryTextColor()); + assertEquals(cDay.getPrimaryAccentColor(), cNight.getPrimaryAccentColor()); + assertEquals(cDay.getSecondaryAccentColor(), cNight.getSecondaryAccentColor()); + assertEquals(cDay.getProtectionColor(), cNight.getProtectionColor()); + assertEquals(cDay.getContrastColor(), cNight.getContrastColor()); + assertEquals(cDay.getRippleAlpha(), cNight.getRippleAlpha()); + } + } + + private void assertValid(Notification.Colors c) { + // Assert that all colors are populated + assertThat(c.getBackgroundColor()).isNotEqualTo(Notification.COLOR_INVALID); + assertThat(c.getProtectionColor()).isNotEqualTo(Notification.COLOR_INVALID); + assertThat(c.getPrimaryTextColor()).isNotEqualTo(Notification.COLOR_INVALID); + assertThat(c.getSecondaryTextColor()).isNotEqualTo(Notification.COLOR_INVALID); + assertThat(c.getPrimaryAccentColor()).isNotEqualTo(Notification.COLOR_INVALID); + assertThat(c.getSecondaryAccentColor()).isNotEqualTo(Notification.COLOR_INVALID); + assertThat(c.getErrorColor()).isNotEqualTo(Notification.COLOR_INVALID); + assertThat(c.getContrastColor()).isNotEqualTo(Notification.COLOR_INVALID); + assertThat(c.getRippleAlpha()).isAtLeast(0x00); + assertThat(c.getRippleAlpha()).isAtMost(0xff); + + // Assert that various colors have sufficient contrast + assertContrastIsAtLeast(c.getPrimaryTextColor(), c.getBackgroundColor(), 4.5); + assertContrastIsAtLeast(c.getSecondaryTextColor(), c.getBackgroundColor(), 4.5); + assertContrastIsAtLeast(c.getPrimaryAccentColor(), c.getBackgroundColor(), 4.5); + assertContrastIsAtLeast(c.getErrorColor(), c.getBackgroundColor(), 4.5); + assertContrastIsAtLeast(c.getContrastColor(), c.getBackgroundColor(), 4.5); + + // This accent color is only used for emphasized buttons + assertContrastIsAtLeast(c.getSecondaryAccentColor(), c.getBackgroundColor(), 1); + } + + private void assertContrastIsAtLeast(int foreground, int background, double minContrast) { + try { + assertThat(calculateContrast(foreground, background)).isAtLeast(minContrast); + } catch (AssertionError e) { + throw new AssertionError( + String.format("Insufficient contrast: foreground=#%08x background=#%08x", + foreground, background), e); + } + } + + private void resolveColorsInNightMode(boolean nightMode, Notification.Colors c, int rawColor, + boolean colorized) { + runInNightMode(nightMode, + () -> c.resolvePalette(mContext, rawColor, colorized, nightMode)); + } + + private void runInNightMode(boolean nightMode, Runnable task) { + final String initialNightMode = changeNightMode(nightMode); + try { + Configuration currentConfig = mContext.getResources().getConfiguration(); + boolean isNightMode = (currentConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) + == Configuration.UI_MODE_NIGHT_YES; + assertEquals(nightMode, isNightMode); + task.run(); + } finally { + runShellCommand("cmd uimode night " + initialNightMode); + } + } + + + // Change the night mode and return the previous mode + private String changeNightMode(boolean nightMode) { + final String nightModeText = runShellCommand("cmd uimode night"); + final String[] nightModeSplit = nightModeText.split(":"); + if (nightModeSplit.length != 2) { + fail("Failed to get initial night mode value from " + nightModeText); + } + String previousMode = nightModeSplit[1].trim(); + runShellCommand("cmd uimode night " + (nightMode ? "yes" : "no")); + return previousMode; } /**