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