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 
218         // Only clickable when the region has more than 1 time zones or no time zone is selected.
219         use(RegionZonePreferenceController.class).setClickable(tzInfo == null ||
220                 (countryTimeZones != null
221                         && countryTimeZones.getPreferredTimeZoneIds().size() > 1));
222         use(TimeZoneInfoPreferenceController.class).setTimeZoneInfo(tzInfo);
223 
224         updatePreferenceStates();
225     }
226 
setDisplayedFixedOffsetTimeZoneInfo(String tzId)227     private void setDisplayedFixedOffsetTimeZoneInfo(String tzId) {
228         if (isFixedOffset(tzId)) {
229             use(FixedOffsetPreferenceController.class).setTimeZoneInfo(
230                     mTimeZoneInfoFormatter.format(tzId));
231         } else {
232             use(FixedOffsetPreferenceController.class).setTimeZoneInfo(null);
233         }
234         updatePreferenceStates();
235     }
236 
onZonePickerRequestResult(TimeZoneData timeZoneData, Intent data)237     private void onZonePickerRequestResult(TimeZoneData timeZoneData, Intent data) {
238         String regionId = data.getStringExtra(RegionSearchPicker.EXTRA_RESULT_REGION_ID);
239         String tzId = data.getStringExtra(RegionZonePicker.EXTRA_RESULT_TIME_ZONE_ID);
240         // Ignore the result if user didn't change the region or time zone.
241         if (Objects.equals(regionId, use(RegionPreferenceController.class).getRegionId())
242                 && Objects.equals(tzId, mSelectedTimeZoneId)) {
243             return;
244         }
245 
246         FilteredCountryTimeZones countryTimeZones =
247                 timeZoneData.lookupCountryTimeZones(regionId);
248         if (countryTimeZones == null
249                 || !countryTimeZones.getPreferredTimeZoneIds().contains(tzId)) {
250             Log.e(TAG, "Unknown time zone id is selected: " + tzId);
251             return;
252         }
253 
254         mSelectedTimeZoneId = tzId;
255         setDisplayedRegion(regionId);
256         setDisplayedTimeZoneInfo(regionId, mSelectedTimeZoneId);
257         saveTimeZone(regionId, mSelectedTimeZoneId);
258 
259         // Switch to the region mode if the user switching from the fixed offset
260         setSelectByRegion(true);
261     }
262 
onFixedOffsetZoneChanged(String tzId)263     private void onFixedOffsetZoneChanged(String tzId) {
264         mSelectedTimeZoneId = tzId;
265         setDisplayedFixedOffsetTimeZoneInfo(tzId);
266         saveTimeZone(null, mSelectedTimeZoneId);
267 
268         // Switch to the fixed offset mode if the user switching from the region mode
269         setSelectByRegion(false);
270     }
271 
saveTimeZone(String regionId, String tzId)272     private void saveTimeZone(String regionId, String tzId) {
273         SharedPreferences.Editor editor = getPreferenceManager().getSharedPreferences().edit();
274         if (regionId == null) {
275             editor.remove(PREF_KEY_REGION);
276         } else {
277             editor.putString(PREF_KEY_REGION, regionId);
278         }
279         editor.apply();
280         ManualTimeZoneSuggestion manualTimeZoneSuggestion =
281                 TimeZoneDetector.createManualTimeZoneSuggestion(tzId, "Settings: Set time zone");
282         TimeZoneDetector timeZoneDetector = getActivity().getSystemService(TimeZoneDetector.class);
283         timeZoneDetector.suggestManualTimeZone(manualTimeZoneSuggestion);
284     }
285 
286     @Override
onCreateOptionsMenu(Menu menu, MenuInflater inflater)287     public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
288         menu.add(0, MENU_BY_REGION, 0, R.string.zone_menu_by_region);
289         menu.add(0, MENU_BY_OFFSET, 0, R.string.zone_menu_by_offset);
290         super.onCreateOptionsMenu(menu, inflater);
291     }
292 
293     @Override
onPrepareOptionsMenu(Menu menu)294     public void onPrepareOptionsMenu(Menu menu) {
295         // Do not show menu when data is not ready,
296         menu.findItem(MENU_BY_REGION).setVisible(mTimeZoneData != null && !mSelectByRegion);
297         menu.findItem(MENU_BY_OFFSET).setVisible(mTimeZoneData != null && mSelectByRegion);
298     }
299 
300     @Override
onOptionsItemSelected(MenuItem item)301     public boolean onOptionsItemSelected(MenuItem item) {
302         switch (item.getItemId()) {
303             case MENU_BY_REGION:
304                 startRegionPicker();
305                 return true;
306 
307             case MENU_BY_OFFSET:
308                 startFixedOffsetPicker();
309                 return true;
310 
311             default:
312                 return false;
313         }
314     }
315 
setupForCurrentTimeZone()316     private void setupForCurrentTimeZone() {
317         mSelectedTimeZoneId = TimeZone.getDefault().getID();
318         setSelectByRegion(!isFixedOffset(mSelectedTimeZoneId));
319     }
320 
isFixedOffset(String tzId)321     private static boolean isFixedOffset(String tzId) {
322         return tzId.startsWith("Etc/GMT") || tzId.equals("Etc/UTC");
323     }
324 
325     /**
326      * Switch the current view to select region or select fixed offset time zone.
327      * When showing the selected region, it guess the selected region from time zone id.
328      * See {@link #findRegionIdForTzId} for more info.
329      */
setSelectByRegion(boolean selectByRegion)330     private void setSelectByRegion(boolean selectByRegion) {
331         mSelectByRegion = selectByRegion;
332         setPreferenceCategoryVisible((PreferenceCategory) findPreference(
333                 PREF_KEY_REGION_CATEGORY), selectByRegion);
334         setPreferenceCategoryVisible((PreferenceCategory) findPreference(
335                 PREF_KEY_FIXED_OFFSET_CATEGORY), !selectByRegion);
336         final String localeRegionId = getLocaleRegionId();
337         final Set<String> allCountryIsoCodes = mTimeZoneData.getRegionIds();
338 
339         String displayRegion = allCountryIsoCodes.contains(localeRegionId) ? localeRegionId : null;
340         setDisplayedRegion(displayRegion);
341         setDisplayedTimeZoneInfo(displayRegion, null);
342 
343         if (!mSelectByRegion) {
344             setDisplayedFixedOffsetTimeZoneInfo(mSelectedTimeZoneId);
345             return;
346         }
347 
348         String regionId = findRegionIdForTzId(mSelectedTimeZoneId);
349         if (regionId != null) {
350             setDisplayedRegion(regionId);
351             setDisplayedTimeZoneInfo(regionId, mSelectedTimeZoneId);
352         }
353     }
354 
355     /**
356      * Find the a region associated with the specified time zone, based on the time zone data.
357      * If there are multiple regions associated with the given time zone, the priority will be given
358      * to the region the user last picked and the country in user's locale.
359      *
360      * @return null if no region associated with the time zone
361      */
findRegionIdForTzId(String tzId)362     private String findRegionIdForTzId(String tzId) {
363         return findRegionIdForTzId(tzId,
364                 getPreferenceManager().getSharedPreferences().getString(PREF_KEY_REGION, null),
365                 getLocaleRegionId());
366     }
367 
368     @VisibleForTesting
findRegionIdForTzId(String tzId, String sharePrefRegionId, String localeRegionId)369     String findRegionIdForTzId(String tzId, String sharePrefRegionId, String localeRegionId) {
370         final Set<String> matchedRegions = mTimeZoneData.lookupCountryCodesForZoneId(tzId);
371         if (matchedRegions.size() == 0) {
372             return null;
373         }
374         if (sharePrefRegionId != null && matchedRegions.contains(sharePrefRegionId)) {
375             return sharePrefRegionId;
376         }
377         if (localeRegionId != null && matchedRegions.contains(localeRegionId)) {
378             return localeRegionId;
379         }
380 
381         return matchedRegions.toArray(new String[matchedRegions.size()])[0];
382     }
383 
setPreferenceCategoryVisible(PreferenceCategory category, boolean isVisible)384     private void setPreferenceCategoryVisible(PreferenceCategory category,
385             boolean isVisible) {
386         // Hiding category doesn't hide all the children preference. Set visibility of its children.
387         // Do not care grandchildren as time_zone_pref.xml has only 2 levels.
388         category.setVisible(isVisible);
389         for (int i = 0; i < category.getPreferenceCount(); i++) {
390             category.getPreference(i).setVisible(isVisible);
391         }
392     }
393 
getLocaleRegionId()394     private String getLocaleRegionId() {
395         return mLocale.getCountry().toUpperCase(Locale.US);
396     }
397 
398     public static final BaseSearchIndexProvider SEARCH_INDEX_DATA_PROVIDER =
399             new BaseSearchIndexProvider(R.xml.time_zone_prefs) {
400                 @Override
401                 protected boolean isPageSearchEnabled(Context context) {
402                     // We can't enter this page if the auto time zone is enabled.
403                     final int autoTimeZone = Settings.Global.getInt(context.getContentResolver(),
404                             Settings.Global.AUTO_TIME_ZONE, 1);
405                     return autoTimeZone == 1 ? false : true;
406                 }
407             };
408 }
409