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);
}
}