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