Merge "Ensure text contrast" into udc-dev am: 0b02b77b7a

Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/22245852

Change-Id: Id9a6f9e7def36ca84f195e067b2360081d1411af
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
diff --git a/core/java/android/app/Notification.java b/core/java/android/app/Notification.java
index cc9c329..7aedd30 100644
--- a/core/java/android/app/Notification.java
+++ b/core/java/android/app/Notification.java
@@ -5286,7 +5286,7 @@
             boolean hasSecondLine = showProgress;
             if (p.hasTitle()) {
                 contentView.setViewVisibility(p.mTitleViewId, View.VISIBLE);
-                contentView.setTextViewText(p.mTitleViewId, processTextSpans(p.mTitle));
+                contentView.setTextViewText(p.mTitleViewId, ensureColorSpanContrast(p.mTitle, p));
                 setTextViewColorPrimary(contentView, p.mTitleViewId, p);
             } else if (p.mTitleViewId != R.id.title) {
                 // This alternate title view ID is not cleared by resetStandardTemplate
@@ -5296,7 +5296,7 @@
             if (p.mText != null && p.mText.length() != 0
                     && (!showProgress || p.mAllowTextWithProgress)) {
                 contentView.setViewVisibility(p.mTextViewId, View.VISIBLE);
-                contentView.setTextViewText(p.mTextViewId, processTextSpans(p.mText));
+                contentView.setTextViewText(p.mTextViewId, ensureColorSpanContrast(p.mText, p));
                 setTextViewColorSecondary(contentView, p.mTextViewId, p);
                 hasSecondLine = true;
             } else if (p.mTextViewId != R.id.text) {
@@ -5323,13 +5323,6 @@
                     RemoteViews.MARGIN_BOTTOM, marginDimen);
         }
 
-        private CharSequence processTextSpans(CharSequence text) {
-            if (mInNightMode) {
-                return ContrastColorUtil.clearColorSpans(text);
-            }
-            return text;
-        }
-
         private void setTextViewColorPrimary(RemoteViews contentView, @IdRes int id,
                 StandardTemplateParams p) {
             contentView.setTextColor(id, getPrimaryTextColor(p));
@@ -5581,9 +5574,8 @@
                 headerText = mN.extras.getCharSequence(EXTRA_INFO_TEXT);
             }
             if (!TextUtils.isEmpty(headerText)) {
-                // TODO: Remove the span entirely to only have the string with propper formating.
-                contentView.setTextViewText(R.id.header_text, processTextSpans(
-                        processLegacyText(headerText)));
+                contentView.setTextViewText(R.id.header_text, ensureColorSpanContrast(
+                        processLegacyText(headerText), p));
                 setTextViewColorSecondary(contentView, R.id.header_text, p);
                 contentView.setViewVisibility(R.id.header_text, View.VISIBLE);
                 if (hasTextToLeft) {
@@ -5604,8 +5596,8 @@
                 return false;
             }
             if (!TextUtils.isEmpty(p.mHeaderTextSecondary)) {
-                contentView.setTextViewText(R.id.header_text_secondary, processTextSpans(
-                        processLegacyText(p.mHeaderTextSecondary)));
+                contentView.setTextViewText(R.id.header_text_secondary, ensureColorSpanContrast(
+                        processLegacyText(p.mHeaderTextSecondary), p));
                 setTextViewColorSecondary(contentView, R.id.header_text_secondary, p);
                 contentView.setViewVisibility(R.id.header_text_secondary, View.VISIBLE);
                 if (hasTextToLeft) {
@@ -5846,7 +5838,7 @@
                 big.setViewVisibility(R.id.notification_material_reply_text_1_container,
                         View.VISIBLE);
                 big.setTextViewText(R.id.notification_material_reply_text_1,
-                        processTextSpans(replyText[0].getText()));
+                        ensureColorSpanContrast(replyText[0].getText(), p));
                 setTextViewColorSecondary(big, R.id.notification_material_reply_text_1, p);
                 big.setViewVisibility(R.id.notification_material_reply_progress,
                         showSpinner ? View.VISIBLE : View.GONE);
@@ -5858,7 +5850,7 @@
                         && p.maxRemoteInputHistory > 1) {
                     big.setViewVisibility(R.id.notification_material_reply_text_2, View.VISIBLE);
                     big.setTextViewText(R.id.notification_material_reply_text_2,
-                            processTextSpans(replyText[1].getText()));
+                            ensureColorSpanContrast(replyText[1].getText(), p));
                     setTextViewColorSecondary(big, R.id.notification_material_reply_text_2, p);
 
                     if (replyText.length > 2 && !TextUtils.isEmpty(replyText[2].getText())
@@ -5866,7 +5858,7 @@
                         big.setViewVisibility(
                                 R.id.notification_material_reply_text_3, View.VISIBLE);
                         big.setTextViewText(R.id.notification_material_reply_text_3,
-                                processTextSpans(replyText[2].getText()));
+                                ensureColorSpanContrast(replyText[2].getText(), p));
                         setTextViewColorSecondary(big, R.id.notification_material_reply_text_3, p);
                     }
                 }
