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