diff --git a/core/java/android/app/Notification.java b/core/java/android/app/Notification.java index e39636dc45fb0..91605b78a56bb 100644 --- a/core/java/android/app/Notification.java +++ b/core/java/android/app/Notification.java @@ -6121,21 +6121,22 @@ public class Notification implements Parcelable if (emphasizedMode) { // change the background bgColor CharSequence title = action.title; - ColorStateList[] outResultColor = new ColorStateList[1]; int buttonFillColor = getColors(p).getSecondaryAccentColor(); if (isLegacy()) { title = ContrastColorUtil.clearColorSpans(title); } else { - int notifBackgroundColor = getColors(p).getBackgroundColor(); - title = ensureColorSpanContrast(title, notifBackgroundColor, outResultColor); + // Check for a full-length span color to use as the button fill color. + Integer fullLengthColor = getFullLengthSpanColor(title); + if (fullLengthColor != null) { + // Ensure the custom button fill has 3:1 contrast w/ notification bg. + int notifBackgroundColor = getColors(p).getBackgroundColor(); + buttonFillColor = ensureButtonFillContrast( + fullLengthColor, notifBackgroundColor); + } + // Remove full-length color spans and ensure text contrast with the button fill. + title = ensureColorSpanContrast(title, buttonFillColor); } button.setTextViewText(R.id.action0, processTextSpans(title)); - boolean hasColorOverride = outResultColor[0] != null; - if (hasColorOverride) { - // There's a span spanning the full text, let's take it and use it as the - // background color - buttonFillColor = outResultColor[0].getDefaultColor(); - } final int textColor = ContrastColorUtil.resolvePrimaryColor(mContext, buttonFillColor, mInNightMode); button.setTextColor(R.id.action0, textColor); @@ -6168,17 +6169,58 @@ public class Notification implements Parcelable } /** - * Ensures contrast on color spans against a background color. also returns the color of the - * text if a span was found that spans over the whole text. + * Extract the color from a full-length span from the text. + * + * @param charSequence the charSequence containing spans + * @return the raw color of the text's last full-length span containing a color, or null if + * no full-length span sets the text color. + * @hide + */ + @VisibleForTesting + @Nullable + public static Integer getFullLengthSpanColor(CharSequence charSequence) { + // NOTE: this method preserves the functionality that for a CharSequence with multiple + // full-length spans, the color of the last one is used. + Integer result = null; + if (charSequence instanceof Spanned) { + Spanned ss = (Spanned) charSequence; + Object[] spans = ss.getSpans(0, ss.length(), Object.class); + // First read through all full-length spans to get the button fill color, which will + // be used as the background color for ensuring contrast of non-full-length spans. + for (Object span : spans) { + int spanStart = ss.getSpanStart(span); + int spanEnd = ss.getSpanEnd(span); + boolean fullLength = (spanEnd - spanStart) == charSequence.length(); + if (!fullLength) { + continue; + } + if (span instanceof TextAppearanceSpan) { + TextAppearanceSpan originalSpan = (TextAppearanceSpan) span; + ColorStateList textColor = originalSpan.getTextColor(); + if (textColor != null) { + result = textColor.getDefaultColor(); + } + } else if (span instanceof ForegroundColorSpan) { + ForegroundColorSpan originalSpan = (ForegroundColorSpan) span; + result = originalSpan.getForegroundColor(); + } + } + } + return result; + } + + /** + * Ensures contrast on color spans against a background color. + * Note that any full-length color spans will be removed instead of being contrasted. * * @param charSequence the charSequence on which the spans are * @param background the background color to ensure the contrast against - * @param outResultColor an array in which a color will be returned as the first element if - * there exists a full length color span. * @return the contrasted charSequence + * @hide */ - private static CharSequence ensureColorSpanContrast(CharSequence charSequence, - int background, ColorStateList[] outResultColor) { + @VisibleForTesting + public static CharSequence ensureColorSpanContrast(CharSequence charSequence, + int background) { if (charSequence instanceof Spanned) { Spanned ss = (Spanned) charSequence; Object[] spans = ss.getSpans(0, ss.length(), Object.class); @@ -6195,19 +6237,19 @@ public class Notification implements Parcelable TextAppearanceSpan originalSpan = (TextAppearanceSpan) resultSpan; ColorStateList textColor = originalSpan.getTextColor(); if (textColor != null) { - int[] colors = textColor.getColors(); - int[] newColors = new int[colors.length]; - for (int i = 0; i < newColors.length; i++) { - boolean isBgDark = isColorDark(background); - newColors[i] = ContrastColorUtil.ensureLargeTextContrast( - colors[i], background, isBgDark); - } - textColor = new ColorStateList(textColor.getStates().clone(), - newColors); if (fullLength) { - outResultColor[0] = textColor; // Let's drop the color from the span textColor = null; + } else { + int[] colors = textColor.getColors(); + int[] newColors = new int[colors.length]; + for (int i = 0; i < newColors.length; i++) { + boolean isBgDark = isColorDark(background); + newColors[i] = ContrastColorUtil.ensureLargeTextContrast( + colors[i], background, isBgDark); + } + textColor = new ColorStateList(textColor.getStates().clone(), + newColors); } resultSpan = new TextAppearanceSpan( originalSpan.getFamily(), @@ -6217,15 +6259,14 @@ public class Notification implements Parcelable originalSpan.getLinkTextColor()); } } else if (resultSpan instanceof ForegroundColorSpan) { - ForegroundColorSpan originalSpan = (ForegroundColorSpan) resultSpan; - int foregroundColor = originalSpan.getForegroundColor(); - boolean isBgDark = isColorDark(background); - foregroundColor = ContrastColorUtil.ensureLargeTextContrast( - foregroundColor, background, isBgDark); if (fullLength) { - outResultColor[0] = ColorStateList.valueOf(foregroundColor); resultSpan = null; } else { + ForegroundColorSpan originalSpan = (ForegroundColorSpan) resultSpan; + int foregroundColor = originalSpan.getForegroundColor(); + boolean isBgDark = isColorDark(background); + foregroundColor = ContrastColorUtil.ensureLargeTextContrast( + foregroundColor, background, isBgDark); resultSpan = new ForegroundColorSpan(foregroundColor); } } else { @@ -6254,6 +6295,21 @@ public class Notification implements Parcelable return ContrastColorUtil.calculateLuminance(color) <= 0.17912878474; } + /** + * Finds a button fill color with sufficient contrast over bg (3:1) that has the same hue + * as the original color, but is lightened or darkened depending on whether the background + * is dark or light. + * + * @hide + */ + @VisibleForTesting + public static int ensureButtonFillContrast(int color, int bg) { + return isColorDark(bg) + ? ContrastColorUtil.findContrastColorAgainstDark(color, bg, true, 3) + : ContrastColorUtil.findContrastColor(color, bg, true, 3); + } + + /** * @return Whether we are currently building a notification from a legacy (an app that * doesn't create material notifications by itself) app. diff --git a/core/tests/coretests/src/android/app/NotificationTest.java b/core/tests/coretests/src/android/app/NotificationTest.java index 685671b083c4a..02d8a4b783a51 100644 --- a/core/tests/coretests/src/android/app/NotificationTest.java +++ b/core/tests/coretests/src/android/app/NotificationTest.java @@ -16,9 +16,11 @@ package android.app; -import static androidx.core.graphics.ColorUtils.calculateContrast; +import static android.app.Notification.Builder.ensureColorSpanContrast; import static com.android.compatibility.common.util.SystemUtil.runShellCommand; +import static com.android.internal.util.ContrastColorUtilTest.assertContrastIsAtLeast; +import static com.android.internal.util.ContrastColorUtilTest.assertContrastIsWithinRange; import static com.google.common.truth.Truth.assertThat; @@ -35,6 +37,7 @@ import android.annotation.Nullable; import android.content.Context; import android.content.Intent; import android.content.LocusId; +import android.content.res.ColorStateList; import android.content.res.Configuration; import android.graphics.BitmapFactory; import android.graphics.Color; @@ -42,12 +45,21 @@ import android.graphics.drawable.Icon; import android.os.Build; import android.os.Parcel; import android.os.Parcelable; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.TextAppearanceSpan; import android.widget.RemoteViews; import androidx.test.InstrumentationRegistry; import androidx.test.filters.SmallTest; import androidx.test.runner.AndroidJUnit4; +import com.android.internal.R; +import com.android.internal.util.ContrastColorUtil; + import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -333,6 +345,163 @@ public class NotificationTest { assertNull(clone.getLocusId()); } + @Test + public void testBuilder_getFullLengthSpanColor_returnsNullForString() { + assertThat(Notification.Builder.getFullLengthSpanColor("String")).isNull(); + } + + @Test + public void testBuilder_getFullLengthSpanColor_returnsNullWithPartialSpan() { + CharSequence text = new SpannableStringBuilder() + .append("text with ") + .append("some red", new ForegroundColorSpan(Color.RED), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + assertThat(Notification.Builder.getFullLengthSpanColor(text)).isNull(); + } + + @Test + public void testBuilder_getFullLengthSpanColor_worksWithSingleSpan() { + CharSequence text = new SpannableStringBuilder() + .append("text that is all red", new ForegroundColorSpan(Color.RED), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + assertThat(Notification.Builder.getFullLengthSpanColor(text)).isEqualTo(Color.RED); + } + + @Test + public void testBuilder_getFullLengthSpanColor_worksWithFullAndPartialSpans() { + Spannable text = new SpannableString("blue text with yellow and green"); + text.setSpan(new ForegroundColorSpan(Color.YELLOW), 15, 21, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(new ForegroundColorSpan(Color.BLUE), 0, text.length(), + Spanned.SPAN_INCLUSIVE_INCLUSIVE); + text.setSpan(new ForegroundColorSpan(Color.GREEN), 26, 31, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + assertThat(Notification.Builder.getFullLengthSpanColor(text)).isEqualTo(Color.BLUE); + } + + @Test + public void testBuilder_getFullLengthSpanColor_worksWithTextAppearance() { + Spannable text = new SpannableString("title text with yellow and green"); + text.setSpan(new ForegroundColorSpan(Color.YELLOW), 15, 21, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + TextAppearanceSpan textAppearanceSpan = new TextAppearanceSpan(mContext, + R.style.TextAppearance_DeviceDefault_Notification_Title); + int expectedTextColor = textAppearanceSpan.getTextColor().getDefaultColor(); + text.setSpan(textAppearanceSpan, 0, text.length(), + Spanned.SPAN_INCLUSIVE_INCLUSIVE); + text.setSpan(new ForegroundColorSpan(Color.GREEN), 26, 31, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + assertThat(Notification.Builder.getFullLengthSpanColor(text)).isEqualTo(expectedTextColor); + } + + @Test + public void testBuilder_ensureColorSpanContrast_removesAllFullLengthColorSpans() { + Spannable text = new SpannableString("blue text with yellow and green"); + text.setSpan(new ForegroundColorSpan(Color.YELLOW), 15, 21, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(new ForegroundColorSpan(Color.BLUE), 0, text.length(), + Spanned.SPAN_INCLUSIVE_INCLUSIVE); + TextAppearanceSpan taSpan = new TextAppearanceSpan(mContext, + R.style.TextAppearance_DeviceDefault_Notification_Title); + assertThat(taSpan.getTextColor()).isNotNull(); // it must be set to prove it is cleared. + text.setSpan(taSpan, 0, text.length(), + Spanned.SPAN_INCLUSIVE_INCLUSIVE); + text.setSpan(new ForegroundColorSpan(Color.GREEN), 26, 31, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + Spannable result = (Spannable) ensureColorSpanContrast(text, Color.BLACK); + Object[] spans = result.getSpans(0, result.length(), Object.class); + assertThat(spans).hasLength(3); + + assertThat(result.getSpanStart(spans[0])).isEqualTo(15); + assertThat(result.getSpanEnd(spans[0])).isEqualTo(21); + assertThat(((ForegroundColorSpan) spans[0]).getForegroundColor()).isEqualTo(Color.YELLOW); + + assertThat(result.getSpanStart(spans[1])).isEqualTo(0); + assertThat(result.getSpanEnd(spans[1])).isEqualTo(31); + assertThat(spans[1]).isNotSameInstanceAs(taSpan); // don't mutate the existing span + assertThat(((TextAppearanceSpan) spans[1]).getFamily()).isEqualTo(taSpan.getFamily()); + assertThat(((TextAppearanceSpan) spans[1]).getTextColor()).isNull(); + + assertThat(result.getSpanStart(spans[2])).isEqualTo(26); + assertThat(result.getSpanEnd(spans[2])).isEqualTo(31); + assertThat(((ForegroundColorSpan) spans[2]).getForegroundColor()).isEqualTo(Color.GREEN); + } + + @Test + public void testBuilder_ensureColorSpanContrast_partialLength_adjusted() { + int background = 0xFFFF0101; // Slightly lighter red + CharSequence text = new SpannableStringBuilder() + .append("text with ") + .append("some red", new ForegroundColorSpan(Color.RED), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + CharSequence result = ensureColorSpanContrast(text, background); + + // ensure the span has been updated to have > 3:1 contrast ratio with fill color + Object[] spans = ((Spannable) result).getSpans(0, result.length(), Object.class); + assertThat(spans).hasLength(1); + int foregroundColor = ((ForegroundColorSpan) spans[0]).getForegroundColor(); + assertContrastIsWithinRange(foregroundColor, background, 3, 3.2); + } + + @Test + public void testBuilder_ensureColorSpanContrast_worksWithComplexInput() { + Spannable text = new SpannableString("blue text with yellow and green and cyan"); + text.setSpan(new ForegroundColorSpan(Color.YELLOW), 15, 21, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(new ForegroundColorSpan(Color.BLUE), 0, text.length(), + Spanned.SPAN_INCLUSIVE_INCLUSIVE); + // cyan TextAppearanceSpan + TextAppearanceSpan taSpan = new TextAppearanceSpan(mContext, + R.style.TextAppearance_DeviceDefault_Notification_Title); + taSpan = new TextAppearanceSpan(taSpan.getFamily(), taSpan.getTextStyle(), + taSpan.getTextSize(), ColorStateList.valueOf(Color.CYAN), null); + text.setSpan(taSpan, 36, 40, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(new ForegroundColorSpan(Color.GREEN), 26, 31, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + Spannable result = (Spannable) ensureColorSpanContrast(text, Color.GRAY); + Object[] spans = result.getSpans(0, result.length(), Object.class); + assertThat(spans).hasLength(3); + + assertThat(result.getSpanStart(spans[0])).isEqualTo(15); + assertThat(result.getSpanEnd(spans[0])).isEqualTo(21); + assertThat(((ForegroundColorSpan) spans[0]).getForegroundColor()).isEqualTo(Color.YELLOW); + + assertThat(result.getSpanStart(spans[1])).isEqualTo(36); + assertThat(result.getSpanEnd(spans[1])).isEqualTo(40); + assertThat(spans[1]).isNotSameInstanceAs(taSpan); // don't mutate the existing span + assertThat(((TextAppearanceSpan) spans[1]).getFamily()).isEqualTo(taSpan.getFamily()); + ColorStateList newCyanList = ((TextAppearanceSpan) spans[1]).getTextColor(); + assertThat(newCyanList).isNotNull(); + assertContrastIsWithinRange(newCyanList.getDefaultColor(), Color.GRAY, 3, 3.2); + + assertThat(result.getSpanStart(spans[2])).isEqualTo(26); + assertThat(result.getSpanEnd(spans[2])).isEqualTo(31); + int newGreen = ((ForegroundColorSpan) spans[2]).getForegroundColor(); + assertThat(newGreen).isNotEqualTo(Color.GREEN); + assertContrastIsWithinRange(newGreen, Color.GRAY, 3, 3.2); + } + + @Test + public void testBuilder_ensureButtonFillContrast_adjustsDarker() { + int background = Color.LTGRAY; + int foreground = Color.LTGRAY; + int result = Notification.Builder.ensureButtonFillContrast(foreground, background); + assertContrastIsWithinRange(result, background, 3, 3.2); + assertThat(ContrastColorUtil.calculateLuminance(result)) + .isLessThan(ContrastColorUtil.calculateLuminance(background)); + } + + @Test + public void testBuilder_ensureButtonFillContrast_adjustsLighter() { + int background = Color.DKGRAY; + int foreground = Color.DKGRAY; + int result = Notification.Builder.ensureButtonFillContrast(foreground, background); + assertContrastIsWithinRange(result, background, 3, 3.2); + assertThat(ContrastColorUtil.calculateLuminance(result)) + .isGreaterThan(ContrastColorUtil.calculateLuminance(background)); + } + @Test public void testColors_ensureColors_dayMode_producesValidPalette() { Notification.Colors c = new Notification.Colors(); @@ -437,16 +606,6 @@ public class NotificationTest { assertContrastIsAtLeast(c.getOnAccentTextColor(), c.getTertiaryAccentColor(), 4.5); } - 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, diff --git a/core/tests/coretests/src/com/android/internal/util/ContrastColorUtilTest.java b/core/tests/coretests/src/com/android/internal/util/ContrastColorUtilTest.java index 9da720cbfa877..cfe660c77817b 100644 --- a/core/tests/coretests/src/com/android/internal/util/ContrastColorUtilTest.java +++ b/core/tests/coretests/src/com/android/internal/util/ContrastColorUtilTest.java @@ -70,13 +70,13 @@ public class ContrastColorUtilTest extends TestCase { assertContrastIsWithinRange(selfContrastColor, lightBg, 4.5, 4.75); } - private void assertContrastIsWithinRange(int foreground, int background, + public static void assertContrastIsWithinRange(int foreground, int background, double minContrast, double maxContrast) { assertContrastIsAtLeast(foreground, background, minContrast); assertContrastIsAtMost(foreground, background, maxContrast); } - private void assertContrastIsAtLeast(int foreground, int background, double minContrast) { + public static void assertContrastIsAtLeast(int foreground, int background, double minContrast) { try { assertThat(calculateContrast(foreground, background)).isAtLeast(minContrast); } catch (AssertionError e) { @@ -86,7 +86,7 @@ public class ContrastColorUtilTest extends TestCase { } } - private void assertContrastIsAtMost(int foreground, int background, double maxContrast) { + public static void assertContrastIsAtMost(int foreground, int background, double maxContrast) { try { assertThat(calculateContrast(foreground, background)).isAtMost(maxContrast); } catch (AssertionError e) {