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
This commit is contained in:
Victor Chang
2022-04-27 18:32:35 +01:00
parent 0edc7fb3cb
commit 20e9b76818
4 changed files with 208 additions and 12 deletions

View File

@@ -608,8 +608,10 @@
<string name="zone_info_exemplar_location_and_offset"><xliff:g id="exemplar_location" example="Los Angeles">%1$s</xliff:g> (<xliff:g id="offset" example="GMT-08:00">%2$s</xliff:g>)</string>
<!-- Label describing a time zone offset and name[CHAR LIMIT=NONE] -->
<string name="zone_info_offset_and_name"><xliff:g id="time_type" example="Pacific Time">%2$s</xliff:g> (<xliff:g id="offset" example="GMT-08:00">%1$s</xliff:g>)</string>
<!-- Label describing a time zone and changes to DST or standard time [CHAR LIMIT=NONE] -->
<string name="zone_info_footer">Uses <xliff:g id="offset_and_name" example="Pacific Time (GMT-08:00)">%1$s</xliff:g>. <xliff:g id="dst_time_type" example="Pacific Daylight Time">%2$s</xliff:g> starts on <xliff:g id="transition_date" example="Mar 11 2018">%3$s</xliff:g>.</string>
<!-- Label describing a time zone and a follow-up sentence [CHAR LIMIT=NONE] -->
<string name="zone_info_footer_first_sentence">Uses <xliff:g id="offset_and_name" example="Pacific Time (GMT-08:00)">%1$s</xliff:g>. <xliff:g id="second_sentence" example="Pacific Daylight Time starts on Mar 11 2018.">%2$s</xliff:g></string>
<!-- Label describing the upcoming daylight savings time change [CHAR LIMIT=NONE] -->
<string name="zone_info_footer_second_sentence"><xliff:g id="dst_time_type" example="Pacific Daylight Time">%1$s</xliff:g> starts on <xliff:g id="transition_date" example="Mar 11 2018">%2$s</xliff:g>.</string>
<!-- Label describing a time zone without DST [CHAR LIMIT=NONE] -->
<string name="zone_info_footer_no_dst">Uses <xliff:g id="offset_and_name" example="GMT-08:00 Pacific Time">%1$s</xliff:g>. No daylight savings time.</string>
<!-- Describes the time type "daylight savings time" (used in zone_change_to_from_dst, when no zone specific name is available) -->

View File

@@ -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<int[]> 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;
}
}

View File

@@ -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) {

View File

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