ConversationList.java revision 6be18bedb5b87dbbcdb54f37d5a0945bd0f71377
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.LogTag;
21import com.android.mms.R;
22import com.android.mms.data.Contact;
23import com.android.mms.data.ContactList;
24import com.android.mms.data.Conversation;
25import com.android.mms.transaction.MessagingNotification;
26import com.android.mms.transaction.SmsRejectedReceiver;
27import com.android.mms.util.DraftCache;
28import com.android.mms.util.Recycler;
29import com.android.mms.mms.pdu.PduHeaders;
30import com.android.mms.mms.util.SqliteWrapper;
31
32import android.app.AlertDialog;
33import android.app.ListActivity;
34import android.content.AsyncQueryHandler;
35import android.content.ContentResolver;
36import android.content.Context;
37import android.content.DialogInterface;
38import android.content.Intent;
39import android.content.SharedPreferences;
40import android.content.DialogInterface.OnClickListener;
41import android.content.res.Configuration;
42import android.database.Cursor;
43import android.database.sqlite.SQLiteException;
44import android.database.sqlite.SQLiteFullException;
45import android.os.Bundle;
46import android.os.Handler;
47import android.preference.PreferenceManager;
48import android.provider.ContactsContract;
49import android.provider.ContactsContract.Contacts;
50import com.android.mms.telephony.TelephonyProvider.Mms;
51import android.util.Log;
52import android.view.ContextMenu;
53import android.view.KeyEvent;
54import android.view.LayoutInflater;
55import android.view.Menu;
56import android.view.MenuItem;
57import android.view.View;
58import android.view.Window;
59import android.view.ContextMenu.ContextMenuInfo;
60import android.view.View.OnCreateContextMenuListener;
61import android.view.View.OnKeyListener;
62import android.widget.AdapterView;
63import android.widget.CheckBox;
64import android.widget.ListView;
65import android.widget.TextView;
66
67/**
68 * This activity provides a list view of existing conversations.
69 */
70public class ConversationList extends ListActivity
71            implements DraftCache.OnDraftChangedListener {
72    private static final String TAG = "ConversationList";
73    private static final boolean DEBUG = false;
74    private static final boolean LOCAL_LOGV = DEBUG;
75
76    private static final int THREAD_LIST_QUERY_TOKEN = 1701;
77    public static final int DELETE_CONVERSATION_TOKEN = 1801;
78    public static final int HAVE_LOCKED_MESSAGES_TOKEN = 1802;
79
80    // IDs of the main menu items.
81    public static final int MENU_COMPOSE_NEW          = 0;
82    public static final int MENU_SEARCH               = 1;
83    public static final int MENU_DELETE_ALL           = 3;
84    public static final int MENU_PREFERENCES          = 4;
85
86    // IDs of the context menu items for the list of conversations.
87    public static final int MENU_DELETE               = 0;
88    public static final int MENU_VIEW                 = 1;
89    public static final int MENU_VIEW_CONTACT         = 2;
90    public static final int MENU_ADD_TO_CONTACTS      = 3;
91
92    private ThreadListQueryHandler mQueryHandler;
93    private ConversationListAdapter mListAdapter;
94    private CharSequence mTitle;
95    private SharedPreferences mPrefs;
96    private Handler mHandler;
97
98    static private final String CHECKED_MESSAGE_LIMITS = "checked_message_limits";
99
100    @Override
101    protected void onCreate(Bundle savedInstanceState) {
102        super.onCreate(savedInstanceState);
103
104        requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
105        setContentView(R.layout.conversation_list_screen);
106
107        mQueryHandler = new ThreadListQueryHandler(getContentResolver());
108
109        ListView listView = getListView();
110        LayoutInflater inflater = LayoutInflater.from(this);
111        ConversationHeaderView headerView = (ConversationHeaderView)
112                inflater.inflate(R.layout.conversation_header, listView, false);
113        headerView.bind(getString(R.string.new_message),
114                getString(R.string.create_new_message));
115        listView.addHeaderView(headerView, null, true);
116
117        listView.setOnCreateContextMenuListener(mConvListOnCreateContextMenuListener);
118        listView.setOnKeyListener(mThreadListKeyListener);
119
120        initListAdapter();
121
122        mTitle = getString(R.string.app_label);
123
124        mHandler = new Handler();
125        mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
126        boolean checkedMessageLimits = mPrefs.getBoolean(CHECKED_MESSAGE_LIMITS, false);
127        if (DEBUG) Log.v(TAG, "checkedMessageLimits: " + checkedMessageLimits);
128        if (!checkedMessageLimits || DEBUG) {
129            runOneTimeStorageLimitCheckForLegacyMessages();
130        }
131    }
132
133    private final ConversationListAdapter.OnContentChangedListener mContentChangedListener =
134        new ConversationListAdapter.OnContentChangedListener() {
135        public void onContentChanged(ConversationListAdapter adapter) {
136            startAsyncQuery();
137        }
138    };
139
140    private void initListAdapter() {
141        mListAdapter = new ConversationListAdapter(this, null);
142        mListAdapter.setOnContentChangedListener(mContentChangedListener);
143        setListAdapter(mListAdapter);
144        getListView().setRecyclerListener(mListAdapter);
145    }
146
147    /**
148     * Checks to see if the number of MMS and SMS messages are under the limits for the
149     * recycler. If so, it will automatically turn on the recycler setting. If not, it
150     * will prompt the user with a message and point them to the setting to manually
151     * turn on the recycler.
152     */
153    public synchronized void runOneTimeStorageLimitCheckForLegacyMessages() {
154        if (Recycler.isAutoDeleteEnabled(this)) {
155            if (DEBUG) Log.v(TAG, "recycler is already turned on");
156            // The recycler is already turned on. We don't need to check anything or warn
157            // the user, just remember that we've made the check.
158            markCheckedMessageLimit();
159            return;
160        }
161        new Thread(new Runnable() {
162            public void run() {
163                if (Recycler.checkForThreadsOverLimit(ConversationList.this)) {
164                    if (DEBUG) Log.v(TAG, "checkForThreadsOverLimit TRUE");
165                    // Dang, one or more of the threads are over the limit. Show an activity
166                    // that'll encourage the user to manually turn on the setting. Delay showing
167                    // this activity until a couple of seconds after the conversation list appears.
168                    mHandler.postDelayed(new Runnable() {
169                        public void run() {
170                            Intent intent = new Intent(ConversationList.this,
171                                    WarnOfStorageLimitsActivity.class);
172                            startActivity(intent);
173                        }
174                    }, 2000);
175                } else {
176                    if (DEBUG) Log.v(TAG, "checkForThreadsOverLimit silently turning on recycler");
177                    // No threads were over the limit. Turn on the recycler by default.
178                    runOnUiThread(new Runnable() {
179                        public void run() {
180                            SharedPreferences.Editor editor = mPrefs.edit();
181                            editor.putBoolean(MessagingPreferenceActivity.AUTO_DELETE, true);
182                            editor.commit();
183                        }
184                    });
185                }
186                // Remember that we don't have to do the check anymore when starting MMS.
187                runOnUiThread(new Runnable() {
188                    public void run() {
189                        markCheckedMessageLimit();
190                    }
191                });
192            }
193        }).start();
194    }
195
196    /**
197     * Mark in preferences that we've checked the user's message limits. Once checked, we'll
198     * never check them again, unless the user wipe-data or resets the device.
199     */
200    private void markCheckedMessageLimit() {
201        if (DEBUG) Log.v(TAG, "markCheckedMessageLimit");
202        SharedPreferences.Editor editor = mPrefs.edit();
203        editor.putBoolean(CHECKED_MESSAGE_LIMITS, true);
204        editor.commit();
205    }
206
207    @Override
208    protected void onNewIntent(Intent intent) {
209        // Handle intents that occur after the activity has already been created.
210        privateOnStart();
211    }
212
213    @Override
214    protected void onStart() {
215        super.onStart();
216
217        MessagingNotification.cancelNotification(getApplicationContext(),
218                SmsRejectedReceiver.SMS_REJECTED_NOTIFICATION_ID);
219
220        try {
221            Conversation.cleanup(this);
222        } catch (SQLiteFullException e) {
223            Log.e(TAG, "ConversationList.onStart disk probably full - finishing: " + e);
224            finish();
225            return;
226        }
227
228        DraftCache.getInstance().addOnDraftChangedListener(this);
229
230        // We used to refresh the DraftCache here, but
231        // refreshing the DraftCache each time we go to the ConversationList seems overly
232        // aggressive. We already update the DraftCache when leaving CMA in onStop() and
233        // onNewIntent(), and when we delete threads or delete all in CMA or this activity.
234        // I hope we don't have to do such a heavy operation each time we enter here.
235
236        privateOnStart();
237
238        // we invalidate the contact cache here because we want to get updated presence
239        // and any contact changes. We don't invalidate the cache by observing presence and contact
240        // changes (since that's too untargeted), so as a tradeoff we do it here.
241        // If we're in the middle of the app initialization where we're loading the conversation
242        // threads, don't invalidate the cache because we're in the process of building it.
243        // TODO: think of a better way to invalidate cache more surgically or based on actual
244        // TODO: changes we care about
245        if (!Conversation.loadingThreads()) {
246            Contact.invalidateCache();
247        }
248    }
249
250    protected void privateOnStart() {
251        startAsyncQuery();
252    }
253
254
255    @Override
256    protected void onStop() {
257        super.onStop();
258
259        DraftCache.getInstance().removeOnDraftChangedListener(this);
260        mListAdapter.changeCursor(null);
261    }
262
263    public void onDraftChanged(final long threadId, final boolean hasDraft) {
264        // Run notifyDataSetChanged() on the main thread.
265        mQueryHandler.post(new Runnable() {
266            public void run() {
267                if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
268                    log("onDraftChanged: threadId=" + threadId + ", hasDraft=" + hasDraft);
269                }
270                mListAdapter.notifyDataSetChanged();
271            }
272        });
273    }
274
275    private void startAsyncQuery() {
276        try {
277            setTitle(getString(R.string.refreshing));
278            setProgressBarIndeterminateVisibility(true);
279
280            Conversation.startQueryForAll(mQueryHandler, THREAD_LIST_QUERY_TOKEN);
281        } catch (SQLiteException e) {
282            SqliteWrapper.checkSQLiteException(this, e);
283        }
284    }
285
286    @Override
287    public boolean onPrepareOptionsMenu(Menu menu) {
288        menu.clear();
289
290        menu.add(0, MENU_COMPOSE_NEW, 0, R.string.menu_compose_new).setIcon(
291                com.android.internal.R.drawable.ic_menu_compose);
292
293        if (mListAdapter.getCount() > 0) {
294            menu.add(0, MENU_DELETE_ALL, 0, R.string.menu_delete_all).setIcon(
295                    android.R.drawable.ic_menu_delete);
296        }
297
298        menu.add(0, MENU_SEARCH, 0, android.R.string.search_go).
299            setIcon(android.R.drawable.ic_menu_search).
300            setAlphabeticShortcut(android.app.SearchManager.MENU_KEY);
301
302        menu.add(0, MENU_PREFERENCES, 0, R.string.menu_preferences).setIcon(
303                android.R.drawable.ic_menu_preferences);
304
305        return true;
306    }
307
308    @Override
309    public boolean onSearchRequested() {
310        startSearch(null, false, null /*appData*/, false);
311        return true;
312    }
313
314    @Override
315    public boolean onOptionsItemSelected(MenuItem item) {
316        switch(item.getItemId()) {
317            case MENU_COMPOSE_NEW:
318                createNewMessage();
319                break;
320            case MENU_SEARCH:
321                onSearchRequested();
322                break;
323            case MENU_DELETE_ALL:
324                // The invalid threadId of -1 means all threads here.
325                confirmDeleteThread(-1L, mQueryHandler);
326                break;
327            case MENU_PREFERENCES: {
328                Intent intent = new Intent(this, MessagingPreferenceActivity.class);
329                startActivityIfNeeded(intent, -1);
330                break;
331            }
332            default:
333                return true;
334        }
335        return false;
336    }
337
338    @Override
339    protected void onListItemClick(ListView l, View v, int position, long id) {
340        if (LOCAL_LOGV) {
341            Log.v(TAG, "onListItemClick: position=" + position + ", id=" + id);
342        }
343
344        if (position == 0) {
345            createNewMessage();
346        } else if (v instanceof ConversationHeaderView) {
347            ConversationHeaderView headerView = (ConversationHeaderView) v;
348            ConversationHeader ch = headerView.getConversationHeader();
349            openThread(ch.getThreadId());
350        }
351    }
352
353    private void createNewMessage() {
354        startActivity(ComposeMessageActivity.createIntent(this, 0));
355    }
356
357    private void openThread(long threadId) {
358        startActivity(ComposeMessageActivity.createIntent(this, threadId));
359    }
360
361    public static Intent createAddContactIntent(String address) {
362        // address must be a single recipient
363        Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT);
364        intent.setType(Contacts.CONTENT_ITEM_TYPE);
365        if (Mms.isEmailAddress(address)) {
366            intent.putExtra(ContactsContract.Intents.Insert.EMAIL, address);
367        } else {
368            intent.putExtra(ContactsContract.Intents.Insert.PHONE, address);
369            intent.putExtra(ContactsContract.Intents.Insert.PHONE_TYPE,
370                    ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE);
371        }
372        intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
373
374        return intent;
375    }
376
377    private final OnCreateContextMenuListener mConvListOnCreateContextMenuListener =
378        new OnCreateContextMenuListener() {
379        public void onCreateContextMenu(ContextMenu menu, View v,
380                ContextMenuInfo menuInfo) {
381            Cursor cursor = mListAdapter.getCursor();
382            if (cursor.getPosition() < 0) {
383                return;
384            }
385            Conversation conv = Conversation.from(ConversationList.this, cursor);
386            ContactList recipients = conv.getRecipients();
387            menu.setHeaderTitle(recipients.formatNames(","));
388
389            AdapterView.AdapterContextMenuInfo info =
390                (AdapterView.AdapterContextMenuInfo) menuInfo;
391            if (info.position > 0) {
392                menu.add(0, MENU_VIEW, 0, R.string.menu_view);
393
394                // Only show if there's a single recipient
395                if (recipients.size() == 1) {
396                    // do we have this recipient in contacts?
397                    if (recipients.get(0).existsInDatabase()) {
398                        menu.add(0, MENU_VIEW_CONTACT, 0, R.string.menu_view_contact);
399                    } else {
400                        menu.add(0, MENU_ADD_TO_CONTACTS, 0, R.string.menu_add_to_contacts);
401                    }
402                }
403                menu.add(0, MENU_DELETE, 0, R.string.menu_delete);
404            }
405        }
406    };
407
408    @Override
409    public boolean onContextItemSelected(MenuItem item) {
410        Cursor cursor = mListAdapter.getCursor();
411        if (cursor.getPosition() >= 0) {
412            Conversation conv = Conversation.from(ConversationList.this, cursor);
413            long threadId = conv.getThreadId();
414            switch (item.getItemId()) {
415            case MENU_DELETE: {
416                confirmDeleteThread(threadId, mQueryHandler);
417                break;
418            }
419            case MENU_VIEW: {
420                openThread(threadId);
421                break;
422            }
423            case MENU_VIEW_CONTACT: {
424                Contact contact = conv.getRecipients().get(0);
425                Intent intent = new Intent(Intent.ACTION_VIEW, contact.getUri());
426                intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
427                startActivity(intent);
428                break;
429            }
430            case MENU_ADD_TO_CONTACTS: {
431                String address = conv.getRecipients().get(0).getNumber();
432                startActivity(createAddContactIntent(address));
433                break;
434            }
435            default:
436                break;
437            }
438        }
439        return super.onContextItemSelected(item);
440    }
441
442    @Override
443    public void onConfigurationChanged(Configuration newConfig) {
444        // We override this method to avoid restarting the entire
445        // activity when the keyboard is opened (declared in
446        // AndroidManifest.xml).  Because the only translatable text
447        // in this activity is "New Message", which has the full width
448        // of phone to work with, localization shouldn't be a problem:
449        // no abbreviated alternate words should be needed even in
450        // 'wide' languages like German or Russian.
451
452        super.onConfigurationChanged(newConfig);
453        if (DEBUG) Log.v(TAG, "onConfigurationChanged: " + newConfig);
454    }
455
456    /**
457     * Start the process of putting up a dialog to confirm deleting a thread,
458     * but first start a background query to see if any of the threads or thread
459     * contain locked messages so we'll know how detailed of a UI to display.
460     * @param threadId id of the thread to delete or -1 for all threads
461     * @param handler query handler to do the background locked query
462     */
463    public static void confirmDeleteThread(long threadId, AsyncQueryHandler handler) {
464        Conversation.startQueryHaveLockedMessages(handler, threadId,
465                HAVE_LOCKED_MESSAGES_TOKEN);
466    }
467
468    /**
469     * Build and show the proper delete thread dialog. The UI is slightly different
470     * depending on whether there are locked messages in the thread(s) and whether we're
471     * deleting a single thread or all threads.
472     * @param listener gets called when the delete button is pressed
473     * @param deleteAll whether to show a single thread or all threads UI
474     * @param hasLockedMessages whether the thread(s) contain locked messages
475     * @param context used to load the various UI elements
476     */
477    public static void confirmDeleteThreadDialog(final DeleteThreadListener listener,
478            boolean deleteAll,
479            boolean hasLockedMessages,
480            Context context) {
481        View contents = View.inflate(context, R.layout.delete_thread_dialog_view, null);
482        TextView msg = (TextView)contents.findViewById(R.id.message);
483        msg.setText(deleteAll
484                ? R.string.confirm_delete_all_conversations
485                        : R.string.confirm_delete_conversation);
486        final CheckBox checkbox = (CheckBox)contents.findViewById(R.id.delete_locked);
487        if (!hasLockedMessages) {
488            checkbox.setVisibility(View.GONE);
489        } else {
490            listener.setDeleteLockedMessage(checkbox.isChecked());
491            checkbox.setOnClickListener(new View.OnClickListener() {
492                public void onClick(View v) {
493                    listener.setDeleteLockedMessage(checkbox.isChecked());
494                }
495            });
496        }
497
498        AlertDialog.Builder builder = new AlertDialog.Builder(context);
499        builder.setTitle(R.string.confirm_dialog_title)
500            .setIcon(android.R.drawable.ic_dialog_alert)
501        .setCancelable(true)
502        .setPositiveButton(R.string.delete, listener)
503        .setNegativeButton(R.string.no, null)
504        .setView(contents)
505        .show();
506    }
507
508    private final OnKeyListener mThreadListKeyListener = new OnKeyListener() {
509        public boolean onKey(View v, int keyCode, KeyEvent event) {
510            if (event.getAction() == KeyEvent.ACTION_DOWN) {
511                switch (keyCode) {
512                    case KeyEvent.KEYCODE_DEL: {
513                        long id = getListView().getSelectedItemId();
514                        if (id > 0) {
515                            confirmDeleteThread(id, mQueryHandler);
516                        }
517                        return true;
518                    }
519                }
520            }
521            return false;
522        }
523    };
524
525    public static class DeleteThreadListener implements OnClickListener {
526        private final long mThreadId;
527        private final AsyncQueryHandler mHandler;
528        private final Context mContext;
529        private boolean mDeleteLockedMessages;
530
531        public DeleteThreadListener(long threadId, AsyncQueryHandler handler, Context context) {
532            mThreadId = threadId;
533            mHandler = handler;
534            mContext = context;
535        }
536
537        public void setDeleteLockedMessage(boolean deleteLockedMessages) {
538            mDeleteLockedMessages = deleteLockedMessages;
539        }
540
541        public void onClick(DialogInterface dialog, final int whichButton) {
542            MessageUtils.handleReadReport(mContext, mThreadId,
543                    PduHeaders.READ_STATUS__DELETED_WITHOUT_BEING_READ, new Runnable() {
544                public void run() {
545                    int token = DELETE_CONVERSATION_TOKEN;
546                    if (mThreadId == -1) {
547                        Conversation.startDeleteAll(mHandler, token, mDeleteLockedMessages);
548                        DraftCache.getInstance().refresh();
549                    } else {
550                        Conversation.startDelete(mHandler, token, mDeleteLockedMessages,
551                                mThreadId);
552                        DraftCache.getInstance().setDraftState(mThreadId, false);
553                    }
554                }
555            });
556        }
557    }
558
559    private final class ThreadListQueryHandler extends AsyncQueryHandler {
560        public ThreadListQueryHandler(ContentResolver contentResolver) {
561            super(contentResolver);
562        }
563
564        @Override
565        protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
566            switch (token) {
567            case THREAD_LIST_QUERY_TOKEN:
568                mListAdapter.changeCursor(cursor);
569                setTitle(mTitle);
570                setProgressBarIndeterminateVisibility(false);
571                break;
572
573            case HAVE_LOCKED_MESSAGES_TOKEN:
574                long threadId = (Long)cookie;
575                confirmDeleteThreadDialog(new DeleteThreadListener(threadId, mQueryHandler,
576                        ConversationList.this), threadId == -1,
577                        cursor != null && cursor.getCount() > 0,
578                        ConversationList.this);
579                break;
580
581            default:
582                Log.e(TAG, "onQueryComplete called with unknown token " + token);
583            }
584        }
585
586        @Override
587        protected void onDeleteComplete(int token, Object cookie, int result) {
588            switch (token) {
589            case DELETE_CONVERSATION_TOKEN:
590                // Make sure the conversation cache reflects the threads in the DB.
591                Conversation.init(ConversationList.this);
592
593                // Update the notification for new messages since they
594                // may be deleted.
595                MessagingNotification.updateNewMessageIndicator(ConversationList.this);
596                // Update the notification for failed messages since they
597                // may be deleted.
598                MessagingNotification.updateSendFailedNotification(ConversationList.this);
599
600                // Make sure the list reflects the delete
601                startAsyncQuery();
602
603                onContentChanged();
604                break;
605            }
606        }
607    }
608
609    private void log(String format, Object... args) {
610        String s = String.format(format, args);
611        Log.d(TAG, "[" + Thread.currentThread().getId() + "] " + s);
612    }
613}
614