FileDocCategorySizeDatePackage
ConversationList.javaAPI DocAndroid 1.5 API24477Wed May 06 22:42:46 BST 2009com.android.mms.ui

ConversationList.java

/*
 * Copyright (C) 2008 Esmertec AG.
 * Copyright (C) 2008 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.mms.ui;

import com.android.mms.R;
import com.android.mms.transaction.MessagingNotification;
import com.android.mms.ui.RecipientList.Recipient;
import com.android.mms.util.ContactInfoCache;
import com.android.mms.util.DraftCache;

import com.google.android.mms.pdu.PduHeaders;
import com.google.android.mms.util.SqliteWrapper;

import android.app.AlertDialog;
import android.app.ListActivity;
import android.content.AsyncQueryHandler;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.DialogInterface.OnClickListener;
import android.content.res.Configuration;
import android.database.ContentObserver;
import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.provider.Contacts;
import android.provider.Contacts.People;
import android.provider.Contacts.Intents.Insert;
import android.provider.Telephony.Mms;
import android.provider.Telephony.Threads;
import android.provider.Telephony.Sms.Conversations;
import android.text.TextUtils;
import android.util.Config;
import android.util.Log;
import android.view.ContextMenu;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.Window;
import android.view.ContextMenu.ContextMenuInfo;
import android.view.View.OnCreateContextMenuListener;
import android.view.View.OnKeyListener;
import android.widget.AdapterView;
import android.widget.ListView;

import java.util.concurrent.ConcurrentHashMap;

/**
 * This activity provides a list view of existing conversations.
 */
