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 de0dbca6739..33e5ca755e6 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/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/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/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/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/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.
+ }
+}
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();
+ }
+}