Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 2f0616bd authored by Jeff DeCew's avatar Jeff DeCew Committed by Android (Google) Code Review
Browse files

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.
parents 8173263d 050a0d0d
Loading
Loading
Loading
Loading
+95 −31
Original line number Diff line number Diff line
@@ -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 {
                    // 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();
                    title = ensureColorSpanContrast(title, notifBackgroundColor, outResultColor);
                        buttonFillColor = ensureButtonFillContrast(
                                fullLengthColor, notifBackgroundColor);
                    }
                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();
                    // Remove full-length color spans and ensure text contrast with the button fill.
                    title = ensureColorSpanContrast(title, buttonFillColor);
                }
                button.setTextViewText(R.id.action0, processTextSpans(title));
                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,6 +6245,10 @@ public class Notification implements Parcelable
                        TextAppearanceSpan originalSpan = (TextAppearanceSpan) resultSpan;
                        ColorStateList textColor = originalSpan.getTextColor();
                        if (textColor != null) {
                            if (fullLength) {
                                // 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++) {
@@ -6204,10 +6258,6 @@ public class Notification implements Parcelable
                                }
                                textColor = new ColorStateList(textColor.getStates().clone(),
                                        newColors);
                            if (fullLength) {
                                outResultColor[0] = textColor;
                                // Let's drop the color from the span
                                textColor = null;
                            }
                            resultSpan = new TextAppearanceSpan(
                                    originalSpan.getFamily(),
@@ -6217,15 +6267,14 @@ public class Notification implements Parcelable
                                    originalSpan.getLinkTextColor());
                        }
                    } else if (resultSpan instanceof ForegroundColorSpan) {
                        if (fullLength) {
                            resultSpan = null;
                        } else {
                            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 {
                            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.
+170 −11
Original line number Diff line number Diff line
@@ -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,
+3 −3
Original line number Diff line number Diff line
@@ -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) {