From ebc050f12eb53efb748076313c9ff28a97edf338 Mon Sep 17 00:00:00 2001 From: Maurice Lam Date: Mon, 31 Oct 2016 16:17:53 -0700 Subject: [PATCH] Fix TTS for GMT offset Add TtsSpans to the GMT offset string so that TalkBack knows to read it out in a more natural way. Retain the old keys with string types for compatibility with TV and wear. Test: m SettingsLibTests && \ adb install -r $OUT/data/app/SettingsLibTests/SettingsLibTests.apk && \ adb shell am instrument -w com.android.settingslib/android.support.test.runner.AndroidJUnitRunner Bug: 30042703 Change-Id: I5629a0113404b32b606c1f03060f32868b59c6d2 --- packages/SettingsLib/res/values/strings.xml | 2 + .../settingslib/datetime/ZoneGetter.java | 134 +++++++++++++----- .../settingslib/utils/ZoneGetterTest.java | 34 ++++- 3 files changed, 134 insertions(+), 36 deletions(-) diff --git a/packages/SettingsLib/res/values/strings.xml b/packages/SettingsLib/res/values/strings.xml index 972fc738cb098..f176aac09856b 100644 --- a/packages/SettingsLib/res/values/strings.xml +++ b/packages/SettingsLib/res/values/strings.xml @@ -863,4 +863,6 @@ Menu + + GMT diff --git a/packages/SettingsLib/src/com/android/settingslib/datetime/ZoneGetter.java b/packages/SettingsLib/src/com/android/settingslib/datetime/ZoneGetter.java index 857ca49eafc2e..4bfca9b61d6ba 100644 --- a/packages/SettingsLib/src/com/android/settingslib/datetime/ZoneGetter.java +++ b/packages/SettingsLib/src/com/android/settingslib/datetime/ZoneGetter.java @@ -19,9 +19,13 @@ package com.android.settingslib.datetime; import android.content.Context; import android.content.res.XmlResourceParser; import android.icu.text.TimeZoneNames; -import android.text.BidiFormatter; -import android.text.TextDirectionHeuristics; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; import android.text.TextUtils; +import android.text.format.DateUtils; +import android.text.style.TtsSpan; import android.util.Log; import android.view.View; @@ -29,7 +33,6 @@ import com.android.settingslib.R; import org.xmlpull.v1.XmlPullParserException; -import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; @@ -65,28 +68,41 @@ public class ZoneGetter { private static final String TAG = "ZoneGetter"; public static final String KEY_ID = "id"; // value: String + + /** + * @deprecated Use {@link #KEY_DISPLAY_LABEL} instead. + */ + @Deprecated public static final String KEY_DISPLAYNAME = "name"; // value: String + + public static final String KEY_DISPLAY_LABEL = "display_label"; // value: CharSequence + + /** + * @deprecated Use {@link #KEY_OFFSET_LABEL} instead. + */ + @Deprecated public static final String KEY_GMT = "gmt"; // value: String public static final String KEY_OFFSET = "offset"; // value: int (Integer) + public static final String KEY_OFFSET_LABEL = "offset_label"; // value: CharSequence private static final String XMLTAG_TIMEZONE = "timezone"; - public static String getTimeZoneOffsetAndName(Context context, TimeZone tz, Date now) { + public static CharSequence getTimeZoneOffsetAndName(Context context, TimeZone tz, Date now) { final Locale locale = Locale.getDefault(); - final String gmtString = getGmtOffsetString(locale, tz, now); + final CharSequence gmtText = getGmtOffsetText(context, locale, tz, now); final TimeZoneNames timeZoneNames = TimeZoneNames.getInstance(locale); final ZoneGetterData data = new ZoneGetterData(context); final boolean useExemplarLocationForLocalNames = shouldUseExemplarLocationForLocalNames(data, timeZoneNames); - final String zoneNameString = getTimeZoneDisplayName(data, timeZoneNames, + final CharSequence zoneName = getTimeZoneDisplayName(data, timeZoneNames, useExemplarLocationForLocalNames, tz, tz.getID()); - if (zoneNameString == null) { - return gmtString; + if (zoneName == null) { + return gmtText; } // We don't use punctuation here to avoid having to worry about localizing that too! - return gmtString + " " + zoneNameString; + return TextUtils.concat(gmtText, " ", zoneName); } public static List> getZonesList(Context context) { @@ -103,28 +119,30 @@ public class ZoneGetter { List> zones = new ArrayList>(); for (int i = 0; i < data.zoneCount; i++) { TimeZone tz = data.timeZones[i]; - String gmtOffsetString = data.gmtOffsetStrings[i]; + CharSequence gmtOffsetText = data.gmtOffsetTexts[i]; - String displayName = getTimeZoneDisplayName(data, timeZoneNames, + CharSequence displayName = getTimeZoneDisplayName(data, timeZoneNames, useExemplarLocationForLocalNames, tz, data.olsonIdsToDisplay[i]); - if (displayName == null || displayName.isEmpty()) { - displayName = gmtOffsetString; + if (TextUtils.isEmpty(displayName)) { + displayName = gmtOffsetText; } int offsetMillis = tz.getOffset(now.getTime()); Map displayEntry = - createDisplayEntry(tz, gmtOffsetString, displayName, offsetMillis); + createDisplayEntry(tz, gmtOffsetText, displayName, offsetMillis); zones.add(displayEntry); } return zones; } private static Map createDisplayEntry( - TimeZone tz, String gmtOffsetString, String displayName, int offsetMillis) { - Map map = new HashMap(); + TimeZone tz, CharSequence gmtOffsetText, CharSequence displayName, int offsetMillis) { + Map map = new HashMap<>(); map.put(KEY_ID, tz.getID()); - map.put(KEY_DISPLAYNAME, displayName); - map.put(KEY_GMT, gmtOffsetString); + map.put(KEY_DISPLAYNAME, displayName.toString()); + map.put(KEY_DISPLAY_LABEL, displayName); + map.put(KEY_GMT, gmtOffsetText.toString()); + map.put(KEY_OFFSET_LABEL, gmtOffsetText); map.put(KEY_OFFSET, offsetMillis); return map; } @@ -162,15 +180,15 @@ public class ZoneGetter { private static boolean shouldUseExemplarLocationForLocalNames(ZoneGetterData data, TimeZoneNames timeZoneNames) { - final Set localZoneNames = new HashSet(); + final Set localZoneNames = new HashSet<>(); final Date now = new Date(); for (int i = 0; i < data.zoneCount; i++) { final String olsonId = data.olsonIdsToDisplay[i]; if (data.localZoneIds.contains(olsonId)) { final TimeZone tz = data.timeZones[i]; - String displayName = getZoneLongName(timeZoneNames, tz, now); + CharSequence displayName = getZoneLongName(timeZoneNames, tz, now); if (displayName == null) { - displayName = data.gmtOffsetStrings[i]; + displayName = data.gmtOffsetTexts[i]; } final boolean nameIsUnique = localZoneNames.add(displayName); if (!nameIsUnique) { @@ -182,8 +200,9 @@ public class ZoneGetter { return false; } - private static String getTimeZoneDisplayName(ZoneGetterData data, TimeZoneNames timeZoneNames, - boolean useExemplarLocationForLocalNames, TimeZone tz, String olsonId) { + private static CharSequence getTimeZoneDisplayName(ZoneGetterData data, + TimeZoneNames timeZoneNames, boolean useExemplarLocationForLocalNames, TimeZone tz, + String olsonId) { final Date now = new Date(); final boolean isLocalZoneId = data.localZoneIds.contains(olsonId); final boolean preferLongName = isLocalZoneId && !useExemplarLocationForLocalNames; @@ -213,23 +232,70 @@ public class ZoneGetter { return names.getDisplayName(tz.getID(), nameType, now.getTime()); } - private static String getGmtOffsetString(Locale locale, TimeZone tz, Date now) { - // Use SimpleDateFormat to format the GMT+00:00 string. - final SimpleDateFormat gmtFormatter = new SimpleDateFormat("ZZZZ"); - gmtFormatter.setTimeZone(tz); - String gmtString = gmtFormatter.format(now); + private static void appendWithTtsSpan(SpannableStringBuilder builder, CharSequence content, + TtsSpan span) { + int start = builder.length(); + builder.append(content); + builder.setSpan(span, start, builder.length(), 0); + } + + private static String twoDigits(int input) { + StringBuilder builder = new StringBuilder(3); + if (input < 0) builder.append('-'); + String string = Integer.toString(Math.abs(input)); + if (string.length() == 1) builder.append("0"); + builder.append(string); + return builder.toString(); + } + + /** + * Get the GMT offset text label for the given time zone, in the format "GMT-08:00". This will + * also add TTS spans to give hints to the text-to-speech engine for the type of data it is. + * + * @param context The context which the string is displayed in. + * @param locale The locale which the string is displayed in. This should be the same as the + * locale of the context. + * @param tz Time zone to get the GMT offset from. + * @param now The current time, used to tell whether daylight savings is active. + * @return A CharSequence suitable for display as the offset label of {@code tz}. + */ + private static CharSequence getGmtOffsetText(Context context, Locale locale, TimeZone tz, + Date now) { + SpannableStringBuilder builder = new SpannableStringBuilder(); + + appendWithTtsSpan(builder, "GMT", + new TtsSpan.TextBuilder(context.getString(R.string.time_zone_gmt)).build()); + + int offsetMillis = tz.getOffset(now.getTime()); + if (offsetMillis >= 0) { + appendWithTtsSpan(builder, "+", new TtsSpan.VerbatimBuilder("+").build()); + } + + final int offsetHours = (int) (offsetMillis / DateUtils.HOUR_IN_MILLIS); + appendWithTtsSpan(builder, twoDigits(offsetHours), + new TtsSpan.MeasureBuilder().setNumber(offsetHours).setUnit("hour").build()); + + builder.append(":"); + + final int offsetMinutes = (int) (offsetMillis / DateUtils.MINUTE_IN_MILLIS); + final int offsetMinutesRemaining = Math.abs(offsetMinutes) % 60; + appendWithTtsSpan(builder, twoDigits(offsetMinutesRemaining), + new TtsSpan.MeasureBuilder().setNumber(offsetMinutesRemaining) + .setUnit("minute").build()); + + CharSequence gmtText = new SpannableString(builder); // Ensure that the "GMT+" stays with the "00:00" even if the digits are RTL. final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); boolean isRtl = TextUtils.getLayoutDirectionFromLocale(locale) == View.LAYOUT_DIRECTION_RTL; - gmtString = bidiFormatter.unicodeWrap(gmtString, - isRtl ? TextDirectionHeuristics.RTL : TextDirectionHeuristics.LTR); - return gmtString; + gmtText = bidiFormatter.unicodeWrap(gmtText, + isRtl ? TextDirectionHeuristicsCompat.RTL : TextDirectionHeuristicsCompat.LTR); + return gmtText; } private static final class ZoneGetterData { public final String[] olsonIdsToDisplay; - public final String[] gmtOffsetStrings; + public final CharSequence[] gmtOffsetTexts; public final TimeZone[] timeZones; public final Set localZoneIds; public final int zoneCount; @@ -243,13 +309,13 @@ public class ZoneGetter { zoneCount = olsonIdsToDisplayList.size(); olsonIdsToDisplay = new String[zoneCount]; timeZones = new TimeZone[zoneCount]; - gmtOffsetStrings = new String[zoneCount]; + gmtOffsetTexts = new CharSequence[zoneCount]; for (int i = 0; i < zoneCount; i++) { final String olsonId = olsonIdsToDisplayList.get(i); olsonIdsToDisplay[i] = olsonId; final TimeZone tz = TimeZone.getTimeZone(olsonId); timeZones[i] = tz; - gmtOffsetStrings[i] = getGmtOffsetString(locale, tz, now); + gmtOffsetTexts[i] = getGmtOffsetText(context, locale, tz, now); } // Create a lookup of local zone IDs. diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/utils/ZoneGetterTest.java b/packages/SettingsLib/tests/integ/src/com/android/settingslib/utils/ZoneGetterTest.java index 57e06ddb5c048..703e9d29e6ac7 100644 --- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/utils/ZoneGetterTest.java +++ b/packages/SettingsLib/tests/integ/src/com/android/settingslib/utils/ZoneGetterTest.java @@ -19,6 +19,9 @@ import android.content.Context; import android.support.test.InstrumentationRegistry; import android.support.test.runner.AndroidJUnit4; import android.support.test.filters.SmallTest; +import android.text.Spanned; +import android.text.style.TtsSpan; + import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -55,14 +58,41 @@ public class ZoneGetterTest { testTimeZoneOffsetAndNameInner(TIME_ZONE_LA_ID, "Pacific Daylight Time"); } + @Test + public void getZonesList_checkTypes() { + final List> zones = + ZoneGetter.getZonesList(InstrumentationRegistry.getContext()); + for (Map zone : zones) { + assertTrue(zone.get(ZoneGetter.KEY_DISPLAYNAME) instanceof String); + assertTrue(zone.get(ZoneGetter.KEY_DISPLAY_LABEL) instanceof CharSequence); + assertTrue(zone.get(ZoneGetter.KEY_OFFSET) instanceof Integer); + assertTrue(zone.get(ZoneGetter.KEY_OFFSET_LABEL) instanceof CharSequence); + assertTrue(zone.get(ZoneGetter.KEY_ID) instanceof String); + assertTrue(zone.get(ZoneGetter.KEY_GMT) instanceof String); + } + } + + @Test + public void getTimeZoneOffsetAndName_withTtsSpan() { + final Context context = InstrumentationRegistry.getContext(); + final TimeZone timeZone = TimeZone.getTimeZone(TIME_ZONE_LA_ID); + + CharSequence timeZoneString = ZoneGetter.getTimeZoneOffsetAndName(context, timeZone, + mCalendar.getTime()); + assertTrue("Time zone string should be spanned", timeZoneString instanceof Spanned); + assertTrue("Time zone display name should have TTS spans", + ((Spanned) timeZoneString).getSpans( + 0, timeZoneString.length(), TtsSpan.class).length > 0); + } + private void testTimeZoneOffsetAndNameInner(String timeZoneId, String expectedName) { final Context context = InstrumentationRegistry.getContext(); final TimeZone timeZone = TimeZone.getTimeZone(timeZoneId); - String timeZoneString = ZoneGetter.getTimeZoneOffsetAndName(context, timeZone, + CharSequence timeZoneString = ZoneGetter.getTimeZoneOffsetAndName(context, timeZone, mCalendar.getTime()); - assertTrue(timeZoneString.endsWith(expectedName)); + assertTrue(timeZoneString.toString().endsWith(expectedName)); } }