@@ -6280,9 +6272,9 @@
                                 fullLengthColor, notifBackgroundColor);
                     }
                     // Remove full-length color spans and ensure text contrast with the button fill.
-                    title = ensureColorSpanContrast(title, buttonFillColor);
+                    title = ContrastColorUtil.ensureColorSpanContrast(title, buttonFillColor);
                 }
-                button.setTextViewText(R.id.action0, processTextSpans(title));
+                button.setTextViewText(R.id.action0, ensureColorSpanContrast(title, p));
                 int textColor = ContrastColorUtil.resolvePrimaryColor(mContext,
                         buttonFillColor, mInNightMode);
                 if (tombstone) {
@@ -6307,8 +6299,8 @@
                     button.setIntDimen(R.id.action0, "setMinimumWidth", minWidthDimen);
                 }
             } else {
-                button.setTextViewText(R.id.action0, processTextSpans(
-                        processLegacyText(action.title)));
+                button.setTextViewText(R.id.action0, ensureColorSpanContrast(
+                        action.title, p));
                 button.setTextColor(R.id.action0, getStandardActionColor(p));
             }
             // CallStyle notifications add action buttons which don't actually exist in mActions,
@@ -6385,72 +6377,12 @@
          * 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
-         * @return the contrasted charSequence
          * @hide
          */
         @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);
-                SpannableStringBuilder builder = new SpannableStringBuilder(ss.toString());
-                for (Object span : spans) {
-                    Object resultSpan = span;
-                    int spanStart = ss.getSpanStart(span);
-                    int spanEnd = ss.getSpanEnd(span);
-                    boolean fullLength = (spanEnd - spanStart) == charSequence.length();
-                    if (resultSpan instanceof CharacterStyle) {
-                        resultSpan = ((CharacterStyle) span).getUnderlying();
-                    }
-                    if (resultSpan instanceof TextAppearanceSpan) {
-                        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++) {
-                                    boolean isBgDark = isColorDark(background);
-                                    newColors[i] = ContrastColorUtil.ensureLargeTextContrast(
-                                            colors[i], background, isBgDark);
-                                }
-                                textColor = new ColorStateList(textColor.getStates().clone(),
-                                        newColors);
-                            }
-                            resultSpan = new TextAppearanceSpan(
-                                    originalSpan.getFamily(),
-                                    originalSpan.getTextStyle(),
-                                    originalSpan.getTextSize(),
-                                    textColor,
-                                    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);
-                            resultSpan = new ForegroundColorSpan(foregroundColor);
-                        }
-                    } else {
-                        resultSpan = span;
-                    }
-                    if (resultSpan != null) {
-                        builder.setSpan(resultSpan, spanStart, spanEnd, ss.getSpanFlags(span));
-                    }
-                }
-                return builder;
-            }
-            return charSequence;
+        public CharSequence ensureColorSpanContrast(CharSequence charSequence,
+                StandardTemplateParams p) {
+            return ContrastColorUtil.ensureColorSpanContrast(charSequence, getBackgroundColor(p));
         }
 
         /**
@@ -7586,8 +7518,8 @@
             RemoteViews contentView = getStandardView(mBuilder.getBigPictureLayoutResource(),
                     p, null /* result */);
             if (mSummaryTextSet) {
-                contentView.setTextViewText(R.id.text, mBuilder.processTextSpans(
-                        mBuilder.processLegacyText(mSummaryText)));
+                contentView.setTextViewText(R.id.text, mBuilder.ensureColorSpanContrast(
+                        mBuilder.processLegacyText(mSummaryText), p));
                 mBuilder.setTextViewColorSecondary(contentView, R.id.text, p);
                 contentView.setViewVisibility(R.id.text, View.VISIBLE);
             }
