From 20e9b768180812673c5aade78d3db66a4d74c26f Mon Sep 17 00:00:00 2001 From: Victor Chang Date: Wed, 27 Apr 2022 18:32:35 +0100 Subject: [PATCH] Titlecasing time zone summary Additional fix: 1. Fixed the SpannableUtil.getResourcesText to actually preserve Spannable (TtsSpan in this use case) during formatting. Bug: 185453652 Test: make RunSettingsRoboTests ROBOTEST_FILTER=com.android.settings.datetime.timezone Change-Id: Iae5e1d4261ec0a34222ae1d042c7f3f027f2e512 --- res/values/strings.xml | 6 +- .../datetime/timezone/SpannableUtil.java | 107 ++++++++++++++++++ .../TimeZoneInfoPreferenceController.java | 35 ++++-- .../datetime/timezone/SpannableUtilTest.java | 72 +++++++++++- 4 files changed, 208 insertions(+), 12 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index 019ac700898..e7f9990a6f0 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -608,8 +608,10 @@ %1$s (%2$s) %2$s (%1$s) - - Uses %1$s. %2$s starts on %3$s. + + Uses %1$s. %2$s + + %1$s starts on %2$s. Uses %1$s. No daylight savings time. diff --git a/src/com/android/settings/datetime/timezone/SpannableUtil.java b/src/com/android/settings/datetime/timezone/SpannableUtil.java index 49c3e7d55e0..ed28f649c0f 100644 --- a/src/com/android/settings/datetime/timezone/SpannableUtil.java +++ b/src/com/android/settings/datetime/timezone/SpannableUtil.java @@ -18,14 +18,72 @@ package com.android.settings.datetime.timezone; import android.annotation.StringRes; import android.content.res.Resources; +import android.icu.text.CaseMap; +import android.icu.text.Edits; import android.text.Spannable; import android.text.SpannableStringBuilder; +import android.util.Log; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Formattable; +import java.util.FormattableFlags; import java.util.Formatter; +import java.util.List; import java.util.Locale; public class SpannableUtil { + private static final String TAG = "SpannableUtil"; + + private static class SpannableFormattable implements Formattable { + + private final Spannable mSpannable; + + private SpannableFormattable(Spannable spannable) { + this.mSpannable = spannable; + } + + @Override + public void formatTo(Formatter formatter, int flags, int width, int precision) { + CharSequence s = handlePrecision(mSpannable, precision); + s = handleWidth(s, width, (flags & FormattableFlags.LEFT_JUSTIFY) != 0); + try { + formatter.out().append(s); + } catch (IOException e) { + // The error should never occur because formatter.out() returns + // SpannableStringBuilder which doesn't throw IOException. + Log.e(TAG, "error in SpannableFormattable", e); + } + } + + private static CharSequence handlePrecision(CharSequence s, int precision) { + if (precision != -1 && precision < s.length()) { + return s.subSequence(0, precision); + } + return s; + } + + private static CharSequence handleWidth(CharSequence s, int width, boolean isLeftJustify) { + if (width == -1) { + return s; + } + int diff = width - s.length(); + if (diff <= 0) { + return s; + } + SpannableStringBuilder sb = new SpannableStringBuilder(); + if (!isLeftJustify) { + sb.append(" ".repeat(diff)); + } + sb.append(s); + if (isLeftJustify) { + sb.append(" ".repeat(diff)); + } + return sb; + } + } /** * {@class Resources} has no method to format string resource with {@class Spannable} a @@ -35,7 +93,56 @@ public class SpannableUtil { Object... args) { final Locale locale = res.getConfiguration().getLocales().get(0); final SpannableStringBuilder builder = new SpannableStringBuilder(); + // Formatter converts CharSequence to String by calling toString() if an arg isn't + // Formattable. Wrap Spannable by SpannableFormattable to preserve Spannable objects. + for (int i = 0; i < args.length; i++) { + if (args[i] instanceof Spannable) { + args[i] = new SpannableFormattable((Spannable) args[i]); + } + } new Formatter(builder, locale).format(res.getString(resId), args); return builder; } + + private static final CaseMap.Title TITLE_CASE_MAP = + CaseMap.toTitle().sentences().noLowercase(); + + /** + * Titlecasing {@link CharSequence} and {@link Spannable} by using {@link CaseMap.Title}. + */ + public static CharSequence titleCaseSentences(Locale locale, CharSequence src) { + if (src instanceof Spannable) { + return applyCaseMapToSpannable(locale, TITLE_CASE_MAP, (Spannable) src); + } else { + return TITLE_CASE_MAP.apply(locale, null, src); + } + } + + private static Spannable applyCaseMapToSpannable(Locale locale, CaseMap.Title caseMap, + Spannable src) { + Edits edits = new Edits(); + SpannableStringBuilder dest = new SpannableStringBuilder(); + caseMap.apply(locale, null, src, dest, edits); + if (!edits.hasChanges()) { + return src; + } + Edits.Iterator iterator = edits.getCoarseChangesIterator(); + List changes = new ArrayList<>(); + while (iterator.next()) { + int[] change = new int[] { + iterator.sourceIndex(), // 0 + iterator.oldLength(), // 1 + iterator.destinationIndex(), // 2 + iterator.newLength(), // 3 + }; + changes.add(change); + } + // Replacement starts from the end to avoid shifting the source index during replacement + Collections.reverse(changes); + SpannableStringBuilder result = new SpannableStringBuilder(src); + for (int[] c : changes) { + result.replace(c[0], c[0] + c[1], dest, c[2], c[2] + c[3]); + } + return result; + } } diff --git a/src/com/android/settings/datetime/timezone/TimeZoneInfoPreferenceController.java b/src/com/android/settings/datetime/timezone/TimeZoneInfoPreferenceController.java index c6ac3287b71..fb2392b9305 100644 --- a/src/com/android/settings/datetime/timezone/TimeZoneInfoPreferenceController.java +++ b/src/com/android/settings/datetime/timezone/TimeZoneInfoPreferenceController.java @@ -17,6 +17,7 @@ package com.android.settings.datetime.timezone; import android.content.Context; +import android.content.res.Resources; import android.icu.text.DateFormat; import android.icu.text.DisplayContext; import android.icu.text.SimpleDateFormat; @@ -32,6 +33,7 @@ import java.time.Instant; import java.time.zone.ZoneOffsetTransition; import java.time.zone.ZoneRules; import java.util.Date; +import java.util.Locale; public class TimeZoneInfoPreferenceController extends BasePreferenceController { @@ -82,14 +84,15 @@ public class TimeZoneInfoPreferenceController extends BasePreferenceController { private CharSequence formatInfo(TimeZoneInfo item) { final CharSequence offsetAndName = formatOffsetAndName(item); final TimeZone timeZone = item.getTimeZone(); - if (!timeZone.observesDaylightTime()) { - return mContext.getString(R.string.zone_info_footer_no_dst, offsetAndName); + ZoneOffsetTransition nextDstTransition = null; + if (timeZone.observesDaylightTime()) { + nextDstTransition = findNextDstTransition(item); + } + if (nextDstTransition == null || !timeZone.observesDaylightTime()) { + return SpannableUtil.getResourcesText(mContext.getResources(), + R.string.zone_info_footer_no_dst, offsetAndName); } - final ZoneOffsetTransition nextDstTransition = findNextDstTransition(item); - if (nextDstTransition == null) { // No future transition - return mContext.getString(R.string.zone_info_footer_no_dst, offsetAndName); - } final boolean toDst = getDSTSavings(timeZone, nextDstTransition.getInstant()) != 0; String timeType = toDst ? item.getDaylightName() : item.getStandardName(); if (timeType == null) { @@ -103,8 +106,24 @@ public class TimeZoneInfoPreferenceController extends BasePreferenceController { final Calendar transitionTime = Calendar.getInstance(timeZone); transitionTime.setTimeInMillis(nextDstTransition.getInstant().toEpochMilli()); final String date = mDateFormat.format(transitionTime); - return SpannableUtil.getResourcesText(mContext.getResources(), - R.string.zone_info_footer, offsetAndName, timeType, date); + return createFooterString(offsetAndName, timeType, date); + } + + /** + * @param offsetAndName {@Spannable} styled text information should be preserved. See + * {@link #formatInfo} and {@link com.android.settingslib.datetime.ZoneGetter#getGmtOffsetText}. + * + */ + private CharSequence createFooterString(CharSequence offsetAndName, String timeType, + String date) { + Resources res = mContext.getResources(); + Locale locale = res.getConfiguration().getLocales().get(0); + CharSequence secondSentence = SpannableUtil.titleCaseSentences(locale, + SpannableUtil.getResourcesText(res, R.string.zone_info_footer_second_sentence, + timeType, date)); + + return SpannableUtil.titleCaseSentences(locale, SpannableUtil.getResourcesText(res, + R.string.zone_info_footer_first_sentence, offsetAndName, secondSentence)); } private ZoneOffsetTransition findNextDstTransition(TimeZoneInfo timeZoneInfo) { diff --git a/tests/robotests/src/com/android/settings/datetime/timezone/SpannableUtilTest.java b/tests/robotests/src/com/android/settings/datetime/timezone/SpannableUtilTest.java index b9a3056ae91..04528d00b4a 100644 --- a/tests/robotests/src/com/android/settings/datetime/timezone/SpannableUtilTest.java +++ b/tests/robotests/src/com/android/settings/datetime/timezone/SpannableUtilTest.java @@ -18,23 +18,91 @@ package com.android.settings.datetime.timezone; import static com.google.common.truth.Truth.assertThat; +import android.icu.text.TimeZoneFormat; import android.text.Spannable; +import android.text.SpannableStringBuilder; import com.android.settings.R; +import com.android.settingslib.datetime.ZoneGetter; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + @RunWith(RobolectricTestRunner.class) public class SpannableUtilTest { @Test - public void testFormat() { + public void testGetResourceText() { + CharSequence gmtString = getGmtString("GMT+00:00"); + Spannable spannable = SpannableUtil.getResourcesText( RuntimeEnvironment.application.getResources(), R.string.zone_info_offset_and_name, - "GMT+00:00", "UTC"); + gmtString, "UTC"); assertThat(spannable.toString()).isEqualTo("UTC (GMT+00:00)"); + + // Verify that the spans are kept. + Object[] spans = ((Spannable) gmtString).getSpans(0, gmtString.length(), Object.class); + Object[] newSpans = spannable.getSpans(0, spannable.length(), Object.class); + assertThat(newSpans.length).isEqualTo(spans.length); + assertThat(newSpans).isEqualTo(spans); + } + + private static CharSequence getGmtString(String tzId) { + Locale locale = Locale.US; + TimeZoneFormat timeZoneFormat = TimeZoneFormat.getInstance(locale); + TimeZone gmtZone = TimeZone.getTimeZone(tzId); + Date date = new Date(0); + return ZoneGetter.getGmtOffsetText(timeZoneFormat, locale, gmtZone, date); + } + /** + * Verify the assumption on the GMT string used in {@link #testGetResourceText()} + */ + @Test + public void testGetGmtString() { + // Create a GMT string and verify the assumptions + CharSequence gmtString = getGmtString("GMT+00:00"); + assertThat(gmtString.toString()).isEqualTo("GMT+00:00"); + assertThat(gmtString).isInstanceOf(Spannable.class); + Object[] spans = ((Spannable) gmtString).getSpans(0, gmtString.length(), Object.class); + assertThat(spans).isNotEmpty(); + + assertThat(getGmtString("GMT-08:00").toString()).isEqualTo("GMT-08:00"); + } + + @Test + public void testTitleCaseSentences_enUS() { + Locale locale = Locale.US; + CharSequence titleCasedFirstSentence = SpannableUtil.titleCaseSentences(locale, + "pacific Daylight Time starts on Mar 11 2018."); + assertThat(titleCasedFirstSentence.toString()) + .isEqualTo("Pacific Daylight Time starts on Mar 11 2018."); + + SpannableStringBuilder sb = new SpannableStringBuilder() + .append("uses ") + .append("Pacific Time (") + .append(getGmtString("GMT-08:00")) + .append("). ") + .append(titleCasedFirstSentence); + + assertThat(sb.toString()).isEqualTo( + "uses Pacific Time (GMT-08:00). Pacific Daylight Time starts on Mar 11 2018."); + + Object[] spans = sb.getSpans(0, sb.length(), Object.class); + assertThat(spans).isNotEmpty(); + + CharSequence titledOutput = SpannableUtil.titleCaseSentences(Locale.US, sb); + assertThat(titledOutput.toString()).isEqualTo( + "Uses Pacific Time (GMT-08:00). Pacific Daylight Time starts on Mar 11 2018."); + assertThat(titledOutput).isInstanceOf(Spannable.class); + Object[] newSpans = ((Spannable) titledOutput).getSpans(0, titledOutput.length(), + Object.class); + assertThat(newSpans.length).isEqualTo(spans.length); + assertThat(newSpans).isEqualTo(spans); } }