Refactor the color span code inside Notification.java and add tests.
* This makes it easy to reduce the contrast requirement between the notification background and the button fill in another review. * This also fixes a bug where partial-length color spans within the emphasized buttons would be contrast-adjusted only with the notification background color rather than the button fill color. Bug: 196393060 Fixes: 197572220 Test: manual testing using the go/notify2-apk and the CallStyle color customizer Test: android.app.NotificationTest Change-Id: I712e6d6b2da1de89afeacda7bd2fc4ad24632811
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user