Merge changes I0fc3b7ae,I712e6d6b into sc-qpr1-dev

* changes:
  Reduce the color contrast requirements for the emphasized action button fill color.
  Refactor the color span code inside Notification.java and add tests.
This commit is contained in:
Jeff DeCew
2021-09-10 15:28:37 +00:00
committed by Android (Google) Code Review
3 changed files with 268 additions and 45 deletions

View File

@@ -1884,6 +1884,14 @@ public class Notification implements Parcelable
* clicks. To launch an activity in those cases, provide a {@link PendingIntent} for the
* activity itself.
*
* <p>How an Action is displayed, including whether the {@code icon}, {@code text}, or
* both are displayed or required, depends on where and how the action is used, and the
* {@link Style} applied to the Notification.
*
* <p>When the {@code title} is a {@link android.text.Spanned}, any colors set by a
* {@link ForegroundColorSpan} or {@link TextAppearanceSpan} may be removed or displayed
* with an altered in luminance to ensure proper contrast within the Notification.
*
* @param icon icon to show for this action
* @param title the title of the action
* @param intent the {@link PendingIntent} to fire when users trigger this action
@@ -6121,21 +6129,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 1.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 +6177,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 +6245,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 +6267,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 +6303,21 @@ public class Notification implements Parcelable
return ContrastColorUtil.calculateLuminance(color) <= 0.17912878474;
}
/**
* Finds a button fill color with sufficient contrast over bg (1.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, 1.3)
: ContrastColorUtil.findContrastColor(color, bg, true, 1.3);
}
/**
* @return Whether we are currently building a notification from a legacy (an app that
* doesn't create material notifications by itself) app.

View File

@@ -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 > 1.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, 1.3, 1.5);
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, 1.3, 1.5);
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,

View File

@@ -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) {