public class ConversationList extends ListActivity
            implements DraftCache.OnDraftChangedListener {
    private static final String TAG = "ConversationList";
    private static final boolean DEBUG = false;
    private static final boolean LOCAL_LOGV = Config.LOGV && DEBUG;

    private static final int THREAD_LIST_QUERY_TOKEN = 1701;
    private static final int SEARCH_TOKEN            = 1702;

    private static final int DELETE_CONVERSATION_TOKEN = 1801;
    
    // IDs of the main menu items.
    private static final int MENU_COMPOSE_NEW            = 0;
    private static final int MENU_SEARCH                 = 1;
    private static final int MENU_DELETE_ALL             = 3;
    private static final int MENU_PREFERENCES            = 4;

    // IDs of the context menu items for the list of conversations.
    public static final int MENU_DELETE                = 0;
    private static final int MENU_VIEW                 = 1;
    private static final int MENU_VIEW_CONTACT         = 2;
    private static final int MENU_ADD_TO_CONTACTS      = 3;

    private ThreadListQueryHandler mQueryHandler;
    private ConversationListAdapter mListAdapter;
    private CharSequence mTitle;
    private Uri mBaseUri;
    private String mSelection;
    private String[] mProjection;
    private int mQueryToken;
    private String mFilter;
    private boolean mSearchFlag;
    private CachingNameStore mCachingNameStore;

    /**
     * An interface that's passed down to ListAdapters to use
     * for looking up the names of contact numbers.
     */
    public static interface CachingNameStore {
        // Returns comma-separated list of contact's display names
        // given a semicolon-delimited string of canonical phone
        // numbers.
        public String getContactNames(String addresses);
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
        setContentView(R.layout.conversation_list_screen);

        mQueryHandler = new ThreadListQueryHandler(getContentResolver());

        ListView listView = getListView();
        LayoutInflater inflater = LayoutInflater.from(this);
        ConversationHeaderView headerView = (ConversationHeaderView)
                inflater.inflate(R.layout.conversation_header, listView, false);
        headerView.bind(getString(R.string.new_message),
                getString(R.string.create_new_message));
        listView.addHeaderView(headerView, null, true);

        listView.setOnCreateContextMenuListener(mConvListOnCreateContextMenuListener);
        listView.setOnKeyListener(mThreadListKeyListener);

        mCachingNameStore = new CachingNameStoreImpl(this);

        initListAdapter();
        
        if (savedInstanceState != null) {
            mBaseUri = (Uri) savedInstanceState.getParcelable("base_uri");
            mSearchFlag = savedInstanceState.getBoolean("search_flag");
            mFilter = savedInstanceState.getString("filter");
            mQueryToken = savedInstanceState.getInt("query_token");
        }
        
        handleCreationIntent(getIntent());
    }

    private void initListAdapter() {
        mListAdapter = new ConversationListAdapter(this, null, true, mCachingNameStore);
        setListAdapter(mListAdapter);
    }
    
    static public boolean isFailedToDeliver(Intent intent) {
        return (intent != null) && intent.getBooleanExtra("undelivered_flag", false);
    }

    @Override
    protected void onNewIntent(Intent intent) {
        // Handle intents that occur after the activity has already been created.
        handleCreationIntent(intent);
    }

    protected void handleCreationIntent(Intent intent) {
        // Handle intents that occur upon creation of the activity.
        initNormalQueryArgs();
   }

    @Override
    protected void onResume() {
        super.onResume();

        DraftCache.getInstance().addOnDraftChangedListener(this);

        getContentResolver().delete(Threads.OBSOLETE_THREADS_URI, null, null);

        // Make sure the draft cache is up to date.
        DraftCache.getInstance().refresh();

        startAsyncQuery();

        // force invalidate the contact info cache, so we will query for fresh info again.
        // This is so we can get fresh presence info again on the screen, since the presence
        // info changes pretty quickly, and we can't get change notifications when presence is
        // updated in the ContactsProvider.
        ContactInfoCache.getInstance().invalidateCache();
    }

    @Override
    protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);

        outState.putParcelable("base_uri", mBaseUri);
        outState.putInt("query_token", mQueryToken);
        outState.putBoolean("search_flag", mSearchFlag);
        if (mSearchFlag) {
            outState.putString("filter", mFilter);
        }
    }

    @Override
    protected void onPause() {
        super.onPause();

        DraftCache.getInstance().removeOnDraftChangedListener(this);
    }

    @Override
    protected void onStop() {
        super.onStop();

        mListAdapter.changeCursor(null);
    }

    public void onDraftChanged(long threadId, boolean hasDraft) {
        // Run notifyDataSetChanged() on the main thread.
        mQueryHandler.post(new Runnable() {
            public void run() {
                mListAdapter.notifyDataSetChanged();
            }
        });
    }
    
    private void initNormalQueryArgs() {
        Uri.Builder builder = Threads.CONTENT_URI.buildUpon();
        builder.appendQueryParameter("simple", "true");
        mBaseUri = builder.build();
        mSelection = null;
        mProjection = ConversationListAdapter.PROJECTION;
        mQueryToken = THREAD_LIST_QUERY_TOKEN;
        mTitle = getString(R.string.app_label);
    }

    private void startAsyncQuery() {
        try {
            setTitle(getString(R.string.refreshing));
            setProgressBarIndeterminateVisibility(true);

            mQueryHandler.cancelOperation(THREAD_LIST_QUERY_TOKEN);
            mQueryHandler.startQuery(THREAD_LIST_QUERY_TOKEN, null, mBaseUri,
                    mProjection, mSelection, null, Conversations.DEFAULT_SORT_ORDER);
        } catch (SQLiteException e) {
            SqliteWrapper.checkSQLiteException(this, e);
        }
    }

    @Override
    public boolean onPrepareOptionsMenu(Menu menu) {
        menu.clear();

        menu.add(0, MENU_COMPOSE_NEW, 0, R.string.menu_compose_new).setIcon(
                com.android.internal.R.drawable.ic_menu_compose);
        // Removed search as part of b/1205708
        //menu.add(0, MENU_SEARCH, 0, R.string.menu_search).setIcon(
        //        R.drawable.ic_menu_search).setAlphabeticShortcut(SearchManager.MENU_KEY);
        if (mListAdapter.getCount() > 0 && !mSearchFlag) {
            menu.add(0, MENU_DELETE_ALL, 0, R.string.menu_delete_all).setIcon(
                    android.R.drawable.ic_menu_delete);
        }

        menu.add(0, MENU_PREFERENCES, 0, R.string.menu_preferences).setIcon(
                android.R.drawable.ic_menu_preferences);

        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch(item.getItemId()) {
            case MENU_COMPOSE_NEW:
                createNewMessage();
                break;
            case MENU_SEARCH:
                onSearchRequested();
                break;
            case MENU_DELETE_ALL:
                confirmDeleteDialog(new DeleteThreadListener(-1L), true);
                break;
            case MENU_PREFERENCES: {
                Intent intent = new Intent(this, MessagingPreferenceActivity.class);
                startActivityIfNeeded(intent, -1);
                break;
            }
            default:
                return true;
        }
        return false;
    }

    @Override
    protected void onListItemClick(ListView l, View v, int position, long id) {
        if (LOCAL_LOGV) {
            Log.v(TAG, "onListItemClick: position=" + position + ", id=" + id);
        }

        if (position == 0) {
            createNewMessage();
        } else if (v instanceof ConversationHeaderView) {
            ConversationHeaderView headerView = (ConversationHeaderView) v;
            ConversationHeader ch = headerView.getConversationHeader();

            // TODO: The 'from' view of the ConversationHeader was
            // repurposed to be the cached display value, rather than
            // the old raw value, which openThread() wanted.  But it
            // turns out openThread() doesn't need it:
            // ComposeMessageActivity will load it.  That's not ideal,
            // though, as it's an SQLite query.  So fix this later to
            // save some latency on starting ComposeMessageActivity.
            String somethingDelimitedAddresses = null;
            openThread(ch.getThreadId(), somethingDelimitedAddresses);
        }
    }

    private void createNewMessage() {
        Intent intent = new Intent(this, ComposeMessageActivity.class);
        startActivity(intent);
    }

    private void openThread(long threadId, String address) {
        Intent intent = new Intent(this, ComposeMessageActivity.class);
        intent.putExtra("thread_id", threadId);
        if (!TextUtils.isEmpty(address)) {
            intent.putExtra("address", address);
        }
        startActivity(intent);
    }

    private void viewContact(String address) {
        // address must be a single recipient
        ContactInfoCache cache = ContactInfoCache.getInstance();
        ContactInfoCache.CacheEntry info;
        if (Mms.isEmailAddress(address)) {
            info = cache.getContactInfoForEmailAddress(getApplicationContext(), address,
                    true /* allow query */);
        } else {
            info = cache.getContactInfo(this, address);
        }
        if (info != null && info.person_id > 0) {
            Uri uri = ContentUris.withAppendedId(People.CONTENT_URI, info.person_id);
            Intent intent = new Intent(Intent.ACTION_VIEW, uri);
            startActivity(intent);            
        }
    }

    public static Intent createAddContactIntent(String address) {
        // address must be a single recipient
        Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT);
        intent.setType(Contacts.People.CONTENT_ITEM_TYPE);
        if (Recipient.isPhoneNumber(address)) {
            intent.putExtra(Insert.PHONE, address);
        } else {
            intent.putExtra(Insert.EMAIL, address);
        }
        
        return intent;
    }

    private final OnCreateContextMenuListener mConvListOnCreateContextMenuListener =
        new OnCreateContextMenuListener() {
        public void onCreateContextMenu(ContextMenu menu, View v,
                ContextMenuInfo menuInfo) {
            Cursor cursor = mListAdapter.getCursor();
            if ((cursor != null) && (cursor.getCount() > 0) && !mSearchFlag) {
                String address = MessageUtils.getRecipientsByIds(
                        ConversationList.this,
                        cursor.getString(ConversationListAdapter.COLUMN_RECIPIENTS_IDS),
                        true /* allow query */);
                // The Recipient IDs column is separated with semicolons for some reason.
                // We should fix this in the content provider rework.
                CharSequence from = (ContactInfoCache.getInstance().getContactName(
                        ConversationList.this, address)).replace(';', ',');
                menu.setHeaderTitle(from);

                AdapterView.AdapterContextMenuInfo info =
                        (AdapterView.AdapterContextMenuInfo) menuInfo;
                if (info.position > 0) {
                    menu.add(0, MENU_VIEW, 0, R.string.menu_view);
                    
                    // Only show if there's a single recipient
                    String recipient = getAddress(cursor);
                    if (!recipient.contains(";")) {
                        // do we have this recipient in contacts?
                        ContactInfoCache.CacheEntry entry = ContactInfoCache.getInstance()
                            .getContactInfo(ConversationList.this, recipient);
                        
                        if (entry != null && entry.person_id > 0) {
                            menu.add(0, MENU_VIEW_CONTACT, 0, R.string.menu_view_contact);
                        } else {
                            menu.add(0, MENU_ADD_TO_CONTACTS, 0, R.string.menu_add_to_contacts);
                        }
                    }
                    menu.add(0, MENU_DELETE, 0, R.string.menu_delete);
                }
            }
        }
    };

    @Override
    public boolean onContextItemSelected(MenuItem item) {
        Cursor cursor = mListAdapter.getCursor();
        long threadId = cursor.getLong(ConversationListAdapter.COLUMN_ID);
        switch (item.getItemId()) {
            case MENU_DELETE: {
                DeleteThreadListener l = new DeleteThreadListener(threadId);
                confirmDeleteDialog(l, false);
                break;
            }
            case MENU_VIEW: {
                String address = getAddress(cursor);
                openThread(threadId, address);
                break;
            }
            case MENU_VIEW_CONTACT: {
                String address = getAddress(cursor);
                viewContact(address);
                break;
            }
            case MENU_ADD_TO_CONTACTS: {
                String address = getAddress(cursor);
                startActivity(createAddContactIntent(address));
                break;
            }
            default:
                break;
        }

        return super.onContextItemSelected(item);
    }
    
    private String getAddress(Cursor cursor) {
        
        long threadId = cursor.getLong(ConversationListAdapter.COLUMN_ID);
        String address = null;
        if (mListAdapter.isSimpleMode()) {
            address = MessageUtils.getRecipientsByIds(
                    this,
                    cursor.getString(ConversationListAdapter.COLUMN_RECIPIENTS_IDS),
                    true /* allow query */);
        } else {
            String msgType = cursor.getString(ConversationListAdapter.COLUMN_MESSAGE_TYPE);
            if (msgType.equals("sms")) {
                address = cursor.getString(ConversationListAdapter.COLUMN_SMS_ADDRESS);
            } else {
                address = MessageUtils.getAddressByThreadId(this, threadId);
           }
        }
        return address;
    }

    public void onConfigurationChanged(Configuration newConfig) {
        // We override this method to avoid restarting the entire
        // activity when the keyboard is opened (declared in
        // AndroidManifest.xml).  Because the only translatable text
        // in this activity is "New Message", which has the full width
        // of phone to work with, localization shouldn't be a problem:
        // no abbreviated alternate words should be needed even in
        // 'wide' languages like German or Russian.

        super.onConfigurationChanged(newConfig);
        if (DEBUG) Log.v(TAG, "onConfigurationChanged: " + newConfig);
    }

    private void confirmDeleteDialog(OnClickListener listener, boolean deleteAll) {
        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setTitle(R.string.confirm_dialog_title);
        builder.setIcon(android.R.drawable.ic_dialog_alert);
        builder.setCancelable(true);
        builder.setPositiveButton(R.string.yes, listener);
        builder.setNegativeButton(R.string.no, null);
        builder.setMessage(deleteAll
                ? R.string.confirm_delete_all_conversations
                : R.string.confirm_delete_conversation);

        builder.show();
    }

    private final OnKeyListener mThreadListKeyListener = new OnKeyListener() {
        public boolean onKey(View v, int keyCode, KeyEvent event) {
            if (event.getAction() == KeyEvent.ACTION_DOWN) {
                switch (keyCode) {
                    case KeyEvent.KEYCODE_DEL: {
                        long id = getListView().getSelectedItemId();
                        if (id > 0) {
                            DeleteThreadListener l = new DeleteThreadListener(
                                    id);
                            confirmDeleteDialog(l, false);
                        }
                        return true;
                    }
                    case KeyEvent.KEYCODE_BACK: {
                        if (mSearchFlag) {
                            mSearchFlag = false;
                            initNormalQueryArgs();
                            startAsyncQuery();

                            return true;
                        }
                        break;
                    }
                }
            }
            return false;
        }
    };

    private class DeleteThreadListener implements OnClickListener {
        private final Uri mDeleteUri;
        private final long mThreadId;

        public DeleteThreadListener(long threadId) {
            mThreadId = threadId;

            if (threadId != -1) {
                mDeleteUri = ContentUris.withAppendedId(
                        Threads.CONTENT_URI, threadId);
            } else {
                mDeleteUri = Threads.CONTENT_URI;
            }
        }

        public void onClick(DialogInterface dialog, int whichButton) {
            MessageUtils.handleReadReport(ConversationList.this, mThreadId,
                    PduHeaders.READ_STATUS__DELETED_WITHOUT_BEING_READ, new Runnable() {
                public void run() {
                    mQueryHandler.startDelete(DELETE_CONVERSATION_TOKEN,
                            null, mDeleteUri, null, null);
                }
            });
        }
    }

    private final class ThreadListQueryHandler extends AsyncQueryHandler {
        public ThreadListQueryHandler(ContentResolver contentResolver) {
            super(contentResolver);
        }

        @Override
        protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
            switch (token) {
            case THREAD_LIST_QUERY_TOKEN:
                mListAdapter.changeCursor(cursor);
                setTitle(mTitle);
                setProgressBarIndeterminateVisibility(false);
                break;
            default:
                Log.e(TAG, "onQueryComplete called with unknown token " + token);
            }
        }

        @Override
        protected void onDeleteComplete(int token, Object cookie, int result) {
            switch (token) {
            case DELETE_CONVERSATION_TOKEN:
                // Update the notification for new messages since they
                // may be deleted.
                MessagingNotification.updateNewMessageIndicator(ConversationList.this);
                // Update the notification for failed messages since they
                // may be deleted.
                MessagingNotification.updateSendFailedNotification(ConversationList.this);
                
                // Make sure the list reflects the delete
                startAsyncQuery();

                onContentChanged();
                break;
            }
        }
    }

    /**
     * This implements the CachingNameStore interface defined above
     * which we pass down to each newly-created ListAdapater, so they
     * share a common, reused cached between activity resumes, not
     * having to hit the Contacts providers all the time.
     */
    private static final class CachingNameStoreImpl implements CachingNameStore {
        private static final String TAG = "ConversationList/CachingNameStoreImpl";
        private final ConcurrentHashMap<String, String> mCachedNames =
                new ConcurrentHashMap<String, String>();
        private final ContentObserver mPhonesObserver;
        private final Context mContext;

        public CachingNameStoreImpl(Context ctxt) {
            mContext = ctxt;
            mPhonesObserver = new ContentObserver(new Handler()) {
                    @Override
                    public void onChange(boolean selfUpdate) {
                        mCachedNames.clear();
                    }
                };
            ctxt.getContentResolver().registerContentObserver(
                    Contacts.Phones.CONTENT_URI,
                    true, mPhonesObserver);
        }

        // Returns comma-separated list of contact's display names
        // given a semicolon-delimited string of canonical phone
        // numbers, getting data either from cache or via a blocking
        // call to a provider.
        public String getContactNames(String addresses) {
            String value = mCachedNames.get(addresses);
            if (value != null) {
                return value;
            }
            String[] values = addresses.split(";");
            if (values.length < 2) {
                if (DEBUG) Log.v(TAG, "Looking up name: " + addresses);
                ContactInfoCache cache = ContactInfoCache.getInstance();
                value = (cache.getContactName(mContext, addresses)).replace(';', ',');
            } else {
                int length = 0;
                for (int i = 0; i < values.length; ++i) {
                    values[i] = getContactNames(values[i]);
                    length += values[i].length() + 2;  // 2 for ", "
                }
                StringBuilder sb = new StringBuilder(length);
                sb.append(values[0]);
                for (int i = 1; i < values.length; ++i) {
                    sb.append(", ");
                    sb.append(values[i]);
                }
                value = sb.toString();
            }
            mCachedNames.put(addresses, value);
            return value;
        }

    }
}