diff options
author | Yorke Lee <yorkelee@google.com> | 2013-07-16 10:38:02 -0700 |
---|---|---|
committer | Yorke Lee <yorkelee@google.com> | 2013-07-16 10:38:02 -0700 |
commit | 6b049128c51b90e17ae14856d98130a22d3a5433 (patch) | |
tree | fa8345d8441312a62c5943ba6b5f2a64d85e3ae0 /src | |
parent | d3bb641606e567b573a0234ccb292475c5eb87b4 (diff) |
Adding new copies of classes for new Dialer UI
No code has been modified at all in this CL. All classes
were copied from existing classes and renamed to add the New prefix.
Change-Id: Idbb522c9dd1ef5db8e3dffcb73155ca603f861b2
Diffstat (limited to 'src')
-rw-r--r-- | src/com/android/dialer/NewDialtactsActivity.java | 1292 | ||||
-rw-r--r-- | src/com/android/dialer/calllog/NewCallLogAdapter.java | 781 | ||||
-rw-r--r-- | src/com/android/dialer/calllog/NewCallLogFragment.java | 612 | ||||
-rw-r--r-- | src/com/android/dialer/calllog/NewCallLogListItemHelper.java | 110 | ||||
-rw-r--r-- | src/com/android/dialer/dialpad/NewDialpadFragment.java | 1732 | ||||
-rw-r--r-- | src/com/android/dialer/list/NewPhoneFavoriteFragment.java | 573 | ||||
-rw-r--r-- | src/com/android/dialer/list/NewPhoneFavoriteMergedAdapter.java | 307 |
7 files changed, 5407 insertions, 0 deletions
diff --git a/src/com/android/dialer/NewDialtactsActivity.java b/src/com/android/dialer/NewDialtactsActivity.java new file mode 100644 index 000000000..584c951b3 --- /dev/null +++ b/src/com/android/dialer/NewDialtactsActivity.java @@ -0,0 +1,1292 @@ +/* + * 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.dialer; + +import android.app.ActionBar; +import android.app.ActionBar.LayoutParams; +import android.app.ActionBar.Tab; +import android.app.ActionBar.TabListener; +import android.app.Activity; +import android.app.backup.BackupManager; +import android.app.Fragment; +import android.app.FragmentManager; +import android.app.FragmentTransaction; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.os.RemoteException; +import android.os.ServiceManager; +import android.preference.PreferenceManager; +import android.provider.CallLog.Calls; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Intents.UI; +import android.support.v13.app.FragmentPagerAdapter; +import android.support.v4.view.ViewPager; +import android.support.v4.view.ViewPager.OnPageChangeListener; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MenuItem.OnMenuItemClickListener; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnFocusChangeListener; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.PopupMenu; +import android.widget.SearchView; +import android.widget.SearchView.OnCloseListener; +import android.widget.SearchView.OnQueryTextListener; +import android.widget.Toast; + +import com.android.contacts.common.CallUtil; +import com.android.contacts.common.activity.TransactionSafeActivity; +import com.android.contacts.common.list.ContactListFilterController; +import com.android.contacts.common.list.ContactListFilterController.ContactListFilterListener; +import com.android.contacts.common.list.ContactListItemView; +import com.android.contacts.common.list.OnPhoneNumberPickerActionListener; +import com.android.contacts.common.list.PhoneNumberPickerFragment; +import com.android.contacts.common.util.AccountFilterUtil; +import com.android.dialer.calllog.CallLogFragment; +import com.android.dialer.dialpad.DialpadFragment; +import com.android.dialer.interactions.PhoneNumberInteraction; +import com.android.dialer.list.PhoneFavoriteFragment; +import com.android.dialer.util.OrientationUtil; +import com.android.internal.telephony.ITelephony; + +/** + * The dialer activity that has one tab with the virtual 12key + * dialer, a tab with recent calls in it, a tab with the contacts and + * a tab with the favorite. This is the container and the tabs are + * embedded using intents. + * The dialer tab's title is 'phone', a more common name (see strings.xml). + */ +public class NewDialtactsActivity extends TransactionSafeActivity implements View.OnClickListener { + private static final String TAG = "DialtactsActivity"; + + public static final boolean DEBUG = false; + + /** Used to open Call Setting */ + private static final String PHONE_PACKAGE = "com.android.phone"; + private static final String CALL_SETTINGS_CLASS_NAME = + "com.android.phone.CallFeaturesSetting"; + + /** @see #getCallOrigin() */ + private static final String CALL_ORIGIN_DIALTACTS = + "com.android.dialer.DialtactsActivity"; + + /** + * Just for backward compatibility. Should behave as same as {@link Intent#ACTION_DIAL}. + */ + private static final String ACTION_TOUCH_DIALER = "com.android.phone.action.TOUCH_DIALER"; + + /** Used both by {@link ActionBar} and {@link ViewPagerAdapter} */ + private static final int TAB_INDEX_DIALER = 0; + private static final int TAB_INDEX_CALL_LOG = 1; + private static final int TAB_INDEX_FAVORITES = 2; + + private static final int TAB_INDEX_COUNT = 3; + + private SharedPreferences mPrefs; + + public static final String SHARED_PREFS_NAME = "com.android.dialer_preferences"; + + /** Last manually selected tab index */ + private static final String PREF_LAST_MANUALLY_SELECTED_TAB = + "DialtactsActivity_last_manually_selected_tab"; + private static final int PREF_LAST_MANUALLY_SELECTED_TAB_DEFAULT = TAB_INDEX_DIALER; + + private static final int SUBACTIVITY_ACCOUNT_FILTER = 1; + + public class ViewPagerAdapter extends FragmentPagerAdapter { + public ViewPagerAdapter(FragmentManager fm) { + super(fm); + } + + @Override + public Fragment getItem(int position) { + switch (position) { + case TAB_INDEX_DIALER: + return new DialpadFragment(); + case TAB_INDEX_CALL_LOG: + return new CallLogFragment(); + case TAB_INDEX_FAVORITES: + return new PhoneFavoriteFragment(); + } + throw new IllegalStateException("No fragment at position " + position); + } + + @Override + public void setPrimaryItem(ViewGroup container, int position, Object object) { + // The parent's setPrimaryItem() also calls setMenuVisibility(), so we want to know + // when it happens. + if (DEBUG) { + Log.d(TAG, "FragmentPagerAdapter#setPrimaryItem(), position: " + position); + } + super.setPrimaryItem(container, position, object); + } + + @Override + public int getCount() { + return TAB_INDEX_COUNT; + } + } + + /** + * True when the app detects user's drag event. This variable should not become true when + * mUserTabClick is true. + * + * During user's drag or tab click, we shouldn't show fake buttons but just show real + * ActionBar at the bottom of the screen, for transition animation. + */ + boolean mDuringSwipe = false; + /** + * True when the app detects user's tab click (at the top of the screen). This variable should + * not become true when mDuringSwipe is true. + * + * During user's drag or tab click, we shouldn't show fake buttons but just show real + * ActionBar at the bottom of the screen, for transition animation. + */ + boolean mUserTabClick = false; + + private class PageChangeListener implements OnPageChangeListener { + private int mCurrentPosition = -1; + /** + * Used during page migration, to remember the next position {@link #onPageSelected(int)} + * specified. + */ + private int mNextPosition = -1; + + @Override + public void onPageScrolled( + int position, float positionOffset, int positionOffsetPixels) { + } + + @Override + public void onPageSelected(int position) { + if (DEBUG) Log.d(TAG, "onPageSelected: position: " + position); + final ActionBar actionBar = getActionBar(); + if (mDialpadFragment != null) { + if (mDuringSwipe && position == TAB_INDEX_DIALER) { + // TODO: Figure out if we want this or not. Right now + // - with this call, both fake buttons and real action bar overlap + // - without this call, there's tiny flicker happening to search/menu buttons. + // If we can reduce the flicker without this call, it would be much better. + // updateFakeMenuButtonsVisibility(true); + } + } + + if (mCurrentPosition == position) { + Log.w(TAG, "Previous position and next position became same (" + position + ")"); + } + + actionBar.selectTab(actionBar.getTabAt(position)); + mNextPosition = position; + } + + public void setCurrentPosition(int position) { + mCurrentPosition = position; + } + + public int getCurrentPosition() { + return mCurrentPosition; + } + + @Override + public void onPageScrollStateChanged(int state) { + switch (state) { + case ViewPager.SCROLL_STATE_IDLE: { + if (mNextPosition == -1) { + // This happens when the user drags the screen just after launching the + // application, and settle down the same screen without actually swiping it. + // At that moment mNextPosition is apparently -1 yet, and we expect it + // being updated by onPageSelected(), which is *not* called if the user + // settle down the exact same tab after the dragging. + if (DEBUG) { + Log.d(TAG, "Next position is not specified correctly. Use current tab (" + + mViewPager.getCurrentItem() + ")"); + } + mNextPosition = mViewPager.getCurrentItem(); + } + if (DEBUG) { + Log.d(TAG, "onPageScrollStateChanged() with SCROLL_STATE_IDLE. " + + "mCurrentPosition: " + mCurrentPosition + + ", mNextPosition: " + mNextPosition); + } + // Interpret IDLE as the end of migration (both swipe and tab click) + mDuringSwipe = false; + mUserTabClick = false; + + updateFakeMenuButtonsVisibility(mNextPosition == TAB_INDEX_DIALER); + sendFragmentVisibilityChange(mCurrentPosition, false); + sendFragmentVisibilityChange(mNextPosition, true); + + invalidateOptionsMenu(); + + mCurrentPosition = mNextPosition; + break; + } + case ViewPager.SCROLL_STATE_DRAGGING: { + if (DEBUG) Log.d(TAG, "onPageScrollStateChanged() with SCROLL_STATE_DRAGGING"); + mDuringSwipe = true; + mUserTabClick = false; + break; + } + case ViewPager.SCROLL_STATE_SETTLING: { + if (DEBUG) Log.d(TAG, "onPageScrollStateChanged() with SCROLL_STATE_SETTLING"); + mDuringSwipe = true; + mUserTabClick = false; + break; + } + default: + break; + } + } + } + + private String mFilterText; + + /** Enables horizontal swipe between Fragments. */ + private ViewPager mViewPager; + private final PageChangeListener mPageChangeListener = new PageChangeListener(); + private DialpadFragment mDialpadFragment; + private CallLogFragment mCallLogFragment; + private PhoneFavoriteFragment mPhoneFavoriteFragment; + + private View mSearchButton; + private View mMenuButton; + + private final ContactListFilterListener mContactListFilterListener = + new ContactListFilterListener() { + @Override + public void onContactListFilterChanged() { + boolean doInvalidateOptionsMenu = false; + + if (mPhoneFavoriteFragment != null && mPhoneFavoriteFragment.isAdded()) { + mPhoneFavoriteFragment.setFilter(mContactListFilterController.getFilter()); + doInvalidateOptionsMenu = true; + } + + if (mSearchFragment != null && mSearchFragment.isAdded()) { + mSearchFragment.setFilter(mContactListFilterController.getFilter()); + doInvalidateOptionsMenu = true; + } else { + Log.w(TAG, "Search Fragment isn't available when ContactListFilter is changed"); + } + + if (doInvalidateOptionsMenu) { + invalidateOptionsMenu(); + } + } + }; + + private final TabListener mTabListener = new TabListener() { + @Override + public void onTabUnselected(Tab tab, FragmentTransaction ft) { + if (DEBUG) Log.d(TAG, "onTabUnselected(). tab: " + tab); + } + + @Override + public void onTabSelected(Tab tab, FragmentTransaction ft) { + if (DEBUG) { + Log.d(TAG, "onTabSelected(). tab: " + tab + ", mDuringSwipe: " + mDuringSwipe); + } + // When the user swipes the screen horizontally, this method will be called after + // ViewPager.SCROLL_STATE_DRAGGING and ViewPager.SCROLL_STATE_SETTLING events, while + // when the user clicks a tab at the ActionBar at the top, this will be called before + // them. This logic interprets the order difference as a difference of the user action. + if (!mDuringSwipe) { + if (DEBUG) { + Log.d(TAG, "Tab select. from: " + mPageChangeListener.getCurrentPosition() + + ", to: " + tab.getPosition()); + } + if (mDialpadFragment != null) { + updateFakeMenuButtonsVisibility(tab.getPosition() == TAB_INDEX_DIALER); + } + mUserTabClick = true; + } + + if (mViewPager.getCurrentItem() != tab.getPosition()) { + mViewPager.setCurrentItem(tab.getPosition(), true); + } + + // During the call, we don't remember the tab position. + if (mDialpadFragment == null || !mDialpadFragment.phoneIsInUse()) { + // Remember this tab index. This function is also called, if the tab is set + // automatically in which case the setter (setCurrentTab) has to set this to its old + // value afterwards + mLastManuallySelectedFragment = tab.getPosition(); + } + } + + @Override + public void onTabReselected(Tab tab, FragmentTransaction ft) { + if (DEBUG) Log.d(TAG, "onTabReselected"); + } + }; + + /** + * Fragment for searching phone numbers. Unlike the other Fragments, this doesn't correspond + * to tab but is shown by a search action. + */ + private PhoneNumberPickerFragment mSearchFragment; + /** + * True when this Activity is in its search UI (with a {@link SearchView} and + * {@link PhoneNumberPickerFragment}). + */ + private boolean mInSearchUi; + private SearchView mSearchView; + + private final OnClickListener mFilterOptionClickListener = new OnClickListener() { + @Override + public void onClick(View view) { + final PopupMenu popupMenu = new PopupMenu(NewDialtactsActivity.this, view); + final Menu menu = popupMenu.getMenu(); + popupMenu.inflate(R.menu.dialtacts_search_options); + final MenuItem filterOptionMenuItem = menu.findItem(R.id.filter_option); + filterOptionMenuItem.setOnMenuItemClickListener(mFilterOptionsMenuItemClickListener); + final MenuItem addContactOptionMenuItem = menu.findItem(R.id.add_contact); + addContactOptionMenuItem.setIntent( + new Intent(Intent.ACTION_INSERT, Contacts.CONTENT_URI)); + popupMenu.show(); + } + }; + + /** + * The index of the Fragment (or, the tab) that has last been manually selected. + * This value does not keep track of programmatically set Tabs (e.g. Call Log after a Call) + */ + private int mLastManuallySelectedFragment; + + private ContactListFilterController mContactListFilterController; + private OnMenuItemClickListener mFilterOptionsMenuItemClickListener = + new OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + AccountFilterUtil.startAccountFilterActivityForResult( + NewDialtactsActivity.this, SUBACTIVITY_ACCOUNT_FILTER, + mContactListFilterController.getFilter()); + return true; + } + }; + + private OnMenuItemClickListener mSearchMenuItemClickListener = + new OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + enterSearchUi(); + return true; + } + }; + + /** + * Listener used when one of phone numbers in search UI is selected. This will initiate a + * phone call using the phone number. + */ + private final OnPhoneNumberPickerActionListener mPhoneNumberPickerActionListener = + new OnPhoneNumberPickerActionListener() { + @Override + public void onPickPhoneNumberAction(Uri dataUri) { + // Specify call-origin so that users will see the previous tab instead of + // CallLog screen (search UI will be automatically exited). + PhoneNumberInteraction.startInteractionForPhoneCall( + NewDialtactsActivity.this, dataUri, getCallOrigin()); + } + + @Override + public void onShortcutIntentCreated(Intent intent) { + Log.w(TAG, "Unsupported intent has come (" + intent + "). Ignoring."); + } + + @Override + public void onHomeInActionBarSelected() { + exitSearchUi(); + } + }; + + /** + * Listener used to send search queries to the phone search fragment. + */ + private final OnQueryTextListener mPhoneSearchQueryTextListener = + new OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + View view = getCurrentFocus(); + if (view != null) { + hideInputMethod(view); + view.clearFocus(); + } + return true; + } + + @Override + public boolean onQueryTextChange(String newText) { + // Show search result with non-empty text. Show a bare list otherwise. + if (mSearchFragment != null) { + mSearchFragment.setQueryString(newText, true); + } + return true; + } + }; + + /** + * Listener used to handle the "close" button on the right side of {@link SearchView}. + * If some text is in the search view, this will clean it up. Otherwise this will exit + * the search UI and let users go back to usual Phone UI. + * + * This does _not_ handle back button. + */ + private final OnCloseListener mPhoneSearchCloseListener = + new OnCloseListener() { + @Override + public boolean onClose() { + if (!TextUtils.isEmpty(mSearchView.getQuery())) { + mSearchView.setQuery(null, true); + } + return true; + } + }; + + private final View.OnLayoutChangeListener mFirstLayoutListener + = new View.OnLayoutChangeListener() { + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, + int oldTop, int oldRight, int oldBottom) { + v.removeOnLayoutChangeListener(this); // Unregister self. + addSearchFragment(); + } + }; + + @Override + protected void onCreate(Bundle icicle) { + super.onCreate(icicle); + + final Intent intent = getIntent(); + fixIntent(intent); + + setContentView(R.layout.dialtacts_activity); + + mContactListFilterController = ContactListFilterController.getInstance(this); + mContactListFilterController.addListener(mContactListFilterListener); + + findViewById(R.id.dialtacts_frame).addOnLayoutChangeListener(mFirstLayoutListener); + + mViewPager = (ViewPager) findViewById(R.id.pager); + mViewPager.setAdapter(new ViewPagerAdapter(getFragmentManager())); + mViewPager.setOnPageChangeListener(mPageChangeListener); + mViewPager.setOffscreenPageLimit(2); + + // Do same width calculation as ActionBar does + DisplayMetrics dm = getResources().getDisplayMetrics(); + int minCellSize = getResources().getDimensionPixelSize(R.dimen.fake_menu_button_min_width); + int cellCount = dm.widthPixels / minCellSize; + int fakeMenuItemWidth = dm.widthPixels / cellCount; + if (DEBUG) Log.d(TAG, "The size of fake menu buttons (in pixel): " + fakeMenuItemWidth); + + // Soft menu button should appear only when there's no hardware menu button. + mMenuButton = findViewById(R.id.overflow_menu); + if (mMenuButton != null) { + mMenuButton.setMinimumWidth(fakeMenuItemWidth); + if (ViewConfiguration.get(this).hasPermanentMenuKey()) { + // This is required for dialpad button's layout, so must not use GONE here. + mMenuButton.setVisibility(View.INVISIBLE); + } else { + mMenuButton.setOnClickListener(this); + } + } + mSearchButton = findViewById(R.id.searchButton); + if (mSearchButton != null) { + mSearchButton.setMinimumWidth(fakeMenuItemWidth); + mSearchButton.setOnClickListener(this); + } + + // Setup the ActionBar tabs (the order matches the tab-index contants TAB_INDEX_*) + setupDialer(); + setupCallLog(); + setupFavorites(); + getActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_TABS); + getActionBar().setDisplayShowTitleEnabled(false); + getActionBar().setDisplayShowHomeEnabled(false); + + // Load the last manually loaded tab + mPrefs = this.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE); + mLastManuallySelectedFragment = mPrefs.getInt(PREF_LAST_MANUALLY_SELECTED_TAB, + PREF_LAST_MANUALLY_SELECTED_TAB_DEFAULT); + if (mLastManuallySelectedFragment >= TAB_INDEX_COUNT) { + // Stored value may have exceeded the number of current tabs. Reset it. + mLastManuallySelectedFragment = PREF_LAST_MANUALLY_SELECTED_TAB_DEFAULT; + } + + setCurrentTab(intent); + + if (UI.FILTER_CONTACTS_ACTION.equals(intent.getAction()) + && icicle == null) { + setupFilterText(intent); + } + } + + @Override + public void onStart() { + super.onStart(); + if (mPhoneFavoriteFragment != null) { + mPhoneFavoriteFragment.setFilter(mContactListFilterController.getFilter()); + } + if (mSearchFragment != null) { + mSearchFragment.setFilter(mContactListFilterController.getFilter()); + } + + if (mDuringSwipe || mUserTabClick) { + if (DEBUG) Log.d(TAG, "reset buggy flag state.."); + mDuringSwipe = false; + mUserTabClick = false; + } + + final int currentPosition = mPageChangeListener.getCurrentPosition(); + if (DEBUG) { + Log.d(TAG, "onStart(). current position: " + mPageChangeListener.getCurrentPosition() + + ". Reset all menu visibility state."); + } + updateFakeMenuButtonsVisibility(currentPosition == TAB_INDEX_DIALER && !mInSearchUi); + for (int i = 0; i < TAB_INDEX_COUNT; i++) { + sendFragmentVisibilityChange(i, i == currentPosition); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + mContactListFilterController.removeListener(mContactListFilterListener); + } + + @Override + public void onClick(View view) { + switch (view.getId()) { + case R.id.searchButton: { + enterSearchUi(); + break; + } + case R.id.overflow_menu: { + if (mDialpadFragment != null) { + PopupMenu popup = mDialpadFragment.constructPopupMenu(view); + if (popup != null) { + popup.show(); + } + } else { + Log.w(TAG, "DialpadFragment is null during onClick() event for " + view); + } + break; + } + default: { + Log.wtf(TAG, "Unexpected onClick event from " + view); + break; + } + } + } + + /** + * Add search fragment. Note this is called during onLayout, so there's some restrictions, + * such as executePendingTransaction can't be used in it. + */ + private void addSearchFragment() { + // In order to take full advantage of "fragment deferred start", we need to create the + // search fragment after all other fragments are created. + // The other fragments are created by the ViewPager on the first onMeasure(). + // We use the first onLayout call, which is after onMeasure(). + + // Just return if the fragment is already created, which happens after configuration + // changes. + if (mSearchFragment != null) return; + + final FragmentTransaction ft = getFragmentManager().beginTransaction(); + final Fragment searchFragment = new PhoneNumberPickerFragment(); + + searchFragment.setUserVisibleHint(false); + ft.add(R.id.dialtacts_frame, searchFragment); + ft.hide(searchFragment); + ft.commitAllowingStateLoss(); + } + + private void prepareSearchView() { + final View searchViewLayout = + getLayoutInflater().inflate(R.layout.dialtacts_custom_action_bar, null); + mSearchView = (SearchView) searchViewLayout.findViewById(R.id.search_view); + mSearchView.setOnQueryTextListener(mPhoneSearchQueryTextListener); + mSearchView.setOnCloseListener(mPhoneSearchCloseListener); + // Since we're using a custom layout for showing SearchView instead of letting the + // search menu icon do that job, we need to manually configure the View so it looks + // "shown via search menu". + // - it should be iconified by default + // - it should not be iconified at this time + // See also comments for onActionViewExpanded()/onActionViewCollapsed() + mSearchView.setIconifiedByDefault(true); + mSearchView.setQueryHint(getString(R.string.hint_findContacts)); + mSearchView.setIconified(false); + mSearchView.setOnQueryTextFocusChangeListener(new OnFocusChangeListener() { + @Override + public void onFocusChange(View view, boolean hasFocus) { + if (hasFocus) { + showInputMethod(view.findFocus()); + } + } + }); + + if (!ViewConfiguration.get(this).hasPermanentMenuKey()) { + // Filter option menu should be shown on the right side of SearchView. + final View filterOptionView = searchViewLayout.findViewById(R.id.search_option); + filterOptionView.setVisibility(View.VISIBLE); + filterOptionView.setOnClickListener(mFilterOptionClickListener); + } + + getActionBar().setCustomView(searchViewLayout, + new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)); + } + + @Override + public void onAttachFragment(Fragment fragment) { + // This method can be called before onCreate(), at which point we cannot rely on ViewPager. + // In that case, we will setup the "current position" soon after the ViewPager is ready. + final int currentPosition = mViewPager != null ? mViewPager.getCurrentItem() : -1; + + if (fragment instanceof DialpadFragment) { + mDialpadFragment = (DialpadFragment) fragment; + } else if (fragment instanceof CallLogFragment) { + mCallLogFragment = (CallLogFragment) fragment; + } else if (fragment instanceof PhoneFavoriteFragment) { + mPhoneFavoriteFragment = (PhoneFavoriteFragment) fragment; + mPhoneFavoriteFragment.setListener(mPhoneFavoriteListener); + if (mContactListFilterController != null + && mContactListFilterController.getFilter() != null) { + mPhoneFavoriteFragment.setFilter(mContactListFilterController.getFilter()); + } + } else if (fragment instanceof PhoneNumberPickerFragment) { + mSearchFragment = (PhoneNumberPickerFragment) fragment; + mSearchFragment.setOnPhoneNumberPickerActionListener(mPhoneNumberPickerActionListener); + mSearchFragment.setQuickContactEnabled(true); + mSearchFragment.setDarkTheme(true); + mSearchFragment.setPhotoPosition(ContactListItemView.getDefaultPhotoPosition( + true /* opposite */)); + mSearchFragment.setUseCallableUri(true); + if (mContactListFilterController != null + && mContactListFilterController.getFilter() != null) { + mSearchFragment.setFilter(mContactListFilterController.getFilter()); + } + // Here we assume that we're not on the search mode, so let's hide the fragment. + // + // We get here either when the fragment is created (normal case), or after configuration + // changes. In the former case, we're not in search mode because we can only + // enter search mode if the fragment is created. (see enterSearchUi()) + // In the latter case we're not in search mode either because we don't retain + // mInSearchUi -- ideally we should but at this point it's not supported. + mSearchFragment.setUserVisibleHint(false); + // After configuration changes fragments will forget their "hidden" state, so make + // sure to hide it. + if (!mSearchFragment.isHidden()) { + final FragmentTransaction transaction = getFragmentManager().beginTransaction(); + transaction.hide(mSearchFragment); + transaction.commitAllowingStateLoss(); + } + } + } + + @Override + protected void onPause() { + super.onPause(); + + mPrefs.edit().putInt(PREF_LAST_MANUALLY_SELECTED_TAB, mLastManuallySelectedFragment) + .apply(); + requestBackup(); + } + + private void requestBackup() { + final BackupManager bm = new BackupManager(this); + bm.dataChanged(); + } + + private void fixIntent(Intent intent) { + // This should be cleaned up: the call key used to send an Intent + // that just said to go to the recent calls list. It now sends this + // abstract action, but this class hasn't been rewritten to deal with it. + if (Intent.ACTION_CALL_BUTTON.equals(intent.getAction())) { + intent.setDataAndType(Calls.CONTENT_URI, Calls.CONTENT_TYPE); + intent.putExtra("call_key", true); + setIntent(intent); + } + } + + private void setupDialer() { + final Tab tab = getActionBar().newTab(); + tab.setContentDescription(R.string.dialerIconLabel); + tab.setTabListener(mTabListener); + tab.setIcon(R.drawable.ic_tab_dialer); + getActionBar().addTab(tab); + } + + private void setupCallLog() { + final Tab tab = getActionBar().newTab(); + tab.setContentDescription(R.string.recentCallsIconLabel); + tab.setIcon(R.drawable.ic_tab_recent); + tab.setTabListener(mTabListener); + getActionBar().addTab(tab); + } + + private void setupFavorites() { + final Tab tab = getActionBar().newTab(); + tab.setContentDescription(R.string.contactsFavoritesLabel); + tab.setIcon(R.drawable.ic_tab_all); + tab.setTabListener(mTabListener); + getActionBar().addTab(tab); + } + + /** + * Returns true if the intent is due to hitting the green send key (hardware call button: + * KEYCODE_CALL) while in a call. + * + * @param intent the intent that launched this activity + * @param recentCallsRequest true if the intent is requesting to view recent calls + * @return true if the intent is due to hitting the green send key while in a call + */ + private boolean isSendKeyWhileInCall(final Intent intent, + final boolean recentCallsRequest) { + // If there is a call in progress go to the call screen + if (recentCallsRequest) { + final boolean callKey = intent.getBooleanExtra("call_key", false); + + try { + ITelephony phone = ITelephony.Stub.asInterface(ServiceManager.checkService("phone")); + if (callKey && phone != null && phone.showCallScreen()) { + return true; + } + } catch (RemoteException e) { + Log.e(TAG, "Failed to handle send while in call", e); + } + } + + return false; + } + + /** + * Sets the current tab based on the intent's request type + * + * @param intent Intent that contains information about which tab should be selected + */ + private void setCurrentTab(Intent intent) { + // If we got here by hitting send and we're in call forward along to the in-call activity + boolean recentCallsRequest = Calls.CONTENT_TYPE.equals(intent.resolveType( + getContentResolver())); + if (isSendKeyWhileInCall(intent, recentCallsRequest)) { + finish(); + return; + } + + // Remember the old manually selected tab index so that it can be restored if it is + // overwritten by one of the programmatic tab selections + final int savedTabIndex = mLastManuallySelectedFragment; + + final int tabIndex; + if ((mDialpadFragment != null && mDialpadFragment.phoneIsInUse()) + || isDialIntent(intent)) { + tabIndex = TAB_INDEX_DIALER; + } else if (recentCallsRequest) { + tabIndex = TAB_INDEX_CALL_LOG; + } else { + tabIndex = mLastManuallySelectedFragment; + } + + final int previousItemIndex = mViewPager.getCurrentItem(); + mViewPager.setCurrentItem(tabIndex, false /* smoothScroll */); + if (previousItemIndex != tabIndex) { + sendFragmentVisibilityChange(previousItemIndex, false /* not visible */ ); + } + mPageChangeListener.setCurrentPosition(tabIndex); + sendFragmentVisibilityChange(tabIndex, true /* visible */ ); + + // Restore to the previous manual selection + mLastManuallySelectedFragment = savedTabIndex; + mDuringSwipe = false; + mUserTabClick = false; + } + + @Override + public void onNewIntent(Intent newIntent) { + setIntent(newIntent); + fixIntent(newIntent); + setCurrentTab(newIntent); + final String action = newIntent.getAction(); + if (UI.FILTER_CONTACTS_ACTION.equals(action)) { + setupFilterText(newIntent); + } + if (mInSearchUi || (mSearchFragment != null && mSearchFragment.isVisible())) { + exitSearchUi(); + } + + if (mViewPager.getCurrentItem() == TAB_INDEX_DIALER) { + if (mDialpadFragment != null) { + mDialpadFragment.setStartedFromNewIntent(true); + } else { + Log.e(TAG, "DialpadFragment isn't ready yet when the tab is already selected."); + } + } else if (mViewPager.getCurrentItem() == TAB_INDEX_CALL_LOG) { + if (mCallLogFragment != null) { + mCallLogFragment.configureScreenFromIntent(newIntent); + } else { + Log.e(TAG, "CallLogFragment isn't ready yet when the tab is already selected."); + } + } + invalidateOptionsMenu(); + } + + /** Returns true if the given intent contains a phone number to populate the dialer with */ + private boolean isDialIntent(Intent intent) { + final String action = intent.getAction(); + if (Intent.ACTION_DIAL.equals(action) || ACTION_TOUCH_DIALER.equals(action)) { + return true; + } + if (Intent.ACTION_VIEW.equals(action)) { + final Uri data = intent.getData(); + if (data != null && CallUtil.SCHEME_TEL.equals(data.getScheme())) { + return true; + } + } + return false; + } + + /** + * Returns an appropriate call origin for this Activity. May return null when no call origin + * should be used (e.g. when some 3rd party application launched the screen. Call origin is + * for remembering the tab in which the user made a phone call, so the external app's DIAL + * request should not be counted.) + */ + public String getCallOrigin() { + return !isDialIntent(getIntent()) ? CALL_ORIGIN_DIALTACTS : null; + } + + /** + * Retrieves the filter text stored in {@link #setupFilterText(Intent)}. + * This text originally came from a FILTER_CONTACTS_ACTION intent received + * by this activity. The stored text will then be cleared after after this + * method returns. + * + * @return The stored filter text + */ + public String getAndClearFilterText() { + String filterText = mFilterText; + mFilterText = null; + return filterText; + } + + /** + * Stores the filter text associated with a FILTER_CONTACTS_ACTION intent. + * This is so child activities can check if they are supposed to display a filter. + * + * @param intent The intent received in {@link #onNewIntent(Intent)} + */ + private void setupFilterText(Intent intent) { + // If the intent was relaunched from history, don't apply the filter text. + if ((intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) { + return; + } + String filter = intent.getStringExtra(UI.FILTER_TEXT_EXTRA_KEY); + if (filter != null && filter.length() > 0) { + mFilterText = filter; + } + } + + @Override + public void onBackPressed() { + if (mInSearchUi) { + // We should let the user go back to usual screens with tabs. + exitSearchUi(); + } else if (isTaskRoot()) { + // Instead of stopping, simply push this to the back of the stack. + // This is only done when running at the top of the stack; + // otherwise, we have been launched by someone else so need to + // allow the user to go back to the caller. + moveTaskToBack(false); + } else { + super.onBackPressed(); + } + } + + private final PhoneFavoriteFragment.Listener mPhoneFavoriteListener = + new PhoneFavoriteFragment.Listener() { + @Override + public void onContactSelected(Uri contactUri) { + PhoneNumberInteraction.startInteractionForPhoneCall( + NewDialtactsActivity.this, contactUri, getCallOrigin()); + } + + @Override + public void onCallNumberDirectly(String phoneNumber) { + Intent intent = CallUtil.getCallIntent(phoneNumber, getCallOrigin()); + startActivity(intent); + } + }; + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.dialtacts_options, menu); + + // set up intents and onClick listeners + final MenuItem callSettingsMenuItem = menu.findItem(R.id.menu_call_settings); + final MenuItem searchMenuItem = menu.findItem(R.id.search_on_action_bar); + final MenuItem filterOptionMenuItem = menu.findItem(R.id.filter_option); + + callSettingsMenuItem.setIntent(DialtactsActivity.getCallSettingsIntent()); + searchMenuItem.setOnMenuItemClickListener(mSearchMenuItemClickListener); + filterOptionMenuItem.setOnMenuItemClickListener(mFilterOptionsMenuItemClickListener); + + return true; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + if (mInSearchUi) { + prepareOptionsMenuInSearchMode(menu); + } else { + // get reference to the currently selected tab + final Tab tab = getActionBar().getSelectedTab(); + if (tab != null) { + switch(tab.getPosition()) { + case TAB_INDEX_DIALER: + prepareOptionsMenuForDialerTab(menu); + break; + case TAB_INDEX_CALL_LOG: + prepareOptionsMenuForCallLogTab(menu); + break; + case TAB_INDEX_FAVORITES: + prepareOptionsMenuForFavoritesTab(menu); + break; + } + } + } + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.add_contact: + try { + startActivity(new Intent(Intent.ACTION_INSERT, Contacts.CONTENT_URI)); + } catch (ActivityNotFoundException e) { + Toast toast = Toast.makeText(this, R.string.add_contact_not_available, + Toast.LENGTH_SHORT); + toast.show(); + } + return true; + } + return super.onOptionsItemSelected(item); + } + + private void prepareOptionsMenuInSearchMode(Menu menu) { + // get references to menu items + final MenuItem searchMenuItem = menu.findItem(R.id.search_on_action_bar); + final MenuItem filterOptionMenuItem = menu.findItem(R.id.filter_option); + final MenuItem addContactOptionMenuItem = menu.findItem(R.id.add_contact); + final MenuItem callSettingsMenuItem = menu.findItem(R.id.menu_call_settings); + final MenuItem emptyRightMenuItem = menu.findItem(R.id.empty_right_menu_item); + + // prepare the menu items + searchMenuItem.setVisible(false); + filterOptionMenuItem.setVisible(ViewConfiguration.get(this).hasPermanentMenuKey()); + addContactOptionMenuItem.setVisible(false); + callSettingsMenuItem.setVisible(false); + emptyRightMenuItem.setVisible(false); + } + + private void prepareOptionsMenuForDialerTab(Menu menu) { + if (DEBUG) { + Log.d(TAG, "onPrepareOptionsMenu(dialer). swipe: " + mDuringSwipe + + ", user tab click: " + mUserTabClick); + } + + // get references to menu items + final MenuItem searchMenuItem = menu.findItem(R.id.search_on_action_bar); + final MenuItem filterOptionMenuItem = menu.findItem(R.id.filter_option); + final MenuItem addContactOptionMenuItem = menu.findItem(R.id.add_contact); + final MenuItem callSettingsMenuItem = menu.findItem(R.id.menu_call_settings); + final MenuItem emptyRightMenuItem = menu.findItem(R.id.empty_right_menu_item); + + // prepare the menu items + filterOptionMenuItem.setVisible(false); + addContactOptionMenuItem.setVisible(false); + if (mDuringSwipe || mUserTabClick) { + // During horizontal movement, the real ActionBar menu items are shown + searchMenuItem.setVisible(true); + callSettingsMenuItem.setVisible(true); + // When there is a permanent menu key, there is no overflow icon on the right of + // the action bar which would force the search menu item (if it is visible) to the + // left. This is the purpose of showing the emptyRightMenuItem. + emptyRightMenuItem.setVisible(ViewConfiguration.get(this).hasPermanentMenuKey()); + } else { + // This is when the user is looking at the dialer pad. In this case, the real + // ActionBar is hidden and fake menu items are shown. + // Except in landscape, in which case the real search menu item is shown. + searchMenuItem.setVisible(OrientationUtil.isLandscape(this)); + // If a permanent menu key is available, then we need to show the call settings item + // so that the call settings item can be invoked by the permanent menu key. + callSettingsMenuItem.setVisible(ViewConfiguration.get(this).hasPermanentMenuKey()); + emptyRightMenuItem.setVisible(false); + } + } + + private void prepareOptionsMenuForCallLogTab(Menu menu) { + // get references to menu items + final MenuItem searchMenuItem = menu.findItem(R.id.search_on_action_bar); + final MenuItem filterOptionMenuItem = menu.findItem(R.id.filter_option); + final MenuItem addContactOptionMenuItem = menu.findItem(R.id.add_contact); + final MenuItem callSettingsMenuItem = menu.findItem(R.id.menu_call_settings); + final MenuItem emptyRightMenuItem = menu.findItem(R.id.empty_right_menu_item); + + // prepare the menu items + searchMenuItem.setVisible(true); + filterOptionMenuItem.setVisible(false); + addContactOptionMenuItem.setVisible(false); + callSettingsMenuItem.setVisible(true); + emptyRightMenuItem.setVisible(ViewConfiguration.get(this).hasPermanentMenuKey()); + } + + private void prepareOptionsMenuForFavoritesTab(Menu menu) { + // get references to menu items + final MenuItem searchMenuItem = menu.findItem(R.id.search_on_action_bar); + final MenuItem filterOptionMenuItem = menu.findItem(R.id.filter_option); + final MenuItem addContactOptionMenuItem = menu.findItem(R.id.add_contact); + final MenuItem callSettingsMenuItem = menu.findItem(R.id.menu_call_settings); + final MenuItem emptyRightMenuItem = menu.findItem(R.id.empty_right_menu_item); + + // prepare the menu items + searchMenuItem.setVisible(true); + filterOptionMenuItem.setVisible(true); + addContactOptionMenuItem.setVisible(true); + callSettingsMenuItem.setVisible(true); + emptyRightMenuItem.setVisible(false); + } + + @Override + public void startSearch(String initialQuery, boolean selectInitialQuery, + Bundle appSearchData, boolean globalSearch) { + if (mSearchFragment != null && mSearchFragment.isAdded() && !globalSearch) { + if (mInSearchUi) { + if (mSearchView.hasFocus()) { + showInputMethod(mSearchView.findFocus()); + } else { + mSearchView.requestFocus(); + } + } else { + enterSearchUi(); + } + } else { + super.startSearch(initialQuery, selectInitialQuery, appSearchData, globalSearch); + } + } + + /** + * Hides every tab and shows search UI for phone lookup. + */ + private void enterSearchUi() { + if (mSearchFragment == null) { + // We add the search fragment dynamically in the first onLayoutChange() and + // mSearchFragment is set sometime later when the fragment transaction is actually + // executed, which means there's a window when users are able to hit the (physical) + // search key but mSearchFragment is still null. + // It's quite hard to handle this case right, so let's just ignore the search key + // in this case. Users can just hit it again and it will work this time. + return; + } + if (mSearchView == null) { + prepareSearchView(); + } + + final ActionBar actionBar = getActionBar(); + + final Tab tab = actionBar.getSelectedTab(); + + // User can search during the call, but we don't want to remember the status. + if (tab != null && (mDialpadFragment == null || + !mDialpadFragment.phoneIsInUse())) { + mLastManuallySelectedFragment = tab.getPosition(); + } + + mSearchView.setQuery(null, true); + + actionBar.setDisplayShowCustomEnabled(true); + actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); + actionBar.setDisplayShowHomeEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(true); + + updateFakeMenuButtonsVisibility(false); + + for (int i = 0; i < TAB_INDEX_COUNT; i++) { + sendFragmentVisibilityChange(i, false /* not visible */ ); + } + + // Show the search fragment and hide everything else. + mSearchFragment.setUserVisibleHint(true); + final FragmentTransaction transaction = getFragmentManager().beginTransaction(); + transaction.show(mSearchFragment); + transaction.commitAllowingStateLoss(); + mViewPager.setVisibility(View.GONE); + + // We need to call this and onActionViewCollapsed() manually, since we are using a custom + // layout instead of asking the search menu item to take care of SearchView. + mSearchView.onActionViewExpanded(); + mInSearchUi = true; + } + + private void showInputMethod(View view) { + InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + if (!imm.showSoftInput(view, 0)) { + Log.w(TAG, "Failed to show soft input method."); + } + } + } + + private void hideInputMethod(View view) { + InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null && view != null) { + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + } + + /** + * Goes back to usual Phone UI with tags. Previously selected Tag and associated Fragment + * should be automatically focused again. + */ + private void exitSearchUi() { + final ActionBar actionBar = getActionBar(); + + // Hide the search fragment, if exists. + if (mSearchFragment != null) { + mSearchFragment.setUserVisibleHint(false); + + final FragmentTransaction transaction = getFragmentManager().beginTransaction(); + transaction.hide(mSearchFragment); + transaction.commitAllowingStateLoss(); + } + + // We want to hide SearchView and show Tabs. Also focus on previously selected one. + actionBar.setDisplayShowCustomEnabled(false); + actionBar.setDisplayShowHomeEnabled(false); + actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS); + + for (int i = 0; i < TAB_INDEX_COUNT; i++) { + sendFragmentVisibilityChange(i, i == mViewPager.getCurrentItem()); + } + + // Before exiting the search screen, reset swipe state. + mDuringSwipe = false; + mUserTabClick = false; + + mViewPager.setVisibility(View.VISIBLE); + + hideInputMethod(getCurrentFocus()); + + // Request to update option menu. + invalidateOptionsMenu(); + + // See comments in onActionViewExpanded() + mSearchView.onActionViewCollapsed(); + mInSearchUi = false; + } + + private Fragment getFragmentAt(int position) { + switch (position) { + case TAB_INDEX_DIALER: + return mDialpadFragment; + case TAB_INDEX_CALL_LOG: + return mCallLogFragment; + case TAB_INDEX_FAVORITES: + return mPhoneFavoriteFragment; + default: + throw new IllegalStateException("Unknown fragment index: " + position); + } + } + + private void sendFragmentVisibilityChange(int position, boolean visibility) { + if (DEBUG) { + Log.d(TAG, "sendFragmentVisibiltyChange(). position: " + position + + ", visibility: " + visibility); + } + // Position can be -1 initially. See PageChangeListener. + if (position >= 0) { + final Fragment fragment = getFragmentAt(position); + if (fragment != null) { + fragment.setMenuVisibility(visibility); + fragment.setUserVisibleHint(visibility); + } + } + } + + /** + * Update visibility of the search button and menu button at the bottom. + * They should be invisible when bottom ActionBar's real items are available, and be visible + * otherwise. + * + * @param visible True when visible. + */ + private void updateFakeMenuButtonsVisibility(boolean visible) { + // Note: Landscape mode does not have the fake menu and search buttons. + if (DEBUG) { + Log.d(TAG, "updateFakeMenuButtonVisibility(" + visible + ")"); + } + + if (mSearchButton != null) { + if (visible) { + mSearchButton.setVisibility(View.VISIBLE); + } else { + mSearchButton.setVisibility(View.INVISIBLE); + } + } + if (mMenuButton != null) { + if (visible && !ViewConfiguration.get(this).hasPermanentMenuKey()) { + mMenuButton.setVisibility(View.VISIBLE); + } else { + mMenuButton.setVisibility(View.INVISIBLE); + } + } + } + + /** Returns an Intent to launch Call Settings screen */ + public static Intent getCallSettingsIntent() { + final Intent intent = new Intent(Intent.ACTION_MAIN); + intent.setClassName(PHONE_PACKAGE, CALL_SETTINGS_CLASS_NAME); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + return intent; + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (resultCode != Activity.RESULT_OK) { + return; + } + switch (requestCode) { + case SUBACTIVITY_ACCOUNT_FILTER: { + AccountFilterUtil.handleAccountFilterResult( + mContactListFilterController, resultCode, data); + } + break; + } + } +} diff --git a/src/com/android/dialer/calllog/NewCallLogAdapter.java b/src/com/android/dialer/calllog/NewCallLogAdapter.java new file mode 100644 index 000000000..079919f59 --- /dev/null +++ b/src/com/android/dialer/calllog/NewCallLogAdapter.java @@ -0,0 +1,781 @@ +/* + * Copyright (C) 2011 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.dialer.calllog; + +import android.content.ContentValues; +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.os.Handler; +import android.os.Message; +import android.provider.CallLog.Calls; +import android.provider.ContactsContract.PhoneLookup; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; + +import com.android.common.widget.GroupingListAdapter; +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.util.UriUtils; +import com.android.dialer.PhoneCallDetails; +import com.android.dialer.PhoneCallDetailsHelper; +import com.android.dialer.R; +import com.android.dialer.util.ExpirableCache; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Objects; + +import java.util.LinkedList; + +/** + * Adapter class to fill in data for the Call Log. + */ +/*package*/ class NewCallLogAdapter extends GroupingListAdapter + implements ViewTreeObserver.OnPreDrawListener, CallLogGroupBuilder.GroupCreator { + /** Interface used to initiate a refresh of the content. */ + public interface CallFetcher { + public void fetchCalls(); + } + + /** + * Stores a phone number of a call with the country code where it originally occurred. + * <p> + * Note the country does not necessarily specifies the country of the phone number itself, but + * it is the country in which the user was in when the call was placed or received. + */ + private static final class NumberWithCountryIso { + public final String number; + public final String countryIso; + + public NumberWithCountryIso(String number, String countryIso) { + this.number = number; + this.countryIso = countryIso; + } + + @Override + public boolean equals(Object o) { + if (o == null) return false; + if (!(o instanceof NumberWithCountryIso)) return false; + NumberWithCountryIso other = (NumberWithCountryIso) o; + return TextUtils.equals(number, other.number) + && TextUtils.equals(countryIso, other.countryIso); + } + + @Override + public int hashCode() { + return (number == null ? 0 : number.hashCode()) + ^ (countryIso == null ? 0 : countryIso.hashCode()); + } + } + + /** The time in millis to delay starting the thread processing requests. */ + private static final int START_PROCESSING_REQUESTS_DELAY_MILLIS = 1000; + + /** The size of the cache of contact info. */ + private static final int CONTACT_INFO_CACHE_SIZE = 100; + + private final Context mContext; + private final ContactInfoHelper mContactInfoHelper; + private final CallFetcher mCallFetcher; + private ViewTreeObserver mViewTreeObserver = null; + + /** + * A cache of the contact details for the phone numbers in the call log. + * <p> + * The content of the cache is expired (but not purged) whenever the application comes to + * the foreground. + * <p> + * The key is number with the country in which the call was placed or received. + */ + private ExpirableCache<NumberWithCountryIso, ContactInfo> mContactInfoCache; + + /** + * A request for contact details for the given number. + */ + private static final class ContactInfoRequest { + /** The number to look-up. */ + public final String number; + /** The country in which a call to or from this number was placed or received. */ + public final String countryIso; + /** The cached contact information stored in the call log. */ + public final ContactInfo callLogInfo; + + public ContactInfoRequest(String number, String countryIso, ContactInfo callLogInfo) { + this.number = number; + this.countryIso = countryIso; + this.callLogInfo = callLogInfo; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (!(obj instanceof ContactInfoRequest)) return false; + + ContactInfoRequest other = (ContactInfoRequest) obj; + + if (!TextUtils.equals(number, other.number)) return false; + if (!TextUtils.equals(countryIso, other.countryIso)) return false; + if (!Objects.equal(callLogInfo, other.callLogInfo)) return false; + + return true; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((callLogInfo == null) ? 0 : callLogInfo.hashCode()); + result = prime * result + ((countryIso == null) ? 0 : countryIso.hashCode()); + result = prime * result + ((number == null) ? 0 : number.hashCode()); + return result; + } + } + + /** + * List of requests to update contact details. + * <p> + * Each request is made of a phone number to look up, and the contact info currently stored in + * the call log for this number. + * <p> + * The requests are added when displaying the contacts and are processed by a background + * thread. + */ + private final LinkedList<ContactInfoRequest> mRequests; + + private boolean mLoading = true; + private static final int REDRAW = 1; + private static final int START_THREAD = 2; + + private QueryThread mCallerIdThread; + + /** Instance of helper class for managing views. */ + private final CallLogListItemHelper mCallLogViewsHelper; + + /** Helper to set up contact photos. */ + private final ContactPhotoManager mContactPhotoManager; + /** Helper to parse and process phone numbers. */ + private PhoneNumberHelper mPhoneNumberHelper; + /** Helper to group call log entries. */ + private final CallLogGroupBuilder mCallLogGroupBuilder; + + /** Can be set to true by tests to disable processing of requests. */ + private volatile boolean mRequestProcessingDisabled = false; + + /** Listener for the primary action in the list, opens the call details. */ + private final View.OnClickListener mPrimaryActionListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + IntentProvider intentProvider = (IntentProvider) view.getTag(); + if (intentProvider != null) { + mContext.startActivity(intentProvider.getIntent(mContext)); + } + } + }; + /** Listener for the secondary action in the list, either call or play. */ + private final View.OnClickListener mSecondaryActionListener = new View.OnClickListener() { + @Override + public void onClick(View view) { + IntentProvider intentProvider = (IntentProvider) view.getTag(); + if (intentProvider != null) { + mContext.startActivity(intentProvider.getIntent(mContext)); + } + } + }; + + @Override + public boolean onPreDraw() { + // We only wanted to listen for the first draw (and this is it). + unregisterPreDrawListener(); + + // Only schedule a thread-creation message if the thread hasn't been + // created yet. This is purely an optimization, to queue fewer messages. + if (mCallerIdThread == null) { + mHandler.sendEmptyMessageDelayed(START_THREAD, START_PROCESSING_REQUESTS_DELAY_MILLIS); + } + + return true; + } + + private Handler mHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case REDRAW: + notifyDataSetChanged(); + break; + case START_THREAD: + startRequestProcessing(); + break; + } + } + }; + + NewCallLogAdapter(Context context, CallFetcher callFetcher, + ContactInfoHelper contactInfoHelper) { + super(context); + + mContext = context; + mCallFetcher = callFetcher; + mContactInfoHelper = contactInfoHelper; + + mContactInfoCache = ExpirableCache.create(CONTACT_INFO_CACHE_SIZE); + mRequests = new LinkedList<ContactInfoRequest>(); + + Resources resources = mContext.getResources(); + CallTypeHelper callTypeHelper = new CallTypeHelper(resources); + + mContactPhotoManager = ContactPhotoManager.getInstance(mContext); + mPhoneNumberHelper = new PhoneNumberHelper(resources); + PhoneCallDetailsHelper phoneCallDetailsHelper = new PhoneCallDetailsHelper( + resources, callTypeHelper, mPhoneNumberHelper); + mCallLogViewsHelper = + new CallLogListItemHelper( + phoneCallDetailsHelper, mPhoneNumberHelper, resources); + mCallLogGroupBuilder = new CallLogGroupBuilder(this); + } + + /** + * Requery on background thread when {@link Cursor} changes. + */ + @Override + protected void onContentChanged() { + mCallFetcher.fetchCalls(); + } + + void setLoading(boolean loading) { + mLoading = loading; + } + + @Override + public boolean isEmpty() { + if (mLoading) { + // We don't want the empty state to show when loading. + return false; + } else { + return super.isEmpty(); + } + } + + /** + * Starts a background thread to process contact-lookup requests, unless one + * has already been started. + */ + private synchronized void startRequestProcessing() { + // For unit-testing. + if (mRequestProcessingDisabled) return; + + // Idempotence... if a thread is already started, don't start another. + if (mCallerIdThread != null) return; + + mCallerIdThread = new QueryThread(); + mCallerIdThread.setPriority(Thread.MIN_PRIORITY); + mCallerIdThread.start(); + } + + /** + * Stops the background thread that processes updates and cancels any + * pending requests to start it. + */ + public synchronized void stopRequestProcessing() { + // Remove any pending requests to start the processing thread. + mHandler.removeMessages(START_THREAD); + if (mCallerIdThread != null) { + // Stop the thread; we are finished with it. + mCallerIdThread.stopProcessing(); + mCallerIdThread.interrupt(); + mCallerIdThread = null; + } + } + + /** + * Stop receiving onPreDraw() notifications. + */ + private void unregisterPreDrawListener() { + if (mViewTreeObserver != null && mViewTreeObserver.isAlive()) { + mViewTreeObserver.removeOnPreDrawListener(this); + } + mViewTreeObserver = null; + } + + public void invalidateCache() { + mContactInfoCache.expireAll(); + + // Restart the request-processing thread after the next draw. + stopRequestProcessing(); + unregisterPreDrawListener(); + } + + /** + * Enqueues a request to look up the contact details for the given phone number. + * <p> + * It also provides the current contact info stored in the call log for this number. + * <p> + * If the {@code immediate} parameter is true, it will start immediately the thread that looks + * up the contact information (if it has not been already started). Otherwise, it will be + * started with a delay. See {@link #START_PROCESSING_REQUESTS_DELAY_MILLIS}. + */ + @VisibleForTesting + void enqueueRequest(String number, String countryIso, ContactInfo callLogInfo, + boolean immediate) { + ContactInfoRequest request = new ContactInfoRequest(number, countryIso, callLogInfo); + synchronized (mRequests) { + if (!mRequests.contains(request)) { + mRequests.add(request); + mRequests.notifyAll(); + } + } + if (immediate) startRequestProcessing(); + } + + /** + * Queries the appropriate content provider for the contact associated with the number. + * <p> + * Upon completion it also updates the cache in the call log, if it is different from + * {@code callLogInfo}. + * <p> + * The number might be either a SIP address or a phone number. + * <p> + * It returns true if it updated the content of the cache and we should therefore tell the + * view to update its content. + */ + private boolean queryContactInfo(String number, String countryIso, ContactInfo callLogInfo) { + final ContactInfo info = mContactInfoHelper.lookupNumber(number, countryIso); + + if (info == null) { + // The lookup failed, just return without requesting to update the view. + return false; + } + + // Check the existing entry in the cache: only if it has changed we should update the + // view. + NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); + ContactInfo existingInfo = mContactInfoCache.getPossiblyExpired(numberCountryIso); + boolean updated = (existingInfo != ContactInfo.EMPTY) && !info.equals(existingInfo); + + // Store the data in the cache so that the UI thread can use to display it. Store it + // even if it has not changed so that it is marked as not expired. + mContactInfoCache.put(numberCountryIso, info); + // Update the call log even if the cache it is up-to-date: it is possible that the cache + // contains the value from a different call log entry. + updateCallLogContactInfoCache(number, countryIso, info, callLogInfo); + return updated; + } + + /* + * Handles requests for contact name and number type. + */ + private class QueryThread extends Thread { + private volatile boolean mDone = false; + + public QueryThread() { + super("CallLogAdapter.QueryThread"); + } + + public void stopProcessing() { + mDone = true; + } + + @Override + public void run() { + boolean needRedraw = false; + while (true) { + // Check if thread is finished, and if so return immediately. + if (mDone) return; + + // Obtain next request, if any is available. + // Keep synchronized section small. + ContactInfoRequest req = null; + synchronized (mRequests) { + if (!mRequests.isEmpty()) { + req = mRequests.removeFirst(); + } + } + + if (req != null) { + // Process the request. If the lookup succeeds, schedule a + // redraw. + needRedraw |= queryContactInfo(req.number, req.countryIso, req.callLogInfo); + } else { + // Throttle redraw rate by only sending them when there are + // more requests. + if (needRedraw) { + needRedraw = false; + mHandler.sendEmptyMessage(REDRAW); + } + + // Wait until another request is available, or until this + // thread is no longer needed (as indicated by being + // interrupted). + try { + synchronized (mRequests) { + mRequests.wait(1000); + } + } catch (InterruptedException ie) { + // Ignore, and attempt to continue processing requests. + } + } + } + } + } + + @Override + protected void addGroups(Cursor cursor) { + mCallLogGroupBuilder.addGroups(cursor); + } + + @Override + protected View newStandAloneView(Context context, ViewGroup parent) { + LayoutInflater inflater = + (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(R.layout.call_log_list_item, parent, false); + findAndCacheViews(view); + return view; + } + + @Override + protected void bindStandAloneView(View view, Context context, Cursor cursor) { + bindView(view, cursor, 1); + } + + @Override + protected View newChildView(Context context, ViewGroup parent) { + LayoutInflater inflater = + (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(R.layout.call_log_list_item, parent, false); + findAndCacheViews(view); + return view; + } + + @Override + protected void bindChildView(View view, Context context, Cursor cursor) { + bindView(view, cursor, 1); + } + + @Override + protected View newGroupView(Context context, ViewGroup parent) { + LayoutInflater inflater = + (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(R.layout.call_log_list_item, parent, false); + findAndCacheViews(view); + return view; + } + + @Override + protected void bindGroupView(View view, Context context, Cursor cursor, int groupSize, + boolean expanded) { + bindView(view, cursor, groupSize); + } + + private void findAndCacheViews(View view) { + // Get the views to bind to. + CallLogListItemViews views = CallLogListItemViews.fromView(view); + views.primaryActionView.setOnClickListener(mPrimaryActionListener); + views.secondaryActionView.setOnClickListener(mSecondaryActionListener); + view.setTag(views); + } + + /** + * Binds the views in the entry to the data in the call log. + * + * @param view the view corresponding to this entry + * @param c the cursor pointing to the entry in the call log + * @param count the number of entries in the current item, greater than 1 if it is a group + */ + private void bindView(View view, Cursor c, int count) { + final CallLogListItemViews views = (CallLogListItemViews) view.getTag(); + + // Default case: an item in the call log. + views.primaryActionView.setVisibility(View.VISIBLE); + views.bottomDivider.setVisibility(View.VISIBLE); + views.listHeaderTextView.setVisibility(View.GONE); + + final String number = c.getString(CallLogQuery.NUMBER); + final int numberPresentation = c.getInt(CallLogQuery.NUMBER_PRESENTATION); + final long date = c.getLong(CallLogQuery.DATE); + final long duration = c.getLong(CallLogQuery.DURATION); + final int callType = c.getInt(CallLogQuery.CALL_TYPE); + final String countryIso = c.getString(CallLogQuery.COUNTRY_ISO); + + final ContactInfo cachedContactInfo = getContactInfoFromCallLog(c); + + /*views.primaryActionView.setTag( + IntentProvider.getCallDetailIntentProvider( + this, c.getPosition(), c.getLong(CallLogQuery.ID), count));*/ + // Store away the voicemail information so we can play it directly. + if (callType == Calls.VOICEMAIL_TYPE) { + String voicemailUri = c.getString(CallLogQuery.VOICEMAIL_URI); + final long rowId = c.getLong(CallLogQuery.ID); + views.secondaryActionView.setTag( + IntentProvider.getPlayVoicemailIntentProvider(rowId, voicemailUri)); + } else if (!TextUtils.isEmpty(number)) { + // Store away the number so we can call it directly if you click on the call icon. + views.secondaryActionView.setTag( + IntentProvider.getReturnCallIntentProvider(number)); + } else { + // No action enabled. + views.secondaryActionView.setTag(null); + } + + // Lookup contacts with this number + NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); + ExpirableCache.CachedValue<ContactInfo> cachedInfo = + mContactInfoCache.getCachedValue(numberCountryIso); + ContactInfo info = cachedInfo == null ? null : cachedInfo.getValue(); + if (!PhoneNumberHelper.canPlaceCallsTo(number, numberPresentation) + || mPhoneNumberHelper.isVoicemailNumber(number)) { + // If this is a number that cannot be dialed, there is no point in looking up a contact + // for it. + info = ContactInfo.EMPTY; + } else if (cachedInfo == null) { + mContactInfoCache.put(numberCountryIso, ContactInfo.EMPTY); + // Use the cached contact info from the call log. + info = cachedContactInfo; + // The db request should happen on a non-UI thread. + // Request the contact details immediately since they are currently missing. + enqueueRequest(number, countryIso, cachedContactInfo, true); + // We will format the phone number when we make the background request. + } else { + if (cachedInfo.isExpired()) { + // The contact info is no longer up to date, we should request it. However, we + // do not need to request them immediately. + enqueueRequest(number, countryIso, cachedContactInfo, false); + } else if (!callLogInfoMatches(cachedContactInfo, info)) { + // The call log information does not match the one we have, look it up again. + // We could simply update the call log directly, but that needs to be done in a + // background thread, so it is easier to simply request a new lookup, which will, as + // a side-effect, update the call log. + enqueueRequest(number, countryIso, cachedContactInfo, false); + } + + if (info == ContactInfo.EMPTY) { + // Use the cached contact info from the call log. + info = cachedContactInfo; + } + } + + final Uri lookupUri = info.lookupUri; + final String name = info.name; + final int ntype = info.type; + final String label = info.label; + final long photoId = info.photoId; + CharSequence formattedNumber = info.formattedNumber; + final int[] callTypes = getCallTypes(c, count); + final String geocode = c.getString(CallLogQuery.GEOCODED_LOCATION); + final PhoneCallDetails details; + if (TextUtils.isEmpty(name)) { + details = new PhoneCallDetails(number, numberPresentation, + formattedNumber, countryIso, geocode, callTypes, date, + duration); + } else { + // We do not pass a photo id since we do not need the high-res picture. + details = new PhoneCallDetails(number, numberPresentation, + formattedNumber, countryIso, geocode, callTypes, date, + duration, name, ntype, label, lookupUri, null); + } + + final boolean isNew = c.getInt(CallLogQuery.IS_READ) == 0; + // New items also use the highlighted version of the text. + final boolean isHighlighted = isNew; + mCallLogViewsHelper.setPhoneCallDetails(views, details, isHighlighted); + setPhoto(views, photoId, lookupUri); + + // Listen for the first draw + if (mViewTreeObserver == null) { + mViewTreeObserver = view.getViewTreeObserver(); + mViewTreeObserver.addOnPreDrawListener(this); + } + } + + /** Checks whether the contact info from the call log matches the one from the contacts db. */ + private boolean callLogInfoMatches(ContactInfo callLogInfo, ContactInfo info) { + // The call log only contains a subset of the fields in the contacts db. + // Only check those. + return TextUtils.equals(callLogInfo.name, info.name) + && callLogInfo.type == info.type + && TextUtils.equals(callLogInfo.label, info.label); + } + + /** Stores the updated contact info in the call log if it is different from the current one. */ + private void updateCallLogContactInfoCache(String number, String countryIso, + ContactInfo updatedInfo, ContactInfo callLogInfo) { + final ContentValues values = new ContentValues(); + boolean needsUpdate = false; + + if (callLogInfo != null) { + if (!TextUtils.equals(updatedInfo.name, callLogInfo.name)) { + values.put(Calls.CACHED_NAME, updatedInfo.name); + needsUpdate = true; + } + + if (updatedInfo.type != callLogInfo.type) { + values.put(Calls.CACHED_NUMBER_TYPE, updatedInfo.type); + needsUpdate = true; + } + + if (!TextUtils.equals(updatedInfo.label, callLogInfo.label)) { + values.put(Calls.CACHED_NUMBER_LABEL, updatedInfo.label); + needsUpdate = true; + } + if (!UriUtils.areEqual(updatedInfo.lookupUri, callLogInfo.lookupUri)) { + values.put(Calls.CACHED_LOOKUP_URI, UriUtils.uriToString(updatedInfo.lookupUri)); + needsUpdate = true; + } + if (!TextUtils.equals(updatedInfo.normalizedNumber, callLogInfo.normalizedNumber)) { + values.put(Calls.CACHED_NORMALIZED_NUMBER, updatedInfo.normalizedNumber); + needsUpdate = true; + } + if (!TextUtils.equals(updatedInfo.number, callLogInfo.number)) { + values.put(Calls.CACHED_MATCHED_NUMBER, updatedInfo.number); + needsUpdate = true; + } + if (updatedInfo.photoId != callLogInfo.photoId) { + values.put(Calls.CACHED_PHOTO_ID, updatedInfo.photoId); + needsUpdate = true; + } + if (!TextUtils.equals(updatedInfo.formattedNumber, callLogInfo.formattedNumber)) { + values.put(Calls.CACHED_FORMATTED_NUMBER, updatedInfo.formattedNumber); + needsUpdate = true; + } + } else { + // No previous values, store all of them. + values.put(Calls.CACHED_NAME, updatedInfo.name); + values.put(Calls.CACHED_NUMBER_TYPE, updatedInfo.type); + values.put(Calls.CACHED_NUMBER_LABEL, updatedInfo.label); + values.put(Calls.CACHED_LOOKUP_URI, UriUtils.uriToString(updatedInfo.lookupUri)); + values.put(Calls.CACHED_MATCHED_NUMBER, updatedInfo.number); + values.put(Calls.CACHED_NORMALIZED_NUMBER, updatedInfo.normalizedNumber); + values.put(Calls.CACHED_PHOTO_ID, updatedInfo.photoId); + values.put(Calls.CACHED_FORMATTED_NUMBER, updatedInfo.formattedNumber); + needsUpdate = true; + } + + if (!needsUpdate) return; + + if (countryIso == null) { + mContext.getContentResolver().update(Calls.CONTENT_URI_WITH_VOICEMAIL, values, + Calls.NUMBER + " = ? AND " + Calls.COUNTRY_ISO + " IS NULL", + new String[]{ number }); + } else { + mContext.getContentResolver().update(Calls.CONTENT_URI_WITH_VOICEMAIL, values, + Calls.NUMBER + " = ? AND " + Calls.COUNTRY_ISO + " = ?", + new String[]{ number, countryIso }); + } + } + + /** Returns the contact information as stored in the call log. */ + private ContactInfo getContactInfoFromCallLog(Cursor c) { + ContactInfo info = new ContactInfo(); + info.lookupUri = UriUtils.parseUriOrNull(c.getString(CallLogQuery.CACHED_LOOKUP_URI)); + info.name = c.getString(CallLogQuery.CACHED_NAME); + info.type = c.getInt(CallLogQuery.CACHED_NUMBER_TYPE); + info.label = c.getString(CallLogQuery.CACHED_NUMBER_LABEL); + String matchedNumber = c.getString(CallLogQuery.CACHED_MATCHED_NUMBER); + info.number = matchedNumber == null ? c.getString(CallLogQuery.NUMBER) : matchedNumber; + info.normalizedNumber = c.getString(CallLogQuery.CACHED_NORMALIZED_NUMBER); + info.photoId = c.getLong(CallLogQuery.CACHED_PHOTO_ID); + info.photoUri = null; // We do not cache the photo URI. + info.formattedNumber = c.getString(CallLogQuery.CACHED_FORMATTED_NUMBER); + return info; + } + + /** + * Returns the call types for the given number of items in the cursor. + * <p> + * It uses the next {@code count} rows in the cursor to extract the types. + * <p> + * It position in the cursor is unchanged by this function. + */ + private int[] getCallTypes(Cursor cursor, int count) { + int position = cursor.getPosition(); + int[] callTypes = new int[count]; + for (int index = 0; index < count; ++index) { + callTypes[index] = cursor.getInt(CallLogQuery.CALL_TYPE); + cursor.moveToNext(); + } + cursor.moveToPosition(position); + return callTypes; + } + + private void setPhoto(CallLogListItemViews views, long photoId, Uri contactUri) { + views.quickContactView.assignContactUri(contactUri); + mContactPhotoManager.loadThumbnail(views.quickContactView, photoId, true); + } + + /** + * Sets whether processing of requests for contact details should be enabled. + * <p> + * This method should be called in tests to disable such processing of requests when not + * needed. + */ + @VisibleForTesting + void disableRequestProcessingForTest() { + mRequestProcessingDisabled = true; + } + + @VisibleForTesting + void injectContactInfoForTest(String number, String countryIso, ContactInfo contactInfo) { + NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); + mContactInfoCache.put(numberCountryIso, contactInfo); + } + + @Override + public void addGroup(int cursorPosition, int size, boolean expanded) { + super.addGroup(cursorPosition, size, expanded); + } + + /* + * Get the number from the Contacts, if available, since sometimes + * the number provided by caller id may not be formatted properly + * depending on the carrier (roaming) in use at the time of the + * incoming call. + * Logic : If the caller-id number starts with a "+", use it + * Else if the number in the contacts starts with a "+", use that one + * Else if the number in the contacts is longer, use that one + */ + public String getBetterNumberFromContacts(String number, String countryIso) { + String matchingNumber = null; + // Look in the cache first. If it's not found then query the Phones db + NumberWithCountryIso numberCountryIso = new NumberWithCountryIso(number, countryIso); + ContactInfo ci = mContactInfoCache.getPossiblyExpired(numberCountryIso); + if (ci != null && ci != ContactInfo.EMPTY) { + matchingNumber = ci.number; + } else { + try { + Cursor phonesCursor = mContext.getContentResolver().query( + Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, number), + PhoneQuery._PROJECTION, null, null, null); + if (phonesCursor != null) { + if (phonesCursor.moveToFirst()) { + matchingNumber = phonesCursor.getString(PhoneQuery.MATCHED_NUMBER); + } + phonesCursor.close(); + } + } catch (Exception e) { + // Use the number from the call log + } + } + if (!TextUtils.isEmpty(matchingNumber) && + (matchingNumber.startsWith("+") + || matchingNumber.length() > number.length())) { + number = matchingNumber; + } + return number; + } +} diff --git a/src/com/android/dialer/calllog/NewCallLogFragment.java b/src/com/android/dialer/calllog/NewCallLogFragment.java new file mode 100644 index 000000000..d5b17952c --- /dev/null +++ b/src/com/android/dialer/calllog/NewCallLogFragment.java @@ -0,0 +1,612 @@ +/* + * Copyright (C) 2011 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.dialer.calllog; + +import android.app.Activity; +import android.app.KeyguardManager; +import android.app.ListFragment; +import android.content.Context; +import android.content.Intent; +import android.database.ContentObserver; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.RemoteException; +import android.os.ServiceManager; +import android.provider.CallLog; +import android.provider.CallLog.Calls; +import android.provider.ContactsContract; +import android.telephony.PhoneNumberUtils; +import android.telephony.PhoneStateListener; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ListView; +import android.widget.TextView; + +import com.android.common.io.MoreCloseables; +import com.android.contacts.common.CallUtil; +import com.android.contacts.common.GeoUtil; +import com.android.dialer.R; +import com.android.dialer.util.EmptyLoader; +import com.android.dialer.voicemail.VoicemailStatusHelper; +import com.android.dialer.voicemail.VoicemailStatusHelper.StatusMessage; +import com.android.dialer.voicemail.VoicemailStatusHelperImpl; +import com.android.internal.telephony.ITelephony; +import com.google.common.annotations.VisibleForTesting; + +import java.util.List; + +/** + * Displays a list of call log entries. + */ +public class NewCallLogFragment extends ListFragment + implements CallLogQueryHandler.Listener, CallLogAdapter.CallFetcher { + private static final String TAG = "CallLogFragment"; + + /** + * ID of the empty loader to defer other fragments. + */ + private static final int EMPTY_LOADER_ID = 0; + + private CallLogAdapter mAdapter; + private CallLogQueryHandler mCallLogQueryHandler; + private boolean mScrollToTop; + + /** Whether there is at least one voicemail source installed. */ + private boolean mVoicemailSourcesAvailable = false; + + private VoicemailStatusHelper mVoicemailStatusHelper; + private View mStatusMessageView; + private TextView mStatusMessageText; + private TextView mStatusMessageAction; + private TextView mFilterStatusView; + private KeyguardManager mKeyguardManager; + + private boolean mEmptyLoaderRunning; + private boolean mCallLogFetched; + private boolean mVoicemailStatusFetched; + + private final Handler mHandler = new Handler(); + + private TelephonyManager mTelephonyManager; + private PhoneStateListener mPhoneStateListener; + + private class CustomContentObserver extends ContentObserver { + public CustomContentObserver() { + super(mHandler); + } + @Override + public void onChange(boolean selfChange) { + mRefreshDataRequired = true; + } + } + + // See issue 6363009 + private final ContentObserver mCallLogObserver = new CustomContentObserver(); + private final ContentObserver mContactsObserver = new CustomContentObserver(); + private boolean mRefreshDataRequired = true; + + // Exactly same variable is in Fragment as a package private. + private boolean mMenuVisible = true; + + // Default to all calls. + private int mCallTypeFilter = CallLogQueryHandler.CALL_TYPE_ALL; + + @Override + public void onCreate(Bundle state) { + super.onCreate(state); + + mCallLogQueryHandler = new CallLogQueryHandler(getActivity().getContentResolver(), this); + mKeyguardManager = + (KeyguardManager) getActivity().getSystemService(Context.KEYGUARD_SERVICE); + getActivity().getContentResolver().registerContentObserver( + CallLog.CONTENT_URI, true, mCallLogObserver); + getActivity().getContentResolver().registerContentObserver( + ContactsContract.Contacts.CONTENT_URI, true, mContactsObserver); + setHasOptionsMenu(true); + } + + /** Called by the CallLogQueryHandler when the list of calls has been fetched or updated. */ + @Override + public void onCallsFetched(Cursor cursor) { + if (getActivity() == null || getActivity().isFinishing()) { + return; + } + mAdapter.setLoading(false); + mAdapter.changeCursor(cursor); + // This will update the state of the "Clear call log" menu item. + getActivity().invalidateOptionsMenu(); + if (mScrollToTop) { + final ListView listView = getListView(); + // The smooth-scroll animation happens over a fixed time period. + // As a result, if it scrolls through a large portion of the list, + // each frame will jump so far from the previous one that the user + // will not experience the illusion of downward motion. Instead, + // if we're not already near the top of the list, we instantly jump + // near the top, and animate from there. + if (listView.getFirstVisiblePosition() > 5) { + listView.setSelection(5); + } + // Workaround for framework issue: the smooth-scroll doesn't + // occur if setSelection() is called immediately before. + mHandler.post(new Runnable() { + @Override + public void run() { + if (getActivity() == null || getActivity().isFinishing()) { + return; + } + listView.smoothScrollToPosition(0); + } + }); + + mScrollToTop = false; + } + mCallLogFetched = true; + destroyEmptyLoaderIfAllDataFetched(); + } + + /** + * Called by {@link CallLogQueryHandler} after a successful query to voicemail status provider. + */ + @Override + public void onVoicemailStatusFetched(Cursor statusCursor) { + if (getActivity() == null || getActivity().isFinishing()) { + return; + } + updateVoicemailStatusMessage(statusCursor); + + int activeSources = mVoicemailStatusHelper.getNumberActivityVoicemailSources(statusCursor); + setVoicemailSourcesAvailable(activeSources != 0); + MoreCloseables.closeQuietly(statusCursor); + mVoicemailStatusFetched = true; + destroyEmptyLoaderIfAllDataFetched(); + } + + private void destroyEmptyLoaderIfAllDataFetched() { + if (mCallLogFetched && mVoicemailStatusFetched && mEmptyLoaderRunning) { + mEmptyLoaderRunning = false; + getLoaderManager().destroyLoader(EMPTY_LOADER_ID); + } + } + + /** Sets whether there are any voicemail sources available in the platform. */ + private void setVoicemailSourcesAvailable(boolean voicemailSourcesAvailable) { + if (mVoicemailSourcesAvailable == voicemailSourcesAvailable) return; + mVoicemailSourcesAvailable = voicemailSourcesAvailable; + + Activity activity = getActivity(); + if (activity != null) { + // This is so that the options menu content is updated. + activity.invalidateOptionsMenu(); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) { + View view = inflater.inflate(R.layout.call_log_fragment, container, false); + mVoicemailStatusHelper = new VoicemailStatusHelperImpl(); + mStatusMessageView = view.findViewById(R.id.voicemail_status); + mStatusMessageText = (TextView) view.findViewById(R.id.voicemail_status_message); + mStatusMessageAction = (TextView) view.findViewById(R.id.voicemail_status_action); + mFilterStatusView = (TextView) view.findViewById(R.id.filter_status); + return view; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + String currentCountryIso = GeoUtil.getCurrentCountryIso(getActivity()); + mAdapter = new CallLogAdapter(getActivity(), this, + new ContactInfoHelper(getActivity(), currentCountryIso)); + setListAdapter(mAdapter); + getListView().setItemsCanFocus(true); + } + + /** + * Based on the new intent, decide whether the list should be configured + * to scroll up to display the first item. + */ + public void configureScreenFromIntent(Intent newIntent) { + // Typically, when switching to the call-log we want to show the user + // the same section of the list that they were most recently looking + // at. However, under some circumstances, we want to automatically + // scroll to the top of the list to present the newest call items. + // For example, immediately after a call is finished, we want to + // display information about that call. + mScrollToTop = Calls.CONTENT_TYPE.equals(newIntent.getType()); + } + + @Override + public void onStart() { + // Start the empty loader now to defer other fragments. We destroy it when both calllog + // and the voicemail status are fetched. + getLoaderManager().initLoader(EMPTY_LOADER_ID, null, + new EmptyLoader.Callback(getActivity())); + mEmptyLoaderRunning = true; + super.onStart(); + } + + @Override + public void onResume() { + super.onResume(); + refreshData(); + } + + private void updateVoicemailStatusMessage(Cursor statusCursor) { + List<StatusMessage> messages = mVoicemailStatusHelper.getStatusMessages(statusCursor); + if (messages.size() == 0) { + mStatusMessageView.setVisibility(View.GONE); + } else { + mStatusMessageView.setVisibility(View.VISIBLE); + // TODO: Change the code to show all messages. For now just pick the first message. + final StatusMessage message = messages.get(0); + if (message.showInCallLog()) { + mStatusMessageText.setText(message.callLogMessageId); + } + if (message.actionMessageId != -1) { + mStatusMessageAction.setText(message.actionMessageId); + } + if (message.actionUri != null) { + mStatusMessageAction.setVisibility(View.VISIBLE); + mStatusMessageAction.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + getActivity().startActivity( + new Intent(Intent.ACTION_VIEW, message.actionUri)); + } + }); + } else { + mStatusMessageAction.setVisibility(View.GONE); + } + } + } + + @Override + public void onPause() { + super.onPause(); + // Kill the requests thread + mAdapter.stopRequestProcessing(); + } + + @Override + public void onStop() { + super.onStop(); + updateOnExit(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mAdapter.stopRequestProcessing(); + mAdapter.changeCursor(null); + getActivity().getContentResolver().unregisterContentObserver(mCallLogObserver); + getActivity().getContentResolver().unregisterContentObserver(mContactsObserver); + unregisterPhoneCallReceiver(); + } + + @Override + public void fetchCalls() { + mCallLogQueryHandler.fetchCalls(mCallTypeFilter); + } + + public void startCallsQuery() { + mAdapter.setLoading(true); + mCallLogQueryHandler.fetchCalls(mCallTypeFilter); + } + + private void startVoicemailStatusQuery() { + mCallLogQueryHandler.fetchVoicemailStatus(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.call_log_options, menu); + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + final MenuItem itemDeleteAll = menu.findItem(R.id.delete_all); + // Check if all the menu items are inflated correctly. As a shortcut, we assume all + // menu items are ready if the first item is non-null. + if (itemDeleteAll != null) { + itemDeleteAll.setEnabled(mAdapter != null && !mAdapter.isEmpty()); + + showAllFilterMenuOptions(menu); + hideCurrentFilterMenuOption(menu); + + // Only hide if not available. Let the above calls handle showing. + if (!mVoicemailSourcesAvailable) { + menu.findItem(R.id.show_voicemails_only).setVisible(false); + } + } + } + + private void hideCurrentFilterMenuOption(Menu menu) { + MenuItem item = null; + switch (mCallTypeFilter) { + case CallLogQueryHandler.CALL_TYPE_ALL: + item = menu.findItem(R.id.show_all_calls); + break; + case Calls.INCOMING_TYPE: + item = menu.findItem(R.id.show_incoming_only); + break; + case Calls.OUTGOING_TYPE: + item = menu.findItem(R.id.show_outgoing_only); + break; + case Calls.MISSED_TYPE: + item = menu.findItem(R.id.show_missed_only); + break; + case Calls.VOICEMAIL_TYPE: + menu.findItem(R.id.show_voicemails_only); + break; + } + if (item != null) { + item.setVisible(false); + } + } + + private void showAllFilterMenuOptions(Menu menu) { + menu.findItem(R.id.show_all_calls).setVisible(true); + menu.findItem(R.id.show_incoming_only).setVisible(true); + menu.findItem(R.id.show_outgoing_only).setVisible(true); + menu.findItem(R.id.show_missed_only).setVisible(true); + menu.findItem(R.id.show_voicemails_only).setVisible(true); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.delete_all: + ClearCallLogDialog.show(getFragmentManager()); + return true; + + case R.id.show_outgoing_only: + // We only need the phone call receiver when there is an active call type filter. + // Not many people may use the filters so don't register the receiver until now . + registerPhoneCallReceiver(); + mCallLogQueryHandler.fetchCalls(Calls.OUTGOING_TYPE); + updateFilterTypeAndHeader(Calls.OUTGOING_TYPE); + return true; + + case R.id.show_incoming_only: + registerPhoneCallReceiver(); + mCallLogQueryHandler.fetchCalls(Calls.INCOMING_TYPE); + updateFilterTypeAndHeader(Calls.INCOMING_TYPE); + return true; + + case R.id.show_missed_only: + registerPhoneCallReceiver(); + mCallLogQueryHandler.fetchCalls(Calls.MISSED_TYPE); + updateFilterTypeAndHeader(Calls.MISSED_TYPE); + return true; + + case R.id.show_voicemails_only: + registerPhoneCallReceiver(); + mCallLogQueryHandler.fetchCalls(Calls.VOICEMAIL_TYPE); + updateFilterTypeAndHeader(Calls.VOICEMAIL_TYPE); + return true; + + case R.id.show_all_calls: + // Filter is being turned off, receiver no longer needed. + unregisterPhoneCallReceiver(); + mCallLogQueryHandler.fetchCalls(CallLogQueryHandler.CALL_TYPE_ALL); + updateFilterTypeAndHeader(CallLogQueryHandler.CALL_TYPE_ALL); + return true; + + default: + return false; + } + } + + private void updateFilterTypeAndHeader(int filterType) { + mCallTypeFilter = filterType; + + switch (filterType) { + case CallLogQueryHandler.CALL_TYPE_ALL: + mFilterStatusView.setVisibility(View.GONE); + break; + case Calls.INCOMING_TYPE: + showFilterStatus(R.string.call_log_incoming_header); + break; + case Calls.OUTGOING_TYPE: + showFilterStatus(R.string.call_log_outgoing_header); + break; + case Calls.MISSED_TYPE: + showFilterStatus(R.string.call_log_missed_header); + break; + case Calls.VOICEMAIL_TYPE: + showFilterStatus(R.string.call_log_voicemail_header); + break; + } + } + + private void showFilterStatus(int resId) { + mFilterStatusView.setText(resId); + mFilterStatusView.setVisibility(View.VISIBLE); + } + + public void callSelectedEntry() { + int position = getListView().getSelectedItemPosition(); + if (position < 0) { + // In touch mode you may often not have something selected, so + // just call the first entry to make sure that [send] [send] calls the + // most recent entry. + position = 0; + } + final Cursor cursor = (Cursor)mAdapter.getItem(position); + if (cursor != null) { + String number = cursor.getString(CallLogQuery.NUMBER); + int numberPresentation = cursor.getInt(CallLogQuery.NUMBER_PRESENTATION); + if (!PhoneNumberHelper.canPlaceCallsTo(number, numberPresentation)) { + // This number can't be called, do nothing + return; + } + Intent intent; + // If "number" is really a SIP address, construct a sip: URI. + if (PhoneNumberUtils.isUriNumber(number)) { + intent = CallUtil.getCallIntent( + Uri.fromParts(CallUtil.SCHEME_SIP, number, null)); + } else { + // We're calling a regular PSTN phone number. + // Construct a tel: URI, but do some other possible cleanup first. + int callType = cursor.getInt(CallLogQuery.CALL_TYPE); + if (!number.startsWith("+") && + (callType == Calls.INCOMING_TYPE + || callType == Calls.MISSED_TYPE)) { + // If the caller-id matches a contact with a better qualified number, use it + String countryIso = cursor.getString(CallLogQuery.COUNTRY_ISO); + number = mAdapter.getBetterNumberFromContacts(number, countryIso); + } + intent = CallUtil.getCallIntent( + Uri.fromParts(CallUtil.SCHEME_TEL, number, null)); + } + intent.setFlags( + Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + startActivity(intent); + } + } + + @VisibleForTesting + CallLogAdapter getAdapter() { + return mAdapter; + } + + @Override + public void setMenuVisibility(boolean menuVisible) { + super.setMenuVisibility(menuVisible); + if (mMenuVisible != menuVisible) { + mMenuVisible = menuVisible; + if (!menuVisible) { + updateOnExit(); + } else if (isResumed()) { + refreshData(); + } + } + } + + /** Requests updates to the data to be shown. */ + private void refreshData() { + // Prevent unnecessary refresh. + if (mRefreshDataRequired) { + // Mark all entries in the contact info cache as out of date, so they will be looked up + // again once being shown. + mAdapter.invalidateCache(); + startCallsQuery(); + startVoicemailStatusQuery(); + updateOnEntry(); + mRefreshDataRequired = false; + } + } + + /** Removes the missed call notifications. */ + private void removeMissedCallNotifications() { + try { + ITelephony telephony = + ITelephony.Stub.asInterface(ServiceManager.getService("phone")); + if (telephony != null) { + telephony.cancelMissedCallsNotification(); + } else { + Log.w(TAG, "Telephony service is null, can't call " + + "cancelMissedCallsNotification"); + } + } catch (RemoteException e) { + Log.e(TAG, "Failed to clear missed calls notification due to remote exception"); + } + } + + /** Updates call data and notification state while leaving the call log tab. */ + private void updateOnExit() { + updateOnTransition(false); + } + + /** Updates call data and notification state while entering the call log tab. */ + private void updateOnEntry() { + updateOnTransition(true); + } + + private void updateOnTransition(boolean onEntry) { + // We don't want to update any call data when keyguard is on because the user has likely not + // seen the new calls yet. + // This might be called before onCreate() and thus we need to check null explicitly. + if (mKeyguardManager != null && !mKeyguardManager.inKeyguardRestrictedInputMode()) { + // On either of the transitions we reset the new flag and update the notifications. + // While exiting we additionally consume all missed calls (by marking them as read). + // This will ensure that they no more appear in the "new" section when we return back. + mCallLogQueryHandler.markNewCallsAsOld(); + if (!onEntry) { + mCallLogQueryHandler.markMissedCallsAsRead(); + } + removeMissedCallNotifications(); + updateVoicemailNotifications(); + } + } + + private void updateVoicemailNotifications() { + Intent serviceIntent = new Intent(getActivity(), CallLogNotificationsService.class); + serviceIntent.setAction(CallLogNotificationsService.ACTION_UPDATE_NOTIFICATIONS); + getActivity().startService(serviceIntent); + } + + /** + * Register a phone call filter to reset the call type when a phone call is place. + */ + private void registerPhoneCallReceiver() { + if (mPhoneStateListener != null) { + return; // Already registered. + } + mTelephonyManager = (TelephonyManager) getActivity().getSystemService( + Context.TELEPHONY_SERVICE); + mPhoneStateListener = new PhoneStateListener() { + @Override + public void onCallStateChanged(int state, String incomingNumber) { + if (state != TelephonyManager.CALL_STATE_OFFHOOK && + state != TelephonyManager.CALL_STATE_RINGING) { + return; + } + mHandler.post(new Runnable() { + @Override + public void run() { + if (getActivity() == null || getActivity().isFinishing()) { + return; + } + updateFilterTypeAndHeader(CallLogQueryHandler.CALL_TYPE_ALL); + } + }); + } + }; + mTelephonyManager.listen(mPhoneStateListener, PhoneStateListener.LISTEN_CALL_STATE); + } + + /** + * Un-registers the phone call receiver. + */ + private void unregisterPhoneCallReceiver() { + if (mPhoneStateListener != null) { + mTelephonyManager.listen(mPhoneStateListener, PhoneStateListener.LISTEN_NONE); + mPhoneStateListener = null; + } + } +} diff --git a/src/com/android/dialer/calllog/NewCallLogListItemHelper.java b/src/com/android/dialer/calllog/NewCallLogListItemHelper.java new file mode 100644 index 000000000..371094d34 --- /dev/null +++ b/src/com/android/dialer/calllog/NewCallLogListItemHelper.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2011 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.dialer.calllog; + +import android.content.res.Resources; +import android.provider.CallLog.Calls; +import android.text.TextUtils; +import android.view.View; + +import com.android.dialer.PhoneCallDetails; +import com.android.dialer.PhoneCallDetailsHelper; +import com.android.dialer.R; + +/** + * Helper class to fill in the views of a call log entry. + */ +/*package*/ class NewCallLogListItemHelper { + /** Helper for populating the details of a phone call. */ + private final PhoneCallDetailsHelper mPhoneCallDetailsHelper; + /** Helper for handling phone numbers. */ + private final PhoneNumberHelper mPhoneNumberHelper; + /** Resources to look up strings. */ + private final Resources mResources; + + /** + * Creates a new helper instance. + * + * @param phoneCallDetailsHelper used to set the details of a phone call + * @param phoneNumberHelper used to process phone number + */ + public NewCallLogListItemHelper(PhoneCallDetailsHelper phoneCallDetailsHelper, + PhoneNumberHelper phoneNumberHelper, Resources resources) { + mPhoneCallDetailsHelper = phoneCallDetailsHelper; + mPhoneNumberHelper = phoneNumberHelper; + mResources = resources; + } + + /** + * Sets the name, label, and number for a contact. + * + * @param views the views to populate + * @param details the details of a phone call needed to fill in the data + * @param isHighlighted whether to use the highlight text for the call + */ + public void setPhoneCallDetails(CallLogListItemViews views, PhoneCallDetails details, + boolean isHighlighted) { + mPhoneCallDetailsHelper.setPhoneCallDetails(views.phoneCallDetailsViews, details, + isHighlighted); + boolean canCall = PhoneNumberHelper.canPlaceCallsTo(details.number, + details.numberPresentation); + boolean canPlay = details.callTypes[0] == Calls.VOICEMAIL_TYPE; + + if (canPlay) { + // Playback action takes preference. + configurePlaySecondaryAction(views, isHighlighted); + views.dividerView.setVisibility(View.VISIBLE); + } else if (canCall) { + // Call is the secondary action. + configureCallSecondaryAction(views, details); + views.dividerView.setVisibility(View.VISIBLE); + } else { + // No action available. + views.secondaryActionView.setVisibility(View.GONE); + views.dividerView.setVisibility(View.GONE); + } + } + + /** Sets the secondary action to correspond to the call button. */ + private void configureCallSecondaryAction(CallLogListItemViews views, + PhoneCallDetails details) { + views.secondaryActionView.setVisibility(View.VISIBLE); + views.secondaryActionView.setImageResource(R.drawable.ic_ab_dialer_holo_dark); + views.secondaryActionView.setContentDescription(getCallActionDescription(details)); + } + + /** Returns the description used by the call action for this phone call. */ + private CharSequence getCallActionDescription(PhoneCallDetails details) { + final CharSequence recipient; + if (!TextUtils.isEmpty(details.name)) { + recipient = details.name; + } else { + recipient = mPhoneNumberHelper.getDisplayNumber( + details.number, details.numberPresentation, details.formattedNumber); + } + return mResources.getString(R.string.description_call, recipient); + } + + /** Sets the secondary action to correspond to the play button. */ + private void configurePlaySecondaryAction(CallLogListItemViews views, boolean isHighlighted) { + views.secondaryActionView.setVisibility(View.VISIBLE); + views.secondaryActionView.setImageResource( + isHighlighted ? R.drawable.ic_play_active_holo_dark : R.drawable.ic_play_holo_dark); + views.secondaryActionView.setContentDescription( + mResources.getString(R.string.description_call_log_play_button)); + } +} diff --git a/src/com/android/dialer/dialpad/NewDialpadFragment.java b/src/com/android/dialer/dialpad/NewDialpadFragment.java new file mode 100644 index 000000000..707651726 --- /dev/null +++ b/src/com/android/dialer/dialpad/NewDialpadFragment.java @@ -0,0 +1,1732 @@ +/* + * Copyright (C) 2011 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.dialer.dialpad; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.app.Fragment; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.AudioManager; +import android.media.ToneGenerator; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.RemoteException; +import android.os.ServiceManager; +import android.os.SystemProperties; +import android.provider.Contacts.Intents.Insert; +import android.provider.Contacts.People; +import android.provider.Contacts.Phones; +import android.provider.Contacts.PhonesColumns; +import android.provider.Settings; +import android.telephony.PhoneNumberUtils; +import android.telephony.PhoneStateListener; +import android.telephony.TelephonyManager; +import android.text.Editable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.text.style.RelativeSizeSpan; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.BaseAdapter; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.PopupMenu; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import com.android.contacts.common.CallUtil; +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.activity.TransactionSafeActivity; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.contacts.common.util.PhoneNumberFormatter; +import com.android.contacts.common.util.StopWatch; +import com.android.dialer.DialtactsActivity; +import com.android.dialer.R; +import com.android.dialer.SpecialCharSequenceMgr; +import com.android.dialer.database.DialerDatabaseHelper; +import com.android.dialer.interactions.PhoneNumberInteraction; +import com.android.dialer.util.OrientationUtil; +import com.android.internal.telephony.ITelephony; +import com.android.phone.common.CallLogAsync; +import com.android.phone.common.HapticFeedback; +import com.google.common.annotations.VisibleForTesting; + +import java.util.List; + +/** + * Fragment that displays a twelve-key phone dialpad. + */ +public class NewDialpadFragment extends Fragment + implements View.OnClickListener, + View.OnLongClickListener, View.OnKeyListener, + AdapterView.OnItemClickListener, TextWatcher, + PopupMenu.OnMenuItemClickListener, + DialpadImageButton.OnPressedListener, + SmartDialLoaderTask.SmartDialLoaderCallback { + private static final String TAG = NewDialpadFragment.class.getSimpleName(); + + private static final boolean DEBUG = DialtactsActivity.DEBUG; + + private static final String EMPTY_NUMBER = ""; + private static final char PAUSE = ','; + private static final char WAIT = ';'; + + /** The length of DTMF tones in milliseconds */ + private static final int TONE_LENGTH_MS = 150; + private static final int TONE_LENGTH_INFINITE = -1; + + /** The DTMF tone volume relative to other sounds in the stream */ + private static final int TONE_RELATIVE_VOLUME = 80; + + /** Stream type used to play the DTMF tones off call, and mapped to the volume control keys */ + private static final int DIAL_TONE_STREAM_TYPE = AudioManager.STREAM_DTMF; + + private ContactsPreferences mContactsPrefs; + + /** + * View (usually FrameLayout) containing mDigits field. This can be null, in which mDigits + * isn't enclosed by the container. + */ + private View mDigitsContainer; + private EditText mDigits; + + /** Remembers if we need to clear digits field when the screen is completely gone. */ + private boolean mClearDigitsOnStop; + + private View mDelete; + private ToneGenerator mToneGenerator; + private final Object mToneGeneratorLock = new Object(); + private View mDialpad; + /** + * Remembers the number of dialpad buttons which are pressed at this moment. + * If it becomes 0, meaning no buttons are pressed, we'll call + * {@link ToneGenerator#stopTone()}; the method shouldn't be called unless the last key is + * released. + */ + private int mDialpadPressCount; + + private View mDialButtonContainer; + private View mDialButton; + private ListView mDialpadChooser; + private DialpadChooserAdapter mDialpadChooserAdapter; + + /** Will be set only if the view has the smart dialing section. */ + private RelativeLayout mSmartDialContainer; + + /** + * Will be set only if the view has the smart dialing section. + */ + private SmartDialController mSmartDialAdapter; + + /** + * Use latin character map by default + */ + private SmartDialMap mSmartDialMap = new LatinSmartDialMap(); + + /** + * Master switch controlling whether or not smart dialing is enabled, and whether the + * smart dialing suggestion strip is visible. + */ + private boolean mSmartDialEnabled = false; + + private DialerDatabaseHelper mDialerDatabaseHelper; + + /** + * Regular expression prohibiting manual phone call. Can be empty, which means "no rule". + */ + private String mProhibitedPhoneNumberRegexp; + + + // Last number dialed, retrieved asynchronously from the call DB + // in onCreate. This number is displayed when the user hits the + // send key and cleared in onPause. + private final CallLogAsync mCallLog = new CallLogAsync(); + private String mLastNumberDialed = EMPTY_NUMBER; + + // determines if we want to playback local DTMF tones. + private boolean mDTMFToneEnabled; + + // Vibration (haptic feedback) for dialer key presses. + private final HapticFeedback mHaptic = new HapticFeedback(); + + /** Identifier for the "Add Call" intent extra. */ + private static final String ADD_CALL_MODE_KEY = "add_call_mode"; + + /** + * Identifier for intent extra for sending an empty Flash message for + * CDMA networks. This message is used by the network to simulate a + * press/depress of the "hookswitch" of a landline phone. Aka "empty flash". + * + * TODO: Using an intent extra to tell the phone to send this flash is a + * temporary measure. To be replaced with an ITelephony call in the future. + * TODO: Keep in sync with the string defined in OutgoingCallBroadcaster.java + * in Phone app until this is replaced with the ITelephony API. + */ + private static final String EXTRA_SEND_EMPTY_FLASH + = "com.android.phone.extra.SEND_EMPTY_FLASH"; + + private String mCurrentCountryIso; + + private final PhoneStateListener mPhoneStateListener = new PhoneStateListener() { + /** + * Listen for phone state changes so that we can take down the + * "dialpad chooser" if the phone becomes idle while the + * chooser UI is visible. + */ + @Override + public void onCallStateChanged(int state, String incomingNumber) { + // Log.i(TAG, "PhoneStateListener.onCallStateChanged: " + // + state + ", '" + incomingNumber + "'"); + if ((state == TelephonyManager.CALL_STATE_IDLE) && dialpadChooserVisible()) { + // Log.i(TAG, "Call ended with dialpad chooser visible! Taking it down..."); + // Note there's a race condition in the UI here: the + // dialpad chooser could conceivably disappear (on its + // own) at the exact moment the user was trying to select + // one of the choices, which would be confusing. (But at + // least that's better than leaving the dialpad chooser + // onscreen, but useless...) + showDialpadChooser(false); + } + } + }; + + private boolean mWasEmptyBeforeTextChange; + + /** + * This field is set to true while processing an incoming DIAL intent, in order to make sure + * that SpecialCharSequenceMgr actions can be triggered by user input but *not* by a + * tel: URI passed by some other app. It will be set to false when all digits are cleared. + */ + private boolean mDigitsFilledByIntent; + + private boolean mStartedFromNewIntent = false; + private boolean mFirstLaunch = false; + + private static final String PREF_DIGITS_FILLED_BY_INTENT = "pref_digits_filled_by_intent"; + + /** + * Return an Intent for launching voicemail screen. + */ + private static Intent getVoicemailIntent() { + final Intent intent = new Intent(Intent.ACTION_CALL_PRIVILEGED, + Uri.fromParts("voicemail", "", null)); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return intent; + } + + private TelephonyManager getTelephonyManager() { + return (TelephonyManager) getActivity().getSystemService(Context.TELEPHONY_SERVICE); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + mWasEmptyBeforeTextChange = TextUtils.isEmpty(s); + } + + @Override + public void onTextChanged(CharSequence input, int start, int before, int changeCount) { + if (mWasEmptyBeforeTextChange != TextUtils.isEmpty(input)) { + final Activity activity = getActivity(); + if (activity != null) { + activity.invalidateOptionsMenu(); + } + } + + // DTMF Tones do not need to be played here any longer - + // the DTMF dialer handles that functionality now. + } + + @Override + public void afterTextChanged(Editable input) { + // When DTMF dialpad buttons are being pressed, we delay SpecialCharSequencMgr sequence, + // since some of SpecialCharSequenceMgr's behavior is too abrupt for the "touch-down" + // behavior. + if (!mDigitsFilledByIntent && + SpecialCharSequenceMgr.handleChars(getActivity(), input.toString(), mDigits)) { + // A special sequence was entered, clear the digits + mDigits.getText().clear(); + } + + if (isDigitsEmpty()) { + mDigitsFilledByIntent = false; + mDigits.setCursorVisible(false); + } + + updateDialAndDeleteButtonEnabledState(); + loadSmartDialEntries(); + } + + @Override + public void onCreate(Bundle state) { + super.onCreate(state); + mFirstLaunch = true; + mContactsPrefs = new ContactsPreferences(getActivity()); + mCurrentCountryIso = GeoUtil.getCurrentCountryIso(getActivity()); + + mDialerDatabaseHelper = DialerDatabaseHelper.getInstance(getActivity()); + SmartDialPrefix.initializeNanpSettings(getActivity()); + + try { + mHaptic.init(getActivity(), + getResources().getBoolean(R.bool.config_enable_dialer_key_vibration)); + } catch (Resources.NotFoundException nfe) { + Log.e(TAG, "Vibrate control bool missing.", nfe); + } + + setHasOptionsMenu(true); + + mProhibitedPhoneNumberRegexp = getResources().getString( + R.string.config_prohibited_phone_number_regexp); + + if (state != null) { + mDigitsFilledByIntent = state.getBoolean(PREF_DIGITS_FILLED_BY_INTENT); + } + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) { + View fragmentView = inflater.inflate(R.layout.dialpad_fragment, container, false); + + // Load up the resources for the text field. + Resources r = getResources(); + + mDigitsContainer = fragmentView.findViewById(R.id.digits_container); + mDigits = (EditText) fragmentView.findViewById(R.id.digits); + mDigits.setKeyListener(UnicodeDialerKeyListener.INSTANCE); + mDigits.setOnClickListener(this); + mDigits.setOnKeyListener(this); + mDigits.setOnLongClickListener(this); + mDigits.addTextChangedListener(this); + PhoneNumberFormatter.setPhoneNumberFormattingTextWatcher(getActivity(), mDigits); + // Check for the presence of the keypad + View oneButton = fragmentView.findViewById(R.id.one); + if (oneButton != null) { + setupKeypad(fragmentView); + } + + DisplayMetrics dm = getResources().getDisplayMetrics(); + int minCellSize = (int) (56 * dm.density); // 56dip == minimum size of menu buttons + int cellCount = dm.widthPixels / minCellSize; + int fakeMenuItemWidth = dm.widthPixels / cellCount; + mDialButtonContainer = fragmentView.findViewById(R.id.dialButtonContainer); + // If in portrait, add padding to the dial button since we need space for the + // search and menu/overflow buttons. + if (mDialButtonContainer != null && !OrientationUtil.isLandscape(this.getActivity())) { + mDialButtonContainer.setPadding( + fakeMenuItemWidth, mDialButtonContainer.getPaddingTop(), + fakeMenuItemWidth, mDialButtonContainer.getPaddingBottom()); + } + mDialButton = fragmentView.findViewById(R.id.dialButton); + if (r.getBoolean(R.bool.config_show_onscreen_dial_button)) { + mDialButton.setOnClickListener(this); + mDialButton.setOnLongClickListener(this); + } else { + mDialButton.setVisibility(View.GONE); // It's VISIBLE by default + mDialButton = null; + } + + mDelete = fragmentView.findViewById(R.id.deleteButton); + if (mDelete != null) { + mDelete.setOnClickListener(this); + mDelete.setOnLongClickListener(this); + } + + mDialpad = fragmentView.findViewById(R.id.dialpad); // This is null in landscape mode. + + // In landscape we put the keyboard in phone mode. + if (null == mDialpad) { + mDigits.setInputType(android.text.InputType.TYPE_CLASS_PHONE); + } else { + mDigits.setCursorVisible(false); + } + + // Set up the "dialpad chooser" UI; see showDialpadChooser(). + mDialpadChooser = (ListView) fragmentView.findViewById(R.id.dialpadChooser); + mDialpadChooser.setOnItemClickListener(this); + + // Smart dial container. This is null if in landscape mode since it is not present + // in the landscape dialer layout. + mSmartDialContainer = (RelativeLayout) fragmentView.findViewById( + R.id.dialpad_smartdial_container); + + if (mSmartDialContainer != null) { + mSmartDialAdapter = new SmartDialController(getActivity(), mSmartDialContainer, + new OnSmartDialShortClick(), new OnSmartDialLongClick()); + } + return fragmentView; + } + + private boolean isLayoutReady() { + return mDigits != null; + } + + public EditText getDigitsWidget() { + return mDigits; + } + + /** + * @return true when {@link #mDigits} is actually filled by the Intent. + */ + private boolean fillDigitsIfNecessary(Intent intent) { + // Only fills digits from an intent if it is a new intent. + // Otherwise falls back to the previously used number. + if (!mFirstLaunch && !mStartedFromNewIntent) { + return false; + } + + final String action = intent.getAction(); + if (Intent.ACTION_DIAL.equals(action) || Intent.ACTION_VIEW.equals(action)) { + Uri uri = intent.getData(); + if (uri != null) { + if (CallUtil.SCHEME_TEL.equals(uri.getScheme())) { + // Put the requested number into the input area + String data = uri.getSchemeSpecificPart(); + // Remember it is filled via Intent. + mDigitsFilledByIntent = true; + final String converted = PhoneNumberUtils.convertKeypadLettersToDigits( + PhoneNumberUtils.replaceUnicodeDigits(data)); + setFormattedDigits(converted, null); + return true; + } else { + String type = intent.getType(); + if (People.CONTENT_ITEM_TYPE.equals(type) + || Phones.CONTENT_ITEM_TYPE.equals(type)) { + // Query the phone number + Cursor c = getActivity().getContentResolver().query(intent.getData(), + new String[] {PhonesColumns.NUMBER, PhonesColumns.NUMBER_KEY}, + null, null, null); + if (c != null) { + try { + if (c.moveToFirst()) { + // Remember it is filled via Intent. + mDigitsFilledByIntent = true; + // Put the number into the input area + setFormattedDigits(c.getString(0), c.getString(1)); + return true; + } + } finally { + c.close(); + } + } + } + } + } + } + return false; + } + + /** + * Determines whether an add call operation is requested. + * + * @param intent The intent. + * @return {@literal true} if add call operation was requested. {@literal false} otherwise. + */ + private static boolean isAddCallMode(Intent intent) { + final String action = intent.getAction(); + if (Intent.ACTION_DIAL.equals(action) || Intent.ACTION_VIEW.equals(action)) { + // see if we are "adding a call" from the InCallScreen; false by default. + return intent.getBooleanExtra(ADD_CALL_MODE_KEY, false); + } else { + return false; + } + } + + /** + * Checks the given Intent and changes dialpad's UI state. For example, if the Intent requires + * the screen to enter "Add Call" mode, this method will show correct UI for the mode. + */ + private void configureScreenFromIntent(Activity parent) { + // If we were not invoked with a DIAL intent, + if (!(parent instanceof DialtactsActivity)) { + setStartedFromNewIntent(false); + return; + } + + // See if we were invoked with a DIAL intent. If we were, fill in the appropriate + // digits in the dialer field. + Intent intent = parent.getIntent(); + + if (!isLayoutReady()) { + // This happens typically when parent's Activity#onNewIntent() is called while + // Fragment#onCreateView() isn't called yet, and thus we cannot configure Views at + // this point. onViewCreate() should call this method after preparing layouts, so + // just ignore this call now. + Log.i(TAG, + "Screen configuration is requested before onCreateView() is called. Ignored"); + return; + } + + boolean needToShowDialpadChooser = false; + + // Be sure *not* to show the dialpad chooser if this is an + // explicit "Add call" action, though. + final boolean isAddCallMode = isAddCallMode(intent); + if (!isAddCallMode) { + + // Don't show the chooser when called via onNewIntent() and phone number is present. + // i.e. User clicks a telephone link from gmail for example. + // In this case, we want to show the dialpad with the phone number. + final boolean digitsFilled = fillDigitsIfNecessary(intent); + if (!(mStartedFromNewIntent && digitsFilled)) { + + final String action = intent.getAction(); + if (Intent.ACTION_DIAL.equals(action) || Intent.ACTION_VIEW.equals(action) + || Intent.ACTION_MAIN.equals(action)) { + // If there's already an active call, bring up an intermediate UI to + // make the user confirm what they really want to do. + if (phoneIsInUse()) { + needToShowDialpadChooser = true; + } + } + + } + } + showDialpadChooser(needToShowDialpadChooser); + setStartedFromNewIntent(false); + } + + public void setStartedFromNewIntent(boolean value) { + mStartedFromNewIntent = value; + } + + /** + * Sets formatted digits to digits field. + */ + private void setFormattedDigits(String data, String normalizedNumber) { + // strip the non-dialable numbers out of the data string. + String dialString = PhoneNumberUtils.extractNetworkPortion(data); + dialString = + PhoneNumberUtils.formatNumber(dialString, normalizedNumber, mCurrentCountryIso); + if (!TextUtils.isEmpty(dialString)) { + Editable digits = mDigits.getText(); + digits.replace(0, digits.length(), dialString); + // for some reason this isn't getting called in the digits.replace call above.. + // but in any case, this will make sure the background drawable looks right + afterTextChanged(digits); + } + } + + private void setupKeypad(View fragmentView) { + int[] buttonIds = new int[] { R.id.one, R.id.two, R.id.three, R.id.four, R.id.five, + R.id.six, R.id.seven, R.id.eight, R.id.nine, R.id.zero, R.id.star, R.id.pound}; + for (int id : buttonIds) { + ((DialpadImageButton) fragmentView.findViewById(id)).setOnPressedListener(this); + } + + // Long-pressing one button will initiate Voicemail. + fragmentView.findViewById(R.id.one).setOnLongClickListener(this); + + // Long-pressing zero button will enter '+' instead. + fragmentView.findViewById(R.id.zero).setOnLongClickListener(this); + + } + + @Override + public void onResume() { + super.onResume(); + + final StopWatch stopWatch = StopWatch.start("Dialpad.onResume"); + + // Query the last dialed number. Do it first because hitting + // the DB is 'slow'. This call is asynchronous. + queryLastOutgoingCall(); + + stopWatch.lap("qloc"); + + final ContentResolver contentResolver = getActivity().getContentResolver(); + + // retrieve the DTMF tone play back setting. + mDTMFToneEnabled = Settings.System.getInt(contentResolver, + Settings.System.DTMF_TONE_WHEN_DIALING, 1) == 1; + + // retrieve dialpad autocomplete setting + mSmartDialEnabled = Settings.Secure.getInt(contentResolver, + Settings.Secure.DIALPAD_AUTOCOMPLETE, 0) == 1 && mSmartDialContainer != null; + + stopWatch.lap("dtwd"); + + // Retrieve the haptic feedback setting. + mHaptic.checkSystemSetting(); + + stopWatch.lap("hptc"); + + // if the mToneGenerator creation fails, just continue without it. It is + // a local audio signal, and is not as important as the dtmf tone itself. + synchronized (mToneGeneratorLock) { + if (mToneGenerator == null) { + try { + mToneGenerator = new ToneGenerator(DIAL_TONE_STREAM_TYPE, TONE_RELATIVE_VOLUME); + } catch (RuntimeException e) { + Log.w(TAG, "Exception caught while creating local tone generator: " + e); + mToneGenerator = null; + } + } + } + stopWatch.lap("tg"); + // Prevent unnecessary confusion. Reset the press count anyway. + mDialpadPressCount = 0; + + // Initialize smart dialing state. This has to be done before anything is filled in before + // the dialpad edittext to prevent entries from being loaded from a null cache. + initializeSmartDialingState(); + + configureScreenFromIntent(getActivity()); + + stopWatch.lap("fdin"); + + // While we're in the foreground, listen for phone state changes, + // purely so that we can take down the "dialpad chooser" if the + // phone becomes idle while the chooser UI is visible. + getTelephonyManager().listen(mPhoneStateListener, PhoneStateListener.LISTEN_CALL_STATE); + + stopWatch.lap("tm"); + + // Potentially show hint text in the mDigits field when the user + // hasn't typed any digits yet. (If there's already an active call, + // this hint text will remind the user that he's about to add a new + // call.) + // + // TODO: consider adding better UI for the case where *both* lines + // are currently in use. (Right now we let the user try to add + // another call, but that call is guaranteed to fail. Perhaps the + // entire dialer UI should be disabled instead.) + if (phoneIsInUse()) { + final SpannableString hint = new SpannableString( + getActivity().getString(R.string.dialerDialpadHintText)); + hint.setSpan(new RelativeSizeSpan(0.8f), 0, hint.length(), 0); + mDigits.setHint(hint); + } else { + // Common case; no hint necessary. + mDigits.setHint(null); + + // Also, a sanity-check: the "dialpad chooser" UI should NEVER + // be visible if the phone is idle! + showDialpadChooser(false); + } + + mFirstLaunch = false; + + stopWatch.lap("hnt"); + + updateDialAndDeleteButtonEnabledState(); + + stopWatch.lap("bes"); + + stopWatch.stopAndLog(TAG, 50); + } + + @Override + public void onPause() { + super.onPause(); + + // Stop listening for phone state changes. + getTelephonyManager().listen(mPhoneStateListener, PhoneStateListener.LISTEN_NONE); + + // Make sure we don't leave this activity with a tone still playing. + stopTone(); + // Just in case reset the counter too. + mDialpadPressCount = 0; + + synchronized (mToneGeneratorLock) { + if (mToneGenerator != null) { + mToneGenerator.release(); + mToneGenerator = null; + } + } + // TODO: I wonder if we should not check if the AsyncTask that + // lookup the last dialed number has completed. + mLastNumberDialed = EMPTY_NUMBER; // Since we are going to query again, free stale number. + + SpecialCharSequenceMgr.cleanup(); + } + + @Override + public void onStop() { + super.onStop(); + if (mClearDigitsOnStop) { + mClearDigitsOnStop = false; + mDigits.getText().clear(); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(PREF_DIGITS_FILLED_BY_INTENT, mDigitsFilledByIntent); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + // Landscape dialer uses the real actionbar menu, whereas portrait uses a fake one + // that is created using constructPopupMenu() + if (OrientationUtil.isLandscape(this.getActivity()) || + ViewConfiguration.get(getActivity()).hasPermanentMenuKey() && + isLayoutReady() && mDialpadChooser != null) { + inflater.inflate(R.menu.dialpad_options, menu); + } + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + // Hardware menu key should be available and Views should already be ready. + if (OrientationUtil.isLandscape(this.getActivity()) || + ViewConfiguration.get(getActivity()).hasPermanentMenuKey() && + isLayoutReady() && mDialpadChooser != null) { + setupMenuItems(menu); + } + } + + private void setupMenuItems(Menu menu) { + final MenuItem callSettingsMenuItem = menu.findItem(R.id.menu_call_settings_dialpad); + final MenuItem addToContactMenuItem = menu.findItem(R.id.menu_add_contacts); + + // Check if all the menu items are inflated correctly. As a shortcut, we assume all menu + // items are ready if the first item is non-null. + if (callSettingsMenuItem == null) { + return; + } + + final Activity activity = getActivity(); + if (activity != null && ViewConfiguration.get(activity).hasPermanentMenuKey()) { + // Call settings should be available via its parent Activity. + callSettingsMenuItem.setVisible(false); + } else { + callSettingsMenuItem.setVisible(true); + callSettingsMenuItem.setIntent(DialtactsActivity.getCallSettingsIntent()); + } + + // We show "add to contacts" menu only when the user is + // seeing usual dialpad and has typed at least one digit. + // We never show a menu if the "choose dialpad" UI is up. + if (dialpadChooserVisible() || isDigitsEmpty()) { + addToContactMenuItem.setVisible(false); + } else { + final CharSequence digits = mDigits.getText(); + + // Put the current digits string into an intent + addToContactMenuItem.setIntent(getAddToContactIntent(digits)); + addToContactMenuItem.setVisible(true); + } + } + + private static Intent getAddToContactIntent(CharSequence digits) { + final Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT); + intent.putExtra(Insert.PHONE, digits); + intent.setType(People.CONTENT_ITEM_TYPE); + return intent; + } + + private void keyPressed(int keyCode) { + switch (keyCode) { + case KeyEvent.KEYCODE_1: + playTone(ToneGenerator.TONE_DTMF_1, TONE_LENGTH_INFINITE); + break; + case KeyEvent.KEYCODE_2: + playTone(ToneGenerator.TONE_DTMF_2, TONE_LENGTH_INFINITE); + break; + case KeyEvent.KEYCODE_3: + playTone(ToneGenerator.TONE_DTMF_3, TONE_LENGTH_INFINITE); + break; + case KeyEvent.KEYCODE_4: + playTone(ToneGenerator.TONE_DTMF_4, TONE_LENGTH_INFINITE); + break; + case KeyEvent.KEYCODE_5: + playTone(ToneGenerator.TONE_DTMF_5, TONE_LENGTH_INFINITE); + break; + case KeyEvent.KEYCODE_6: + playTone(ToneGenerator.TONE_DTMF_6, TONE_LENGTH_INFINITE); + break; + case KeyEvent.KEYCODE_7: + playTone(ToneGenerator.TONE_DTMF_7, TONE_LENGTH_INFINITE); + break; + case KeyEvent.KEYCODE_8: + playTone(ToneGenerator.TONE_DTMF_8, TONE_LENGTH_INFINITE); + break; + case KeyEvent.KEYCODE_9: + playTone(ToneGenerator.TONE_DTMF_9, TONE_LENGTH_INFINITE); + break; + case KeyEvent.KEYCODE_0: + playTone(ToneGenerator.TONE_DTMF_0, TONE_LENGTH_INFINITE); + break; + case KeyEvent.KEYCODE_POUND: + playTone(ToneGenerator.TONE_DTMF_P, TONE_LENGTH_INFINITE); + break; + case KeyEvent.KEYCODE_STAR: + playTone(ToneGenerator.TONE_DTMF_S, TONE_LENGTH_INFINITE); + break; + default: + break; + } + + mHaptic.vibrate(); + KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, keyCode); + mDigits.onKeyDown(keyCode, event); + + // If the cursor is at the end of the text we hide it. + final int length = mDigits.length(); + if (length == mDigits.getSelectionStart() && length == mDigits.getSelectionEnd()) { + mDigits.setCursorVisible(false); + } + } + + @Override + public boolean onKey(View view, int keyCode, KeyEvent event) { + switch (view.getId()) { + case R.id.digits: + if (keyCode == KeyEvent.KEYCODE_ENTER) { + dialButtonPressed(); + return true; + } + break; + } + return false; + } + + /** + * When a key is pressed, we start playing DTMF tone, do vibration, and enter the digit + * immediately. When a key is released, we stop the tone. Note that the "key press" event will + * be delivered by the system with certain amount of delay, it won't be synced with user's + * actual "touch-down" behavior. + */ + @Override + public void onPressed(View view, boolean pressed) { + if (DEBUG) Log.d(TAG, "onPressed(). view: " + view + ", pressed: " + pressed); + if (pressed) { + switch (view.getId()) { + case R.id.one: { + keyPressed(KeyEvent.KEYCODE_1); + break; + } + case R.id.two: { + keyPressed(KeyEvent.KEYCODE_2); + break; + } + case R.id.three: { + keyPressed(KeyEvent.KEYCODE_3); + break; + } + case R.id.four: { + keyPressed(KeyEvent.KEYCODE_4); + break; + } + case R.id.five: { + keyPressed(KeyEvent.KEYCODE_5); + break; + } + case R.id.six: { + keyPressed(KeyEvent.KEYCODE_6); + break; + } + case R.id.seven: { + keyPressed(KeyEvent.KEYCODE_7); + break; + } + case R.id.eight: { + keyPressed(KeyEvent.KEYCODE_8); + break; + } + case R.id.nine: { + keyPressed(KeyEvent.KEYCODE_9); + break; + } + case R.id.zero: { + keyPressed(KeyEvent.KEYCODE_0); + break; + } + case R.id.pound: { + keyPressed(KeyEvent.KEYCODE_POUND); + break; + } + case R.id.star: { + keyPressed(KeyEvent.KEYCODE_STAR); + break; + } + default: { + Log.wtf(TAG, "Unexpected onTouch(ACTION_DOWN) event from: " + view); + break; + } + } + mDialpadPressCount++; + } else { + view.jumpDrawablesToCurrentState(); + mDialpadPressCount--; + if (mDialpadPressCount < 0) { + // e.g. + // - when the user action is detected as horizontal swipe, at which only + // "up" event is thrown. + // - when the user long-press '0' button, at which dialpad will decrease this count + // while it still gets press-up event here. + if (DEBUG) Log.d(TAG, "mKeyPressCount become negative."); + stopTone(); + mDialpadPressCount = 0; + } else if (mDialpadPressCount == 0) { + stopTone(); + } + } + } + + @Override + public void onClick(View view) { + switch (view.getId()) { + case R.id.deleteButton: { + keyPressed(KeyEvent.KEYCODE_DEL); + return; + } + case R.id.dialButton: { + mHaptic.vibrate(); // Vibrate here too, just like we do for the regular keys + dialButtonPressed(); + return; + } + case R.id.digits: { + if (!isDigitsEmpty()) { + mDigits.setCursorVisible(true); + } + return; + } + default: { + Log.wtf(TAG, "Unexpected onClick() event from: " + view); + return; + } + } + } + + public PopupMenu constructPopupMenu(View anchorView) { + final Context context = getActivity(); + if (context == null) { + return null; + } + final PopupMenu popupMenu = new PopupMenu(context, anchorView); + final Menu menu = popupMenu.getMenu(); + popupMenu.inflate(R.menu.dialpad_options); + popupMenu.setOnMenuItemClickListener(this); + setupMenuItems(menu); + return popupMenu; + } + + @Override + public boolean onLongClick(View view) { + final Editable digits = mDigits.getText(); + final int id = view.getId(); + switch (id) { + case R.id.deleteButton: { + digits.clear(); + // TODO: The framework forgets to clear the pressed + // status of disabled button. Until this is fixed, + // clear manually the pressed status. b/2133127 + mDelete.setPressed(false); + return true; + } + case R.id.one: { + // '1' may be already entered since we rely on onTouch() event for numeric buttons. + // Just for safety we also check if the digits field is empty or not. + if (isDigitsEmpty() || TextUtils.equals(mDigits.getText(), "1")) { + // We'll try to initiate voicemail and thus we want to remove irrelevant string. + removePreviousDigitIfPossible(); + + if (isVoicemailAvailable()) { + callVoicemail(); + } else if (getActivity() != null) { + // Voicemail is unavailable maybe because Airplane mode is turned on. + // Check the current status and show the most appropriate error message. + final boolean isAirplaneModeOn = + Settings.System.getInt(getActivity().getContentResolver(), + Settings.System.AIRPLANE_MODE_ON, 0) != 0; + if (isAirplaneModeOn) { + DialogFragment dialogFragment = ErrorDialogFragment.newInstance( + R.string.dialog_voicemail_airplane_mode_message); + dialogFragment.show(getFragmentManager(), + "voicemail_request_during_airplane_mode"); + } else { + DialogFragment dialogFragment = ErrorDialogFragment.newInstance( + R.string.dialog_voicemail_not_ready_message); + dialogFragment.show(getFragmentManager(), "voicemail_not_ready"); + } + } + return true; + } + return false; + } + case R.id.zero: { + // Remove tentative input ('0') done by onTouch(). + removePreviousDigitIfPossible(); + keyPressed(KeyEvent.KEYCODE_PLUS); + + // Stop tone immediately and decrease the press count, so that possible subsequent + // dial button presses won't honor the 0 click any more. + // Note: this *will* make mDialpadPressCount negative when the 0 key is released, + // which should be handled appropriately. + stopTone(); + if (mDialpadPressCount > 0) mDialpadPressCount--; + + return true; + } + case R.id.digits: { + // Right now EditText does not show the "paste" option when cursor is not visible. + // To show that, make the cursor visible, and return false, letting the EditText + // show the option by itself. + mDigits.setCursorVisible(true); + return false; + } + case R.id.dialButton: { + if (isDigitsEmpty()) { + handleDialButtonClickWithEmptyDigits(); + // This event should be consumed so that onClick() won't do the exactly same + // thing. + return true; + } else { + return false; + } + } + } + return false; + } + + /** + * Remove the digit just before the current position. This can be used if we want to replace + * the previous digit or cancel previously entered character. + */ + private void removePreviousDigitIfPossible() { + final Editable editable = mDigits.getText(); + final int currentPosition = mDigits.getSelectionStart(); + if (currentPosition > 0) { + mDigits.setSelection(currentPosition); + mDigits.getText().delete(currentPosition - 1, currentPosition); + } + } + + public void callVoicemail() { + startActivity(getVoicemailIntent()); + mClearDigitsOnStop = true; + getActivity().finish(); + } + + public static class ErrorDialogFragment extends DialogFragment { + private int mTitleResId; + private int mMessageResId; + + private static final String ARG_TITLE_RES_ID = "argTitleResId"; + private static final String ARG_MESSAGE_RES_ID = "argMessageResId"; + + public static ErrorDialogFragment newInstance(int messageResId) { + return newInstance(0, messageResId); + } + + public static ErrorDialogFragment newInstance(int titleResId, int messageResId) { + final ErrorDialogFragment fragment = new ErrorDialogFragment(); + final Bundle args = new Bundle(); + args.putInt(ARG_TITLE_RES_ID, titleResId); + args.putInt(ARG_MESSAGE_RES_ID, messageResId); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mTitleResId = getArguments().getInt(ARG_TITLE_RES_ID); + mMessageResId = getArguments().getInt(ARG_MESSAGE_RES_ID); + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + if (mTitleResId != 0) { + builder.setTitle(mTitleResId); + } + if (mMessageResId != 0) { + builder.setMessage(mMessageResId); + } + builder.setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dismiss(); + } + }); + return builder.create(); + } + } + + /** + * In most cases, when the dial button is pressed, there is a + * number in digits area. Pack it in the intent, start the + * outgoing call broadcast as a separate task and finish this + * activity. + * + * When there is no digit and the phone is CDMA and off hook, + * we're sending a blank flash for CDMA. CDMA networks use Flash + * messages when special processing needs to be done, mainly for + * 3-way or call waiting scenarios. Presumably, here we're in a + * special 3-way scenario where the network needs a blank flash + * before being able to add the new participant. (This is not the + * case with all 3-way calls, just certain CDMA infrastructures.) + * + * Otherwise, there is no digit, display the last dialed + * number. Don't finish since the user may want to edit it. The + * user needs to press the dial button again, to dial it (general + * case described above). + */ + public void dialButtonPressed() { + if (isDigitsEmpty()) { // No number entered. + handleDialButtonClickWithEmptyDigits(); + } else { + final String number = mDigits.getText().toString(); + + // "persist.radio.otaspdial" is a temporary hack needed for one carrier's automated + // test equipment. + // TODO: clean it up. + if (number != null + && !TextUtils.isEmpty(mProhibitedPhoneNumberRegexp) + && number.matches(mProhibitedPhoneNumberRegexp) + && (SystemProperties.getInt("persist.radio.otaspdial", 0) != 1)) { + Log.i(TAG, "The phone number is prohibited explicitly by a rule."); + if (getActivity() != null) { + DialogFragment dialogFragment = ErrorDialogFragment.newInstance( + R.string.dialog_phone_call_prohibited_message); + dialogFragment.show(getFragmentManager(), "phone_prohibited_dialog"); + } + + // Clear the digits just in case. + mDigits.getText().clear(); + } else { + final Intent intent = CallUtil.getCallIntent(number, + (getActivity() instanceof DialtactsActivity ? + ((DialtactsActivity) getActivity()).getCallOrigin() : null)); + startActivity(intent); + mClearDigitsOnStop = true; + getActivity().finish(); + } + } + } + + private String getCallOrigin() { + return (getActivity() instanceof DialtactsActivity) ? + ((DialtactsActivity) getActivity()).getCallOrigin() : null; + } + + private void handleDialButtonClickWithEmptyDigits() { + if (phoneIsCdma() && phoneIsOffhook()) { + // This is really CDMA specific. On GSM is it possible + // to be off hook and wanted to add a 3rd party using + // the redial feature. + startActivity(newFlashIntent()); + } else { + if (!TextUtils.isEmpty(mLastNumberDialed)) { + // Recall the last number dialed. + mDigits.setText(mLastNumberDialed); + + // ...and move the cursor to the end of the digits string, + // so you'll be able to delete digits using the Delete + // button (just as if you had typed the number manually.) + // + // Note we use mDigits.getText().length() here, not + // mLastNumberDialed.length(), since the EditText widget now + // contains a *formatted* version of mLastNumberDialed (due to + // mTextWatcher) and its length may have changed. + mDigits.setSelection(mDigits.getText().length()); + } else { + // There's no "last number dialed" or the + // background query is still running. There's + // nothing useful for the Dial button to do in + // this case. Note: with a soft dial button, this + // can never happens since the dial button is + // disabled under these conditons. + playTone(ToneGenerator.TONE_PROP_NACK); + } + } + } + + /** + * Plays the specified tone for TONE_LENGTH_MS milliseconds. + */ + private void playTone(int tone) { + playTone(tone, TONE_LENGTH_MS); + } + + /** + * Play the specified tone for the specified milliseconds + * + * The tone is played locally, using the audio stream for phone calls. + * Tones are played only if the "Audible touch tones" user preference + * is checked, and are NOT played if the device is in silent mode. + * + * The tone length can be -1, meaning "keep playing the tone." If the caller does so, it should + * call stopTone() afterward. + * + * @param tone a tone code from {@link ToneGenerator} + * @param durationMs tone length. + */ + private void playTone(int tone, int durationMs) { + // if local tone playback is disabled, just return. + if (!mDTMFToneEnabled) { + return; + } + + // Also do nothing if the phone is in silent mode. + // We need to re-check the ringer mode for *every* playTone() + // call, rather than keeping a local flag that's updated in + // onResume(), since it's possible to toggle silent mode without + // leaving the current activity (via the ENDCALL-longpress menu.) + AudioManager audioManager = + (AudioManager) getActivity().getSystemService(Context.AUDIO_SERVICE); + int ringerMode = audioManager.getRingerMode(); + if ((ringerMode == AudioManager.RINGER_MODE_SILENT) + || (ringerMode == AudioManager.RINGER_MODE_VIBRATE)) { + return; + } + + synchronized (mToneGeneratorLock) { + if (mToneGenerator == null) { + Log.w(TAG, "playTone: mToneGenerator == null, tone: " + tone); + return; + } + + // Start the new tone (will stop any playing tone) + mToneGenerator.startTone(tone, durationMs); + } + } + + /** + * Stop the tone if it is played. + */ + private void stopTone() { + // if local tone playback is disabled, just return. + if (!mDTMFToneEnabled) { + return; + } + synchronized (mToneGeneratorLock) { + if (mToneGenerator == null) { + Log.w(TAG, "stopTone: mToneGenerator == null"); + return; + } + mToneGenerator.stopTone(); + } + } + + /** + * Brings up the "dialpad chooser" UI in place of the usual Dialer + * elements (the textfield/button and the dialpad underneath). + * + * We show this UI if the user brings up the Dialer while a call is + * already in progress, since there's a good chance we got here + * accidentally (and the user really wanted the in-call dialpad instead). + * So in this situation we display an intermediate UI that lets the user + * explicitly choose between the in-call dialpad ("Use touch tone + * keypad") and the regular Dialer ("Add call"). (Or, the option "Return + * to call in progress" just goes back to the in-call UI with no dialpad + * at all.) + * + * @param enabled If true, show the "dialpad chooser" instead + * of the regular Dialer UI + */ + private void showDialpadChooser(boolean enabled) { + // Check if onCreateView() is already called by checking one of View objects. + if (!isLayoutReady()) { + return; + } + + if (enabled) { + // Log.i(TAG, "Showing dialpad chooser!"); + if (mDigitsContainer != null) { + mDigitsContainer.setVisibility(View.GONE); + } else { + // mDigits is not enclosed by the container. Make the digits field itself gone. + mDigits.setVisibility(View.GONE); + } + if (mDialpad != null) mDialpad.setVisibility(View.GONE); + if (mDialButtonContainer != null) mDialButtonContainer.setVisibility(View.GONE); + + mDialpadChooser.setVisibility(View.VISIBLE); + + // Instantiate the DialpadChooserAdapter and hook it up to the + // ListView. We do this only once. + if (mDialpadChooserAdapter == null) { + mDialpadChooserAdapter = new DialpadChooserAdapter(getActivity()); + } + mDialpadChooser.setAdapter(mDialpadChooserAdapter); + } else { + // Log.i(TAG, "Displaying normal Dialer UI."); + if (mDigitsContainer != null) { + mDigitsContainer.setVisibility(View.VISIBLE); + } else { + mDigits.setVisibility(View.VISIBLE); + } + if (mDialpad != null) mDialpad.setVisibility(View.VISIBLE); + if (mDialButtonContainer != null) mDialButtonContainer.setVisibility(View.VISIBLE); + mDialpadChooser.setVisibility(View.GONE); + } + } + + /** + * @return true if we're currently showing the "dialpad chooser" UI. + */ + private boolean dialpadChooserVisible() { + return mDialpadChooser.getVisibility() == View.VISIBLE; + } + + /** + * Simple list adapter, binding to an icon + text label + * for each item in the "dialpad chooser" list. + */ + private static class DialpadChooserAdapter extends BaseAdapter { + private LayoutInflater mInflater; + + // Simple struct for a single "choice" item. + static class ChoiceItem { + String text; + Bitmap icon; + int id; + + public ChoiceItem(String s, Bitmap b, int i) { + text = s; + icon = b; + id = i; + } + } + + // IDs for the possible "choices": + static final int DIALPAD_CHOICE_USE_DTMF_DIALPAD = 101; + static final int DIALPAD_CHOICE_RETURN_TO_CALL = 102; + static final int DIALPAD_CHOICE_ADD_NEW_CALL = 103; + + private static final int NUM_ITEMS = 3; + private ChoiceItem mChoiceItems[] = new ChoiceItem[NUM_ITEMS]; + + public DialpadChooserAdapter(Context context) { + // Cache the LayoutInflate to avoid asking for a new one each time. + mInflater = LayoutInflater.from(context); + + // Initialize the possible choices. + // TODO: could this be specified entirely in XML? + + // - "Use touch tone keypad" + mChoiceItems[0] = new ChoiceItem( + context.getString(R.string.dialer_useDtmfDialpad), + BitmapFactory.decodeResource(context.getResources(), + R.drawable.ic_dialer_fork_tt_keypad), + DIALPAD_CHOICE_USE_DTMF_DIALPAD); + + // - "Return to call in progress" + mChoiceItems[1] = new ChoiceItem( + context.getString(R.string.dialer_returnToInCallScreen), + BitmapFactory.decodeResource(context.getResources(), + R.drawable.ic_dialer_fork_current_call), + DIALPAD_CHOICE_RETURN_TO_CALL); + + // - "Add call" + mChoiceItems[2] = new ChoiceItem( + context.getString(R.string.dialer_addAnotherCall), + BitmapFactory.decodeResource(context.getResources(), + R.drawable.ic_dialer_fork_add_call), + DIALPAD_CHOICE_ADD_NEW_CALL); + } + + @Override + public int getCount() { + return NUM_ITEMS; + } + + /** + * Return the ChoiceItem for a given position. + */ + @Override + public Object getItem(int position) { + return mChoiceItems[position]; + } + + /** + * Return a unique ID for each possible choice. + */ + @Override + public long getItemId(int position) { + return position; + } + + /** + * Make a view for each row. + */ + @Override + public View getView(int position, View convertView, ViewGroup parent) { + // When convertView is non-null, we can reuse it (there's no need + // to reinflate it.) + if (convertView == null) { + convertView = mInflater.inflate(R.layout.dialpad_chooser_list_item, null); + } + + TextView text = (TextView) convertView.findViewById(R.id.text); + text.setText(mChoiceItems[position].text); + + ImageView icon = (ImageView) convertView.findViewById(R.id.icon); + icon.setImageBitmap(mChoiceItems[position].icon); + + return convertView; + } + } + + /** + * Handle clicks from the dialpad chooser. + */ + @Override + public void onItemClick(AdapterView<?> parent, View v, int position, long id) { + DialpadChooserAdapter.ChoiceItem item = + (DialpadChooserAdapter.ChoiceItem) parent.getItemAtPosition(position); + int itemId = item.id; + switch (itemId) { + case DialpadChooserAdapter.DIALPAD_CHOICE_USE_DTMF_DIALPAD: + // Log.i(TAG, "DIALPAD_CHOICE_USE_DTMF_DIALPAD"); + // Fire off an intent to go back to the in-call UI + // with the dialpad visible. + returnToInCallScreen(true); + break; + + case DialpadChooserAdapter.DIALPAD_CHOICE_RETURN_TO_CALL: + // Log.i(TAG, "DIALPAD_CHOICE_RETURN_TO_CALL"); + // Fire off an intent to go back to the in-call UI + // (with the dialpad hidden). + returnToInCallScreen(false); + break; + + case DialpadChooserAdapter.DIALPAD_CHOICE_ADD_NEW_CALL: + // Log.i(TAG, "DIALPAD_CHOICE_ADD_NEW_CALL"); + // Ok, guess the user really did want to be here (in the + // regular Dialer) after all. Bring back the normal Dialer UI. + showDialpadChooser(false); + break; + + default: + Log.w(TAG, "onItemClick: unexpected itemId: " + itemId); + break; + } + } + + /** + * Returns to the in-call UI (where there's presumably a call in + * progress) in response to the user selecting "use touch tone keypad" + * or "return to call" from the dialpad chooser. + */ + private void returnToInCallScreen(boolean showDialpad) { + try { + ITelephony phone = ITelephony.Stub.asInterface(ServiceManager.checkService("phone")); + if (phone != null) phone.showCallScreenWithDialpad(showDialpad); + } catch (RemoteException e) { + Log.w(TAG, "phone.showCallScreenWithDialpad() failed", e); + } + + // Finally, finish() ourselves so that we don't stay on the + // activity stack. + // Note that we do this whether or not the showCallScreenWithDialpad() + // call above had any effect or not! (That call is a no-op if the + // phone is idle, which can happen if the current call ends while + // the dialpad chooser is up. In this case we can't show the + // InCallScreen, and there's no point staying here in the Dialer, + // so we just take the user back where he came from...) + getActivity().finish(); + } + + /** + * @return true if the phone is "in use", meaning that at least one line + * is active (ie. off hook or ringing or dialing). + */ + public boolean phoneIsInUse() { + return getTelephonyManager().getCallState() != TelephonyManager.CALL_STATE_IDLE; + } + + /** + * @return true if the phone is a CDMA phone type + */ + private boolean phoneIsCdma() { + return getTelephonyManager().getPhoneType() == TelephonyManager.PHONE_TYPE_CDMA; + } + + /** + * @return true if the phone state is OFFHOOK + */ + private boolean phoneIsOffhook() { + return getTelephonyManager().getCallState() == TelephonyManager.CALL_STATE_OFFHOOK; + } + + /** + * Returns true whenever any one of the options from the menu is selected. + * Code changes to support dialpad options + */ + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_2s_pause: + updateDialString(PAUSE); + return true; + case R.id.menu_add_wait: + updateDialString(WAIT); + return true; + default: + return false; + } + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + return onOptionsItemSelected(item); + } + + /** + * Updates the dial string (mDigits) after inserting a Pause character (,) + * or Wait character (;). + */ + private void updateDialString(char newDigit) { + if(newDigit != WAIT && newDigit != PAUSE) { + Log.wtf(TAG, "Not expected for anything other than PAUSE & WAIT"); + return; + } + + int selectionStart; + int selectionEnd; + + // SpannableStringBuilder editable_text = new SpannableStringBuilder(mDigits.getText()); + int anchor = mDigits.getSelectionStart(); + int point = mDigits.getSelectionEnd(); + + selectionStart = Math.min(anchor, point); + selectionEnd = Math.max(anchor, point); + + if (selectionStart == -1) { + selectionStart = selectionEnd = mDigits.length(); + } + + Editable digits = mDigits.getText(); + + if (canAddDigit(digits, selectionStart, selectionEnd, newDigit)) { + digits.replace(selectionStart, selectionEnd, Character.toString(newDigit)); + + if (selectionStart != selectionEnd) { + // Unselect: back to a regular cursor, just pass the character inserted. + mDigits.setSelection(selectionStart + 1); + } + } + } + + /** + * Update the enabledness of the "Dial" and "Backspace" buttons if applicable. + */ + private void updateDialAndDeleteButtonEnabledState() { + final boolean digitsNotEmpty = !isDigitsEmpty(); + + if (mDialButton != null) { + // On CDMA phones, if we're already on a call, we *always* + // enable the Dial button (since you can press it without + // entering any digits to send an empty flash.) + if (phoneIsCdma() && phoneIsOffhook()) { + mDialButton.setEnabled(true); + } else { + // Common case: GSM, or CDMA but not on a call. + // Enable the Dial button if some digits have + // been entered, or if there is a last dialed number + // that could be redialed. + mDialButton.setEnabled(digitsNotEmpty || + !TextUtils.isEmpty(mLastNumberDialed)); + } + } + mDelete.setEnabled(digitsNotEmpty); + } + + /** + * Check if voicemail is enabled/accessible. + * + * @return true if voicemail is enabled and accessibly. Note that this can be false + * "temporarily" after the app boot. + * @see TelephonyManager#getVoiceMailNumber() + */ + private boolean isVoicemailAvailable() { + try { + return getTelephonyManager().getVoiceMailNumber() != null; + } catch (SecurityException se) { + // Possibly no READ_PHONE_STATE privilege. + Log.w(TAG, "SecurityException is thrown. Maybe privilege isn't sufficient."); + } + return false; + } + + /** + * Returns true of the newDigit parameter can be added at the current selection + * point, otherwise returns false. + * Only prevents input of WAIT and PAUSE digits at an unsupported position. + * Fails early if start == -1 or start is larger than end. + */ + @VisibleForTesting + /* package */ static boolean canAddDigit(CharSequence digits, int start, int end, + char newDigit) { + if(newDigit != WAIT && newDigit != PAUSE) { + Log.wtf(TAG, "Should not be called for anything other than PAUSE & WAIT"); + return false; + } + + // False if no selection, or selection is reversed (end < start) + if (start == -1 || end < start) { + return false; + } + + // unsupported selection-out-of-bounds state + if (start > digits.length() || end > digits.length()) return false; + + // Special digit cannot be the first digit + if (start == 0) return false; + + if (newDigit == WAIT) { + // preceding char is ';' (WAIT) + if (digits.charAt(start - 1) == WAIT) return false; + + // next char is ';' (WAIT) + if ((digits.length() > end) && (digits.charAt(end) == WAIT)) return false; + } + + return true; + } + + /** + * @return true if the widget with the phone number digits is empty. + */ + private boolean isDigitsEmpty() { + return mDigits.length() == 0; + } + + /** + * Starts the asyn query to get the last dialed/outgoing + * number. When the background query finishes, mLastNumberDialed + * is set to the last dialed number or an empty string if none + * exists yet. + */ + private void queryLastOutgoingCall() { + mLastNumberDialed = EMPTY_NUMBER; + CallLogAsync.GetLastOutgoingCallArgs lastCallArgs = + new CallLogAsync.GetLastOutgoingCallArgs( + getActivity(), + new CallLogAsync.OnLastOutgoingCallComplete() { + @Override + public void lastOutgoingCall(String number) { + // TODO: Filter out emergency numbers if + // the carrier does not want redial for + // these. + mLastNumberDialed = number; + updateDialAndDeleteButtonEnabledState(); + } + }); + mCallLog.getLastOutgoingCall(lastCallArgs); + } + + private Intent newFlashIntent() { + final Intent intent = CallUtil.getCallIntent(EMPTY_NUMBER); + intent.putExtra(EXTRA_SEND_EMPTY_FLASH, true); + return intent; + } + + private String mLastDigitsForSmartDial; + + private void loadSmartDialEntries() { + if (!mSmartDialEnabled || mSmartDialAdapter == null) { + // No smart dial views. Landscape? + return; + } + + // Update only when the digits have changed. + final String digits = SmartDialNameMatcher.normalizeNumber(mDigits.getText().toString(), + mSmartDialMap); + if (TextUtils.equals(digits, mLastDigitsForSmartDial)) { + return; + } + mLastDigitsForSmartDial = digits; + + if (digits.length() < 1) { + mSmartDialAdapter.clear(); + } else { + final SmartDialLoaderTask task = new SmartDialLoaderTask(this, digits, getActivity()); + task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new String[] {}); + } + } + + @Override + public void setSmartDialAdapterEntries(List<SmartDialEntry> data, String query) { + if (data == null || query == null || !query.equals(mLastDigitsForSmartDial)) { + return; + } + mSmartDialAdapter.setEntries(data); + } + + private void initializeSmartDialingState() { + // Handle smart dialing related state + if (mSmartDialEnabled) { + mSmartDialContainer.setVisibility(View.VISIBLE); + + if (DEBUG) { + Log.w(TAG, "Creating smart dial database"); + } + mDialerDatabaseHelper.startSmartDialUpdateThread(); + } else { + if (mSmartDialContainer != null) { + mSmartDialContainer.setVisibility(View.GONE); + } + } + } + + private class OnSmartDialLongClick implements View.OnLongClickListener { + @Override + public boolean onLongClick(View view) { + final SmartDialEntry entry = (SmartDialEntry) view.getTag(); + if (entry == null) return false; // just in case. + mClearDigitsOnStop = true; + // Show the phone number disambiguation dialog without using the primary + // phone number so that the user can decide which number to call + PhoneNumberInteraction.startInteractionForPhoneCall( + (TransactionSafeActivity) getActivity(), entry.contactUri, false); + return true; + } + } + + private class OnSmartDialShortClick implements View.OnClickListener { + @Override + public void onClick(View view) { + final SmartDialEntry entry = (SmartDialEntry) view.getTag(); + if (entry == null) return; // just in case. + // Dial the displayed phone number immediately + final Intent intent = CallUtil.getCallIntent(entry.phoneNumber.toString(), + (getActivity() instanceof DialtactsActivity ? + ((DialtactsActivity) getActivity()).getCallOrigin() : null)); + startActivity(intent); + mClearDigitsOnStop = true; + } + } +} diff --git a/src/com/android/dialer/list/NewPhoneFavoriteFragment.java b/src/com/android/dialer/list/NewPhoneFavoriteFragment.java new file mode 100644 index 000000000..e694d6067 --- /dev/null +++ b/src/com/android/dialer/list/NewPhoneFavoriteFragment.java @@ -0,0 +1,573 @@ +/* + * Copyright (C) 2011 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.dialer.list; + +import android.app.Activity; +import android.app.Fragment; +import android.app.LoaderManager; +import android.content.CursorLoader; +import android.content.Intent; +import android.content.Loader; +import android.database.Cursor; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Directory; +import android.provider.Settings; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.AbsListView; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.FrameLayout; +import android.widget.ListView; +import android.widget.TextView; + +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.ContactTileLoaderFactory; +import com.android.contacts.common.dialog.ClearFrequentsDialog; +import com.android.contacts.common.list.ContactListFilter; +import com.android.contacts.common.list.ContactListFilterController; +import com.android.contacts.common.list.ContactListItemView; +import com.android.contacts.common.list.ContactTileAdapter; +import com.android.contacts.common.list.ContactTileView; +import com.android.contacts.common.list.PhoneNumberListAdapter; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.contacts.common.util.AccountFilterUtil; +import com.android.contacts.common.interactions.ImportExportDialogFragment; +import com.android.dialer.DialtactsActivity; +import com.android.dialer.R; + +/** + * Fragment for Phone UI's favorite screen. + * + * This fragment contains three kinds of contacts in one screen: "starred", "frequent", and "all" + * contacts. To show them at once, this merges results from {@link com.android.contacts.common.list.ContactTileAdapter} and + * {@link com.android.contacts.common.list.PhoneNumberListAdapter} into one unified list using {@link PhoneFavoriteMergedAdapter}. + * A contact filter header is also inserted between those adapters' results. + */ +public class NewPhoneFavoriteFragment extends Fragment implements OnItemClickListener { + private static final String TAG = NewPhoneFavoriteFragment.class.getSimpleName(); + private static final boolean DEBUG = false; + + /** + * Used with LoaderManager. + */ + private static int LOADER_ID_CONTACT_TILE = 1; + private static int LOADER_ID_ALL_CONTACTS = 2; + + private static final String KEY_FILTER = "filter"; + + private static final int REQUEST_CODE_ACCOUNT_FILTER = 1; + + public interface Listener { + public void onContactSelected(Uri contactUri); + public void onCallNumberDirectly(String phoneNumber); + } + + private class ContactTileLoaderListener implements LoaderManager.LoaderCallbacks<Cursor> { + @Override + public CursorLoader onCreateLoader(int id, Bundle args) { + if (DEBUG) Log.d(TAG, "ContactTileLoaderListener#onCreateLoader."); + return ContactTileLoaderFactory.createStrequentPhoneOnlyLoader(getActivity()); + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor data) { + if (DEBUG) Log.d(TAG, "ContactTileLoaderListener#onLoadFinished"); + mContactTileAdapter.setContactCursor(data); + + if (mAllContactsForceReload) { + mAllContactsAdapter.onDataReload(); + // Use restartLoader() to make LoaderManager to load the section again. + getLoaderManager().restartLoader( + LOADER_ID_ALL_CONTACTS, null, mAllContactsLoaderListener); + } else if (!mAllContactsLoaderStarted) { + // Load "all" contacts if not loaded yet. + getLoaderManager().initLoader( + LOADER_ID_ALL_CONTACTS, null, mAllContactsLoaderListener); + } + mAllContactsForceReload = false; + mAllContactsLoaderStarted = true; + + // Show the filter header with "loading" state. + updateFilterHeaderView(); + mAccountFilterHeader.setVisibility(View.VISIBLE); + + // invalidate the options menu if needed + invalidateOptionsMenuIfNeeded(); + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + if (DEBUG) Log.d(TAG, "ContactTileLoaderListener#onLoaderReset. "); + } + } + + private class AllContactsLoaderListener implements LoaderManager.LoaderCallbacks<Cursor> { + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + if (DEBUG) Log.d(TAG, "AllContactsLoaderListener#onCreateLoader"); + CursorLoader loader = new CursorLoader(getActivity(), null, null, null, null, null); + mAllContactsAdapter.configureLoader(loader, Directory.DEFAULT); + return loader; + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor data) { + if (DEBUG) Log.d(TAG, "AllContactsLoaderListener#onLoadFinished"); + mAllContactsAdapter.changeCursor(0, data); + updateFilterHeaderView(); + mHandler.removeMessages(MESSAGE_SHOW_LOADING_EFFECT); + mLoadingView.setVisibility(View.VISIBLE); + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + if (DEBUG) Log.d(TAG, "AllContactsLoaderListener#onLoaderReset. "); + } + } + + private class ContactTileAdapterListener implements ContactTileView.Listener { + @Override + public void onContactSelected(Uri contactUri, Rect targetRect) { + if (mListener != null) { + mListener.onContactSelected(contactUri); + } + } + + @Override + public void onCallNumberDirectly(String phoneNumber) { + if (mListener != null) { + mListener.onCallNumberDirectly(phoneNumber); + } + } + + @Override + public int getApproximateTileWidth() { + return getView().getWidth() / mContactTileAdapter.getColumnCount(); + } + } + + private class FilterHeaderClickListener implements OnClickListener { + @Override + public void onClick(View view) { + AccountFilterUtil.startAccountFilterActivityForResult( + NewPhoneFavoriteFragment.this, + REQUEST_CODE_ACCOUNT_FILTER, + mFilter); + } + } + + private class ContactsPreferenceChangeListener + implements ContactsPreferences.ChangeListener { + @Override + public void onChange() { + if (loadContactsPreferences()) { + requestReloadAllContacts(); + } + } + } + + private class ScrollListener implements ListView.OnScrollListener { + private boolean mShouldShowFastScroller; + @Override + public void onScroll(AbsListView view, + int firstVisibleItem, int visibleItemCount, int totalItemCount) { + // FastScroller should be visible only when the user is seeing "all" contacts section. + final boolean shouldShow = mAdapter.shouldShowFirstScroller(firstVisibleItem); + if (shouldShow != mShouldShowFastScroller) { + mListView.setVerticalScrollBarEnabled(shouldShow); + mListView.setFastScrollEnabled(shouldShow); + mListView.setFastScrollAlwaysVisible(shouldShow); + mShouldShowFastScroller = shouldShow; + } + } + + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + } + } + + private static final int MESSAGE_SHOW_LOADING_EFFECT = 1; + private static final int LOADING_EFFECT_DELAY = 500; // ms + private final Handler mHandler = new Handler() { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case MESSAGE_SHOW_LOADING_EFFECT: + mLoadingView.setVisibility(View.VISIBLE); + break; + } + } + }; + + private Listener mListener; + private PhoneFavoriteMergedAdapter mAdapter; + private ContactTileAdapter mContactTileAdapter; + private PhoneNumberListAdapter mAllContactsAdapter; + + /** + * true when the loader for {@link PhoneNumberListAdapter} has started already. + */ + private boolean mAllContactsLoaderStarted; + /** + * true when the loader for {@link PhoneNumberListAdapter} must reload "all" contacts again. + * It typically happens when {@link ContactsPreferences} has changed its settings + * (display order and sort order) + */ + private boolean mAllContactsForceReload; + + private ContactsPreferences mContactsPrefs; + private ContactListFilter mFilter; + + private TextView mEmptyView; + private ListView mListView; + /** + * Layout containing {@link #mAccountFilterHeader}. Used to limit area being "pressed". + */ + private FrameLayout mAccountFilterHeaderContainer; + private View mAccountFilterHeader; + + /** + * Layout used when contacts load is slower than expected and thus "loading" view should be + * shown. + */ + private View mLoadingView; + + private final ContactTileView.Listener mContactTileAdapterListener = + new ContactTileAdapterListener(); + private final LoaderManager.LoaderCallbacks<Cursor> mContactTileLoaderListener = + new ContactTileLoaderListener(); + private final LoaderManager.LoaderCallbacks<Cursor> mAllContactsLoaderListener = + new AllContactsLoaderListener(); + private final OnClickListener mFilterHeaderClickListener = new FilterHeaderClickListener(); + private final ContactsPreferenceChangeListener mContactsPreferenceChangeListener = + new ContactsPreferenceChangeListener(); + private final ScrollListener mScrollListener = new ScrollListener(); + + private boolean mOptionsMenuHasFrequents; + + @Override + public void onAttach(Activity activity) { + if (DEBUG) Log.d(TAG, "onAttach()"); + super.onAttach(activity); + + mContactsPrefs = new ContactsPreferences(activity); + + // Construct two base adapters which will become part of PhoneFavoriteMergedAdapter. + // We don't construct the resultant adapter at this moment since it requires LayoutInflater + // that will be available on onCreateView(). + + mContactTileAdapter = new ContactTileAdapter(activity, mContactTileAdapterListener, + getResources().getInteger(R.integer.contact_tile_column_count_in_favorites), + ContactTileAdapter.DisplayType.STREQUENT_PHONE_ONLY); + mContactTileAdapter.setPhotoLoader(ContactPhotoManager.getInstance(activity)); + + // Setup the "all" adapter manually. See also the setup logic in ContactEntryListFragment. + mAllContactsAdapter = new PhoneNumberListAdapter(activity); + mAllContactsAdapter.setDisplayPhotos(true); + mAllContactsAdapter.setQuickContactEnabled(true); + mAllContactsAdapter.setSearchMode(false); + mAllContactsAdapter.setIncludeProfile(false); + mAllContactsAdapter.setSelectionVisible(false); + mAllContactsAdapter.setDarkTheme(true); + mAllContactsAdapter.setPhotoLoader(ContactPhotoManager.getInstance(activity)); + // Disable directory header. + mAllContactsAdapter.setHasHeader(0, false); + // Show A-Z section index. + mAllContactsAdapter.setSectionHeaderDisplayEnabled(true); + // Disable pinned header. It doesn't work with this fragment. + mAllContactsAdapter.setPinnedPartitionHeadersEnabled(false); + // Put photos on START (LEFT in LTR layout direction and RIGHT in RTL layout direction) + // for consistency with "frequent" contacts section. + mAllContactsAdapter.setPhotoPosition(ContactListItemView.getDefaultPhotoPosition( + true /* opposite */ )); + + // Use Callable.CONTENT_URI which will include not only phone numbers but also SIP + // addresses. + mAllContactsAdapter.setUseCallableUri(true); + + mAllContactsAdapter.setContactNameDisplayOrder(mContactsPrefs.getDisplayOrder()); + mAllContactsAdapter.setSortOrder(mContactsPrefs.getSortOrder()); + } + + @Override + public void onCreate(Bundle savedState) { + if (DEBUG) Log.d(TAG, "onCreate()"); + super.onCreate(savedState); + if (savedState != null) { + mFilter = savedState.getParcelable(KEY_FILTER); + + if (mFilter != null) { + mAllContactsAdapter.setFilter(mFilter); + } + } + setHasOptionsMenu(true); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putParcelable(KEY_FILTER, mFilter); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + final View listLayout = inflater.inflate( + R.layout.phone_contact_tile_list, container, false); + + mListView = (ListView) listLayout.findViewById(R.id.contact_tile_list); + mListView.setItemsCanFocus(true); + mListView.setOnItemClickListener(this); + mListView.setVerticalScrollBarEnabled(false); + mListView.setVerticalScrollbarPosition(View.SCROLLBAR_POSITION_RIGHT); + mListView.setScrollBarStyle(ListView.SCROLLBARS_OUTSIDE_OVERLAY); + + // Create the account filter header but keep it hidden until "all" contacts are loaded. + mAccountFilterHeaderContainer = new FrameLayout(getActivity(), null); + mAccountFilterHeader = inflater.inflate(R.layout.account_filter_header_for_phone_favorite, + mListView, false); + mAccountFilterHeader.setOnClickListener(mFilterHeaderClickListener); + mAccountFilterHeaderContainer.addView(mAccountFilterHeader); + + mLoadingView = inflater.inflate(R.layout.phone_loading_contacts, mListView, false); + + mAdapter = new PhoneFavoriteMergedAdapter(getActivity(), + mContactTileAdapter, mAccountFilterHeaderContainer, mAllContactsAdapter, + mLoadingView); + + mListView.setAdapter(mAdapter); + + mListView.setOnScrollListener(mScrollListener); + mListView.setFastScrollEnabled(false); + mListView.setFastScrollAlwaysVisible(false); + + mEmptyView = (TextView) listLayout.findViewById(R.id.contact_tile_list_empty); + mEmptyView.setText(getString(R.string.listTotalAllContactsZero)); + mListView.setEmptyView(mEmptyView); + + updateFilterHeaderView(); + + return listLayout; + } + + private boolean isOptionsMenuChanged() { + return mOptionsMenuHasFrequents != hasFrequents(); + } + + private void invalidateOptionsMenuIfNeeded() { + if (isOptionsMenuChanged()) { + getActivity().invalidateOptionsMenu(); + } + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.phone_favorite_options, menu); + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + final MenuItem clearFrequents = menu.findItem(R.id.menu_clear_frequents); + mOptionsMenuHasFrequents = hasFrequents(); + clearFrequents.setVisible(mOptionsMenuHasFrequents); + } + + private boolean hasFrequents() { + return mContactTileAdapter.getNumFrequents() > 0; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_import_export: + // We hard-code the "contactsAreAvailable" argument because doing it properly would + // involve querying a {@link ProviderStatusLoader}, which we don't want to do right + // now in Dialtacts for (potential) performance reasons. Compare with how it is + // done in {@link PeopleActivity}. + ImportExportDialogFragment.show(getFragmentManager(), true, + DialtactsActivity.class); + return true; + case R.id.menu_accounts: + final Intent intent = new Intent(Settings.ACTION_SYNC_SETTINGS); + intent.putExtra(Settings.EXTRA_AUTHORITIES, new String[] { + ContactsContract.AUTHORITY + }); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); + startActivity(intent); + return true; + case R.id.menu_clear_frequents: + ClearFrequentsDialog.show(getFragmentManager()); + return true; + } + return false; + } + + @Override + public void onStart() { + super.onStart(); + + mContactsPrefs.registerChangeListener(mContactsPreferenceChangeListener); + + // If ContactsPreferences has changed, we need to reload "all" contacts with the new + // settings. If mAllContactsFoarceReload is already true, it should be kept. + if (loadContactsPreferences()) { + mAllContactsForceReload = true; + } + + // Use initLoader() instead of restartLoader() to refraining unnecessary reload. + // This method call implicitly assures ContactTileLoaderListener's onLoadFinished() will + // be called, on which we'll check if "all" contacts should be reloaded again or not. + getLoaderManager().initLoader(LOADER_ID_CONTACT_TILE, null, mContactTileLoaderListener); + + // Delay showing "loading" view until certain amount of time so that users won't see + // instant flash of the view when the contacts load is fast enough. + // This will be kept shown until both tile and all sections are loaded. + mLoadingView.setVisibility(View.INVISIBLE); + mHandler.sendEmptyMessageDelayed(MESSAGE_SHOW_LOADING_EFFECT, LOADING_EFFECT_DELAY); + } + + @Override + public void onStop() { + super.onStop(); + mContactsPrefs.unregisterChangeListener(); + } + + /** + * {@inheritDoc} + * + * This is only effective for elements provided by {@link #mContactTileAdapter}. + * {@link #mContactTileAdapter} has its own logic for click events. + */ + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + final int contactTileAdapterCount = mContactTileAdapter.getCount(); + if (position <= contactTileAdapterCount) { + Log.e(TAG, "onItemClick() event for unexpected position. " + + "The position " + position + " is before \"all\" section. Ignored."); + } else { + final int localPosition = position - mContactTileAdapter.getCount() - 1; + if (mListener != null) { + mListener.onContactSelected(mAllContactsAdapter.getDataUri(localPosition)); + } + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == REQUEST_CODE_ACCOUNT_FILTER) { + if (getActivity() != null) { + AccountFilterUtil.handleAccountFilterResult( + ContactListFilterController.getInstance(getActivity()), resultCode, data); + } else { + Log.e(TAG, "getActivity() returns null during Fragment#onActivityResult()"); + } + } + } + + private boolean loadContactsPreferences() { + if (mContactsPrefs == null || mAllContactsAdapter == null) { + return false; + } + + boolean changed = false; + final int currentDisplayOrder = mContactsPrefs.getDisplayOrder(); + if (mAllContactsAdapter.getContactNameDisplayOrder() != currentDisplayOrder) { + mAllContactsAdapter.setContactNameDisplayOrder(currentDisplayOrder); + changed = true; + } + + final int currentSortOrder = mContactsPrefs.getSortOrder(); + if (mAllContactsAdapter.getSortOrder() != currentSortOrder) { + mAllContactsAdapter.setSortOrder(currentSortOrder); + changed = true; + } + + return changed; + } + + /** + * Requests to reload "all" contacts. If the section is already loaded, this method will + * force reloading it now. If the section isn't loaded yet, the actual load may be done later + * (on {@link #onStart()}. + */ + private void requestReloadAllContacts() { + if (DEBUG) { + Log.d(TAG, "requestReloadAllContacts()" + + " mAllContactsAdapter: " + mAllContactsAdapter + + ", mAllContactsLoaderStarted: " + mAllContactsLoaderStarted); + } + + if (mAllContactsAdapter == null || !mAllContactsLoaderStarted) { + // Remember this request until next load on onStart(). + mAllContactsForceReload = true; + return; + } + + if (DEBUG) Log.d(TAG, "Reload \"all\" contacts now."); + + mAllContactsAdapter.onDataReload(); + // Use restartLoader() to make LoaderManager to load the section again. + getLoaderManager().restartLoader(LOADER_ID_ALL_CONTACTS, null, mAllContactsLoaderListener); + } + + private void updateFilterHeaderView() { + final ContactListFilter filter = getFilter(); + if (mAccountFilterHeader == null || mAllContactsAdapter == null || filter == null) { + return; + } + AccountFilterUtil.updateAccountFilterTitleForPhone(mAccountFilterHeader, filter, true); + } + + public ContactListFilter getFilter() { + return mFilter; + } + + public void setFilter(ContactListFilter filter) { + if ((mFilter == null && filter == null) || (mFilter != null && mFilter.equals(filter))) { + return; + } + + if (DEBUG) { + Log.d(TAG, "setFilter(). old filter (" + mFilter + + ") will be replaced with new filter (" + filter + ")"); + } + + mFilter = filter; + + if (mAllContactsAdapter != null) { + mAllContactsAdapter.setFilter(mFilter); + requestReloadAllContacts(); + updateFilterHeaderView(); + } + } + + public void setListener(Listener listener) { + mListener = listener; + } +} diff --git a/src/com/android/dialer/list/NewPhoneFavoriteMergedAdapter.java b/src/com/android/dialer/list/NewPhoneFavoriteMergedAdapter.java new file mode 100644 index 000000000..047609f7d --- /dev/null +++ b/src/com/android/dialer/list/NewPhoneFavoriteMergedAdapter.java @@ -0,0 +1,307 @@ +/* + * Copyright (C) 2011 Google Inc. + * Licensed to 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.dialer.list; + +import android.content.Context; +import android.content.res.Resources; +import android.database.DataSetObserver; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.FrameLayout; +import android.widget.SectionIndexer; + +import com.android.contacts.common.list.ContactEntryListAdapter; +import com.android.contacts.common.list.ContactListItemView; +import com.android.contacts.common.list.ContactTileAdapter; +import com.android.dialer.R; + +/** + * An adapter that combines items from {@link com.android.contacts.common.list.ContactTileAdapter} and + * {@link com.android.contacts.common.list.ContactEntryListAdapter} into a single list. In between those two results, + * an account filter header will be inserted. + */ +public class NewPhoneFavoriteMergedAdapter extends BaseAdapter implements SectionIndexer { + + private class CustomDataSetObserver extends DataSetObserver { + @Override + public void onChanged() { + notifyDataSetChanged(); + } + } + + private final ContactTileAdapter mContactTileAdapter; + private final ContactEntryListAdapter mContactEntryListAdapter; + private final View mAccountFilterHeaderContainer; + private final View mLoadingView; + + private final int mItemPaddingLeft; + private final int mItemPaddingRight; + + // Make frequent header consistent with account filter header. + private final int mFrequentHeaderPaddingTop; + + private final DataSetObserver mObserver; + + public NewPhoneFavoriteMergedAdapter(Context context, + ContactTileAdapter contactTileAdapter, + View accountFilterHeaderContainer, + ContactEntryListAdapter contactEntryListAdapter, + View loadingView) { + Resources resources = context.getResources(); + mItemPaddingLeft = resources.getDimensionPixelSize(R.dimen.detail_item_side_margin); + mItemPaddingRight = resources.getDimensionPixelSize(R.dimen.list_visible_scrollbar_padding); + mFrequentHeaderPaddingTop = resources.getDimensionPixelSize( + R.dimen.contact_browser_list_top_margin); + mContactTileAdapter = contactTileAdapter; + mContactEntryListAdapter = contactEntryListAdapter; + + mAccountFilterHeaderContainer = accountFilterHeaderContainer; + + mObserver = new CustomDataSetObserver(); + mContactTileAdapter.registerDataSetObserver(mObserver); + mContactEntryListAdapter.registerDataSetObserver(mObserver); + + mLoadingView = loadingView; + } + + @Override + public boolean isEmpty() { + // Cannot use the super's method here because we add extra rows in getCount() to account + // for headers + return mContactTileAdapter.getCount() + mContactEntryListAdapter.getCount() == 0; + } + + @Override + public int getCount() { + final int contactTileAdapterCount = mContactTileAdapter.getCount(); + final int contactEntryListAdapterCount = mContactEntryListAdapter.getCount(); + if (mContactEntryListAdapter.isLoading()) { + // Hide "all" contacts during its being loaded. Instead show "loading" view. + // + // "+2" for mAccountFilterHeaderContainer and mLoadingView + return contactTileAdapterCount + 2; + } else { + // "+1" for mAccountFilterHeaderContainer + return contactTileAdapterCount + contactEntryListAdapterCount + 1; + } + } + + @Override + public Object getItem(int position) { + final int contactTileAdapterCount = mContactTileAdapter.getCount(); + final int contactEntryListAdapterCount = mContactEntryListAdapter.getCount(); + if (position < contactTileAdapterCount) { // For "tile" and "frequent" sections + return mContactTileAdapter.getItem(position); + } else if (position == contactTileAdapterCount) { // For "all" section's account header + return mAccountFilterHeaderContainer; + } else { // For "all" section + if (mContactEntryListAdapter.isLoading()) { // "All" section is being loaded. + return mLoadingView; + } else { + // "-1" for mAccountFilterHeaderContainer + final int localPosition = position - contactTileAdapterCount - 1; + return mContactTileAdapter.getItem(localPosition); + } + } + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getViewTypeCount() { + // "+2" for mAccountFilterHeaderContainer and mLoadingView + return (mContactTileAdapter.getViewTypeCount() + + mContactEntryListAdapter.getViewTypeCount() + + 2); + } + + @Override + public int getItemViewType(int position) { + final int contactTileAdapterCount = mContactTileAdapter.getCount(); + final int contactEntryListAdapterCount = mContactEntryListAdapter.getCount(); + // There should be four kinds of types that are usually used, and one more exceptional + // type (IGNORE_ITEM_VIEW_TYPE), which sometimes comes from mContactTileAdapter. + // + // The four ordinary view types have the index equal to or more than 0, and less than + // mContactTileAdapter.getViewTypeCount()+ mContactEntryListAdapter.getViewTypeCount() + 2. + // (See also this class's getViewTypeCount()) + // + // We have those values for: + // - The view types mContactTileAdapter originally has + // - The view types mContactEntryListAdapter originally has + // - mAccountFilterHeaderContainer ("all" section's account header), and + // - mLoadingView + // + // Those types should not be mixed, so we have a different range for each kinds of types: + // - Types for mContactTileAdapter ("tile" and "frequent" sections) + // They should have the index, >=0 and <mContactTileAdapter.getViewTypeCount() + // + // - Types for mContactEntryListAdapter ("all" sections) + // They should have the index, >=mContactTileAdapter.getViewTypeCount() and + // <(mContactTileAdapter.getViewTypeCount() + mContactEntryListAdapter.getViewTypeCount()) + // + // - Type for "all" section's account header + // It should have the exact index + // mContactTileAdapter.getViewTypeCount()+ mContactEntryListAdapter.getViewTypeCount() + // + // - Type for "loading" view used during "all" section is being loaded. + // It should have the exact index + // mContactTileAdapter.getViewTypeCount()+ mContactEntryListAdapter.getViewTypeCount() + 1 + // + // As an exception, IGNORE_ITEM_VIEW_TYPE (-1) will be remained as is, which will be used + // by framework's Adapter implementation and thus should be left as is. + if (position < contactTileAdapterCount) { // For "tile" and "frequent" sections + return mContactTileAdapter.getItemViewType(position); + } else if (position == contactTileAdapterCount) { // For "all" section's account header + return mContactTileAdapter.getViewTypeCount() + + mContactEntryListAdapter.getViewTypeCount(); + } else { // For "all" section + if (mContactEntryListAdapter.isLoading()) { // "All" section is being loaded. + return mContactTileAdapter.getViewTypeCount() + + mContactEntryListAdapter.getViewTypeCount() + 1; + } else { + // "-1" for mAccountFilterHeaderContainer + final int localPosition = position - contactTileAdapterCount - 1; + final int type = mContactEntryListAdapter.getItemViewType(localPosition); + // IGNORE_ITEM_VIEW_TYPE must be handled differently. + return (type < 0) ? type : type + mContactTileAdapter.getViewTypeCount(); + } + } + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + final int contactTileAdapterCount = mContactTileAdapter.getCount(); + final int contactEntryListAdapterCount = mContactEntryListAdapter.getCount(); + + // Obtain a View relevant for that position, and adjust its horizontal padding. Each + // View has different implementation, so we use different way to control those padding. + if (position < contactTileAdapterCount) { // For "tile" and "frequent" sections + final View view = mContactTileAdapter.getView(position, convertView, parent); + final int frequentHeaderPosition = mContactTileAdapter.getFrequentHeaderPosition(); + if (position < frequentHeaderPosition) { // "starred" contacts + // No padding adjustment. + } else if (position == frequentHeaderPosition) { + view.setPadding(mItemPaddingLeft, mFrequentHeaderPaddingTop, + mItemPaddingRight, view.getPaddingBottom()); + } else { + // Views for "frequent" contacts use FrameLayout's margins instead of padding. + final FrameLayout frameLayout = (FrameLayout) view; + final View child = frameLayout.getChildAt(0); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT); + params.setMargins(mItemPaddingLeft, 0, mItemPaddingRight, 0); + child.setLayoutParams(params); + } + return view; + } else if (position == contactTileAdapterCount) { // For "all" section's account header + mAccountFilterHeaderContainer.setPadding(mItemPaddingLeft, + mAccountFilterHeaderContainer.getPaddingTop(), + mItemPaddingRight, + mAccountFilterHeaderContainer.getPaddingBottom()); + + // Show a single "No Contacts" label under the "all" section account header + // if no contacts are displayed. + mAccountFilterHeaderContainer.findViewById( + R.id.contact_list_all_empty).setVisibility( + contactEntryListAdapterCount == 0 ? View.VISIBLE : View.GONE); + return mAccountFilterHeaderContainer; + } else { // For "all" section + if (mContactEntryListAdapter.isLoading()) { // "All" section is being loaded. + mLoadingView.setPadding(mItemPaddingLeft, + mLoadingView.getPaddingTop(), + mItemPaddingRight, + mLoadingView.getPaddingBottom()); + return mLoadingView; + } else { + // "-1" for mAccountFilterHeaderContainer + final int localPosition = position - contactTileAdapterCount - 1; + final ContactListItemView itemView = (ContactListItemView) + mContactEntryListAdapter.getView(localPosition, convertView, null); + itemView.setPadding(mItemPaddingLeft, itemView.getPaddingTop(), + mItemPaddingRight, itemView.getPaddingBottom()); + itemView.setSelectionBoundsHorizontalMargin(mItemPaddingLeft, mItemPaddingRight); + return itemView; + } + } + } + + @Override + public boolean areAllItemsEnabled() { + // If "all" section is being loaded we'll show mLoadingView, which is not enabled. + // Otherwise check the all the other components in the ListView and return appropriate + // result. + return !mContactEntryListAdapter.isLoading() + && (mContactTileAdapter.areAllItemsEnabled() + && mAccountFilterHeaderContainer.isEnabled() + && mContactEntryListAdapter.areAllItemsEnabled()); + } + + @Override + public boolean isEnabled(int position) { + final int contactTileAdapterCount = mContactTileAdapter.getCount(); + final int contactEntryListAdapterCount = mContactEntryListAdapter.getCount(); + if (position < contactTileAdapterCount) { // For "tile" and "frequent" sections + return mContactTileAdapter.isEnabled(position); + } else if (position == contactTileAdapterCount) { // For "all" section's account header + // This will be handled by View's onClick event instead of ListView's onItemClick event. + return false; + } else { // For "all" section + if (mContactEntryListAdapter.isLoading()) { // "All" section is being loaded. + return false; + } else { + // "-1" for mAccountFilterHeaderContainer + final int localPosition = position - contactTileAdapterCount - 1; + return mContactEntryListAdapter.isEnabled(localPosition); + } + } + } + + @Override + public int getPositionForSection(int sectionIndex) { + final int contactTileAdapterCount = mContactTileAdapter.getCount(); + final int localPosition = mContactEntryListAdapter.getPositionForSection(sectionIndex); + return contactTileAdapterCount + 1 + localPosition; + } + + @Override + public int getSectionForPosition(int position) { + final int contactTileAdapterCount = mContactTileAdapter.getCount(); + if (position <= contactTileAdapterCount) { + return 0; + } else { + // "-1" for mAccountFilterHeaderContainer + final int localPosition = position - contactTileAdapterCount - 1; + return mContactEntryListAdapter.getSectionForPosition(localPosition); + } + } + + @Override + public Object[] getSections() { + return mContactEntryListAdapter.getSections(); + } + + public boolean shouldShowFirstScroller(int firstVisibleItem) { + final int contactTileAdapterCount = mContactTileAdapter.getCount(); + return firstVisibleItem > contactTileAdapterCount; + } +} |