@@ -8207,6 +8139,13 @@
         @Override
         public void addExtras(Bundle extras) {
             super.addExtras(extras);
+            addExtras(extras, false, 0);
+        }
+
+        /**
+         * @hide
+         */
+        public void addExtras(Bundle extras, boolean ensureContrast, int backgroundColor) {
             if (mUser != null) {
                 // For legacy usages
                 extras.putCharSequence(EXTRA_SELF_DISPLAY_NAME, mUser.getName());
@@ -8215,11 +8154,13 @@
             if (mConversationTitle != null) {
                 extras.putCharSequence(EXTRA_CONVERSATION_TITLE, mConversationTitle);
             }
-            if (!mMessages.isEmpty()) { extras.putParcelableArray(EXTRA_MESSAGES,
-                    Message.getBundleArrayForMessages(mMessages));
+            if (!mMessages.isEmpty()) {
+                extras.putParcelableArray(EXTRA_MESSAGES,
+                        getBundleArrayForMessages(mMessages, ensureContrast, backgroundColor));
             }
-            if (!mHistoricMessages.isEmpty()) { extras.putParcelableArray(EXTRA_HISTORIC_MESSAGES,
-                    Message.getBundleArrayForMessages(mHistoricMessages));
+            if (!mHistoricMessages.isEmpty()) {
+                extras.putParcelableArray(EXTRA_HISTORIC_MESSAGES, getBundleArrayForMessages(
+                        mHistoricMessages, ensureContrast, backgroundColor));
             }
             if (mShortcutIcon != null) {
                 extras.putParcelable(EXTRA_CONVERSATION_ICON, mShortcutIcon);
@@ -8230,6 +8171,20 @@
             extras.putBoolean(EXTRA_IS_GROUP_CONVERSATION, mIsGroupConversation);
         }
 
+        private static Bundle[] getBundleArrayForMessages(List<Message> messages,
+                boolean ensureContrast, int backgroundColor) {
+            Bundle[] bundles = new Bundle[messages.size()];
+            final int N = messages.size();
+            for (int i = 0; i < N; i++) {
+                final Message m = messages.get(i);
+                if (ensureContrast) {
+                    m.ensureColorContrast(backgroundColor);
+                }
+                bundles[i] = m.toBundle();
+            }
+            return bundles;
+        }
+
         private void fixTitleAndTextExtras(Bundle extras) {
             Message m = findLatestIncomingMessage();
             CharSequence text = (m == null) ? null : m.mText;
@@ -8441,7 +8396,7 @@
                 mBuilder.setTextViewColorSecondary(contentView, R.id.app_name_divider, p);
             }
 
-            addExtras(mBuilder.mN.extras);
+            addExtras(mBuilder.mN.extras, true, mBuilder.getBackgroundColor(p));
             contentView.setInt(R.id.status_bar_latest_event_content, "setLayoutColor",
                     mBuilder.getSmallIconColor(p));
             contentView.setInt(R.id.status_bar_latest_event_content, "setSenderTextColor",
@@ -8587,7 +8542,7 @@
             static final String KEY_EXTRAS_BUNDLE = "extras";
             static final String KEY_REMOTE_INPUT_HISTORY = "remote_input_history";
 
-            private final CharSequence mText;
+            private CharSequence mText;
             private final long mTimestamp;
             @Nullable
             private final Person mSender;
@@ -8696,6 +8651,15 @@
             }
 
             /**
+             * Updates TextAppearance spans in the message text so it has sufficient contrast
+             * against its background.
+             * @hide
+             */
+            public void ensureColorContrast(int backgroundColor) {
+                mText = ContrastColorUtil.ensureColorSpanContrast(mText, backgroundColor);
+            }
+
+            /**
              * Get the text to be used for this message, or the fallback text if a type and content
              * Uri have been set
              */
@@ -8788,15 +8752,6 @@
                 return bundle;
             }
 
-            static Bundle[] getBundleArrayForMessages(List<Message> messages) {
-                Bundle[] bundles = new Bundle[messages.size()];
-                final int N = messages.size();
-                for (int i = 0; i < N; i++) {
-                    bundles[i] = messages.get(i).toBundle();
-                }
-                return bundles;
-            }
-
             /**
              * Returns a list of messages read from the given bundle list, e.g.
              * {@link #EXTRA_MESSAGES} or {@link #EXTRA_HISTORIC_MESSAGES}.
@@ -9011,7 +8966,7 @@
                 if (!TextUtils.isEmpty(str)) {
                     contentView.setViewVisibility(rowIds[i], View.VISIBLE);
                     contentView.setTextViewText(rowIds[i],
-                            mBuilder.processTextSpans(mBuilder.processLegacyText(str)));
+                            mBuilder.ensureColorSpanContrast(mBuilder.processLegacyText(str), p));
                     mBuilder.setTextViewColorSecondary(contentView, rowIds[i], p);
                     contentView.setViewPadding(rowIds[i], 0, topPadding, 0, 0);
                     if (first) {
diff --git a/core/java/com/android/internal/util/ContrastColorUtil.java b/core/java/com/android/internal/util/ContrastColorUtil.java
index ced2722..77de272 100644
--- a/core/java/com/android/internal/util/ContrastColorUtil.java
+++ b/core/java/com/android/internal/util/ContrastColorUtil.java
@@ -40,6 +40,8 @@
 import android.util.Log;
 import android.util.Pair;
 
+import com.android.internal.annotations.VisibleForTesting;
+
 import java.util.Arrays;
 import java.util.WeakHashMap;
 
@@ -280,6 +282,92 @@
         return charSequence;
     }
 
+    /**
+     * 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
+     * @return the contrasted charSequence
+     */
+    public static CharSequence ensureColorSpanContrast(CharSequence charSequence,
+            int background) {
+        if (charSequence == null) {
+            return charSequence;
+        }
+        if (charSequence instanceof Spanned) {
+            Spanned ss = (Spanned) charSequence;
+            Object[] spans = ss.getSpans(0, ss.length(), Object.class);
+            SpannableStringBuilder builder = new SpannableStringBuilder(ss.toString());
+            for (Object span : spans) {
+                Object resultSpan = span;
+                int spanStart = ss.getSpanStart(span);
+                int spanEnd = ss.getSpanEnd(span);
+                boolean fullLength = (spanEnd - spanStart) == charSequence.length();
+                if (resultSpan instanceof CharacterStyle) {
+                    resultSpan = ((CharacterStyle) span).getUnderlying();
+                }
+                if (resultSpan instanceof TextAppearanceSpan) {
+                    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++) {
+                                boolean isBgDark = isColorDark(background);
+                                newColors[i] = ContrastColorUtil.ensureLargeTextContrast(
+                                        colors[i], background, isBgDark);
+                            }
+                            textColor = new ColorStateList(textColor.getStates().clone(),
+                                    newColors);
+                        }
+                        resultSpan = new TextAppearanceSpan(
+                                originalSpan.getFamily(),
+                                originalSpan.getTextStyle(),
+                                originalSpan.getTextSize(),
+                                textColor,
+                                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);
+                        resultSpan = new ForegroundColorSpan(foregroundColor);
+                    }
+                } else {
+                    resultSpan = span;
+                }
+                if (resultSpan != null) {
+                    builder.setSpan(resultSpan, spanStart, spanEnd, ss.getSpanFlags(span));
+                }
+            }
+            return builder;
+        }
+        return charSequence;
+    }
+
+    /**
+     * Determines if the color is light or dark.  Specifically, this is using the same metric as
+     * {@link ContrastColorUtil#resolvePrimaryColor(Context, int, boolean)} and peers so that
+     * the direction of color shift is consistent.
+     *
+     * @param color the color to check
+     * @return true if the color has higher contrast with white than black
+     */
+    public static boolean isColorDark(int color) {
+        // as per shouldUseDark(), this uses the color contrast midpoint.
+        return calculateLuminance(color) <= 0.17912878474;
+    }
+
     private int processColor(int color) {
         return Color.argb(Color.alpha(color),
                 255 - Color.red(color),
diff --git a/core/tests/coretests/src/android/app/NotificationTest.java b/core/tests/coretests/src/android/app/NotificationTest.java
index 6debbfe..c5b00c9 100644
--- a/core/tests/coretests/src/android/app/NotificationTest.java
+++ b/core/tests/coretests/src/android/app/NotificationTest.java
@@ -16,7 +16,6 @@
 
 package android.app;
 
-import static android.app.Notification.Builder.ensureColorSpanContrast;
 import static android.app.Notification.CarExtender.UnreadConversation.KEY_ON_READ;
 import static android.app.Notification.CarExtender.UnreadConversation.KEY_ON_REPLY;
 import static android.app.Notification.CarExtender.UnreadConversation.KEY_REMOTE_INPUT;
@@ -66,7 +65,6 @@
 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.Bitmap;
 import android.graphics.BitmapFactory;
@@ -436,93 +434,7 @@
         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() {
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 cfe660c..5f5bf11 100644
--- a/core/tests/coretests/src/com/android/internal/util/ContrastColorUtilTest.java
+++ b/core/tests/coretests/src/com/android/internal/util/ContrastColorUtilTest.java
@@ -20,14 +20,35 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import android.content.Context;
+import android.content.res.ColorStateList;
 import android.graphics.Color;
+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 androidx.test.InstrumentationRegistry;
 import androidx.test.filters.SmallTest;
 
+import com.android.internal.R;
+
 import junit.framework.TestCase;
 
+import org.junit.Before;
+import org.junit.Test;
+
 public class ContrastColorUtilTest extends TestCase {
 
+    private Context mContext;
+
+    @Before
+    public void setUp() {
+        mContext = InstrumentationRegistry.getContext();
+    }
+
     @SmallTest
     public void testEnsureTextContrastAgainstDark() {
         int darkBg = 0xFF35302A;
@@ -70,6 +91,91 @@
         assertContrastIsWithinRange(selfContrastColor, lightBg, 4.5, 4.75);
     }
 
+    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) ContrastColorUtil.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);
+    }
+
+    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 = ContrastColorUtil.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);
+    }
+
+    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) ContrastColorUtil.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);
+    }
+
     public static void assertContrastIsWithinRange(int foreground, int background,
             double minContrast, double maxContrast) {
         assertContrastIsAtLeast(foreground, background, minContrast);
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridNotificationView.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridNotificationView.java
index fc9d9e8..797038d 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridNotificationView.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/HybridNotificationView.java
@@ -28,6 +28,7 @@
 
 import androidx.annotation.ColorInt;
 
+import com.android.internal.util.ContrastColorUtil;
 import com.android.keyguard.AlphaOptimizedLinearLayout;
 import com.android.systemui.R;
 import com.android.systemui.statusbar.CrossFadeHelper;
@@ -109,7 +110,7 @@
 
     public void bind(@Nullable CharSequence title, @Nullable CharSequence text,
             @Nullable View contentView) {
-        mTitleView.setText(title);
+        mTitleView.setText(title.toString());
         mTitleView.setVisibility(TextUtils.isEmpty(title) ? GONE : VISIBLE);
         if (TextUtils.isEmpty(text)) {
             mTextView.setVisibility(GONE);
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/RemoteInputView.java b/packages/SystemUI/src/com/android/systemui/statusbar/policy/RemoteInputView.java
index 4866f73..a08aa88 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/RemoteInputView.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/RemoteInputView.java
@@ -78,6 +78,7 @@
 import com.android.internal.graphics.ColorUtils;
 import com.android.internal.logging.UiEvent;
 import com.android.internal.logging.UiEventLogger;
+import com.android.internal.util.ContrastColorUtil;
 import com.android.systemui.Dependency;
 import com.android.systemui.R;
 import com.android.systemui.animation.InterpolatorsAndroidX;
@@ -221,7 +222,7 @@
         final int stroke = colorized ? mContext.getResources().getDimensionPixelSize(
                 R.dimen.remote_input_view_text_stroke) : 0;
         if (colorized) {
-            final boolean dark = Notification.Builder.isColorDark(backgroundColor);
+            final boolean dark = ContrastColorUtil.isColorDark(backgroundColor);
             final int foregroundColor = dark ? Color.WHITE : Color.BLACK;
             final int inverseColor = dark ? Color.BLACK : Color.WHITE;
             editBgColor = backgroundColor;
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/SmartReplyView.java b/packages/SystemUI/src/com/android/systemui/statusbar/policy/SmartReplyView.java
index a537b2a..9e88ceb 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/SmartReplyView.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/SmartReplyView.java
@@ -726,7 +726,7 @@
         mCurrentBackgroundColor = backgroundColor;
         mCurrentColorized = colorized;
 
-        final boolean dark = Notification.Builder.isColorDark(backgroundColor);
+        final boolean dark = ContrastColorUtil.isColorDark(backgroundColor);
 
         mCurrentTextColor = ContrastColorUtil.ensureTextContrast(
                 dark ? mDefaultTextColorDarkBg : mDefaultTextColor,