Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 20e9b768 authored by Victor Chang's avatar Victor Chang
Browse files

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
parent 0edc7fb3
Loading
Loading
Loading
Loading
+4 −2
Original line number Diff line number Diff line
@@ -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) -->
+107 −0
Original line number Diff line number Diff line
@@ -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;
    }
}
+27 −8
Original line number Diff line number Diff line
@@ -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);
        }

        final ZoneOffsetTransition nextDstTransition = findNextDstTransition(item);
        if (nextDstTransition == null) { // No future transition
            return mContext.getString(R.string.zone_info_footer_no_dst, offsetAndName);
        if (nextDstTransition == null || !timeZone.observesDaylightTime()) {
            return SpannableUtil.getResourcesText(mContext.getResources(),
                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) {
+70 −2
Original line number Diff line number Diff line
@@ -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);
    }
}