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