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(); + } +}