diff options
7 files changed, 257 insertions, 194 deletions
diff --git a/core/java/android/app/Notification.java b/core/java/android/app/Notification.java index cc9c3299fd0f..7aedd30d660e 100644 --- a/core/java/android/app/Notification.java +++ b/core/java/android/app/Notification.java @@ -5286,7 +5286,7 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable && 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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable * 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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable @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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable } /** + * 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 @@ public class Notification implements Parcelable 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 @@ public class Notification implements Parcelable 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 ced272225f48..77de27242e78 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.text.style.TextAppearanceSpan; 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 @@ public class ContrastColorUtil { 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 6debbfeae084..c5b00c9bfb22 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.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.Bitmap; import android.graphics.BitmapFactory; @@ -436,93 +434,7 @@ public class NotificationTest { 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 cfe660c77817..5f5bf1165004 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 androidx.core.graphics.ColorUtils.calculateContrast; 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 @@ public class ContrastColorUtilTest extends TestCase { 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 fc9d9e8b736c..797038d1d615 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 android.widget.TextView; 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 class HybridNotificationView extends AlphaOptimizedLinearLayout 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 4866f73b8d9f..a08aa88e0c5a 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.annotations.VisibleForTesting; 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 @@ public class RemoteInputView extends LinearLayout implements View.OnClickListene 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 a537b2a238cd..9e88ceb3a0d1 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 @@ public class SmartReplyView extends ViewGroup { mCurrentBackgroundColor = backgroundColor; mCurrentColorized = colorized; - final boolean dark = Notification.Builder.isColorDark(backgroundColor); + final boolean dark = ContrastColorUtil.isColorDark(backgroundColor); mCurrentTextColor = ContrastColorUtil.ensureTextContrast( dark ? mDefaultTextColorDarkBg : mDefaultTextColor, |