• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2012 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.deskclock.worldclock;
18 
19 import android.content.ActivityNotFoundException;
20 import android.content.Context;
21 import android.content.Intent;
22 import android.content.SharedPreferences;
23 import android.media.AudioManager;
24 import android.os.Build;
25 import android.os.Bundle;
26 import android.preference.PreferenceManager;
27 import android.support.v4.view.MenuItemCompat;
28 import android.support.v7.widget.SearchView;
29 import android.text.TextUtils;
30 import android.text.format.DateFormat;
31 import android.util.TypedValue;
32 import android.view.LayoutInflater;
33 import android.view.Menu;
34 import android.view.MenuItem;
35 import android.view.View;
36 import android.view.View.OnClickListener;
37 import android.view.ViewGroup;
38 import android.view.inputmethod.EditorInfo;
39 import android.widget.BaseAdapter;
40 import android.widget.CheckBox;
41 import android.widget.CompoundButton;
42 import android.widget.CompoundButton.OnCheckedChangeListener;
43 import android.widget.Filter;
44 import android.widget.Filterable;
45 import android.widget.ListView;
46 import android.widget.SectionIndexer;
47 import android.widget.TextView;
48 
49 import com.android.deskclock.BaseActivity;
50 import com.android.deskclock.R;
51 import com.android.deskclock.SettingsActivity;
52 import com.android.deskclock.Utils;
53 
54 import java.util.ArrayList;
55 import java.util.Arrays;
56 import java.util.Calendar;
57 import java.util.Collection;
58 import java.util.HashMap;
59 import java.util.HashSet;
60 import java.util.List;
61 import java.util.Locale;
62 import java.util.TimeZone;
63 
64 /**
65  * Cities chooser for the world clock
66  */
67 public class CitiesActivity extends BaseActivity implements OnCheckedChangeListener,
68         View.OnClickListener, SearchView.OnQueryTextListener {
69 
70     private static final String KEY_SEARCH_QUERY = "search_query";
71     private static final String KEY_SEARCH_MODE = "search_mode";
72     private static final String KEY_LIST_POSITION = "list_position";
73 
74     private static final String PREF_SORT = "sort_preference";
75 
76     private static final int SORT_BY_NAME = 0;
77     private static final int SORT_BY_GMT_OFFSET = 1;
78 
79     /**
80      * This must be false for production. If true, turns on logging, test code,
81      * etc.
82      */
83     static final boolean DEBUG = false;
84     static final String TAG = "CitiesActivity";
85 
86     private LayoutInflater mFactory;
87     private ListView mCitiesList;
88     private CityAdapter mAdapter;
89     private HashMap<String, CityObj> mUserSelectedCities;
90     private Calendar mCalendar;
91 
92     private SearchView mSearchView;
93     private StringBuffer mQueryTextBuffer = new StringBuffer();
94     private boolean mSearchMode;
95     private int mPosition = -1;
96 
97     private SharedPreferences mPrefs;
98     private int mSortType;
99 
100     private String mSelectedCitiesHeaderString;
101 
102     /***
103      * Adapter for a list of cities with the respected time zone. The Adapter
104      * sorts the list alphabetically and create an indexer.
105      ***/
106     private class CityAdapter extends BaseAdapter implements Filterable, SectionIndexer {
107         private static final int VIEW_TYPE_CITY = 0;
108         private static final int VIEW_TYPE_HEADER = 1;
109 
110         private static final String DELETED_ENTRY = "C0";
111 
112         private List<CityObj> mDisplayedCitiesList;
113 
114         private CityObj[] mCities;
115         private CityObj[] mSelectedCities;
116 
117         private final int mLayoutDirection;
118 
119         // A map that caches names of cities in local memory.  The names in this map are
120         // preferred over the names of the selected cities stored in SharedPreferences, which could
121         // be in a different language.  This map gets reloaded on a locale change, when the new
122         // language's city strings are read from the xml file.
123         private HashMap<String, String> mCityNameMap = new HashMap<String, String>();
124 
125         private String[] mSectionHeaders;
126         private Integer[] mSectionPositions;
127 
128         private CityNameComparator mSortByNameComparator = new CityNameComparator();
129         private CityGmtOffsetComparator mSortByTimeComparator = new CityGmtOffsetComparator();
130 
131         private final LayoutInflater mInflater;
132         private boolean mIs24HoursMode; // AM/PM or 24 hours mode
133 
134         private final String mPattern12;
135         private final String mPattern24;
136 
137         private int mSelectedEndPosition = 0;
138 
139         private Filter mFilter = new Filter() {
140 
141             @Override
142             protected synchronized FilterResults performFiltering(CharSequence constraint) {
143                 FilterResults results = new FilterResults();
144                 String modifiedQuery = constraint.toString().trim().toUpperCase();
145 
146                 ArrayList<CityObj> filteredList = new ArrayList<>();
147                 ArrayList<String> sectionHeaders = new ArrayList<>();
148                 ArrayList<Integer> sectionPositions = new ArrayList<>();
149 
150                 // Update the list first when user using search filter
151                 final Collection<CityObj> selectedCities = mUserSelectedCities.values();
152                 mSelectedCities = selectedCities.toArray(new CityObj[selectedCities.size()]);
153                 // If the search query is empty, add in the selected cities
154                 if (TextUtils.isEmpty(modifiedQuery) && mSelectedCities != null) {
155                     if (mSelectedCities.length > 0) {
156                         sectionHeaders.add("+");
157                         sectionPositions.add(0);
158                         filteredList.add(new CityObj(mSelectedCitiesHeaderString,
159                                 mSelectedCitiesHeaderString, null, null));
160                     }
161                     for (CityObj city : mSelectedCities) {
162                         city.isHeader = false;
163                         filteredList.add(city);
164                     }
165                 }
166 
167                 final HashSet<String> selectedCityIds = new HashSet<>();
168                 for (CityObj c : mSelectedCities) {
169                     selectedCityIds.add(c.mCityId);
170                 }
171                 mSelectedEndPosition = filteredList.size();
172 
173                 long currentTime = System.currentTimeMillis();
174                 String val = null;
175                 int offset = -100000; //some value that cannot be a real offset
176                 for (CityObj city : mCities) {
177 
178                     // If the city is a deleted entry, ignore it.
179                     if (city.mCityId.equals(DELETED_ENTRY)) {
180                         continue;
181                     }
182 
183                     // If the search query is empty, add section headers.
184                     if (TextUtils.isEmpty(modifiedQuery)) {
185                         if (!selectedCityIds.contains(city.mCityId)) {
186                             // If the list is sorted by name, and the city has an index
187                             // different than the previous city's index, update the section header.
188                             if (mSortType == SORT_BY_NAME
189                                     && !city.mCityIndex.equals(val)) {
190                                 val = city.mCityIndex.toUpperCase();
191                                 sectionHeaders.add(val);
192                                 sectionPositions.add(filteredList.size());
193                                 city.isHeader = true;
194                             } else {
195                                 city.isHeader = false;
196                             }
197 
198                             // If the list is sorted by time, and the gmt offset is different than
199                             // the previous city's gmt offset, insert a section header.
200                             if (mSortType == SORT_BY_GMT_OFFSET) {
201                                 TimeZone timezone = TimeZone.getTimeZone(city.mTimeZone);
202                                 int newOffset = timezone.getOffset(currentTime);
203                                 if (offset != newOffset) {
204                                     offset = newOffset;
205                                     // Because JB fastscroll only supports ~1 char strings
206                                     // and KK ellipsizes strings, trim section headers to the
207                                     // nearest hour.
208                                     final String offsetString = Utils.getGMTHourOffset(timezone,
209                                             Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT
210                                             /* useShortForm */ );
211                                     sectionHeaders.add(offsetString);
212                                     sectionPositions.add(filteredList.size());
213                                     city.isHeader = true;
214                                 } else {
215                                     city.isHeader = false;
216                                 }
217                             }
218 
219                             filteredList.add(city);
220                         }
221                     } else {
222                         // If the city name begins with the non-empty query, add it into the list.
223                         String cityName = city.mCityName.trim().toUpperCase();
224                         if (city.mCityId != null && cityName.startsWith(modifiedQuery)) {
225                             city.isHeader = false;
226                             filteredList.add(city);
227                         }
228                     }
229                 }
230 
231                 mSectionHeaders = sectionHeaders.toArray(new String[sectionHeaders.size()]);
232                 mSectionPositions = sectionPositions.toArray(new Integer[sectionPositions.size()]);
233 
234                 results.values = filteredList;
235                 results.count = filteredList.size();
236                 return results;
237             }
238 
239             @Override
240             protected void publishResults(CharSequence constraint, FilterResults results) {
241                 mDisplayedCitiesList = (ArrayList<CityObj>) results.values;
242                 if (mPosition >= 0) {
243                     mCitiesList.setSelectionFromTop(mPosition, 0);
244                     mPosition = -1;
245                 }
246                 notifyDataSetChanged();
247             }
248         };
249 
CityAdapter( Context context, LayoutInflater factory)250         public CityAdapter(
251                 Context context, LayoutInflater factory) {
252             super();
253             mCalendar = Calendar.getInstance();
254             mCalendar.setTimeInMillis(System.currentTimeMillis());
255             mLayoutDirection = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault());
256             mInflater = factory;
257 
258             // Load the cities from xml.
259             mCities = Utils.loadCitiesFromXml(context);
260 
261             // Reload the city name map with the recently parsed city names of the currently
262             // selected language for use with selected cities.
263             mCityNameMap.clear();
264             for (CityObj city : mCities) {
265                 mCityNameMap.put(city.mCityId, city.mCityName);
266             }
267 
268             // Re-organize the selected cities into an array.
269             Collection<CityObj> selectedCities = mUserSelectedCities.values();
270             mSelectedCities = selectedCities.toArray(new CityObj[selectedCities.size()]);
271 
272             // Override the selected city names in the shared preferences with the
273             // city names in the updated city name map, which will always reflect the
274             // current language.
275             for (CityObj city : mSelectedCities) {
276                 String newCityName = mCityNameMap.get(city.mCityId);
277                 if (newCityName != null) {
278                     city.mCityName = newCityName;
279                 }
280             }
281 
282             mPattern24 = Utils.isJBMR2OrLater()
283                     ? DateFormat.getBestDateTimePattern(Locale.getDefault(), "Hm")
284                     : getString(R.string.time_format_24_mode);
285 
286             // There's an RTL layout bug that causes jank when fast-scrolling through
287             // the list in 12-hour mode in an RTL locale. We can work around this by
288             // ensuring the strings are the same length by using "hh" instead of "h".
289             String pattern12 = Utils.isJBMR2OrLater()
290                     ? DateFormat.getBestDateTimePattern(Locale.getDefault(), "hma")
291                     : getString(R.string.time_format_12_mode);
292 
293             if (mLayoutDirection == View.LAYOUT_DIRECTION_RTL) {
294                 pattern12 = pattern12.replaceAll("h", "hh");
295             }
296             mPattern12 = pattern12;
297 
298             sortCities(mSortType);
299             set24HoursMode(context);
300         }
301 
toggleSort()302         public void toggleSort() {
303             if (mSortType == SORT_BY_NAME) {
304                 sortCities(SORT_BY_GMT_OFFSET);
305             } else {
306                 sortCities(SORT_BY_NAME);
307             }
308         }
309 
sortCities(final int sortType)310         private void sortCities(final int sortType) {
311             mSortType = sortType;
312             Arrays.sort(mCities, sortType == SORT_BY_NAME ? mSortByNameComparator
313                     : mSortByTimeComparator);
314             if (mSelectedCities != null) {
315                 Arrays.sort(mSelectedCities, sortType == SORT_BY_NAME ? mSortByNameComparator
316                         : mSortByTimeComparator);
317             }
318             mPrefs.edit().putInt(PREF_SORT, sortType).commit();
319             mFilter.filter(mQueryTextBuffer.toString());
320         }
321 
322         @Override
getCount()323         public int getCount() {
324             return mDisplayedCitiesList != null ? mDisplayedCitiesList.size() : 0;
325         }
326 
327         @Override
getItem(int p)328         public Object getItem(int p) {
329             if (mDisplayedCitiesList != null && p >= 0 && p < mDisplayedCitiesList.size()) {
330                 return mDisplayedCitiesList.get(p);
331             }
332             return null;
333         }
334 
335         @Override
getItemId(int p)336         public long getItemId(int p) {
337             return p;
338         }
339 
340         @Override
isEnabled(int p)341         public boolean isEnabled(int p) {
342             return mDisplayedCitiesList != null && mDisplayedCitiesList.get(p).mCityId != null;
343         }
344 
345         @Override
getView(int position, View view, ViewGroup parent)346         public synchronized View getView(int position, View view, ViewGroup parent) {
347             if (mDisplayedCitiesList == null || position < 0
348                     || position >= mDisplayedCitiesList.size()) {
349                 return null;
350             }
351             CityObj c = mDisplayedCitiesList.get(position);
352             // Header view: A CityObj with nothing but the "selected cities" label
353             if (c.mCityId == null) {
354                 if (view == null) {
355                     view = mInflater.inflate(R.layout.city_list_header, parent, false);
356                 }
357             } else { // City view
358                 // Make sure to recycle a City view only
359                 if (view == null) {
360                     view = mInflater.inflate(R.layout.city_list_item, parent, false);
361                     final CityViewHolder holder = new CityViewHolder();
362                     holder.index = (TextView) view.findViewById(R.id.index);
363                     holder.name = (TextView) view.findViewById(R.id.city_name);
364                     holder.time = (TextView) view.findViewById(R.id.city_time);
365                     holder.selected = (CheckBox) view.findViewById(R.id.city_onoff);
366                     view.setTag(holder);
367                 }
368                 view.setOnClickListener(CitiesActivity.this);
369                 CityViewHolder holder = (CityViewHolder) view.getTag();
370 
371                 holder.selected.setTag(c);
372                 holder.selected.setChecked(mUserSelectedCities.containsKey(c.mCityId));
373                 holder.selected.setOnCheckedChangeListener(CitiesActivity.this);
374                 holder.name.setText(c.mCityName, TextView.BufferType.SPANNABLE);
375                 holder.time.setText(getTimeCharSequence(c.mTimeZone));
376                 if (c.isHeader) {
377                     holder.index.setVisibility(View.VISIBLE);
378                     if (mSortType == SORT_BY_NAME) {
379                         holder.index.setText(c.mCityIndex);
380                         holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 24);
381                     } else { // SORT_BY_GMT_OFFSET
382                         holder.index.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14);
383                         holder.index.setText(Utils.getGMTHourOffset(
384                                 TimeZone.getTimeZone(c.mTimeZone), false));
385                     }
386                 } else {
387                     // If not a header, use the invisible index for left padding
388                     holder.index.setVisibility(View.INVISIBLE);
389                 }
390                 // skip checkbox and other animations
391                 view.jumpDrawablesToCurrentState();
392             }
393             return view;
394         }
395 
getTimeCharSequence(String timeZone)396         private CharSequence getTimeCharSequence(String timeZone) {
397             mCalendar.setTimeZone(TimeZone.getTimeZone(timeZone));
398             return DateFormat.format(mIs24HoursMode ? mPattern24 : mPattern12, mCalendar);
399         }
400 
401         @Override
getViewTypeCount()402         public int getViewTypeCount() {
403             return 2;
404         }
405 
406         @Override
getItemViewType(int position)407         public int getItemViewType(int position) {
408             return (mDisplayedCitiesList.get(position).mCityId != null)
409                     ? VIEW_TYPE_CITY : VIEW_TYPE_HEADER;
410         }
411 
412         private class CityViewHolder {
413             TextView index;
414             TextView name;
415             TextView time;
416             CheckBox selected;
417         }
418 
set24HoursMode(Context c)419         public void set24HoursMode(Context c) {
420             mIs24HoursMode = DateFormat.is24HourFormat(c);
421             notifyDataSetChanged();
422         }
423 
424         @Override
getPositionForSection(int section)425         public int getPositionForSection(int section) {
426             return !isEmpty(mSectionPositions) ? mSectionPositions[section] : 0;
427         }
428 
429 
430         @Override
getSectionForPosition(int p)431         public int getSectionForPosition(int p) {
432             final Integer[] positions = mSectionPositions;
433             if (!isEmpty(positions)) {
434                 for (int i = 0; i < positions.length - 1; i++) {
435                     if (p >= positions[i]
436                             && p < positions[i + 1]) {
437                         return i;
438                     }
439                 }
440                 if (p >= positions[positions.length - 1]) {
441                     return positions.length - 1;
442                 }
443             }
444             return 0;
445         }
446 
447         @Override
getSections()448         public Object[] getSections() {
449             return mSectionHeaders;
450         }
451 
452         @Override
getFilter()453         public Filter getFilter() {
454             return mFilter;
455         }
456 
isEmpty(Object[] array)457         private boolean isEmpty(Object[] array) {
458             return array == null || array.length == 0;
459         }
460     }
461 
462     @Override
onCreate(Bundle savedInstanceState)463     protected void onCreate(Bundle savedInstanceState) {
464         super.onCreate(savedInstanceState);
465         setVolumeControlStream(AudioManager.STREAM_ALARM);
466 
467         mFactory = LayoutInflater.from(this);
468         mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
469         mSortType = mPrefs.getInt(PREF_SORT, SORT_BY_NAME);
470         mSelectedCitiesHeaderString = getString(R.string.selected_cities_label);
471         if (savedInstanceState != null) {
472             mQueryTextBuffer.append(savedInstanceState.getString(KEY_SEARCH_QUERY));
473             mSearchMode = savedInstanceState.getBoolean(KEY_SEARCH_MODE);
474             mPosition = savedInstanceState.getInt(KEY_LIST_POSITION);
475         }
476         updateLayout();
477     }
478 
479     @Override
onSaveInstanceState(Bundle bundle)480     public void onSaveInstanceState(Bundle bundle) {
481         super.onSaveInstanceState(bundle);
482         bundle.putString(KEY_SEARCH_QUERY, mQueryTextBuffer.toString());
483         bundle.putBoolean(KEY_SEARCH_MODE, mSearchMode);
484         bundle.putInt(KEY_LIST_POSITION, mCitiesList.getFirstVisiblePosition());
485     }
486 
updateLayout()487     private void updateLayout() {
488         setContentView(R.layout.cities_activity);
489         mCitiesList = (ListView) findViewById(R.id.cities_list);
490         setFastScroll(TextUtils.isEmpty(mQueryTextBuffer.toString().trim()));
491         mCitiesList.setScrollBarStyle(View.SCROLLBARS_INSIDE_INSET);
492         mUserSelectedCities = Cities.readCitiesFromSharedPrefs(
493                 PreferenceManager.getDefaultSharedPreferences(this));
494         mAdapter = new CityAdapter(this, mFactory);
495         mCitiesList.setAdapter(mAdapter);
496     }
497 
setFastScroll(boolean enabled)498     private void setFastScroll(boolean enabled) {
499         if (mCitiesList != null) {
500             mCitiesList.setFastScrollAlwaysVisible(enabled);
501             mCitiesList.setFastScrollEnabled(enabled);
502         }
503     }
504 
505     @Override
onResume()506     public void onResume() {
507         super.onResume();
508         if (mAdapter != null) {
509             mAdapter.set24HoursMode(this);
510         }
511     }
512 
513     @Override
onPause()514     public void onPause() {
515         super.onPause();
516         Cities.saveCitiesToSharedPrefs(PreferenceManager.getDefaultSharedPreferences(this),
517                 mUserSelectedCities);
518         Intent i = new Intent(Cities.WORLDCLOCK_UPDATE_INTENT);
519         sendBroadcast(i);
520     }
521 
522     @Override
onOptionsItemSelected(MenuItem item)523     public boolean onOptionsItemSelected(MenuItem item) {
524         switch (item.getItemId()) {
525             case android.R.id.home:
526                 finish();
527                 return true;
528             case R.id.menu_item_settings:
529                 startActivity(new Intent(this, SettingsActivity.class));
530                 return true;
531             case R.id.menu_item_help:
532                 Intent i = item.getIntent();
533                 if (i != null) {
534                     try {
535                         startActivity(i);
536                     } catch (ActivityNotFoundException e) {
537                         // No activity found to match the intent - ignore
538                     }
539                 }
540                 return true;
541             case R.id.menu_item_sort:
542                 if (mAdapter != null) {
543                     mAdapter.toggleSort();
544                     setFastScroll(TextUtils.isEmpty(mQueryTextBuffer.toString().trim()));
545                 }
546                 return true;
547             default:
548                 break;
549         }
550         return super.onOptionsItemSelected(item);
551     }
552 
553     @Override
onCreateOptionsMenu(Menu menu)554     public boolean onCreateOptionsMenu(Menu menu) {
555         getMenuInflater().inflate(R.menu.cities_menu, menu);
556         MenuItem help = menu.findItem(R.id.menu_item_help);
557         if (help != null) {
558             Utils.prepareHelpMenuItem(this, help);
559         }
560 
561         MenuItem searchMenu = menu.findItem(R.id.menu_item_search);
562         mSearchView = (SearchView) MenuItemCompat.getActionView(searchMenu);
563         mSearchView.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI);
564         mSearchView.setOnSearchClickListener(new OnClickListener() {
565 
566             @Override
567             public void onClick(View arg0) {
568                 mSearchMode = true;
569             }
570         });
571         mSearchView.setOnCloseListener(new SearchView.OnCloseListener() {
572 
573             @Override
574             public boolean onClose() {
575                 mSearchMode = false;
576                 return false;
577             }
578         });
579         if (mSearchView != null) {
580             mSearchView.setOnQueryTextListener(this);
581             mSearchView.setQuery(mQueryTextBuffer.toString(), false);
582             if (mSearchMode) {
583                 mSearchView.requestFocus();
584                 mSearchView.setIconified(false);
585             }
586         }
587         return super.onCreateOptionsMenu(menu);
588     }
589 
590     @Override
onPrepareOptionsMenu(Menu menu)591     public boolean onPrepareOptionsMenu(Menu menu) {
592         MenuItem sortMenuItem = menu.findItem(R.id.menu_item_sort);
593         if (mSortType == SORT_BY_NAME) {
594             sortMenuItem.setTitle(getString(R.string.menu_item_sort_by_gmt_offset));
595         } else {
596             sortMenuItem.setTitle(getString(R.string.menu_item_sort_by_name));
597         }
598         return super.onPrepareOptionsMenu(menu);
599     }
600 
601     @Override
onCheckedChanged(CompoundButton b, boolean checked)602     public void onCheckedChanged(CompoundButton b, boolean checked) {
603         CityObj c = (CityObj) b.getTag();
604         if (checked) {
605             mUserSelectedCities.put(c.mCityId, c);
606         } else {
607             mUserSelectedCities.remove(c.mCityId);
608         }
609     }
610 
611     @Override
onClick(View v)612     public void onClick(View v) {
613         CompoundButton b = (CompoundButton) v.findViewById(R.id.city_onoff);
614         boolean checked = b.isChecked();
615         onCheckedChanged(b, checked);
616         b.setChecked(!checked);
617     }
618 
619     @Override
onQueryTextChange(String queryText)620     public boolean onQueryTextChange(String queryText) {
621         mQueryTextBuffer.setLength(0);
622         mQueryTextBuffer.append(queryText);
623         mCitiesList.setFastScrollEnabled(TextUtils.isEmpty(mQueryTextBuffer.toString().trim()));
624         mAdapter.getFilter().filter(queryText);
625         return true;
626     }
627 
628     @Override
onQueryTextSubmit(String arg0)629     public boolean onQueryTextSubmit(String arg0) {
630         return false;
631     }
632 }
633