From 0bed37a0863fa897df377f3bbfad4d626e7feb0e Mon Sep 17 00:00:00 2001 From: Joachim Sauer Date: Tue, 7 Nov 2017 13:52:12 +0000 Subject: [PATCH 1/2] Data loading component for new time zone picker. Add new data loading classes for improved manual time zone picker. These classes use existing sources mostly from ICU4J to construct the list of regions and timezones to present to the user. Test: SettingsRoboTests Bug: 62255208 Change-Id: I244c391a41b0b53cd3f7857f9c0d1ef766a39b17 --- .../datetime/timezone/DataLoader.java | 205 ++++++++++++++++++ .../datetime/timezone/RegionInfo.java | 60 +++++ .../datetime/timezone/TimeZoneInfo.java | 136 ++++++++++++ .../datetime/timezone/DataLoaderTest.java | 94 ++++++++ 4 files changed, 495 insertions(+) create mode 100644 src/com/android/settings/datetime/timezone/DataLoader.java create mode 100644 src/com/android/settings/datetime/timezone/RegionInfo.java create mode 100644 src/com/android/settings/datetime/timezone/TimeZoneInfo.java create mode 100644 tests/robotests/src/com/android/settings/datetime/timezone/DataLoaderTest.java 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. + } +} From 0cdbe1897cd56a613a04377b6c3e2f19add806de Mon Sep 17 00:00:00 2001 From: Joachim Sauer Date: Tue, 7 Nov 2017 13:56:16 +0000 Subject: [PATCH 2/2] New manual time zone picker. This implements a new manual time zone picker that allows selection of a time zone for a selected country. It also allows selecting a fixed offset time zone (most importantly Etc/UTC, which is a frequently requested feature). The new time zone picker is currently behind a feature flag (settings_zone_picker_v2), which is disabled by default. Test: manual Test: SettingsFunctionalTests Test: SettingsRobotTests Bug: 62255208 Change-Id: I89c5a04bcb562b6facf5f31a8aa4ad1cdd51ab10 --- res/layout/time_zone_list.xml | 44 ++++ res/layout/time_zone_list_item.xml | 62 +++++ res/values/strings.xml | 11 + .../android/settings/core/FeatureFlags.java | 1 + .../TimeZonePreferenceController.java | 8 + .../datetime/timezone/TimeZoneAdapter.java | 208 ++++++++++++++++ .../datetime/timezone/ViewHolder.java | 40 ++++ .../datetime/timezone/ZonePicker.java | 224 ++++++++++++++++++ .../timezone/TimeZoneAdapterTest.java | 105 ++++++++ 9 files changed, 703 insertions(+) create mode 100644 res/layout/time_zone_list.xml create mode 100644 res/layout/time_zone_list_item.xml create mode 100644 src/com/android/settings/datetime/timezone/TimeZoneAdapter.java create mode 100644 src/com/android/settings/datetime/timezone/ViewHolder.java create mode 100644 src/com/android/settings/datetime/timezone/ZonePicker.java create mode 100644 tests/robotests/src/com/android/settings/datetime/timezone/TimeZoneAdapterTest.java diff --git a/res/layout/time_zone_list.xml b/res/layout/time_zone_list.xml new file mode 100644 index 00000000000..a3c47cd904a --- /dev/null +++ b/res/layout/time_zone_list.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + diff --git a/res/layout/time_zone_list_item.xml b/res/layout/time_zone_list_item.xml new file mode 100644 index 00000000000..471c9d85a50 --- /dev/null +++ b/res/layout/time_zone_list_item.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 8c7b6f4789f..3708a3e5f2c 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -752,6 +752,17 @@ Sort alphabetically Sort by time zone + + %1$s starts on %2$s. + + Daylight savings time + + Standard time + + Time zone by region + + Fixed offset time zones + Date diff --git a/src/com/android/settings/core/FeatureFlags.java b/src/com/android/settings/core/FeatureFlags.java index e88fb11179a..7d9b331f5bf 100644 --- a/src/com/android/settings/core/FeatureFlags.java +++ b/src/com/android/settings/core/FeatureFlags.java @@ -26,4 +26,5 @@ public class FeatureFlags { public static final String BATTERY_SETTINGS_V2 = "settings_battery_v2"; public static final String BATTERY_DISPLAY_APP_LIST = "settings_battery_display_app_list"; public static final String SECURITY_SETTINGS_V2 = "settings_security_settings_v2"; + public static final String ZONE_PICKER_V2 = "settings_zone_picker_v2"; } diff --git a/src/com/android/settings/datetime/TimeZonePreferenceController.java b/src/com/android/settings/datetime/TimeZonePreferenceController.java index 435b1fe77a3..e29e24550b3 100644 --- a/src/com/android/settings/datetime/TimeZonePreferenceController.java +++ b/src/com/android/settings/datetime/TimeZonePreferenceController.java @@ -20,7 +20,10 @@ import android.content.Context; import android.support.annotation.VisibleForTesting; import android.support.v7.preference.Preference; +import android.util.FeatureFlagUtils; +import com.android.settings.core.FeatureFlags; import com.android.settings.core.PreferenceControllerMixin; +import com.android.settings.datetime.timezone.ZonePicker; import com.android.settingslib.RestrictedPreference; import com.android.settingslib.core.AbstractPreferenceController; import com.android.settingslib.datetime.ZoneGetter; @@ -33,11 +36,13 @@ public class TimeZonePreferenceController extends AbstractPreferenceController private static final String KEY_TIMEZONE = "timezone"; private final AutoTimeZonePreferenceController mAutoTimeZonePreferenceController; + private final boolean mZonePickerV2; public TimeZonePreferenceController(Context context, AutoTimeZonePreferenceController autoTimeZonePreferenceController) { super(context); mAutoTimeZonePreferenceController = autoTimeZonePreferenceController; + mZonePickerV2 = FeatureFlagUtils.isEnabled(mContext, FeatureFlags.ZONE_PICKER_V2); } @Override @@ -45,6 +50,9 @@ public class TimeZonePreferenceController extends AbstractPreferenceController if (!(preference instanceof RestrictedPreference)) { return; } + if (mZonePickerV2) { + preference.setFragment(ZonePicker.class.getName()); + } preference.setSummary(getTimeZoneOffsetAndName()); if( !((RestrictedPreference) preference).isDisabledByAdmin()) { preference.setEnabled(!mAutoTimeZonePreferenceController.isEnabled()); diff --git a/src/com/android/settings/datetime/timezone/TimeZoneAdapter.java b/src/com/android/settings/datetime/timezone/TimeZoneAdapter.java new file mode 100644 index 00000000000..79075ca78f5 --- /dev/null +++ b/src/com/android/settings/datetime/timezone/TimeZoneAdapter.java @@ -0,0 +1,208 @@ +/* + * 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.content.Context; +import android.graphics.Typeface; +import android.icu.impl.OlsonTimeZone; +import android.icu.text.DateFormat; +import android.icu.text.DisplayContext; +import android.icu.text.SimpleDateFormat; +import android.icu.util.Calendar; +import android.icu.util.TimeZone; +import android.icu.util.TimeZoneTransition; +import android.support.annotation.NonNull; +import android.support.v7.widget.RecyclerView; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import com.android.settings.R; + +import java.util.Collections; +import java.util.Date; +import java.util.List; + +/** + * Adapter for showing {@link TimeZoneInfo} objects in a recycler view. + */ +class TimeZoneAdapter extends RecyclerView.Adapter { + + static final int VIEW_TYPE_NORMAL = 1; + static final int VIEW_TYPE_SELECTED = 2; + + private final DateFormat mTimeFormat; + private final DateFormat mDateFormat; + private final View.OnClickListener mOnClickListener; + private final Context mContext; + private final String mCurrentTimeZone; + + private List mTimeZoneInfos; + + TimeZoneAdapter(View.OnClickListener onClickListener, Context context) { + mOnClickListener = onClickListener; + mContext = context; + mTimeFormat = DateFormat.getTimeInstance(SimpleDateFormat.SHORT); + mDateFormat = DateFormat.getDateInstance(SimpleDateFormat.MEDIUM); + mDateFormat.setContext(DisplayContext.CAPITALIZATION_NONE); + mCurrentTimeZone = TimeZone.getDefault().getID(); + setHasStableIds(true); + } + + @Override + public long getItemId(int position) { + return getItem(position).getItemId(); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.time_zone_list_item, parent, false); + view.setOnClickListener(mOnClickListener); + final ViewHolder viewHolder = new ViewHolder(view); + if (viewType == VIEW_TYPE_SELECTED) { + viewHolder.mNameView.setTypeface( + viewHolder.mNameView.getTypeface(), Typeface.BOLD); + } + return viewHolder; + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + final TimeZoneInfo item = getItem(position); + final ViewHolder tzHolder = (ViewHolder) holder; + tzHolder.mNameView.setText(formatName(item)); + tzHolder.mDetailsView.setText(formatDetails(item)); + tzHolder.mTimeView.setText(formatTime(item)); + String dstText = formatDstText(item); + tzHolder.mDstView.setText(dstText); + // Hide DST TextView when it has no content. + tzHolder.mDstView.setVisibility(dstText != null ? View.VISIBLE : View.GONE); + + } + + @Override + public int getItemCount() { + return getTimeZones().size(); + } + + @Override + public int getItemViewType(int position) { + final TimeZoneInfo tz = getItem(position); + if (tz.getId().equals(mCurrentTimeZone)) { + return VIEW_TYPE_SELECTED; + } else { + return VIEW_TYPE_NORMAL; + } + } + + public TimeZoneInfo getItem(int position) { + return getTimeZones().get(position); + } + + private CharSequence formatName(TimeZoneInfo item) { + CharSequence name = item.getExemplarLocation(); + if (name == null) { + name = item.getGenericName(); + } + if (name == null && item.getTimeZone().inDaylightTime(new Date())) { + name = item.getDaylightName(); + } + if (name == null) { + name = item.getStandardName(); + } + if (name == null) { + name = item.getGmtOffset(); + } + return name; + } + + private CharSequence formatDetails(TimeZoneInfo item) { + String name = item.getGenericName(); + if (name == null) { + if (item.getTimeZone().inDaylightTime(new Date())) { + name = item.getDaylightName(); + } else { + name = item.getStandardName(); + } + } + if (name == null) { + return item.getGmtOffset(); + } else { + return TextUtils.concat(item.getGmtOffset(), " ", name); + } + } + + private String formatDstText(TimeZoneInfo item) { + final TimeZone timeZone = item.getTimeZone(); + if (!timeZone.observesDaylightTime()) { + return null; + } + + final TimeZoneTransition nextDstTransition = findNextDstTransition(timeZone); + if (nextDstTransition == null) { + return null; + } + final boolean toDst = nextDstTransition.getTo().getDSTSavings() != 0; + String timeType = toDst ? item.getDaylightName() : item.getStandardName(); + if (timeType == null) { + // Fall back to generic "summer time" and "standard time" if the time zone has no + // specific names. + timeType = toDst ? + mContext.getString(R.string.zone_time_type_dst) : + mContext.getString(R.string.zone_time_type_standard); + + } + final Calendar transitionTime = Calendar.getInstance(timeZone); + transitionTime.setTimeInMillis(nextDstTransition.getTime()); + final String date = mDateFormat.format(transitionTime); + return mContext.getString(R.string.zone_change_to_from_dst, timeType, date); + } + + private TimeZoneTransition findNextDstTransition(TimeZone timeZone) { + if (!(timeZone instanceof OlsonTimeZone)) { + return null; + } + final OlsonTimeZone olsonTimeZone = (OlsonTimeZone) timeZone; + TimeZoneTransition transition = olsonTimeZone.getNextTransition( + System.currentTimeMillis(), /* inclusive */ false); + do { + if (transition.getTo().getDSTSavings() != transition.getFrom().getDSTSavings()) { + break; + } + transition = olsonTimeZone.getNextTransition( + transition.getTime(), /*inclusive */ false); + } while (transition != null); + return transition; + } + + private String formatTime(TimeZoneInfo item) { + return mTimeFormat.format(Calendar.getInstance(item.getTimeZone())); + } + + private List getTimeZones() { + if (mTimeZoneInfos == null) { + return Collections.emptyList(); + } + return mTimeZoneInfos; + } + + void setTimeZoneInfos(List timeZoneInfos) { + mTimeZoneInfos = timeZoneInfos; + notifyDataSetChanged(); + } +} diff --git a/src/com/android/settings/datetime/timezone/ViewHolder.java b/src/com/android/settings/datetime/timezone/ViewHolder.java new file mode 100644 index 00000000000..3cb2c4e29d8 --- /dev/null +++ b/src/com/android/settings/datetime/timezone/ViewHolder.java @@ -0,0 +1,40 @@ +/* + * 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.support.v7.widget.RecyclerView; +import android.view.View; +import android.widget.TextView; +import com.android.settings.R; + +/** + * View holder for a time zone list item. + */ +class ViewHolder extends RecyclerView.ViewHolder { + + final TextView mNameView; + final TextView mDstView; + final TextView mDetailsView; + final TextView mTimeView; + + public ViewHolder(View itemView) { + super(itemView); + mNameView = itemView.findViewById(R.id.tz_item_name); + mDstView = itemView.findViewById(R.id.tz_item_dst); + mDetailsView = itemView.findViewById(R.id.tz_item_details); + mTimeView = itemView.findViewById(R.id.tz_item_time); + } +} diff --git a/src/com/android/settings/datetime/timezone/ZonePicker.java b/src/com/android/settings/datetime/timezone/ZonePicker.java new file mode 100644 index 00000000000..eafbaa29bf2 --- /dev/null +++ b/src/com/android/settings/datetime/timezone/ZonePicker.java @@ -0,0 +1,224 @@ +/* + * 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.app.Activity; +import android.app.AlarmManager; +import android.content.Context; +import android.icu.util.TimeZone; +import android.os.Bundle; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Spinner; +import com.android.internal.logging.nano.MetricsProto; +import com.android.settings.R; +import com.android.settings.core.InstrumentedFragment; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * The class displaying a region list and a list of time zones for the selected region. + * Choosing an item from the list will set the time zone. Pressing Back without choosing from the + * list will not result in a change in the time zone setting. + */ +public class ZonePicker extends InstrumentedFragment + implements AdapterView.OnItemSelectedListener, View.OnClickListener { + + private static final int MENU_BY_REGION = Menu.FIRST; + private static final int MENU_BY_OFFSET = Menu.FIRST + 1; + + private Locale mLocale; + private List mRegions; + private Map> mZoneInfos; + private List mFixedOffsetTimeZones; + private TimeZoneAdapter mTimeZoneAdapter; + private String mSelectedTimeZone; + private boolean mSelectByRegion; + private DataLoader mDataLoader; + private RecyclerView mRecyclerView; + + @Override + public int getMetricsCategory() { + return MetricsProto.MetricsEvent.ZONE_PICKER; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.time_zone_list, container, false); + + mLocale = getContext().getResources().getConfiguration().locale; + mDataLoader = new DataLoader(mLocale); + // TOOD: move this off the UI thread. + mRegions = mDataLoader.loadRegionInfos(); + mZoneInfos = new HashMap<>(); + mSelectByRegion = true; + mSelectedTimeZone = TimeZone.getDefault().getID(); + + mTimeZoneAdapter = new TimeZoneAdapter(this, getContext()); + mRecyclerView = view.findViewById(R.id.tz_list); + mRecyclerView.setAdapter(mTimeZoneAdapter); + mRecyclerView.setLayoutManager( + new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, /* reverseLayout */ false)); + + final ArrayAdapter regionAdapter = new ArrayAdapter<>(getContext(), + R.layout.filter_spinner_item, mRegions); + regionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + final Spinner spinner = view.findViewById(R.id.tz_region_spinner); + spinner.setAdapter(regionAdapter); + spinner.setOnItemSelectedListener(this); + setupForCurrentTimeZone(spinner); + setHasOptionsMenu(true); + return view; + } + + private void setupForCurrentTimeZone(Spinner spinner) { + final String localeRegionId = mLocale.getCountry().toUpperCase(Locale.ROOT); + final String currentTimeZone = TimeZone.getDefault().getID(); + boolean fixedOffset = currentTimeZone.startsWith("Etc/GMT") || + currentTimeZone.equals("Etc/UTC"); + + for (int regionIndex = 0; regionIndex < mRegions.size(); regionIndex++) { + final RegionInfo region = mRegions.get(regionIndex); + if (localeRegionId.equals(region.getId())) { + spinner.setSelection(regionIndex); + } + if (!fixedOffset) { + for (String timeZoneId: region.getTimeZoneIds()) { + if (TextUtils.equals(timeZoneId, mSelectedTimeZone)) { + spinner.setSelection(regionIndex); + return; + } + } + } + } + + if (fixedOffset) { + setSelectByRegion(false); + } + } + + @Override + public void onClick(View view) { + // Ignore extra clicks + if (!isResumed()) { + return; + } + final int position = mRecyclerView.getChildAdapterPosition(view); + if (position == RecyclerView.NO_POSITION) { + return; + } + final TimeZoneInfo timeZoneInfo = mTimeZoneAdapter.getItem(position); + + // Update the system timezone value + final Activity activity = getActivity(); + final AlarmManager alarm = (AlarmManager) activity.getSystemService(Context.ALARM_SERVICE); + alarm.setTimeZone(timeZoneInfo.getId()); + + activity.onBackPressed(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + menu.add(0, MENU_BY_REGION, 0, R.string.zone_menu_by_region); + menu.add(0, MENU_BY_OFFSET, 0, R.string.zone_menu_by_offset); + super.onCreateOptionsMenu(menu, inflater); + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + if (mSelectByRegion) { + menu.findItem(MENU_BY_REGION).setVisible(false); + menu.findItem(MENU_BY_OFFSET).setVisible(true); + } else { + menu.findItem(MENU_BY_REGION).setVisible(true); + menu.findItem(MENU_BY_OFFSET).setVisible(false); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + + case MENU_BY_REGION: + setSelectByRegion(true); + return true; + + case MENU_BY_OFFSET: + setSelectByRegion(false); + return true; + + default: + return false; + } + } + + private void setSelectByRegion(boolean selectByRegion) { + mSelectByRegion = selectByRegion; + getView().findViewById(R.id.tz_region_spinner_layout).setVisibility( + mSelectByRegion ? View.VISIBLE : View.GONE); + List tzInfos; + if (selectByRegion) { + Spinner regionSpinner = getView().findViewById(R.id.tz_region_spinner); + int selectedRegion = regionSpinner.getSelectedItemPosition(); + if (selectedRegion == -1) { + // Arbitrarily pick the first item if no region was selected above. + selectedRegion = 0; + regionSpinner.setSelection(selectedRegion); + } + tzInfos = getTimeZoneInfos(mRegions.get(selectedRegion)); + } else { + if (mFixedOffsetTimeZones == null) { + mFixedOffsetTimeZones = mDataLoader.loadFixedOffsets(); + } + tzInfos = mFixedOffsetTimeZones; + } + mTimeZoneAdapter.setTimeZoneInfos(tzInfos); + } + + private List getTimeZoneInfos(RegionInfo regionInfo) { + List tzInfos = mZoneInfos.get(regionInfo.getId()); + if (tzInfos == null) { + // TODO: move this off the UI thread. + Collection tzIds = regionInfo.getTimeZoneIds(); + tzInfos = mDataLoader.loadTimeZoneInfos(tzIds); + mZoneInfos.put(regionInfo.getId(), tzInfos); + } + return tzInfos; + } + + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + mTimeZoneAdapter.setTimeZoneInfos(getTimeZoneInfos(mRegions.get(position))); + } + + @Override + public void onNothingSelected(AdapterView parent) { + mTimeZoneAdapter.setTimeZoneInfos(null); + } + +} diff --git a/tests/robotests/src/com/android/settings/datetime/timezone/TimeZoneAdapterTest.java b/tests/robotests/src/com/android/settings/datetime/timezone/TimeZoneAdapterTest.java new file mode 100644 index 00000000000..5f29a0b65f1 --- /dev/null +++ b/tests/robotests/src/com/android/settings/datetime/timezone/TimeZoneAdapterTest.java @@ -0,0 +1,105 @@ +/* + * 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; +import android.view.View; +import android.widget.FrameLayout; +import com.android.settings.TestConfig; +import com.android.settings.testutils.SettingsRobolectricTestRunner; +import com.android.settings.testutils.shadow.SettingsShadowResources; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import java.util.Collections; + +import static com.google.common.truth.Truth.assertThat; + +@RunWith(SettingsRobolectricTestRunner.class) +@Config(manifest = TestConfig.MANIFEST_PATH, sdk = TestConfig.SDK_VERSION, + shadows = { + SettingsShadowResources.class, + SettingsShadowResources.SettingsShadowTheme.class}) +public class TimeZoneAdapterTest { + @Mock + private View.OnClickListener mOnClickListener; + + private TimeZoneAdapter mTimeZoneAdapter; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mTimeZoneAdapter = new TimeZoneAdapter(mOnClickListener, RuntimeEnvironment.application); + } + + @Test + public void getItemViewType_onDefaultTimeZone_returnsTypeSelected() { + final TimeZoneInfo tzi = dummyTimeZoneInfo(TimeZone.getDefault()); + mTimeZoneAdapter.setTimeZoneInfos(Collections.singletonList(tzi)); + assertThat(mTimeZoneAdapter.getItemViewType(0)).isEqualTo(TimeZoneAdapter.VIEW_TYPE_SELECTED); + } + + @Test + public void getItemViewType_onNonDefaultTimeZone_returnsTypeNormal() { + final TimeZoneInfo tzi = dummyTimeZoneInfo(getNonDefaultTimeZone()); + mTimeZoneAdapter.setTimeZoneInfos(Collections.singletonList(tzi)); + assertThat(mTimeZoneAdapter.getItemViewType(0)).isEqualTo(TimeZoneAdapter.VIEW_TYPE_NORMAL); + } + + @Test + public void bindViewHolder_onDstTimeZone_showsDstLabel() { + final TimeZoneInfo tzi = dummyTimeZoneInfo(TimeZone.getTimeZone("America/Los_Angeles")); + mTimeZoneAdapter.setTimeZoneInfos(Collections.singletonList(tzi)); + + final FrameLayout parent = new FrameLayout(RuntimeEnvironment.application); + + final ViewHolder viewHolder = (ViewHolder) mTimeZoneAdapter.createViewHolder(parent, TimeZoneAdapter.VIEW_TYPE_NORMAL); + mTimeZoneAdapter.bindViewHolder(viewHolder, 0); + assertThat(viewHolder.mDstView.getVisibility()).isEqualTo(View.VISIBLE); + } + + @Test + public void bindViewHolder_onNonDstTimeZone_hidesDstLabel() { + final TimeZoneInfo tzi = dummyTimeZoneInfo(TimeZone.getTimeZone("Etc/UTC")); + mTimeZoneAdapter.setTimeZoneInfos(Collections.singletonList(tzi)); + + final FrameLayout parent = new FrameLayout(RuntimeEnvironment.application); + + final ViewHolder viewHolder = (ViewHolder) mTimeZoneAdapter.createViewHolder(parent, TimeZoneAdapter.VIEW_TYPE_NORMAL); + mTimeZoneAdapter.bindViewHolder(viewHolder, 0); + assertThat(viewHolder.mDstView.getVisibility()).isEqualTo(View.GONE); + } + + // Pick an arbitrary time zone that's not the current default. + private static TimeZone getNonDefaultTimeZone() { + final String[] availableIDs = TimeZone.getAvailableIDs(); + int index = 0; + if (TextUtils.equals(availableIDs[index], TimeZone.getDefault().getID())) { + index++; + } + return TimeZone.getTimeZone(availableIDs[index]); + } + + private TimeZoneInfo dummyTimeZoneInfo(TimeZone timeZone) { + return new TimeZoneInfo.Builder(timeZone).setGmtOffset("GMT+0").setItemId(1).build(); + } +}