1 /*
2  * Copyright (C) 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.settings.datetime.timezone;
18 
19 import android.app.Activity;
20 import android.app.settings.SettingsEnums;
21 import android.app.timezonedetector.ManualTimeZoneSuggestion;
22 import android.app.timezonedetector.TimeZoneDetector;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.content.SharedPreferences;
26 import android.icu.util.TimeZone;
27 import android.os.Bundle;
28 import android.provider.Settings;
29 import android.util.Log;
30 import android.view.Menu;
31 import android.view.MenuInflater;
32 import android.view.MenuItem;
33 
34 import androidx.annotation.VisibleForTesting;
35 import androidx.preference.PreferenceCategory;
36 
37 import com.android.settings.R;
38 import com.android.settings.core.SubSettingLauncher;
39 import com.android.settings.dashboard.DashboardFragment;
40 import com.android.settings.datetime.timezone.model.FilteredCountryTimeZones;
41 import com.android.settings.datetime.timezone.model.TimeZoneData;
42 import com.android.settings.datetime.timezone.model.TimeZoneDataLoader;
43 import com.android.settings.search.BaseSearchIndexProvider;
44 import com.android.settingslib.core.AbstractPreferenceController;
45 import com.android.settingslib.search.SearchIndexable;
46 
47 import java.util.ArrayList;
48 import java.util.Date;
49 import java.util.List;
50 import java.util.Locale;
51 import java.util.Objects;
52 import java.util.Set;
53 
54 /**
55  * The class displays a time zone picker either by regions or fixed offset time zones.
56  */
57 @SearchIndexable
58 public class TimeZoneSettings extends DashboardFragment {
59 
60     private static final String TAG = "TimeZoneSettings";
61 
62     private static final int MENU_BY_REGION = Menu.FIRST;
63     private static final int MENU_BY_OFFSET = Menu.FIRST + 1;
64 
65     private static final int REQUEST_CODE_REGION_PICKER = 1;
66     private static final int REQUEST_CODE_ZONE_PICKER = 2;
67     private static final int REQUEST_CODE_FIXED_OFFSET_ZONE_PICKER = 3;
68 
69     private static final String PREF_KEY_REGION = "time_zone_region";
70     private static final String PREF_KEY_REGION_CATEGORY = "time_zone_region_preference_category";
71     private static final String PREF_KEY_FIXED_OFFSET_CATEGORY =
72             "time_zone_fixed_offset_preference_category";
73 
74     private Locale mLocale;
75     private boolean mSelectByRegion;
76     private TimeZoneData mTimeZoneData;
77     private Intent mPendingZonePickerRequestResult;
78 
79     private String mSelectedTimeZoneId;
80     private TimeZoneInfo.Formatter mTimeZoneInfoFormatter;
81 
82     @Override
getMetricsCategory()83     public int getMetricsCategory() {
84         return SettingsEnums.ZONE_PICKER;
85     }
86 
87     @Override
getPreferenceScreenResId()88     protected int getPreferenceScreenResId() {
89         return R.xml.time_zone_prefs;
90     }
91 
92     @Override
getLogTag()93     protected String getLogTag() {
94         return TAG;
95     }
96 
97     /**
98      * Called during onAttach
99      */
100     @VisibleForTesting
101     @Override
createPreferenceControllers(Context context)102     public List<AbstractPreferenceController> createPreferenceControllers(Context context) {
103         mLocale = context.getResources().getConfiguration().getLocales().get(0);
104         mTimeZoneInfoFormatter = new TimeZoneInfo.Formatter(mLocale, new Date());
105         final List<AbstractPreferenceController> controllers = new ArrayList<>();
106         RegionPreferenceController regionPreferenceController =
107                 new RegionPreferenceController(context);
108         regionPreferenceController.setOnClickListener(this::startRegionPicker);
109         RegionZonePreferenceController regionZonePreferenceController =
110                 new RegionZonePreferenceController(context);
111         regionZonePreferenceController.setOnClickListener(this::onRegionZonePreferenceClicked);
112         FixedOffsetPreferenceController fixedOffsetPreferenceController =
113                 new FixedOffsetPreferenceController(context);
114         fixedOffsetPreferenceController.setOnClickListener(this::startFixedOffsetPicker);
115 
116         controllers.add(regionPreferenceController);
117         controllers.add(regionZonePreferenceController);
118         controllers.add(fixedOffsetPreferenceController);
119         return controllers;
120     }
121 
122     @Override
onCreate(Bundle icicle)123     public void onCreate(Bundle icicle) {
124         super.onCreate(icicle);
125         // Hide all interactive preferences
126         setPreferenceCategoryVisible((PreferenceCategory) findPreference(
127                 PREF_KEY_REGION_CATEGORY), false);
128         setPreferenceCategoryVisible((PreferenceCategory) findPreference(
129                 PREF_KEY_FIXED_OFFSET_CATEGORY), false);
130 
131         // Start loading TimeZoneData
132         getLoaderManager().initLoader(0, null, new TimeZoneDataLoader.LoaderCreator(
133                 getContext(), this::onTimeZoneDataReady));
134     }
135 
136     @Override
onActivityResult(int requestCode, int resultCode, Intent data)137     public void onActivityResult(int requestCode, int resultCode, Intent data) {
138         if (resultCode != Activity.RESULT_OK || data == null) {
139             return;
140         }
141 
142         switch (requestCode) {
143             case REQUEST_CODE_REGION_PICKER:
144             case REQUEST_CODE_ZONE_PICKER: {
145                 if (mTimeZoneData == null) {
146                     mPendingZonePickerRequestResult = data;
147                 } else {
148                     onZonePickerRequestResult(mTimeZoneData, data);
149                 }
150                 break;
151             }
152             case REQUEST_CODE_FIXED_OFFSET_ZONE_PICKER: {
153                 String tzId = data.getStringExtra(FixedOffsetPicker.EXTRA_RESULT_TIME_ZONE_ID);
154                 // Ignore the result if user didn't change the time zone.
155                 if (tzId != null && !tzId.equals(mSelectedTimeZoneId)) {
156                     onFixedOffsetZoneChanged(tzId);
157                 }
158                 break;
159             }
160         }
161     }
162 
163     @VisibleForTesting
setTimeZoneData(TimeZoneData timeZoneData)164     void setTimeZoneData(TimeZoneData timeZoneData) {
165         mTimeZoneData = timeZoneData;
166     }
167 
onTimeZoneDataReady(TimeZoneData timeZoneData)168     private void onTimeZoneDataReady(TimeZoneData timeZoneData) {
169         if (mTimeZoneData == null && timeZoneData != null) {
170             mTimeZoneData = timeZoneData;
171             setupForCurrentTimeZone();
172             getActivity().invalidateOptionsMenu();
173             if (mPendingZonePickerRequestResult != null) {
174                 onZonePickerRequestResult(timeZoneData, mPendingZonePickerRequestResult);
175                 mPendingZonePickerRequestResult = null;
176             }
177         }
178     }
179 
startRegionPicker()180     private void startRegionPicker() {
181         startPickerFragment(RegionSearchPicker.class, new Bundle(), REQUEST_CODE_REGION_PICKER);
182     }
183 
onRegionZonePreferenceClicked()184     private void onRegionZonePreferenceClicked() {
185         final Bundle args = new Bundle();
186         args.putString(RegionZonePicker.EXTRA_REGION_ID,
187                 use(RegionPreferenceController.class).getRegionId());
188         startPickerFragment(RegionZonePicker.class, args, REQUEST_CODE_ZONE_PICKER);
189     }
190 
startFixedOffsetPicker()191     private void startFixedOffsetPicker() {
192         startPickerFragment(FixedOffsetPicker.class, new Bundle(),
193                 REQUEST_CODE_FIXED_OFFSET_ZONE_PICKER);
194     }
195 
startPickerFragment(Class<? extends BaseTimeZonePicker> fragmentClass, Bundle args, int resultRequestCode)196     private void startPickerFragment(Class<? extends BaseTimeZonePicker> fragmentClass, Bundle args,
197             int resultRequestCode) {
198         new SubSettingLauncher(getContext())
199                 .setDestination(fragmentClass.getCanonicalName())
200                 .setArguments(args)
201                 .setSourceMetricsCategory(getMetricsCategory())
202                 .setResultListener(this, resultRequestCode)
203                 .launch();
204     }
205 
setDisplayedRegion(String regionId)206     private void setDisplayedRegion(String regionId) {
207         use(RegionPreferenceController.class).setRegionId(regionId);
208         updatePreferenceStates();
209     }
210 
setDisplayedTimeZoneInfo(String regionId, String tzId)211     private void setDisplayedTimeZoneInfo(String regionId, String tzId) {
212         final TimeZoneInfo tzInfo = tzId == null ? null : mTimeZoneInfoFormatter.format(tzId);
213         final FilteredCountryTimeZones countryTimeZones =
214                 mTimeZoneData.lookupCountryTimeZones(regionId);
215 
216         use(RegionZonePreferenceController.class).setTimeZoneInfo(tzInfo);
217         // Only clickable when the region has more than 1 time zones or no time zone is selected.
218 
219         use(RegionZonePreferenceController.class).setClickable(tzInfo == null ||
220                 (countryTimeZones != null && countryTimeZones.getTimeZoneIds().size() > 1));
221         use(TimeZoneInfoPreferenceController.class).setTimeZoneInfo(tzInfo);
222 
223         updatePreferenceStates();
224     }
225 
setDisplayedFixedOffsetTimeZoneInfo(String tzId)226     private void setDisplayedFixedOffsetTimeZoneInfo(String tzId) {
227         if (isFixedOffset(tzId)) {
228             use(FixedOffsetPreferenceController.class).setTimeZoneInfo(
229                     mTimeZoneInfoFormatter.format(tzId));
230         } else {
231             use(FixedOffsetPreferenceController.class).setTimeZoneInfo(null);
232         }
233         updatePreferenceStates();
234     }
235 
onZonePickerRequestResult(TimeZoneData timeZoneData, Intent data)236     private void onZonePickerRequestResult(TimeZoneData timeZoneData, Intent data) {
237         String regionId = data.getStringExtra(RegionSearchPicker.EXTRA_RESULT_REGION_ID);
238         String tzId = data.getStringExtra(RegionZonePicker.EXTRA_RESULT_TIME_ZONE_ID);
239         // Ignore the result if user didn't change the region or time zone.
240         if (Objects.equals(regionId, use(RegionPreferenceController.class).getRegionId())
241                 && Objects.equals(tzId, mSelectedTimeZoneId)) {
242             return;
243         }
244 
245         FilteredCountryTimeZones countryTimeZones =
246                 timeZoneData.lookupCountryTimeZones(regionId);
247         if (countryTimeZones == null || !countryTimeZones.getTimeZoneIds().contains(tzId)) {
248             Log.e(TAG, "Unknown time zone id is selected: " + tzId);
249             return;
250         }
251 
252         mSelectedTimeZoneId = tzId;
253         setDisplayedRegion(regionId);
254         setDisplayedTimeZoneInfo(regionId, mSelectedTimeZoneId);
255         saveTimeZone(regionId, mSelectedTimeZoneId);
256 
257         // Switch to the region mode if the user switching from the fixed offset
258         setSelectByRegion(true);
259     }
260 
onFixedOffsetZoneChanged(String tzId)261     private void onFixedOffsetZoneChanged(String tzId) {
262         mSelectedTimeZoneId = tzId;
263         setDisplayedFixedOffsetTimeZoneInfo(tzId);
264         saveTimeZone(null, mSelectedTimeZoneId);
265 
266         // Switch to the fixed offset mode if the user switching from the region mode
267         setSelectByRegion(false);
268     }
269 
saveTimeZone(String regionId, String tzId)270     private void saveTimeZone(String regionId, String tzId) {
271         SharedPreferences.Editor editor = getPreferenceManager().getSharedPreferences().edit();
272         if (regionId == null) {
273             editor.remove(PREF_KEY_REGION);
274         } else {
275             editor.putString(PREF_KEY_REGION, regionId);
276         }
277         editor.apply();
278         ManualTimeZoneSuggestion manualTimeZoneSuggestion =
279                 TimeZoneDetector.createManualTimeZoneSuggestion(tzId, "Settings: Set time zone");
280         TimeZoneDetector timeZoneDetector = getActivity().getSystemService(TimeZoneDetector.class);
281         timeZoneDetector.suggestManualTimeZone(manualTimeZoneSuggestion);
282     }
283 
284     @Override
onCreateOptionsMenu(Menu menu, MenuInflater inflater)285     public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
286         menu.add(0, MENU_BY_REGION, 0, R.string.zone_menu_by_region);
287         menu.add(0, MENU_BY_OFFSET, 0, R.string.zone_menu_by_offset);
288         super.onCreateOptionsMenu(menu, inflater);
289     }
290 
291     @Override
onPrepareOptionsMenu(Menu menu)292     public void onPrepareOptionsMenu(Menu menu) {
293         // Do not show menu when data is not ready,
294         menu.findItem(MENU_BY_REGION).setVisible(mTimeZoneData != null && !mSelectByRegion);
295         menu.findItem(MENU_BY_OFFSET).setVisible(mTimeZoneData != null && mSelectByRegion);
296     }
297 
298     @Override
onOptionsItemSelected(MenuItem item)299     public boolean onOptionsItemSelected(MenuItem item) {
300         switch (item.getItemId()) {
301             case MENU_BY_REGION:
302                 startRegionPicker();
303                 return true;
304 
305             case MENU_BY_OFFSET:
306                 startFixedOffsetPicker();
307                 return true;
308 
309             default:
310                 return false;
311         }
312     }
313 
setupForCurrentTimeZone()314     private void setupForCurrentTimeZone() {
315         mSelectedTimeZoneId = TimeZone.getDefault().getID();
316         setSelectByRegion(!isFixedOffset(mSelectedTimeZoneId));
317     }
318 
isFixedOffset(String tzId)319     private static boolean isFixedOffset(String tzId) {
320         return tzId.startsWith("Etc/GMT") || tzId.equals("Etc/UTC");
321     }
322 
323     /**
324      * Switch the current view to select region or select fixed offset time zone.
325      * When showing the selected region, it guess the selected region from time zone id.
326      * See {@link #findRegionIdForTzId} for more info.
327      */
setSelectByRegion(boolean selectByRegion)328     private void setSelectByRegion(boolean selectByRegion) {
329         mSelectByRegion = selectByRegion;
330         setPreferenceCategoryVisible((PreferenceCategory) findPreference(
331                 PREF_KEY_REGION_CATEGORY), selectByRegion);
332         setPreferenceCategoryVisible((PreferenceCategory) findPreference(
333                 PREF_KEY_FIXED_OFFSET_CATEGORY), !selectByRegion);
334         final String localeRegionId = getLocaleRegionId();
335         final Set<String> allCountryIsoCodes = mTimeZoneData.getRegionIds();
336 
337         String displayRegion = allCountryIsoCodes.contains(localeRegionId) ? localeRegionId : null;
338         setDisplayedRegion(displayRegion);
339         setDisplayedTimeZoneInfo(displayRegion, null);
340 
341         if (!mSelectByRegion) {
342             setDisplayedFixedOffsetTimeZoneInfo(mSelectedTimeZoneId);
343             return;
344         }
345 
346         String regionId = findRegionIdForTzId(mSelectedTimeZoneId);
347         if (regionId != null) {
348             setDisplayedRegion(regionId);
349             setDisplayedTimeZoneInfo(regionId, mSelectedTimeZoneId);
350         }
351     }
352 
353     /**
354      * Find the a region associated with the specified time zone, based on the time zone data.
355      * If there are multiple regions associated with the given time zone, the priority will be given
356      * to the region the user last picked and the country in user's locale.
357      *
358      * @return null if no region associated with the time zone
359      */
findRegionIdForTzId(String tzId)360     private String findRegionIdForTzId(String tzId) {
361         return findRegionIdForTzId(tzId,
362                 getPreferenceManager().getSharedPreferences().getString(PREF_KEY_REGION, null),
363                 getLocaleRegionId());
364     }
365 
366     @VisibleForTesting
findRegionIdForTzId(String tzId, String sharePrefRegionId, String localeRegionId)367     String findRegionIdForTzId(String tzId, String sharePrefRegionId, String localeRegionId) {
368         final Set<String> matchedRegions = mTimeZoneData.lookupCountryCodesForZoneId(tzId);
369         if (matchedRegions.size() == 0) {
370             return null;
371         }
372         if (sharePrefRegionId != null && matchedRegions.contains(sharePrefRegionId)) {
373             return sharePrefRegionId;
374         }
375         if (localeRegionId != null && matchedRegions.contains(localeRegionId)) {
376             return localeRegionId;
377         }
378 
379         return matchedRegions.toArray(new String[matchedRegions.size()])[0];
380     }
381 
setPreferenceCategoryVisible(PreferenceCategory category, boolean isVisible)382     private void setPreferenceCategoryVisible(PreferenceCategory category,
383             boolean isVisible) {
384         // Hiding category doesn't hide all the children preference. Set visibility of its children.
385         // Do not care grandchildren as time_zone_pref.xml has only 2 levels.
386         category.setVisible(isVisible);
387         for (int i = 0; i < category.getPreferenceCount(); i++) {
388             category.getPreference(i).setVisible(isVisible);
389         }
390     }
391 
getLocaleRegionId()392     private String getLocaleRegionId() {
393         return mLocale.getCountry().toUpperCase(Locale.US);
394     }
395 
396     public static final BaseSearchIndexProvider SEARCH_INDEX_DATA_PROVIDER =
397             new BaseSearchIndexProvider(R.xml.time_zone_prefs) {
398                 @Override
399                 protected boolean isPageSearchEnabled(Context context) {
400                     // We can't enter this page if the auto time zone is enabled.
401                     final int autoTimeZone = Settings.Global.getInt(context.getContentResolver(),
402                             Settings.Global.AUTO_TIME_ZONE, 1);
403                     return autoTimeZone == 1 ? false : true;
404                 }
405             };
406 }
407