ConversationList.java revision e82e62f94d4ba4ac139faf055f40fbbc2b99b551
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
18package com.android.mms.ui;
19
20import com.android.mms.R;
21import com.android.mms.transaction.MessagingNotification;
22import com.android.mms.util.ContactNameCache;
23import com.google.android.mms.pdu.PduHeaders;
24import com.google.android.mms.util.SqliteWrapper;
25
26import android.app.AlertDialog;
27import android.app.ListActivity;
28import android.content.AsyncQueryHandler;
29import android.content.ContentResolver;
30import android.content.ContentUris;
31import android.content.Context;
32import android.content.DialogInterface;
33import android.content.Intent;
34import android.content.DialogInterface.OnClickListener;
35import android.content.res.Configuration;
36import android.database.ContentObserver;
37import android.database.Cursor;
38import android.database.sqlite.SQLiteException;
39import android.net.Uri;
40import android.os.Bundle;
41import android.os.Handler;
42import android.provider.Contacts;
43import android.provider.Telephony.Mms;
44import android.provider.Telephony.Sms;
45import android.provider.Telephony.Threads;
46import android.provider.Telephony.Sms.Conversations;
47import android.text.TextUtils;
48import android.util.Config;
49import android.util.Log;
50import android.view.ContextMenu;
51import android.view.KeyEvent;
52import android.view.LayoutInflater;
53import android.view.Menu;
54import android.view.MenuItem;
55import android.view.View;
56import android.view.Window;
57import android.view.ContextMenu.ContextMenuInfo;
58import android.view.View.OnCreateContextMenuListener;
59import android.view.View.OnKeyListener;
60import android.widget.AdapterView;
61import android.widget.ListView;
62
63import java.util.concurrent.ConcurrentHashMap;
64
65/**
66 * This activity provides a list view of existing conversations.
67 */
68public class ConversationList extends ListActivity {
69    private static final String TAG = "ConversationList";
70    private static final boolean DEBUG = false;
71    private static final boolean LOCAL_LOGV = Config.LOGV && DEBUG;
72
73    private static final int THREAD_LIST_QUERY_TOKEN = 1701;
74    private static final int SEARCH_TOKEN            = 1702;
75
76    private static final int DELETE_CONVERSATION_TOKEN = 1801;
77
78    // IDs of the main menu items.
79    private static final int MENU_COMPOSE_NEW            = 0;
80    private static final int MENU_SEARCH                 = 1;
81    private static final int MENU_UNDELIVERED_MESSAGES   = 2;
82    private static final int MENU_DELETE_ALL             = 3;
83    private static final int MENU_PREFERENCES            = 4;
84    private static final int MENU_VIEW_BROADCAST_THREADS = 5;
85
86    // IDs of the context menu items for the list of conversations.
87    public static final int MENU_DELETE               = 0;
88    private static final int MENU_VIEW                 = 1;
89
90    private Cursor mCursor;
91    private final Object mCursorLock = new Object();
92    private ThreadListQueryHandler mQueryHandler;
93    private ConversationListAdapter mListAdapter;
94    private CharSequence mTitle;
95    private Uri mBaseUri;
96    private String mQuery;
97    private String[] mProjection;
98    private int mQueryToken;
99    private String mFilter;
100    private boolean mSearchFlag;
101    private CachingNameStore mCachingNameStore;
102
103    /**
104     * An interface that's passed down to ListAdapters to use
105     * for looking up the names of contact numbers.
106     */
107    public static interface CachingNameStore {
108        // Returns comma-separated list of contact's display names
109        // given a semicolon-delimited string of canonical phone
110        // numbers.
111        public String getContactNames(String addresses);
112    }
113
114    @Override
115    protected void onCreate(Bundle savedInstanceState) {
116        super.onCreate(savedInstanceState);
117
118        requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
119        setContentView(R.layout.conversation_list_screen);
120
121        mQueryHandler = new ThreadListQueryHandler(getContentResolver());
122
123        ListView listView = getListView();
124        LayoutInflater inflater = LayoutInflater.from(this);
125        ConversationHeaderView headerView = (ConversationHeaderView)
126                inflater.inflate(R.layout.conversation_header, listView, false);
127        headerView.bind(getString(R.string.new_message),
128                getString(R.string.create_new_message));
129        listView.addHeaderView(headerView, null, true);
130
131        listView.setOnCreateContextMenuListener(mConvListOnCreateContextMenuListener);
132        listView.setOnKeyListener(mThreadListKeyListener);
133
134        mCachingNameStore = new CachingNameStoreImpl(this);
135
136        if (savedInstanceState != null) {
137            mBaseUri = (Uri) savedInstanceState.getParcelable("base_uri");
138            mSearchFlag = savedInstanceState.getBoolean("search_flag");
139            mFilter = savedInstanceState.getString("filter");
140            mQueryToken = savedInstanceState.getInt("query_token");
141        }
142
143        handleCreationIntent(getIntent());
144    }
145
146    @Override
147    protected void onNewIntent(Intent intent) {
148        // Handle intents that occur after the activity has already been created.
149        handleCreationIntent(intent);
150    }
151
152    protected void handleCreationIntent(Intent intent) {
153        // Handle intents that occur upon creation of the activity.
154        initNormalQueryArgs();
155    }
156
157    @Override
158    protected void onResume() {
159        super.onResume();
160
161        if (mListAdapter != null) {
162            mListAdapter.initDraftCache();  // we might have a draft now
163            mListAdapter.registerObservers();
164        }
165
166        getContentResolver().delete(Threads.OBSOLETE_THREADS_URI, null, null);
167
168        synchronized (mCursorLock) {
169            if (mCursor == null) {
170                startAsyncQuery();
171            } else {
172                SqliteWrapper.requery(this, mCursor);
173            }
174        }
175    }
176
177    @Override
178    protected void onSaveInstanceState(Bundle outState) {
179        super.onSaveInstanceState(outState);
180
181        outState.putParcelable("base_uri", mBaseUri);
182        outState.putInt("query_token", mQueryToken);
183        outState.putBoolean("search_flag", mSearchFlag);
184        if (mSearchFlag) {
185            outState.putString("filter", mFilter);
186        }
187    }
188
189    @Override
190    protected void onPause() {
191        super.onPause();
192
193        if (mListAdapter != null) {
194            mListAdapter.unregisterObservers();
195        }
196    }
197
198    @Override
199    protected void onStop() {
200        super.onStop();
201
202        synchronized (mCursorLock) {
203            if (mCursor != null) {
204                if (mListAdapter != null) {
205                    mListAdapter.changeCursor(null);
206                }
207                mCursor.close();
208                mCursor = null;
209            }
210        }
211    }
212
213    @Override
214    protected void onDestroy() {
215        super.onDestroy();
216
217        if (mCursor != null) {
218            mCursor.close();
219        }
220    }
221
222    private void initNormalQueryArgs() {
223        Uri.Builder builder = Threads.CONTENT_URI.buildUpon();
224        builder.appendQueryParameter("simple", "true");
225        mBaseUri = builder.build();
226        mQuery = null;
227        mProjection = ConversationListAdapter.PROJECTION;
228        mQueryToken = THREAD_LIST_QUERY_TOKEN;
229        mTitle = getString(R.string.app_label);
230    }
231
232    private void startAsyncQuery() {
233        try {
234            synchronized (mCursorLock) {
235                setTitle(getString(R.string.refreshing));
236                setProgressBarIndeterminateVisibility(true);
237
238                mQueryHandler.cancelOperation(THREAD_LIST_QUERY_TOKEN);
239                // FIXME: I have to pass the mQueryToken as cookie since the
240                // AsyncQueryHandler.onQueryComplete() method doesn't provide
241                // the same token as what I input here.
242                mQueryHandler.startQuery(0, mQueryToken, mBaseUri, mProjection, mQuery, null,
243                        Conversations.DEFAULT_SORT_ORDER);
244            }
245        } catch (SQLiteException e) {
246            SqliteWrapper.checkSQLiteException(this, e);
247        }
248    }
249
250    @Override
251    public boolean onPrepareOptionsMenu(Menu menu) {
252        menu.clear();
253
254        menu.add(0, MENU_COMPOSE_NEW, 0, R.string.menu_compose_new).setIcon(
255                R.drawable.ic_menu_compose);
256        // Removed search as part of b/1205708
257        //menu.add(0, MENU_SEARCH, 0, R.string.menu_search).setIcon(
258        //        R.drawable.ic_menu_search).setAlphabeticShortcut(SearchManager.MENU_KEY);
259        if ((mCursor != null) && (mCursor.getCount() > 0) && !mSearchFlag) {
260            menu.add(0, MENU_DELETE_ALL, 0, R.string.menu_delete_all).setIcon(
261                    android.R.drawable.ic_menu_delete);
262        }
263
264        // Check undelivered messages
265        Cursor mmsCursor = SqliteWrapper.query(this, getContentResolver(),
266                                Mms.Outbox.CONTENT_URI, null, null, null, null);
267        Cursor smsCursor = SqliteWrapper.query(this, getContentResolver(),
268                                Uri.withAppendedPath(Sms.CONTENT_URI, "undelivered"),
269                                null, null, null, null);
270        if (((mmsCursor != null) && (mmsCursor.getCount() > 0)) ||
271                ((smsCursor != null) && (smsCursor.getCount() > 0))) {
272                menu.add(0, MENU_UNDELIVERED_MESSAGES, 0, R.string.menu_undelivered_messages).setIcon(
273                        R.drawable.ic_menu_undelivered);
274        }
275        if (mmsCursor != null) {
276            mmsCursor.close();
277        }
278        if (smsCursor != null) {
279            smsCursor.close();
280        }
281
282        menu.add(0, MENU_PREFERENCES, 0, R.string.menu_preferences).setIcon(
283                android.R.drawable.ic_menu_preferences);
284
285        return true;
286    }
287
288    @Override
289    public boolean onOptionsItemSelected(MenuItem item) {
290        switch(item.getItemId()) {
291            case MENU_COMPOSE_NEW:
292                createNewMessage();
293                break;
294            case MENU_SEARCH:
295                onSearchRequested();
296                break;
297            case MENU_DELETE_ALL:
298                confirmDeleteDialog(new DeleteThreadListener(-1L), true);
299                break;
300            case MENU_UNDELIVERED_MESSAGES: {
301                Intent intent = new Intent(this, UndeliveredMessagesActivity.class);
302                startActivityIfNeeded(intent, -1);
303                break;
304            }
305            case MENU_PREFERENCES: {
306                Intent intent = new Intent(this, MessagingPreferenceActivity.class);
307                startActivityIfNeeded(intent, -1);
308                break;
309            }
310            default:
311                return true;
312        }
313        return false;
314    }
315
316    @Override
317    protected void onListItemClick(ListView l, View v, int position, long id) {
318        if (LOCAL_LOGV) {
319            Log.v(TAG, "onListItemClick: position=" + position + ", id=" + id);
320        }
321
322        if (position == 0) {
323            createNewMessage();
324        } else if (v instanceof ConversationHeaderView) {
325            ConversationHeaderView headerView = (ConversationHeaderView) v;
326            ConversationHeader ch = headerView.getConversationHeader();
327
328            // TODO: The 'from' view of the ConversationHeader was
329            // repurposed to be the cached display value, rather than
330            // the old raw value, which openThread() wanted.  But it
331            // turns out openThread() doesn't need it:
332            // ComposeMessageActivity will load it.  That's not ideal,
333            // though, as it's an SQLite query.  So fix this later to
334            // save some latency on starting ComposeMessageActivity.
335            String somethingDelimitedAddresses = null;
336            openThread(ch.getThreadId(), somethingDelimitedAddresses);
337        }
338    }
339
340    private void createNewMessage() {
341        Intent intent = new Intent(this, ComposeMessageActivity.class);
342        startActivity(intent);
343    }
344
345    private void openThread(long threadId, String address) {
346        Intent intent = new Intent(this, ComposeMessageActivity.class);
347        intent.putExtra("thread_id", threadId);
348        if (!TextUtils.isEmpty(address)) {
349            intent.putExtra("address", address);
350        }
351        startActivity(intent);
352    }
353
354    private final OnCreateContextMenuListener mConvListOnCreateContextMenuListener =
355        new OnCreateContextMenuListener() {
356        public void onCreateContextMenu(ContextMenu menu, View v,
357                ContextMenuInfo menuInfo) {
358            if ((mCursor != null) && (mCursor.getCount() > 0) && !mSearchFlag) {
359                String address = MessageUtils.getRecipientsByIds(
360                        ConversationList.this,
361                        mCursor.getString(ConversationListAdapter.COLUMN_RECIPIENTS_IDS));
362                // The Recipient IDs column is separated with semicolons for some reason.
363                // We should fix this in the content provider rework.
364                CharSequence from = (ContactNameCache.getInstance().getContactName(
365                        ConversationList.this, address)).replace(';', ',');
366                menu.setHeaderTitle(from);
367
368                AdapterView.AdapterContextMenuInfo info = (AdapterView.AdapterContextMenuInfo) menuInfo;
369                if (info.position > 0) {
370                    menu.add(0, MENU_VIEW, 0, R.string.menu_view);
371                    menu.add(0, MENU_DELETE, 0, R.string.menu_delete);
372                }
373            }
374        }
375    };
376
377    @Override
378    public boolean onContextItemSelected(MenuItem item) {
379        long threadId = mCursor.getLong(ConversationListAdapter.COLUMN_ID);
380        switch (item.getItemId()) {
381            case MENU_DELETE: {
382                DeleteThreadListener l = new DeleteThreadListener(threadId);
383                confirmDeleteDialog(l, false);
384                break;
385            }
386            case MENU_VIEW: {
387                String address = null;
388                if (mListAdapter.isSimpleMode()) {
389                    address = MessageUtils.getRecipientsByIds(
390                                this,
391                                mCursor.getString(ConversationListAdapter.COLUMN_RECIPIENTS_IDS));
392                } else {
393                    String msgType = mCursor.getString(ConversationListAdapter.COLUMN_MESSAGE_TYPE);
394                    if (msgType.equals("sms")) {
395                        address = mCursor.getString(ConversationListAdapter.COLUMN_SMS_ADDRESS);
396                    } else {
397                        address = MessageUtils.getAddressByThreadId(this, threadId);
398                   }
399                }
400                openThread(threadId, address);
401                break;
402            }
403            default:
404                break;
405        }
406
407        return super.onContextItemSelected(item);
408    }
409
410    public void onConfigurationChanged(Configuration newConfig) {
411        // We override this method to avoid restarting the entire
412        // activity when the keyboard is opened (declared in
413        // AndroidManifest.xml).  Because the only translatable text
414        // in this activity is "New Message", which has the full width
415        // of phone to work with, localization shouldn't be a problem:
416        // no abbreviated alternate words should be needed even in
417        // 'wide' languages like German or Russian.
418
419        super.onConfigurationChanged(newConfig);
420        if (DEBUG) Log.v(TAG, "onConfigurationChanged: " + newConfig);
421    }
422
423    private void confirmDeleteDialog(OnClickListener listener, boolean deleteAll) {
424        AlertDialog.Builder builder = new AlertDialog.Builder(this);
425        builder.setTitle(R.string.confirm_dialog_title);
426        builder.setIcon(android.R.drawable.ic_dialog_alert);
427        builder.setCancelable(true);
428        builder.setPositiveButton(R.string.yes, listener);
429        builder.setNegativeButton(R.string.no, null);
430        builder.setMessage(deleteAll
431                ? R.string.confirm_delete_all_conversations
432                : R.string.confirm_delete_conversation);
433
434        builder.show();
435    }
436
437    private final OnKeyListener mThreadListKeyListener = new OnKeyListener() {
438        public boolean onKey(View v, int keyCode, KeyEvent event) {
439            if (event.getAction() == KeyEvent.ACTION_DOWN) {
440                switch (keyCode) {
441                    case KeyEvent.KEYCODE_DEL: {
442                        long id = getListView().getSelectedItemId();
443                        if (id > 0) {
444                            DeleteThreadListener l = new DeleteThreadListener(
445                                    id);
446                            confirmDeleteDialog(l, false);
447                        }
448                        return true;
449                    }
450                    case KeyEvent.KEYCODE_BACK: {
451                        if (mSearchFlag) {
452                            mSearchFlag = false;
453                            initNormalQueryArgs();
454                            startAsyncQuery();
455
456                            return true;
457                        }
458                        break;
459                    }
460                }
461            }
462            return false;
463        }
464    };
465
466    private class DeleteThreadListener implements OnClickListener {
467        private final Uri mDeleteUri;
468        private final long mThreadId;
469
470        public DeleteThreadListener(long threadId) {
471            mThreadId = threadId;
472
473            if (threadId != -1) {
474                mDeleteUri = ContentUris.withAppendedId(
475                        Threads.CONTENT_URI, threadId);
476            } else {
477                mDeleteUri = Threads.CONTENT_URI;
478            }
479        }
480
481        public void onClick(DialogInterface dialog, int whichButton) {
482            MessageUtils.handleReadReport(ConversationList.this, mThreadId,
483                    PduHeaders.READ_STATUS__DELETED_WITHOUT_BEING_READ, new Runnable() {
484                public void run() {
485                    mQueryHandler.startDelete(DELETE_CONVERSATION_TOKEN,
486                            null, mDeleteUri, null, null);
487                }
488            });
489        }
490    }
491
492    private final class ThreadListQueryHandler extends AsyncQueryHandler {
493        public ThreadListQueryHandler(ContentResolver contentResolver) {
494            super(contentResolver);
495        }
496
497        @Override
498        protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
499            synchronized (mCursorLock) {
500                if (mCursor != null) {
501                    mCursor.close();
502                }
503
504                if (cursor != null) {
505                    mCursor = cursor;
506                    switch ((Integer) cookie) {
507                        case THREAD_LIST_QUERY_TOKEN:
508                            mListAdapter = new ConversationListAdapter(
509                                    ConversationList.this,
510                                    cursor,
511                                    true,  // simple (non-search)
512                                    mListAdapter,
513                                    mCachingNameStore);
514                            break;
515                        case SEARCH_TOKEN:
516                            mListAdapter = new ConversationListAdapter(
517                                    ConversationList.this,
518                                    cursor,
519                                    false,  // non-simple (search)
520                                    mListAdapter,
521                                    mCachingNameStore);
522                            break;
523                        default:
524                            Log.e(TAG, "Bad query token: " + token);
525                            break;
526                    }
527
528                    ConversationList.this.setListAdapter(mListAdapter);
529                } else {
530                    Log.e(TAG, "Cannot init the cursor for the thread list.");
531                    finish();
532                }
533
534                setTitle(mTitle);
535                setProgressBarIndeterminateVisibility(false);
536            }
537        }
538
539        @Override
540        protected void onDeleteComplete(int token, Object cookie, int result) {
541            switch (token) {
542            case DELETE_CONVERSATION_TOKEN:
543                // Update the notification for new messages since they
544                // may be deleted.
545                MessagingNotification.updateNewMessageIndicator(ConversationList.this);
546                // Update the notification for failed messages since they
547                // may be deleted.
548                MessagingNotification.updateSendFailedNotification(ConversationList.this);
549                break;
550            }
551        }
552    }
553
554    /**
555     * This implements the CachingNameStore interface defined above
556     * which we pass down to each newly-created ListAdapater, so they
557     * share a common, reused cached between activity resumes, not
558     * having to hit the Contacts providers all the time.
559     */
560    private static final class CachingNameStoreImpl implements CachingNameStore {
561        private static final String TAG = "ConversationList/CachingNameStoreImpl";
562        private final ConcurrentHashMap<String, String> mCachedNames =
563                new ConcurrentHashMap<String, String>();
564        private final ContentObserver mPhonesObserver;
565        private final Context mContext;
566
567        public CachingNameStoreImpl(Context ctxt) {
568            mContext = ctxt;
569            mPhonesObserver = new ContentObserver(new Handler()) {
570                    @Override
571                    public void onChange(boolean selfUpdate) {
572                        mCachedNames.clear();
573                    }
574                };
575            ctxt.getContentResolver().registerContentObserver(
576                    Contacts.Phones.CONTENT_URI,
577                    true, mPhonesObserver);
578        }
579
580        // Returns comma-separated list of contact's display names
581        // given a semicolon-delimited string of canonical phone
582        // numbers, getting data either from cache or via a blocking
583        // call to a provider.
584        public String getContactNames(String addresses) {
585            String value = mCachedNames.get(addresses);
586            if (value != null) {
587                return value;
588            }
589            String[] values = addresses.split(";");
590            if (values.length < 2) {
591                if (DEBUG) Log.v(TAG, "Looking up name: " + addresses);
592                ContactNameCache cache = ContactNameCache.getInstance();
593                value = (cache.getContactName(mContext, addresses)).replace(';', ',');
594            } else {
595                int length = 0;
596                for (int i = 0; i < values.length; ++i) {
597                    values[i] = getContactNames(values[i]);
598                    length += values[i].length() + 2;  // 2 for ", "
599                }
600                StringBuilder sb = new StringBuilder(length);
601                sb.append(values[0]);
602                for (int i = 1; i < values.length; ++i) {
603                    sb.append(", ");
604                    sb.append(values[i]);
605                }
606                value = sb.toString();
607            }
608            mCachedNames.put(addresses, value);
609            return value;
610        }
611
612    }
613}
614