• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2008 Esmertec AG.
3  * Copyright (C) 2008 The Android Open Source Project
4  *
5  * Licensed under the Apache License, Version 2.0 (the "License");
6  * you may not use this file except in compliance with the License.
7  * You may obtain a copy of the License at
8  *
9  *      http://www.apache.org/licenses/LICENSE-2.0
10  *
11  * Unless required by applicable law or agreed to in writing, software
12  * distributed under the License is distributed on an "AS IS" BASIS,
13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14  * See the License for the specific language governing permissions and
15  * limitations under the License.
16  */
17 
18 package com.android.mms.ui;
19 
20 import java.util.ArrayList;
21 import java.util.Collection;
22 import java.util.HashSet;
23 
24 import android.app.ActionBar;
25 import android.app.AlertDialog;
26 import android.app.ListActivity;
27 import android.app.SearchManager;
28 import android.app.SearchableInfo;
29 import android.content.ActivityNotFoundException;
30 import android.content.AsyncQueryHandler;
31 import android.content.ComponentName;
32 import android.content.ContentResolver;
33 import android.content.Context;
34 import android.content.DialogInterface;
35 import android.content.DialogInterface.OnClickListener;
36 import android.content.Intent;
37 import android.content.SharedPreferences;
38 import android.content.pm.PackageManager;
39 import android.content.res.Configuration;
40 import android.database.Cursor;
41 import android.database.sqlite.SQLiteException;
42 import android.database.sqlite.SqliteWrapper;
43 import android.os.Bundle;
44 import android.os.Handler;
45 import android.preference.PreferenceManager;
46 import android.provider.ContactsContract;
47 import android.provider.ContactsContract.Contacts;
48 import android.provider.Telephony.Mms;
49 import android.provider.Telephony.Threads;
50 import android.util.Log;
51 import android.view.ActionMode;
52 import android.view.ContextMenu;
53 import android.view.ContextMenu.ContextMenuInfo;
54 import android.view.Gravity;
55 import android.view.KeyEvent;
56 import android.view.LayoutInflater;
57 import android.view.Menu;
58 import android.view.MenuInflater;
59 import android.view.MenuItem;
60 import android.view.View;
61 import android.view.View.OnCreateContextMenuListener;
62 import android.view.View.OnKeyListener;
63 import android.view.ViewGroup;
64 import android.widget.AdapterView;
65 import android.widget.CheckBox;
66 import android.widget.ListView;
67 import android.widget.SearchView;
68 import android.widget.TextView;
69 
70 import com.android.mms.LogTag;
71 import com.android.mms.R;
72 import com.android.mms.data.Contact;
73 import com.android.mms.data.ContactList;
74 import com.android.mms.data.Conversation;
75 import com.android.mms.data.Conversation.ConversationQueryHandler;
76 import com.android.mms.transaction.MessagingNotification;
77 import com.android.mms.transaction.SmsRejectedReceiver;
78 import com.android.mms.util.DraftCache;
79 import com.android.mms.util.Recycler;
80 import com.android.mms.widget.MmsWidgetProvider;
81 import com.google.android.mms.pdu.PduHeaders;
82 
83 /**
84  * This activity provides a list view of existing conversations.
85  */
86 public class ConversationList extends ListActivity implements DraftCache.OnDraftChangedListener {
87     private static final String TAG = "ConversationList";
88     private static final boolean DEBUG = false;
89     private static final boolean DEBUGCLEANUP = true;
90     private static final boolean LOCAL_LOGV = DEBUG;
91 
92     private static final int THREAD_LIST_QUERY_TOKEN       = 1701;
93     private static final int UNREAD_THREADS_QUERY_TOKEN    = 1702;
94     public static final int DELETE_CONVERSATION_TOKEN      = 1801;
95     public static final int HAVE_LOCKED_MESSAGES_TOKEN     = 1802;
96     private static final int DELETE_OBSOLETE_THREADS_TOKEN = 1803;
97 
98     // IDs of the context menu items for the list of conversations.
99     public static final int MENU_DELETE               = 0;
100     public static final int MENU_VIEW                 = 1;
101     public static final int MENU_VIEW_CONTACT         = 2;
102     public static final int MENU_ADD_TO_CONTACTS      = 3;
103 
104     private ThreadListQueryHandler mQueryHandler;
105     private ConversationListAdapter mListAdapter;
106     private SharedPreferences mPrefs;
107     private Handler mHandler;
108     private boolean mDoOnceAfterFirstQuery;
109     private TextView mUnreadConvCount;
110     private MenuItem mSearchItem;
111     private SearchView mSearchView;
112     private int mSavedFirstVisiblePosition = AdapterView.INVALID_POSITION;
113     private int mSavedFirstItemOffset;
114 
115     // keys for extras and icicles
116     private final static String LAST_LIST_POS = "last_list_pos";
117     private final static String LAST_LIST_OFFSET = "last_list_offset";
118 
119     static private final String CHECKED_MESSAGE_LIMITS = "checked_message_limits";
120 
121     @Override
onCreate(Bundle savedInstanceState)122     protected void onCreate(Bundle savedInstanceState) {
123         super.onCreate(savedInstanceState);
124 
125         setContentView(R.layout.conversation_list_screen);
126 
127         mQueryHandler = new ThreadListQueryHandler(getContentResolver());
128 
129         ListView listView = getListView();
130         listView.setOnCreateContextMenuListener(mConvListOnCreateContextMenuListener);
131         listView.setOnKeyListener(mThreadListKeyListener);
132         listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL);
133         listView.setMultiChoiceModeListener(new ModeCallback());
134 
135         // Tell the list view which view to display when the list is empty
136         listView.setEmptyView(findViewById(R.id.empty));
137 
138         initListAdapter();
139 
140         setupActionBar();
141 
142         setTitle(R.string.app_label);
143 
144         mHandler = new Handler();
145         mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
146         boolean checkedMessageLimits = mPrefs.getBoolean(CHECKED_MESSAGE_LIMITS, false);
147         if (DEBUG) Log.v(TAG, "checkedMessageLimits: " + checkedMessageLimits);
148         if (!checkedMessageLimits || DEBUG) {
149             runOneTimeStorageLimitCheckForLegacyMessages();
150         }
151 
152         if (savedInstanceState != null) {
153             mSavedFirstVisiblePosition = savedInstanceState.getInt(LAST_LIST_POS,
154                     AdapterView.INVALID_POSITION);
155             mSavedFirstItemOffset = savedInstanceState.getInt(LAST_LIST_OFFSET, 0);
156         } else {
157             mSavedFirstVisiblePosition = AdapterView.INVALID_POSITION;
158             mSavedFirstItemOffset = 0;
159         }
160     }
161 
162     @Override
onSaveInstanceState(Bundle outState)163     public void onSaveInstanceState(Bundle outState) {
164         super.onSaveInstanceState(outState);
165 
166         outState.putInt(LAST_LIST_POS, mSavedFirstVisiblePosition);
167         outState.putInt(LAST_LIST_OFFSET, mSavedFirstItemOffset);
168     }
169 
170     @Override
onPause()171     public void onPause() {
172         super.onPause();
173 
174         // Remember where the list is scrolled to so we can restore the scroll position
175         // when we come back to this activity and *after* we complete querying for the
176         // conversations.
177         ListView listView = getListView();
178         mSavedFirstVisiblePosition = listView.getFirstVisiblePosition();
179         View firstChild = listView.getChildAt(0);
180         mSavedFirstItemOffset = (firstChild == null) ? 0 : firstChild.getTop();
181     }
182 
setupActionBar()183     private void setupActionBar() {
184         ActionBar actionBar = getActionBar();
185 
186         ViewGroup v = (ViewGroup)LayoutInflater.from(this)
187             .inflate(R.layout.conversation_list_actionbar, null);
188         actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM,
189                 ActionBar.DISPLAY_SHOW_CUSTOM);
190         actionBar.setCustomView(v,
191                 new ActionBar.LayoutParams(ActionBar.LayoutParams.WRAP_CONTENT,
192                         ActionBar.LayoutParams.WRAP_CONTENT,
193                         Gravity.CENTER_VERTICAL | Gravity.RIGHT));
194 
195         mUnreadConvCount = (TextView)v.findViewById(R.id.unread_conv_count);
196     }
197 
198     private final ConversationListAdapter.OnContentChangedListener mContentChangedListener =
199         new ConversationListAdapter.OnContentChangedListener() {
200         @Override
201         public void onContentChanged(ConversationListAdapter adapter) {
202             startAsyncQuery();
203         }
204     };
205 
initListAdapter()206     private void initListAdapter() {
207         mListAdapter = new ConversationListAdapter(this, null);
208         mListAdapter.setOnContentChangedListener(mContentChangedListener);
209         setListAdapter(mListAdapter);
210         getListView().setRecyclerListener(mListAdapter);
211     }
212 
213     /**
214      * Checks to see if the number of MMS and SMS messages are under the limits for the
215      * recycler. If so, it will automatically turn on the recycler setting. If not, it
216      * will prompt the user with a message and point them to the setting to manually
217      * turn on the recycler.
218      */
runOneTimeStorageLimitCheckForLegacyMessages()219     public synchronized void runOneTimeStorageLimitCheckForLegacyMessages() {
220         if (Recycler.isAutoDeleteEnabled(this)) {
221             if (DEBUG) Log.v(TAG, "recycler is already turned on");
222             // The recycler is already turned on. We don't need to check anything or warn
223             // the user, just remember that we've made the check.
224             markCheckedMessageLimit();
225             return;
226         }
227         new Thread(new Runnable() {
228             @Override
229             public void run() {
230                 if (Recycler.checkForThreadsOverLimit(ConversationList.this)) {
231                     if (DEBUG) Log.v(TAG, "checkForThreadsOverLimit TRUE");
232                     // Dang, one or more of the threads are over the limit. Show an activity
233                     // that'll encourage the user to manually turn on the setting. Delay showing
234                     // this activity until a couple of seconds after the conversation list appears.
235                     mHandler.postDelayed(new Runnable() {
236                         @Override
237                         public void run() {
238                             Intent intent = new Intent(ConversationList.this,
239                                     WarnOfStorageLimitsActivity.class);
240                             startActivity(intent);
241                         }
242                     }, 2000);
243                 } else {
244                     if (DEBUG) Log.v(TAG, "checkForThreadsOverLimit silently turning on recycler");
245                     // No threads were over the limit. Turn on the recycler by default.
246                     runOnUiThread(new Runnable() {
247                         @Override
248                         public void run() {
249                             SharedPreferences.Editor editor = mPrefs.edit();
250                             editor.putBoolean(MessagingPreferenceActivity.AUTO_DELETE, true);
251                             editor.apply();
252                         }
253                     });
254                 }
255                 // Remember that we don't have to do the check anymore when starting MMS.
256                 runOnUiThread(new Runnable() {
257                     @Override
258                     public void run() {
259                         markCheckedMessageLimit();
260                     }
261                 });
262             }
263         }, "ConversationList.runOneTimeStorageLimitCheckForLegacyMessages").start();
264     }
265 
266     /**
267      * Mark in preferences that we've checked the user's message limits. Once checked, we'll
268      * never check them again, unless the user wipe-data or resets the device.
269      */
markCheckedMessageLimit()270     private void markCheckedMessageLimit() {
271         if (DEBUG) Log.v(TAG, "markCheckedMessageLimit");
272         SharedPreferences.Editor editor = mPrefs.edit();
273         editor.putBoolean(CHECKED_MESSAGE_LIMITS, true);
274         editor.apply();
275     }
276 
277     @Override
onNewIntent(Intent intent)278     protected void onNewIntent(Intent intent) {
279         // Handle intents that occur after the activity has already been created.
280         startAsyncQuery();
281     }
282 
283     @Override
onStart()284     protected void onStart() {
285         super.onStart();
286 
287         MessagingNotification.cancelNotification(getApplicationContext(),
288                 SmsRejectedReceiver.SMS_REJECTED_NOTIFICATION_ID);
289 
290         DraftCache.getInstance().addOnDraftChangedListener(this);
291 
292         mDoOnceAfterFirstQuery = true;
293 
294         startAsyncQuery();
295 
296         // We used to refresh the DraftCache here, but
297         // refreshing the DraftCache each time we go to the ConversationList seems overly
298         // aggressive. We already update the DraftCache when leaving CMA in onStop() and
299         // onNewIntent(), and when we delete threads or delete all in CMA or this activity.
300         // I hope we don't have to do such a heavy operation each time we enter here.
301 
302         // we invalidate the contact cache here because we want to get updated presence
303         // and any contact changes. We don't invalidate the cache by observing presence and contact
304         // changes (since that's too untargeted), so as a tradeoff we do it here.
305         // If we're in the middle of the app initialization where we're loading the conversation
306         // threads, don't invalidate the cache because we're in the process of building it.
307         // TODO: think of a better way to invalidate cache more surgically or based on actual
308         // TODO: changes we care about
309         if (!Conversation.loadingThreads()) {
310             Contact.invalidateCache();
311         }
312     }
313 
314     @Override
onStop()315     protected void onStop() {
316         super.onStop();
317 
318         DraftCache.getInstance().removeOnDraftChangedListener(this);
319 
320         // Simply setting the choice mode causes the previous choice mode to finish and we exit
321         // multi-select mode (if we're in it) and remove all the selections.
322         getListView().setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL);
323 
324         // Close the cursor in the ListAdapter if the activity stopped.
325         Cursor cursor = mListAdapter.getCursor();
326 
327         if (cursor != null && !cursor.isClosed()) {
328             cursor.close();
329         }
330 
331         mListAdapter.changeCursor(null);
332     }
333 
334     @Override
onDraftChanged(final long threadId, final boolean hasDraft)335     public void onDraftChanged(final long threadId, final boolean hasDraft) {
336         // Run notifyDataSetChanged() on the main thread.
337         mQueryHandler.post(new Runnable() {
338             @Override
339             public void run() {
340                 if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
341                     log("onDraftChanged: threadId=" + threadId + ", hasDraft=" + hasDraft);
342                 }
343                 mListAdapter.notifyDataSetChanged();
344             }
345         });
346     }
347 
startAsyncQuery()348     private void startAsyncQuery() {
349         try {
350             ((TextView)(getListView().getEmptyView())).setText(R.string.loading_conversations);
351 
352             Conversation.startQueryForAll(mQueryHandler, THREAD_LIST_QUERY_TOKEN);
353             Conversation.startQuery(mQueryHandler, UNREAD_THREADS_QUERY_TOKEN, Threads.READ + "=0");
354         } catch (SQLiteException e) {
355             SqliteWrapper.checkSQLiteException(this, e);
356         }
357     }
358 
359     SearchView.OnQueryTextListener mQueryTextListener = new SearchView.OnQueryTextListener() {
360         @Override
361         public boolean onQueryTextSubmit(String query) {
362             Intent intent = new Intent();
363             intent.setClass(ConversationList.this, SearchActivity.class);
364             intent.putExtra(SearchManager.QUERY, query);
365             startActivity(intent);
366             mSearchItem.collapseActionView();
367             return true;
368         }
369 
370         @Override
371         public boolean onQueryTextChange(String newText) {
372             return false;
373         }
374     };
375 
376     @Override
onCreateOptionsMenu(Menu menu)377     public boolean onCreateOptionsMenu(Menu menu) {
378         getMenuInflater().inflate(R.menu.conversation_list_menu, menu);
379 
380         mSearchItem = menu.findItem(R.id.search);
381         mSearchView = (SearchView) mSearchItem.getActionView();
382 
383         mSearchView.setOnQueryTextListener(mQueryTextListener);
384         mSearchView.setQueryHint(getString(R.string.search_hint));
385         mSearchView.setIconifiedByDefault(true);
386         SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE);
387 
388         if (searchManager != null) {
389             SearchableInfo info = searchManager.getSearchableInfo(this.getComponentName());
390             mSearchView.setSearchableInfo(info);
391         }
392 
393         MenuItem cellBroadcastItem = menu.findItem(R.id.action_cell_broadcasts);
394         if (cellBroadcastItem != null) {
395             // Enable link to Cell broadcast activity depending on the value in config.xml.
396             boolean isCellBroadcastAppLinkEnabled = this.getResources().getBoolean(
397                     com.android.internal.R.bool.config_cellBroadcastAppLinks);
398             try {
399                 if (isCellBroadcastAppLinkEnabled) {
400                     PackageManager pm = getPackageManager();
401                     if (pm.getApplicationEnabledSetting("com.android.cellbroadcastreceiver")
402                             == PackageManager.COMPONENT_ENABLED_STATE_DISABLED) {
403                         isCellBroadcastAppLinkEnabled = false;  // CMAS app disabled
404                     }
405                 }
406             } catch (IllegalArgumentException ignored) {
407                 isCellBroadcastAppLinkEnabled = false;  // CMAS app not installed
408             }
409             if (!isCellBroadcastAppLinkEnabled) {
410                 cellBroadcastItem.setVisible(false);
411             }
412         }
413 
414         return true;
415     }
416 
417     @Override
onPrepareOptionsMenu(Menu menu)418     public boolean onPrepareOptionsMenu(Menu menu) {
419         MenuItem item = menu.findItem(R.id.action_delete_all);
420         if (item != null) {
421             item.setVisible(mListAdapter.getCount() > 0);
422         }
423         if (!LogTag.DEBUG_DUMP) {
424             item = menu.findItem(R.id.action_debug_dump);
425             if (item != null) {
426                 item.setVisible(false);
427             }
428         }
429         return true;
430     }
431 
432     @Override
onSearchRequested()433     public boolean onSearchRequested() {
434         if (mSearchItem != null) {
435             mSearchItem.expandActionView();
436         }
437         return true;
438     }
439 
440     @Override
onOptionsItemSelected(MenuItem item)441     public boolean onOptionsItemSelected(MenuItem item) {
442         switch(item.getItemId()) {
443             case R.id.action_compose_new:
444                 createNewMessage();
445                 break;
446             case R.id.action_delete_all:
447                 // The invalid threadId of -1 means all threads here.
448                 confirmDeleteThread(-1L, mQueryHandler);
449                 break;
450             case R.id.action_settings:
451                 Intent intent = new Intent(this, MessagingPreferenceActivity.class);
452                 startActivityIfNeeded(intent, -1);
453                 break;
454             case R.id.action_debug_dump:
455                 LogTag.dumpInternalTables(this);
456                 break;
457             case R.id.action_cell_broadcasts:
458                 Intent cellBroadcastIntent = new Intent(Intent.ACTION_MAIN);
459                 cellBroadcastIntent.setComponent(new ComponentName(
460                         "com.android.cellbroadcastreceiver",
461                         "com.android.cellbroadcastreceiver.CellBroadcastListActivity"));
462                 cellBroadcastIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
463                 try {
464                     startActivity(cellBroadcastIntent);
465                 } catch (ActivityNotFoundException ignored) {
466                     Log.e(TAG, "ActivityNotFoundException for CellBroadcastListActivity");
467                 }
468                 return true;
469             default:
470                 return true;
471         }
472         return false;
473     }
474 
475     @Override
onListItemClick(ListView l, View v, int position, long id)476     protected void onListItemClick(ListView l, View v, int position, long id) {
477         // Note: don't read the thread id data from the ConversationListItem view passed in.
478         // It's unreliable to read the cached data stored in the view because the ListItem
479         // can be recycled, and the same view could be assigned to a different position
480         // if you click the list item fast enough. Instead, get the cursor at the position
481         // clicked and load the data from the cursor.
482         // (ConversationListAdapter extends CursorAdapter, so getItemAtPosition() should
483         // return the cursor object, which is moved to the position passed in)
484         Cursor cursor  = (Cursor) getListView().getItemAtPosition(position);
485         Conversation conv = Conversation.from(this, cursor);
486         long tid = conv.getThreadId();
487 
488         if (LogTag.VERBOSE) {
489             Log.d(TAG, "onListItemClick: pos=" + position + ", view=" + v + ", tid=" + tid);
490         }
491 
492         openThread(tid);
493     }
494 
createNewMessage()495     private void createNewMessage() {
496         startActivity(ComposeMessageActivity.createIntent(this, 0));
497     }
498 
openThread(long threadId)499     private void openThread(long threadId) {
500         startActivity(ComposeMessageActivity.createIntent(this, threadId));
501     }
502 
createAddContactIntent(String address)503     public static Intent createAddContactIntent(String address) {
504         // address must be a single recipient
505         Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT);
506         intent.setType(Contacts.CONTENT_ITEM_TYPE);
507         if (Mms.isEmailAddress(address)) {
508             intent.putExtra(ContactsContract.Intents.Insert.EMAIL, address);
509         } else {
510             intent.putExtra(ContactsContract.Intents.Insert.PHONE, address);
511             intent.putExtra(ContactsContract.Intents.Insert.PHONE_TYPE,
512                     ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE);
513         }
514         intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
515 
516         return intent;
517     }
518 
519     private final OnCreateContextMenuListener mConvListOnCreateContextMenuListener =
520         new OnCreateContextMenuListener() {
521         @Override
522         public void onCreateContextMenu(ContextMenu menu, View v,
523                 ContextMenuInfo menuInfo) {
524             Cursor cursor = mListAdapter.getCursor();
525             if (cursor == null || cursor.getPosition() < 0) {
526                 return;
527             }
528             Conversation conv = Conversation.from(ConversationList.this, cursor);
529             ContactList recipients = conv.getRecipients();
530             menu.setHeaderTitle(recipients.formatNames(","));
531 
532             AdapterView.AdapterContextMenuInfo info =
533                 (AdapterView.AdapterContextMenuInfo) menuInfo;
534             menu.add(0, MENU_VIEW, 0, R.string.menu_view);
535 
536             // Only show if there's a single recipient
537             if (recipients.size() == 1) {
538                 // do we have this recipient in contacts?
539                 if (recipients.get(0).existsInDatabase()) {
540                     menu.add(0, MENU_VIEW_CONTACT, 0, R.string.menu_view_contact);
541                 } else {
542                     menu.add(0, MENU_ADD_TO_CONTACTS, 0, R.string.menu_add_to_contacts);
543                 }
544             }
545             menu.add(0, MENU_DELETE, 0, R.string.menu_delete);
546         }
547     };
548 
549     @Override
onContextItemSelected(MenuItem item)550     public boolean onContextItemSelected(MenuItem item) {
551         Cursor cursor = mListAdapter.getCursor();
552         if (cursor != null && cursor.getPosition() >= 0) {
553             Conversation conv = Conversation.from(ConversationList.this, cursor);
554             long threadId = conv.getThreadId();
555             switch (item.getItemId()) {
556             case MENU_DELETE: {
557                 confirmDeleteThread(threadId, mQueryHandler);
558                 break;
559             }
560             case MENU_VIEW: {
561                 openThread(threadId);
562                 break;
563             }
564             case MENU_VIEW_CONTACT: {
565                 Contact contact = conv.getRecipients().get(0);
566                 Intent intent = new Intent(Intent.ACTION_VIEW, contact.getUri());
567                 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
568                 startActivity(intent);
569                 break;
570             }
571             case MENU_ADD_TO_CONTACTS: {
572                 String address = conv.getRecipients().get(0).getNumber();
573                 startActivity(createAddContactIntent(address));
574                 break;
575             }
576             default:
577                 break;
578             }
579         }
580         return super.onContextItemSelected(item);
581     }
582 
583     @Override
onConfigurationChanged(Configuration newConfig)584     public void onConfigurationChanged(Configuration newConfig) {
585         // We override this method to avoid restarting the entire
586         // activity when the keyboard is opened (declared in
587         // AndroidManifest.xml).  Because the only translatable text
588         // in this activity is "New Message", which has the full width
589         // of phone to work with, localization shouldn't be a problem:
590         // no abbreviated alternate words should be needed even in
591         // 'wide' languages like German or Russian.
592 
593         super.onConfigurationChanged(newConfig);
594         if (DEBUG) Log.v(TAG, "onConfigurationChanged: " + newConfig);
595     }
596 
597     /**
598      * Start the process of putting up a dialog to confirm deleting a thread,
599      * but first start a background query to see if any of the threads or thread
600      * contain locked messages so we'll know how detailed of a UI to display.
601      * @param threadId id of the thread to delete or -1 for all threads
602      * @param handler query handler to do the background locked query
603      */
confirmDeleteThread(long threadId, AsyncQueryHandler handler)604     public static void confirmDeleteThread(long threadId, AsyncQueryHandler handler) {
605         ArrayList<Long> threadIds = null;
606         if (threadId != -1) {
607             threadIds = new ArrayList<Long>();
608             threadIds.add(threadId);
609         }
610         confirmDeleteThreads(threadIds, handler);
611     }
612 
613     /**
614      * Start the process of putting up a dialog to confirm deleting threads,
615      * but first start a background query to see if any of the threads
616      * contain locked messages so we'll know how detailed of a UI to display.
617      * @param threadIds list of threadIds to delete or null for all threads
618      * @param handler query handler to do the background locked query
619      */
confirmDeleteThreads(Collection<Long> threadIds, AsyncQueryHandler handler)620     public static void confirmDeleteThreads(Collection<Long> threadIds, AsyncQueryHandler handler) {
621         Conversation.startQueryHaveLockedMessages(handler, threadIds,
622                 HAVE_LOCKED_MESSAGES_TOKEN);
623     }
624 
625     /**
626      * Build and show the proper delete thread dialog. The UI is slightly different
627      * depending on whether there are locked messages in the thread(s) and whether we're
628      * deleting single/multiple threads or all threads.
629      * @param listener gets called when the delete button is pressed
630      * @param threadIds the thread IDs to be deleted (pass null for all threads)
631      * @param hasLockedMessages whether the thread(s) contain locked messages
632      * @param context used to load the various UI elements
633      */
confirmDeleteThreadDialog(final DeleteThreadListener listener, Collection<Long> threadIds, boolean hasLockedMessages, Context context)634     public static void confirmDeleteThreadDialog(final DeleteThreadListener listener,
635             Collection<Long> threadIds,
636             boolean hasLockedMessages,
637             Context context) {
638         View contents = View.inflate(context, R.layout.delete_thread_dialog_view, null);
639         TextView msg = (TextView)contents.findViewById(R.id.message);
640 
641         if (threadIds == null) {
642             msg.setText(R.string.confirm_delete_all_conversations);
643         } else {
644             // Show the number of threads getting deleted in the confirmation dialog.
645             int cnt = threadIds.size();
646             msg.setText(context.getResources().getQuantityString(
647                 R.plurals.confirm_delete_conversation, cnt, cnt));
648         }
649 
650         final CheckBox checkbox = (CheckBox)contents.findViewById(R.id.delete_locked);
651         if (!hasLockedMessages) {
652             checkbox.setVisibility(View.GONE);
653         } else {
654             listener.setDeleteLockedMessage(checkbox.isChecked());
655             checkbox.setOnClickListener(new View.OnClickListener() {
656                 @Override
657                 public void onClick(View v) {
658                     listener.setDeleteLockedMessage(checkbox.isChecked());
659                 }
660             });
661         }
662 
663         AlertDialog.Builder builder = new AlertDialog.Builder(context);
664         builder.setTitle(R.string.confirm_dialog_title)
665             .setIconAttribute(android.R.attr.alertDialogIcon)
666             .setCancelable(true)
667             .setPositiveButton(R.string.delete, listener)
668             .setNegativeButton(R.string.no, null)
669             .setView(contents)
670             .show();
671     }
672 
673     private final OnKeyListener mThreadListKeyListener = new OnKeyListener() {
674         @Override
675         public boolean onKey(View v, int keyCode, KeyEvent event) {
676             if (event.getAction() == KeyEvent.ACTION_DOWN) {
677                 switch (keyCode) {
678                     case KeyEvent.KEYCODE_DEL: {
679                         long id = getListView().getSelectedItemId();
680                         if (id > 0) {
681                             confirmDeleteThread(id, mQueryHandler);
682                         }
683                         return true;
684                     }
685                 }
686             }
687             return false;
688         }
689     };
690 
691     public static class DeleteThreadListener implements OnClickListener {
692         private final Collection<Long> mThreadIds;
693         private final ConversationQueryHandler mHandler;
694         private final Context mContext;
695         private boolean mDeleteLockedMessages;
696 
DeleteThreadListener(Collection<Long> threadIds, ConversationQueryHandler handler, Context context)697         public DeleteThreadListener(Collection<Long> threadIds, ConversationQueryHandler handler,
698                 Context context) {
699             mThreadIds = threadIds;
700             mHandler = handler;
701             mContext = context;
702         }
703 
setDeleteLockedMessage(boolean deleteLockedMessages)704         public void setDeleteLockedMessage(boolean deleteLockedMessages) {
705             mDeleteLockedMessages = deleteLockedMessages;
706         }
707 
708         @Override
onClick(DialogInterface dialog, final int whichButton)709         public void onClick(DialogInterface dialog, final int whichButton) {
710             MessageUtils.handleReadReport(mContext, mThreadIds,
711                     PduHeaders.READ_STATUS__DELETED_WITHOUT_BEING_READ, new Runnable() {
712                 @Override
713                 public void run() {
714                     int token = DELETE_CONVERSATION_TOKEN;
715                     if (mThreadIds == null) {
716                         Conversation.startDeleteAll(mHandler, token, mDeleteLockedMessages);
717                         DraftCache.getInstance().refresh();
718                     } else {
719                         Conversation.startDelete(mHandler, token, mDeleteLockedMessages,
720                                 mThreadIds);
721                     }
722                 }
723             });
724             dialog.dismiss();
725         }
726     }
727 
728     private final Runnable mDeleteObsoleteThreadsRunnable = new Runnable() {
729         @Override
730         public void run() {
731             if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
732                 LogTag.debug("mDeleteObsoleteThreadsRunnable getSavingDraft(): " +
733                         DraftCache.getInstance().getSavingDraft());
734             }
735             if (DraftCache.getInstance().getSavingDraft()) {
736                 // We're still saving a draft. Try again in a second. We don't want to delete
737                 // any threads out from under the draft.
738                 if (DEBUGCLEANUP) {
739                     LogTag.debug("mDeleteObsoleteThreadsRunnable saving draft, trying again");
740                 }
741                 mHandler.postDelayed(mDeleteObsoleteThreadsRunnable, 1000);
742             } else {
743                 if (DEBUGCLEANUP) {
744                     LogTag.debug("mDeleteObsoleteThreadsRunnable calling " +
745                             "asyncDeleteObsoleteThreads");
746                 }
747                 Conversation.asyncDeleteObsoleteThreads(mQueryHandler,
748                         DELETE_OBSOLETE_THREADS_TOKEN);
749             }
750         }
751     };
752 
753     private final class ThreadListQueryHandler extends ConversationQueryHandler {
ThreadListQueryHandler(ContentResolver contentResolver)754         public ThreadListQueryHandler(ContentResolver contentResolver) {
755             super(contentResolver);
756         }
757 
758         // Test code used for various scenarios where its desirable to insert a delay in
759         // responding to query complete. To use, uncomment out the block below and then
760         // comment out the @Override and onQueryComplete line.
761 //        @Override
762 //        protected void onQueryComplete(final int token, final Object cookie, final Cursor cursor) {
763 //            mHandler.postDelayed(new Runnable() {
764 //                public void run() {
765 //                    myonQueryComplete(token, cookie, cursor);
766 //                    }
767 //            }, 2000);
768 //        }
769 //
770 //        protected void myonQueryComplete(int token, Object cookie, Cursor cursor) {
771 
772         @Override
onQueryComplete(int token, Object cookie, Cursor cursor)773         protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
774             switch (token) {
775             case THREAD_LIST_QUERY_TOKEN:
776                 mListAdapter.changeCursor(cursor);
777 
778                 if (mListAdapter.getCount() == 0) {
779                     ((TextView)(getListView().getEmptyView())).setText(R.string.no_conversations);
780                 }
781 
782                 if (mDoOnceAfterFirstQuery) {
783                     mDoOnceAfterFirstQuery = false;
784                     // Delay doing a couple of DB operations until we've initially queried the DB
785                     // for the list of conversations to display. We don't want to slow down showing
786                     // the initial UI.
787 
788                     // 1. Delete any obsolete threads. Obsolete threads are threads that aren't
789                     // referenced by at least one message in the pdu or sms tables.
790                     mHandler.post(mDeleteObsoleteThreadsRunnable);
791 
792                     // 2. Mark all the conversations as seen.
793                     Conversation.markAllConversationsAsSeen(getApplicationContext());
794                 }
795                 if (mSavedFirstVisiblePosition != AdapterView.INVALID_POSITION) {
796                     // Restore the list to its previous position.
797                     getListView().setSelectionFromTop(mSavedFirstVisiblePosition,
798                             mSavedFirstItemOffset);
799                     mSavedFirstVisiblePosition = AdapterView.INVALID_POSITION;
800                 }
801                 break;
802 
803             case UNREAD_THREADS_QUERY_TOKEN:
804                 int count = 0;
805                 if (cursor != null) {
806                     count = cursor.getCount();
807                     cursor.close();
808                 }
809                 mUnreadConvCount.setText(count > 0 ? Integer.toString(count) : null);
810                 break;
811 
812             case HAVE_LOCKED_MESSAGES_TOKEN:
813                 @SuppressWarnings("unchecked")
814                 Collection<Long> threadIds = (Collection<Long>)cookie;
815                 confirmDeleteThreadDialog(new DeleteThreadListener(threadIds, mQueryHandler,
816                         ConversationList.this), threadIds,
817                         cursor != null && cursor.getCount() > 0,
818                         ConversationList.this);
819                 if (cursor != null) {
820                     cursor.close();
821                 }
822                 break;
823 
824             default:
825                 Log.e(TAG, "onQueryComplete called with unknown token " + token);
826             }
827         }
828 
829         @Override
onDeleteComplete(int token, Object cookie, int result)830         protected void onDeleteComplete(int token, Object cookie, int result) {
831             super.onDeleteComplete(token, cookie, result);
832             switch (token) {
833             case DELETE_CONVERSATION_TOKEN:
834                 long threadId = cookie != null ? (Long)cookie : -1;     // default to all threads
835 
836                 if (threadId == -1) {
837                     // Rebuild the contacts cache now that all threads and their associated unique
838                     // recipients have been deleted.
839                     Contact.init(ConversationList.this);
840                 } else {
841                     // Remove any recipients referenced by this single thread from the
842                     // contacts cache. It's possible for two or more threads to reference
843                     // the same contact. That's ok if we remove it. We'll recreate that contact
844                     // when we init all Conversations below.
845                     Conversation conv = Conversation.get(ConversationList.this, threadId, false);
846                     if (conv != null) {
847                         ContactList recipients = conv.getRecipients();
848                         for (Contact contact : recipients) {
849                             contact.removeFromCache();
850                         }
851                     }
852                 }
853                 // Make sure the conversation cache reflects the threads in the DB.
854                 Conversation.init(ConversationList.this);
855 
856                 // Update the notification for new messages since they
857                 // may be deleted.
858                 MessagingNotification.nonBlockingUpdateNewMessageIndicator(ConversationList.this,
859                         MessagingNotification.THREAD_NONE, false);
860                 // Update the notification for failed messages since they
861                 // may be deleted.
862                 MessagingNotification.nonBlockingUpdateSendFailedNotification(ConversationList.this);
863 
864                 // Make sure the list reflects the delete
865                 startAsyncQuery();
866 
867                 MmsWidgetProvider.notifyDatasetChanged(getApplicationContext());
868                 break;
869 
870             case DELETE_OBSOLETE_THREADS_TOKEN:
871                 if (DEBUGCLEANUP) {
872                     LogTag.debug("onQueryComplete finished DELETE_OBSOLETE_THREADS_TOKEN");
873                 }
874                 break;
875             }
876         }
877     }
878 
879     private class ModeCallback implements ListView.MultiChoiceModeListener {
880         private View mMultiSelectActionBarView;
881         private TextView mSelectedConvCount;
882         private HashSet<Long> mSelectedThreadIds;
883 
884         @Override
onCreateActionMode(ActionMode mode, Menu menu)885         public boolean onCreateActionMode(ActionMode mode, Menu menu) {
886             MenuInflater inflater = getMenuInflater();
887             mSelectedThreadIds = new HashSet<Long>();
888             inflater.inflate(R.menu.conversation_multi_select_menu, menu);
889 
890             if (mMultiSelectActionBarView == null) {
891                 mMultiSelectActionBarView = LayoutInflater.from(ConversationList.this)
892                     .inflate(R.layout.conversation_list_multi_select_actionbar, null);
893 
894                 mSelectedConvCount =
895                     (TextView)mMultiSelectActionBarView.findViewById(R.id.selected_conv_count);
896             }
897             mode.setCustomView(mMultiSelectActionBarView);
898             ((TextView)mMultiSelectActionBarView.findViewById(R.id.title))
899                 .setText(R.string.select_conversations);
900             return true;
901         }
902 
903         @Override
onPrepareActionMode(ActionMode mode, Menu menu)904         public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
905             if (mMultiSelectActionBarView == null) {
906                 ViewGroup v = (ViewGroup)LayoutInflater.from(ConversationList.this)
907                     .inflate(R.layout.conversation_list_multi_select_actionbar, null);
908                 mode.setCustomView(v);
909 
910                 mSelectedConvCount = (TextView)v.findViewById(R.id.selected_conv_count);
911             }
912             return true;
913         }
914 
915         @Override
onActionItemClicked(ActionMode mode, MenuItem item)916         public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
917             switch (item.getItemId()) {
918                 case R.id.delete:
919                     if (mSelectedThreadIds.size() > 0) {
920                         confirmDeleteThreads(mSelectedThreadIds, mQueryHandler);
921                     }
922                     mode.finish();
923                     break;
924 
925                 default:
926                     break;
927             }
928             return true;
929         }
930 
931         @Override
onDestroyActionMode(ActionMode mode)932         public void onDestroyActionMode(ActionMode mode) {
933             ConversationListAdapter adapter = (ConversationListAdapter)getListView().getAdapter();
934             adapter.uncheckAll();
935             mSelectedThreadIds = null;
936         }
937 
938         @Override
onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked)939         public void onItemCheckedStateChanged(ActionMode mode,
940                 int position, long id, boolean checked) {
941             ListView listView = getListView();
942             final int checkedCount = listView.getCheckedItemCount();
943             mSelectedConvCount.setText(Integer.toString(checkedCount));
944 
945             Cursor cursor  = (Cursor)listView.getItemAtPosition(position);
946             Conversation conv = Conversation.from(ConversationList.this, cursor);
947             conv.setIsChecked(checked);
948             long threadId = conv.getThreadId();
949 
950             if (checked) {
951                 mSelectedThreadIds.add(threadId);
952             } else {
953                 mSelectedThreadIds.remove(threadId);
954             }
955         }
956 
957     }
958 
log(String format, Object... args)959     private void log(String format, Object... args) {
960         String s = String.format(format, args);
961         Log.d(TAG, "[" + Thread.currentThread().getId() + "] " + s);
962     }
963 }
964