diff --git a/src/com/android/settings/datetime/timezone/DataLoader.java b/src/com/android/settings/datetime/timezone/DataLoader.java new file mode 100644 index 00000000000..038558a426a --- /dev/null +++ b/src/com/android/settings/datetime/timezone/DataLoader.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.settings.datetime.timezone; + +import android.graphics.Paint; +import android.icu.text.Collator; +import android.icu.text.LocaleDisplayNames; +import android.icu.text.TimeZoneFormat; +import android.icu.text.TimeZoneNames; +import android.icu.text.TimeZoneNames.NameType; +import android.icu.util.Region; +import android.icu.util.Region.RegionType; +import android.icu.util.TimeZone; +import android.icu.util.TimeZone.SystemTimeZoneType; +import com.android.settingslib.datetime.ZoneGetter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Provides data for manual selection of time zones based associated to regions. This class makes no + * attempt to avoid IO and processing intensive actions. This means it should not be called from the + * UI thread. + */ +public class DataLoader { + + private static final int MIN_HOURS_OFFSET = -14; + private static final int MAX_HOURS_OFFSET = +12; + + private final Locale mLocale; + + private final Collator mCollator; + private final LocaleDisplayNames mLocaleDisplayNames; + private final TimeZoneFormat mTimeZoneFormat; + private final Paint mPaint; + private final AtomicLong nextItemId = new AtomicLong(1); + private final long mNow = System.currentTimeMillis(); + + public DataLoader(Locale locale) { + mLocale = locale; + mCollator = Collator.getInstance(locale); + mLocaleDisplayNames = LocaleDisplayNames.getInstance(locale); + mTimeZoneFormat = TimeZoneFormat.getInstance(locale); + mPaint = new Paint(); + } + + /** + * Returns a {@link RegionInfo} object for each region that has selectable time zones. The + * returned list will be sorted properly for display in the locale. + */ + public List loadRegionInfos() { + final Set regions = Region.getAvailable(RegionType.TERRITORY); + final TreeSet regionInfos = new TreeSet<>(new RegionInfoComparator()); + for (final Region region : regions) { + final String regionId = region.toString(); + final Set timeZoneIds = getTimeZoneIds(regionId); + if (timeZoneIds.isEmpty()) { + continue; + } + + final String name = mLocaleDisplayNames.regionDisplayName(regionId); + final String regionalIndicator = createRegionalIndicator(regionId); + + regionInfos.add(new RegionInfo(regionId, name, regionalIndicator, timeZoneIds)); + } + + return Collections.unmodifiableList(new ArrayList<>(regionInfos)); + } + + /** + * Returns a list of {@link TimeZoneInfo} objects. The returned list will be sorted properly for + * display in the locale.It may be smaller than the input collection, if equivalent IDs are + * passed in. + * + * @param timeZoneIds a list of Olson IDs. + */ + public List loadTimeZoneInfos(Collection timeZoneIds) { + final TreeSet timeZoneInfos = new TreeSet<>(new TimeZoneInfoComparator()); + outer: + for (final String timeZoneId : timeZoneIds) { + final TimeZone timeZone = TimeZone.getFrozenTimeZone(timeZoneId); + for (final TimeZoneInfo other : timeZoneInfos) { + if (other.getTimeZone().hasSameRules(timeZone)) { + continue outer; + } + } + timeZoneInfos.add(createTimeZoneInfo(timeZone)); + } + return Collections.unmodifiableList(new ArrayList<>(timeZoneInfos)); + } + + /** + * Returns a {@link TimeZoneInfo} for each fixed offset time zone, such as UTC or GMT+4. The + * returned list will be sorted in a reasonable way for display. + */ + public List loadFixedOffsets() { + final List timeZoneInfos = new ArrayList<>(); + timeZoneInfos.add(createTimeZoneInfo(TimeZone.getFrozenTimeZone("Etc/UTC"))); + for (int hoursOffset = MAX_HOURS_OFFSET; hoursOffset >= MIN_HOURS_OFFSET; --hoursOffset) { + if (hoursOffset == 0) { + // UTC is handled above, so don't add GMT +/-0 again. + continue; + } + final String id = String.format("Etc/GMT%+d", hoursOffset); + timeZoneInfos.add(createTimeZoneInfo(TimeZone.getFrozenTimeZone(id))); + } + return Collections.unmodifiableList(timeZoneInfos); + } + + /** + * Gets the set of ids for relevant TimeZones in the given region. + */ + private Set getTimeZoneIds(String regionId) { + return TimeZone.getAvailableIDs( + SystemTimeZoneType.CANONICAL_LOCATION, regionId, /* rawOffset */ null); + } + + private TimeZoneInfo createTimeZoneInfo(TimeZone timeZone) { + // Every timezone we handle must be an OlsonTimeZone. + final String id = timeZone.getID(); + final TimeZoneNames timeZoneNames = mTimeZoneFormat.getTimeZoneNames(); + final java.util.TimeZone javaTimeZone = android.icu.impl.TimeZoneAdapter.wrap(timeZone); + final CharSequence gmtOffset = ZoneGetter.getGmtOffsetText(mTimeZoneFormat, mLocale, + javaTimeZone, new Date(mNow)); + return new TimeZoneInfo.Builder(timeZone) + .setGenericName(timeZoneNames.getDisplayName(id, NameType.LONG_GENERIC, mNow)) + .setStandardName(timeZoneNames.getDisplayName(id, NameType.LONG_STANDARD, mNow)) + .setDaylightName(timeZoneNames.getDisplayName(id, NameType.LONG_DAYLIGHT, mNow)) + .setExemplarLocation(timeZoneNames.getExemplarLocationName(id)) + .setGmtOffset(gmtOffset) + .setItemId(nextItemId.getAndIncrement()) + .build(); + } + + /** + * Create a Unicode Region Indicator Symbol for a given region id (a.k.a flag emoji). If the + * system can't render a flag for this region or the input is not a region id, this returns + * {@code null}. + * + * @param id the two-character region id. + * @return a String representing the flag of the region or {@code null}. + */ + private String createRegionalIndicator(String id) { + if (id.length() != 2) { + return null; + } + final char c1 = id.charAt(0); + final char c2 = id.charAt(1); + if ('A' > c1 || c1 > 'Z' || 'A' > c2 || c2 > 'Z') { + return null; + } + // Regional Indicator A is U+1F1E6 which is 0xD83C 0xDDE6 in UTF-16. + final String regionalIndicator = new String( + new char[]{0xd83c, (char) (0xdde6 - 'A' + c1), 0xd83c, (char) (0xdde6 - 'A' + c2)}); + if (!mPaint.hasGlyph(regionalIndicator)) { + return null; + } + return regionalIndicator; + } + + private class TimeZoneInfoComparator implements Comparator { + + @Override + public int compare(TimeZoneInfo tzi1, TimeZoneInfo tzi2) { + int result = + Integer + .compare(tzi1.getTimeZone().getRawOffset(), tzi2.getTimeZone().getRawOffset()); + if (result == 0) { + result = mCollator.compare(tzi1.getExemplarLocation(), tzi2.getExemplarLocation()); + } + if (result == 0 && tzi1.getGenericName() != null && tzi2.getGenericName() != null) { + result = mCollator.compare(tzi1.getGenericName(), tzi2.getGenericName()); + } + return result; + } + } + + private class RegionInfoComparator implements Comparator { + + @Override + public int compare(RegionInfo r1, RegionInfo r2) { + return mCollator.compare(r1.getName(), r2.getName()); + } + } +} diff --git a/src/com/android/settings/datetime/timezone/RegionInfo.java b/src/com/android/settings/datetime/timezone/RegionInfo.java new file mode 100644 index 00000000000..99fbaf09a05 --- /dev/null +++ b/src/com/android/settings/datetime/timezone/RegionInfo.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.settings.datetime.timezone; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * Data object describing a geographical region. + * + * Regions are roughly equivalent to countries, but not every region is a country (for example "U.S. + * overseas territories" is treated as a country). + */ +public class RegionInfo { + + private final String mId; + private final String mName; + private final String mRegionalIndicator; + private final Collection mTimeZoneIds; + + public RegionInfo(String id, String name, String regionalIndicator, + Collection timeZoneIds) { + mId = id; + mName = name; + mRegionalIndicator = regionalIndicator; + mTimeZoneIds = Collections.unmodifiableList(new ArrayList<>(timeZoneIds)); + } + + public String getId() { + return mId; + } + + public String getName() { + return mName; + } + + public Collection getTimeZoneIds() { + return mTimeZoneIds; + } + + @Override + public String toString() { + return mRegionalIndicator != null ? mRegionalIndicator + " " + mName : mName; + } +} diff --git a/src/com/android/settings/datetime/timezone/TimeZoneInfo.java b/src/com/android/settings/datetime/timezone/TimeZoneInfo.java new file mode 100644 index 00000000000..96a20674de5 --- /dev/null +++ b/src/com/android/settings/datetime/timezone/TimeZoneInfo.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.settings.datetime.timezone; + +import android.icu.util.TimeZone; +import android.text.TextUtils; + +/** + * Data object containing information for displaying a time zone for the user to select. + */ +public class TimeZoneInfo { + + private final String mId; + private final TimeZone mTimeZone; + private final String mGenericName; + private final String mStandardName; + private final String mDaylightName; + private final String mExemplarLocation; + private final CharSequence mGmtOffset; + // Arbitrary id that's unique within all TimeZoneInfo objects created by a given DataLoader instance. + private final long mItemId; + + public TimeZoneInfo(Builder builder) { + mTimeZone = builder.mTimeZone; + mId = mTimeZone.getID(); + mGenericName = builder.mGenericName; + mStandardName = builder.mStandardName; + mDaylightName = builder.mDaylightName; + mExemplarLocation = builder.mExemplarLocation; + mGmtOffset = builder.mGmtOffset; + mItemId = builder.mItemId; + } + + public String getId() { + return mId; + } + + public TimeZone getTimeZone() { + return mTimeZone; + } + + public String getExemplarLocation() { + return mExemplarLocation; + } + + public String getGenericName() { + return mGenericName; + } + + public String getStandardName() { + return mStandardName; + } + + public String getDaylightName() { + return mDaylightName; + } + + public CharSequence getGmtOffset() { + return mGmtOffset; + } + + public long getItemId() { + return mItemId; + } + + public static class Builder { + private final TimeZone mTimeZone; + private String mGenericName; + private String mStandardName; + private String mDaylightName; + private String mExemplarLocation; + private CharSequence mGmtOffset; + private long mItemId = -1; + + public Builder(TimeZone timeZone) { + if (timeZone == null) { + throw new IllegalArgumentException("TimeZone must not be null!"); + } + mTimeZone = timeZone; + } + + public Builder setGenericName(String genericName) { + this.mGenericName = genericName; + return this; + } + + public Builder setStandardName(String standardName) { + this.mStandardName = standardName; + return this; + } + + public Builder setDaylightName(String daylightName) { + mDaylightName = daylightName; + return this; + } + + public Builder setExemplarLocation(String exemplarLocation) { + mExemplarLocation = exemplarLocation; + return this; + } + + public Builder setGmtOffset(CharSequence gmtOffset) { + mGmtOffset = gmtOffset; + return this; + } + + public Builder setItemId(long itemId) { + mItemId = itemId; + return this; + } + + public TimeZoneInfo build() { + if (TextUtils.isEmpty(mGmtOffset)) { + throw new IllegalStateException("gmtOffset must not be empty!"); + } + if (mItemId == -1) { + throw new IllegalStateException("ItemId not set!"); + } + return new TimeZoneInfo(this); + } + + } +} diff --git a/tests/robotests/src/com/android/settings/datetime/timezone/DataLoaderTest.java b/tests/robotests/src/com/android/settings/datetime/timezone/DataLoaderTest.java new file mode 100644 index 00000000000..23bfabb89d0 --- /dev/null +++ b/tests/robotests/src/com/android/settings/datetime/timezone/DataLoaderTest.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.settings.datetime.timezone; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.android.settings.TestConfig; +import com.android.settings.testutils.SettingsRobolectricTestRunner; + +import java.util.List; +import java.util.Locale; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +@RunWith(SettingsRobolectricTestRunner.class) +@Config(manifest = TestConfig.MANIFEST_PATH, sdk = TestConfig.SDK_VERSION) +public class DataLoaderTest { + + @Test + public void testHasData() { + List regions = new DataLoader(Locale.US).loadRegionInfos(); + // Sanity check. Real size is closer to 200. + assertNotNull(regions); + assertTrue(regions.size() > 100); + assertEquals("Afghanistan", regions.get(0).getName()); + assertEquals("Zimbabwe", regions.get(regions.size() - 1).getName()); + } + + @Test + public void testRegionsWithTimeZone() { + List regions = new DataLoader(Locale.US).loadRegionInfos(); + checkRegionHasTimeZone(regions, "AT", "Europe/Vienna"); + checkRegionHasTimeZone(regions, "US", "America/Los_Angeles"); + checkRegionHasTimeZone(regions, "CN", "Asia/Shanghai"); + checkRegionHasTimeZone(regions, "AU", "Australia/Sydney"); + } + + @Test + public void testFixedOffsetTimeZones() { + List timeZones = new DataLoader(Locale.US).loadFixedOffsets(); + // Etc/GMT would be equivalent to Etc/UTC, except for how it is labelled. Users have + // explicitly asked for UTC to be supported, so make sure we label it as such. + checkHasTimeZone(timeZones, "Etc/UTC"); + checkHasTimeZone(timeZones, "Etc/GMT-1"); + checkHasTimeZone(timeZones, "Etc/GMT-14"); + checkHasTimeZone(timeZones, "Etc/GMT+1"); + checkHasTimeZone(timeZones, "Etc/GMT+12"); + } + + private void checkRegionHasTimeZone(List regions, String regionId, String tzId) { + RegionInfo ri = findRegion(regions, regionId); + assertTrue("Region " + regionId + " does not have time zone " + tzId, + ri.getTimeZoneIds().contains(tzId)); + } + + private void checkHasTimeZone(List timeZoneInfos, String tzId) { + for (TimeZoneInfo tz : timeZoneInfos) { + if (tz.getId().equals(tzId)) { + return; + } + } + fail("Fixed offset time zones do not contain " + tzId); + } + + private RegionInfo findRegion(List regions, String regionId) { + for (RegionInfo region : regions) { + if (region.getId().equals(regionId)) { + assertNotNull(region.getName()); + return region; + } + + } + fail("No region with id " + regionId + " found."); + return null; // can't reach. + } +}