diff options
Diffstat (limited to 'java/com/android/dialer/app/list')
20 files changed, 4367 insertions, 0 deletions
diff --git a/java/com/android/dialer/app/list/AllContactsFragment.java b/java/com/android/dialer/app/list/AllContactsFragment.java new file mode 100644 index 000000000..093e8f384 --- /dev/null +++ b/java/com/android/dialer/app/list/AllContactsFragment.java @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2013 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.app.list; + +import static android.Manifest.permission.READ_CONTACTS; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.Loader; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.QuickContact; +import android.support.annotation.Nullable; +import android.support.v13.app.FragmentCompat; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import com.android.contacts.common.list.ContactEntryListAdapter; +import com.android.contacts.common.list.ContactEntryListFragment; +import com.android.contacts.common.list.ContactListFilter; +import com.android.contacts.common.list.DefaultContactListAdapter; +import com.android.contacts.common.util.FabUtil; +import com.android.dialer.app.R; +import com.android.dialer.app.list.ListsFragment.ListsPage; +import com.android.dialer.app.widget.EmptyContentView; +import com.android.dialer.app.widget.EmptyContentView.OnEmptyViewActionButtonClickedListener; +import com.android.dialer.common.LogUtil; +import com.android.dialer.compat.CompatUtils; +import com.android.dialer.util.DialerUtils; +import com.android.dialer.util.IntentUtil; +import com.android.dialer.util.PermissionsUtil; + +/** Fragments to show all contacts with phone numbers. */ +public class AllContactsFragment extends ContactEntryListFragment<ContactEntryListAdapter> + implements ListsPage, + OnEmptyViewActionButtonClickedListener, + FragmentCompat.OnRequestPermissionsResultCallback { + + private static final int READ_CONTACTS_PERMISSION_REQUEST_CODE = 1; + + private EmptyContentView mEmptyListView; + + /** + * Listen to broadcast events about permissions in order to be notified if the READ_CONTACTS + * permission is granted via the UI in another fragment. + */ + private BroadcastReceiver mReadContactsPermissionGrantedReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + reloadData(); + } + }; + + public AllContactsFragment() { + setQuickContactEnabled(false); + setAdjustSelectionBoundsEnabled(true); + setPhotoLoaderEnabled(true); + setSectionHeaderDisplayEnabled(true); + setDarkTheme(false); + setVisibleScrollbarEnabled(true); + } + + @Override + public void onViewCreated(View view, android.os.Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mEmptyListView = (EmptyContentView) view.findViewById(R.id.empty_list_view); + mEmptyListView.setImage(R.drawable.empty_contacts); + mEmptyListView.setDescription(R.string.all_contacts_empty); + mEmptyListView.setActionClickedListener(this); + getListView().setEmptyView(mEmptyListView); + mEmptyListView.setVisibility(View.GONE); + + FabUtil.addBottomPaddingToListViewForFab(getListView(), getResources()); + } + + @Override + public void onStart() { + super.onStart(); + PermissionsUtil.registerPermissionReceiver( + getActivity(), mReadContactsPermissionGrantedReceiver, READ_CONTACTS); + } + + @Override + public void onStop() { + PermissionsUtil.unregisterPermissionReceiver( + getActivity(), mReadContactsPermissionGrantedReceiver); + super.onStop(); + } + + @Override + protected void startLoading() { + if (PermissionsUtil.hasPermission(getActivity(), READ_CONTACTS)) { + super.startLoading(); + mEmptyListView.setDescription(R.string.all_contacts_empty); + mEmptyListView.setActionLabel(R.string.all_contacts_empty_add_contact_action); + } else { + mEmptyListView.setDescription(R.string.permission_no_contacts); + mEmptyListView.setActionLabel(R.string.permission_single_turn_on); + mEmptyListView.setVisibility(View.VISIBLE); + } + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor data) { + super.onLoadFinished(loader, data); + + if (data == null || data.getCount() == 0) { + mEmptyListView.setVisibility(View.VISIBLE); + } + } + + @Override + protected ContactEntryListAdapter createListAdapter() { + final DefaultContactListAdapter adapter = + new DefaultContactListAdapter(getActivity()) { + @Override + protected void bindView(View itemView, int partition, Cursor cursor, int position) { + super.bindView(itemView, partition, cursor, position); + itemView.setTag(this.getContactUri(partition, cursor)); + } + }; + adapter.setDisplayPhotos(true); + adapter.setFilter( + ContactListFilter.createFilterWithType(ContactListFilter.FILTER_TYPE_DEFAULT)); + adapter.setSectionHeaderDisplayEnabled(isSectionHeaderDisplayEnabled()); + return adapter; + } + + @Override + protected View inflateView(LayoutInflater inflater, ViewGroup container) { + return inflater.inflate(R.layout.all_contacts_fragment, null); + } + + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + final Uri uri = (Uri) view.getTag(); + if (uri != null) { + if (CompatUtils.hasPrioritizedMimeType()) { + QuickContact.showQuickContact(getContext(), view, uri, null, Phone.CONTENT_ITEM_TYPE); + } else { + QuickContact.showQuickContact(getActivity(), view, uri, QuickContact.MODE_LARGE, null); + } + } + } + + @Override + protected void onItemClick(int position, long id) { + // Do nothing. Implemented to satisfy ContactEntryListFragment. + } + + @Override + public void onEmptyViewActionButtonClicked() { + final Activity activity = getActivity(); + if (activity == null) { + return; + } + + if (!PermissionsUtil.hasPermission(activity, READ_CONTACTS)) { + FragmentCompat.requestPermissions( + this, new String[] {READ_CONTACTS}, READ_CONTACTS_PERMISSION_REQUEST_CODE); + } else { + // Add new contact + DialerUtils.startActivityWithErrorToast( + activity, IntentUtil.getNewContactIntent(), R.string.add_contact_not_available); + } + } + + @Override + public void onRequestPermissionsResult( + int requestCode, String[] permissions, int[] grantResults) { + if (requestCode == READ_CONTACTS_PERMISSION_REQUEST_CODE) { + if (grantResults.length >= 1 && PackageManager.PERMISSION_GRANTED == grantResults[0]) { + // Force a refresh of the data since we were missing the permission before this. + reloadData(); + } + } + } + + @Override + public void onPageResume(@Nullable Activity activity) { + LogUtil.i("AllContactsFragment.onPageResume", null); + } + + @Override + public void onPagePause(@Nullable Activity activity) { + LogUtil.i("AllContactsFragment.onPagePause", null); + } +} diff --git a/java/com/android/dialer/app/list/BlockedListSearchAdapter.java b/java/com/android/dialer/app/list/BlockedListSearchAdapter.java new file mode 100644 index 000000000..a90ce7a0d --- /dev/null +++ b/java/com/android/dialer/app/list/BlockedListSearchAdapter.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2015 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.app.list; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.view.View; +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.list.ContactListItemView; +import com.android.dialer.app.R; +import com.android.dialer.blocking.FilteredNumberAsyncQueryHandler; + +/** List adapter to display search results for adding a blocked number. */ +public class BlockedListSearchAdapter extends RegularSearchListAdapter { + + private Resources mResources; + private FilteredNumberAsyncQueryHandler mFilteredNumberAsyncQueryHandler; + + public BlockedListSearchAdapter(Context context) { + super(context); + mResources = context.getResources(); + disableAllShortcuts(); + setShortcutEnabled(SHORTCUT_BLOCK_NUMBER, true); + + mFilteredNumberAsyncQueryHandler = new FilteredNumberAsyncQueryHandler(context); + } + + @Override + protected boolean isChanged(boolean showNumberShortcuts) { + return setShortcutEnabled(SHORTCUT_BLOCK_NUMBER, showNumberShortcuts || mIsQuerySipAddress); + } + + public void setViewBlocked(ContactListItemView view, Integer id) { + view.setTag(R.id.block_id, id); + final int textColor = mResources.getColor(R.color.blocked_number_block_color); + view.getDataView().setTextColor(textColor); + view.getLabelView().setTextColor(textColor); + //TODO: Add icon + } + + public void setViewUnblocked(ContactListItemView view) { + view.setTag(R.id.block_id, null); + final int textColor = mResources.getColor(R.color.dialer_secondary_text_color); + view.getDataView().setTextColor(textColor); + view.getLabelView().setTextColor(textColor); + //TODO: Remove icon + } + + @Override + protected void bindView(View itemView, int partition, Cursor cursor, int position) { + super.bindView(itemView, partition, cursor, position); + + final ContactListItemView view = (ContactListItemView) itemView; + // Reset view state to unblocked. + setViewUnblocked(view); + + final String number = getPhoneNumber(position); + final String countryIso = GeoUtil.getCurrentCountryIso(mContext); + final FilteredNumberAsyncQueryHandler.OnCheckBlockedListener onCheckListener = + new FilteredNumberAsyncQueryHandler.OnCheckBlockedListener() { + @Override + public void onCheckComplete(Integer id) { + if (id != null && id != FilteredNumberAsyncQueryHandler.INVALID_ID) { + setViewBlocked(view, id); + } + } + }; + mFilteredNumberAsyncQueryHandler.isBlockedNumber(onCheckListener, number, countryIso); + } +} diff --git a/java/com/android/dialer/app/list/BlockedListSearchFragment.java b/java/com/android/dialer/app/list/BlockedListSearchFragment.java new file mode 100644 index 000000000..2129981c0 --- /dev/null +++ b/java/com/android/dialer/app/list/BlockedListSearchFragment.java @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2015 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.app.list; + +import android.app.Activity; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.telephony.PhoneNumberUtils; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.Log; +import android.util.TypedValue; +import android.view.View; +import android.widget.AdapterView; +import android.widget.EditText; +import android.widget.Toast; +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.list.ContactEntryListAdapter; +import com.android.contacts.common.util.ContactDisplayUtils; +import com.android.dialer.app.R; +import com.android.dialer.app.widget.SearchEditTextLayout; +import com.android.dialer.blocking.BlockNumberDialogFragment; +import com.android.dialer.blocking.FilteredNumberAsyncQueryHandler; +import com.android.dialer.blocking.FilteredNumberAsyncQueryHandler.OnCheckBlockedListener; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.InteractionEvent; + +public class BlockedListSearchFragment extends RegularSearchFragment + implements BlockNumberDialogFragment.Callback { + + private static final String TAG = BlockedListSearchFragment.class.getSimpleName(); + + private final TextWatcher mPhoneSearchQueryTextListener = + new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + setQueryString(s.toString()); + } + + @Override + public void afterTextChanged(Editable s) {} + }; + private final SearchEditTextLayout.Callback mSearchLayoutCallback = + new SearchEditTextLayout.Callback() { + @Override + public void onBackButtonClicked() { + getActivity().onBackPressed(); + } + + @Override + public void onSearchViewClicked() {} + }; + private FilteredNumberAsyncQueryHandler mFilteredNumberAsyncQueryHandler; + private EditText mSearchView; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setShowEmptyListForNullQuery(true); + /* + * Pass in the empty string here so ContactEntryListFragment#setQueryString interprets it as + * an empty search query, rather than as an uninitalized value. In the latter case, the + * adapter returned by #createListAdapter is used, which populates the view with contacts. + * Passing in the empty string forces ContactEntryListFragment to interpret it as an empty + * query, which results in showing an empty view + */ + setQueryString(getQueryString() == null ? "" : getQueryString()); + mFilteredNumberAsyncQueryHandler = new FilteredNumberAsyncQueryHandler(getContext()); + } + + @Override + public void onResume() { + super.onResume(); + + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + actionBar.setCustomView(R.layout.search_edittext); + actionBar.setDisplayShowCustomEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(false); + actionBar.setDisplayShowHomeEnabled(false); + + final SearchEditTextLayout searchEditTextLayout = + (SearchEditTextLayout) actionBar.getCustomView().findViewById(R.id.search_view_container); + searchEditTextLayout.expand(false, true); + searchEditTextLayout.setCallback(mSearchLayoutCallback); + searchEditTextLayout.setBackgroundDrawable(null); + + mSearchView = (EditText) searchEditTextLayout.findViewById(R.id.search_view); + mSearchView.addTextChangedListener(mPhoneSearchQueryTextListener); + mSearchView.setHint(R.string.block_number_search_hint); + + searchEditTextLayout + .findViewById(R.id.search_box_expanded) + .setBackgroundColor(getContext().getResources().getColor(android.R.color.white)); + + if (!TextUtils.isEmpty(getQueryString())) { + mSearchView.setText(getQueryString()); + } + + // TODO: Don't set custom text size; use default search text size. + mSearchView.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + getResources().getDimension(R.dimen.blocked_number_search_text_size)); + } + + @Override + protected ContactEntryListAdapter createListAdapter() { + BlockedListSearchAdapter adapter = new BlockedListSearchAdapter(getActivity()); + adapter.setDisplayPhotos(true); + // Don't show SIP addresses. + adapter.setUseCallableUri(false); + // Keep in sync with the queryString set in #onCreate + adapter.setQueryString(getQueryString() == null ? "" : getQueryString()); + return adapter; + } + + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + super.onItemClick(parent, view, position, id); + final int adapterPosition = position - getListView().getHeaderViewsCount(); + final BlockedListSearchAdapter adapter = (BlockedListSearchAdapter) getAdapter(); + final int shortcutType = adapter.getShortcutTypeFromPosition(adapterPosition); + final Integer blockId = (Integer) view.getTag(R.id.block_id); + final String number; + switch (shortcutType) { + case DialerPhoneNumberListAdapter.SHORTCUT_INVALID: + // Handles click on a search result, either contact or nearby places result. + number = adapter.getPhoneNumber(adapterPosition); + blockContactNumber(number, blockId); + break; + case DialerPhoneNumberListAdapter.SHORTCUT_BLOCK_NUMBER: + // Handles click on 'Block number' shortcut to add the user query as a number. + number = adapter.getQueryString(); + blockNumber(number); + break; + default: + Log.w(TAG, "Ignoring unsupported shortcut type: " + shortcutType); + break; + } + } + + @Override + protected void onItemClick(int position, long id) { + // Prevent SearchFragment.onItemClicked from being called. + } + + private void blockNumber(final String number) { + final String countryIso = GeoUtil.getCurrentCountryIso(getContext()); + final OnCheckBlockedListener onCheckListener = + new OnCheckBlockedListener() { + @Override + public void onCheckComplete(Integer id) { + if (id == null) { + BlockNumberDialogFragment.show( + id, + number, + countryIso, + PhoneNumberUtils.formatNumber(number, countryIso), + R.id.blocked_numbers_activity_container, + getFragmentManager(), + BlockedListSearchFragment.this); + } else if (id == FilteredNumberAsyncQueryHandler.INVALID_ID) { + Toast.makeText( + getContext(), + ContactDisplayUtils.getTtsSpannedPhoneNumber( + getResources(), R.string.invalidNumber, number), + Toast.LENGTH_SHORT) + .show(); + } else { + Toast.makeText( + getContext(), + ContactDisplayUtils.getTtsSpannedPhoneNumber( + getResources(), R.string.alreadyBlocked, number), + Toast.LENGTH_SHORT) + .show(); + } + } + }; + mFilteredNumberAsyncQueryHandler.isBlockedNumber(onCheckListener, number, countryIso); + } + + @Override + public void onFilterNumberSuccess() { + Logger.get(getContext()).logInteraction(InteractionEvent.Type.BLOCK_NUMBER_MANAGEMENT_SCREEN); + goBack(); + } + + @Override + public void onUnfilterNumberSuccess() { + Log.wtf(TAG, "Unblocked a number from the BlockedListSearchFragment"); + goBack(); + } + + private void goBack() { + Activity activity = getActivity(); + if (activity == null) { + return; + } + activity.onBackPressed(); + } + + @Override + public void onChangeFilteredNumberUndo() { + getAdapter().notifyDataSetChanged(); + } + + private void blockContactNumber(final String number, final Integer blockId) { + if (blockId != null) { + Toast.makeText( + getContext(), + ContactDisplayUtils.getTtsSpannedPhoneNumber( + getResources(), R.string.alreadyBlocked, number), + Toast.LENGTH_SHORT) + .show(); + return; + } + + BlockNumberDialogFragment.show( + blockId, + number, + GeoUtil.getCurrentCountryIso(getContext()), + number, + R.id.blocked_numbers_activity_container, + getFragmentManager(), + this); + } +} diff --git a/java/com/android/dialer/app/list/ContentChangedFilter.java b/java/com/android/dialer/app/list/ContentChangedFilter.java new file mode 100644 index 000000000..663846da5 --- /dev/null +++ b/java/com/android/dialer/app/list/ContentChangedFilter.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2016 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.app.list; + +import android.view.View; +import android.view.View.AccessibilityDelegate; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityEvent; + +/** + * AccessibilityDelegate that will filter out TYPE_WINDOW_CONTENT_CHANGED Used to suppress "Showing + * items x of y" from firing of ListView whenever it's content changes. AccessibilityEvent can only + * be rejected at a view's parent once it is generated, use addToParent() to add this delegate to + * the parent. + */ +public class ContentChangedFilter extends AccessibilityDelegate { + + //the view we don't want TYPE_WINDOW_CONTENT_CHANGED to fire. + private View mView; + + private ContentChangedFilter(View view) { + super(); + mView = view; + } + + /** Add this delegate to the parent of @param view to filter out TYPE_WINDOW_CONTENT_CHANGED */ + public static void addToParent(View view) { + View parent = (View) view.getParent(); + parent.setAccessibilityDelegate(new ContentChangedFilter(view)); + } + + @Override + public boolean onRequestSendAccessibilityEvent( + ViewGroup host, View child, AccessibilityEvent event) { + if (child == mView) { + if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) { + return false; + } + } + return super.onRequestSendAccessibilityEvent(host, child, event); + } +} diff --git a/java/com/android/dialer/app/list/DialerPhoneNumberListAdapter.java b/java/com/android/dialer/app/list/DialerPhoneNumberListAdapter.java new file mode 100644 index 000000000..7e2525f24 --- /dev/null +++ b/java/com/android/dialer/app/list/DialerPhoneNumberListAdapter.java @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2016 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.app.list; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.telephony.PhoneNumberUtils; +import android.text.BidiFormatter; +import android.text.TextDirectionHeuristics; +import android.view.View; +import android.view.ViewGroup; +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.list.ContactListItemView; +import com.android.contacts.common.list.PhoneNumberListAdapter; +import com.android.contacts.common.util.ContactDisplayUtils; +import com.android.dialer.app.R; +import com.android.dialer.util.CallUtil; + +/** + * {@link PhoneNumberListAdapter} with the following added shortcuts, that are displayed as list + * items: 1) Directly calling the phone number query 2) Adding the phone number query to a contact + * + * <p>These shortcuts can be enabled or disabled to toggle whether or not they show up in the list. + */ +public class DialerPhoneNumberListAdapter extends PhoneNumberListAdapter { + + public static final int SHORTCUT_INVALID = -1; + public static final int SHORTCUT_DIRECT_CALL = 0; + public static final int SHORTCUT_CREATE_NEW_CONTACT = 1; + public static final int SHORTCUT_ADD_TO_EXISTING_CONTACT = 2; + public static final int SHORTCUT_SEND_SMS_MESSAGE = 3; + public static final int SHORTCUT_MAKE_VIDEO_CALL = 4; + public static final int SHORTCUT_BLOCK_NUMBER = 5; + public static final int SHORTCUT_COUNT = 6; + private final boolean[] mShortcutEnabled = new boolean[SHORTCUT_COUNT]; + private final BidiFormatter mBidiFormatter = BidiFormatter.getInstance(); + private String mFormattedQueryString; + private String mCountryIso; + private boolean mVideoCallingEnabled = false; + + public DialerPhoneNumberListAdapter(Context context) { + super(context); + + mCountryIso = GeoUtil.getCurrentCountryIso(context); + mVideoCallingEnabled = CallUtil.isVideoEnabled(context); + } + + @Override + public int getCount() { + return super.getCount() + getShortcutCount(); + } + + /** @return The number of enabled shortcuts. Ranges from 0 to a maximum of SHORTCUT_COUNT */ + public int getShortcutCount() { + int count = 0; + for (int i = 0; i < mShortcutEnabled.length; i++) { + if (mShortcutEnabled[i]) { + count++; + } + } + return count; + } + + public void disableAllShortcuts() { + for (int i = 0; i < mShortcutEnabled.length; i++) { + mShortcutEnabled[i] = false; + } + } + + @Override + public int getItemViewType(int position) { + final int shortcut = getShortcutTypeFromPosition(position); + if (shortcut >= 0) { + // shortcutPos should always range from 1 to SHORTCUT_COUNT + return super.getViewTypeCount() + shortcut; + } else { + return super.getItemViewType(position); + } + } + + @Override + public int getViewTypeCount() { + // Number of item view types in the super implementation + 2 for the 2 new shortcuts + return super.getViewTypeCount() + SHORTCUT_COUNT; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + final int shortcutType = getShortcutTypeFromPosition(position); + if (shortcutType >= 0) { + if (convertView != null) { + assignShortcutToView((ContactListItemView) convertView, shortcutType); + return convertView; + } else { + final ContactListItemView v = + new ContactListItemView(getContext(), null, mVideoCallingEnabled); + assignShortcutToView(v, shortcutType); + return v; + } + } else { + return super.getView(position, convertView, parent); + } + } + + @Override + protected ContactListItemView newView( + Context context, int partition, Cursor cursor, int position, ViewGroup parent) { + final ContactListItemView view = super.newView(context, partition, cursor, position, parent); + + view.setSupportVideoCallIcon(mVideoCallingEnabled); + return view; + } + + /** + * @param position The position of the item + * @return The enabled shortcut type matching the given position if the item is a shortcut, -1 + * otherwise + */ + public int getShortcutTypeFromPosition(int position) { + int shortcutCount = position - super.getCount(); + if (shortcutCount >= 0) { + // Iterate through the array of shortcuts, looking only for shortcuts where + // mShortcutEnabled[i] is true + for (int i = 0; shortcutCount >= 0 && i < mShortcutEnabled.length; i++) { + if (mShortcutEnabled[i]) { + shortcutCount--; + if (shortcutCount < 0) { + return i; + } + } + } + throw new IllegalArgumentException( + "Invalid position - greater than cursor count " + " but not a shortcut."); + } + return SHORTCUT_INVALID; + } + + @Override + public boolean isEmpty() { + return getShortcutCount() == 0 && super.isEmpty(); + } + + @Override + public boolean isEnabled(int position) { + final int shortcutType = getShortcutTypeFromPosition(position); + if (shortcutType >= 0) { + return true; + } else { + return super.isEnabled(position); + } + } + + private void assignShortcutToView(ContactListItemView v, int shortcutType) { + final CharSequence text; + final int drawableId; + final Resources resources = getContext().getResources(); + final String number = getFormattedQueryString(); + switch (shortcutType) { + case SHORTCUT_DIRECT_CALL: + text = + ContactDisplayUtils.getTtsSpannedPhoneNumber( + resources, + R.string.search_shortcut_call_number, + mBidiFormatter.unicodeWrap(number, TextDirectionHeuristics.LTR)); + drawableId = R.drawable.ic_search_phone; + break; + case SHORTCUT_CREATE_NEW_CONTACT: + text = resources.getString(R.string.search_shortcut_create_new_contact); + drawableId = R.drawable.ic_search_add_contact; + break; + case SHORTCUT_ADD_TO_EXISTING_CONTACT: + text = resources.getString(R.string.search_shortcut_add_to_contact); + drawableId = R.drawable.ic_person_24dp; + break; + case SHORTCUT_SEND_SMS_MESSAGE: + text = resources.getString(R.string.search_shortcut_send_sms_message); + drawableId = R.drawable.ic_message_24dp; + break; + case SHORTCUT_MAKE_VIDEO_CALL: + text = resources.getString(R.string.search_shortcut_make_video_call); + drawableId = R.drawable.ic_videocam; + break; + case SHORTCUT_BLOCK_NUMBER: + text = resources.getString(R.string.search_shortcut_block_number); + drawableId = R.drawable.ic_not_interested_googblue_24dp; + break; + default: + throw new IllegalArgumentException("Invalid shortcut type"); + } + v.setDrawableResource(drawableId); + v.setDisplayName(text); + v.setPhotoPosition(super.getPhotoPosition()); + v.setAdjustSelectionBoundsEnabled(false); + } + + /** @return True if the shortcut state (disabled vs enabled) was changed by this operation */ + public boolean setShortcutEnabled(int shortcutType, boolean visible) { + final boolean changed = mShortcutEnabled[shortcutType] != visible; + mShortcutEnabled[shortcutType] = visible; + return changed; + } + + public String getFormattedQueryString() { + return mFormattedQueryString; + } + + @Override + public void setQueryString(String queryString) { + mFormattedQueryString = + PhoneNumberUtils.formatNumber(PhoneNumberUtils.normalizeNumber(queryString), mCountryIso); + super.setQueryString(queryString); + } +} diff --git a/java/com/android/dialer/app/list/DragDropController.java b/java/com/android/dialer/app/list/DragDropController.java new file mode 100644 index 000000000..c22dd1318 --- /dev/null +++ b/java/com/android/dialer/app/list/DragDropController.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2016 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.app.list; + +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.view.View; +import java.util.ArrayList; +import java.util.List; + +/** + * Class that handles and combines drag events generated from multiple views, and then fires off + * events to any OnDragDropListeners that have registered for callbacks. + */ +public class DragDropController { + + private final List<OnDragDropListener> mOnDragDropListeners = new ArrayList<OnDragDropListener>(); + private final DragItemContainer mDragItemContainer; + private final int[] mLocationOnScreen = new int[2]; + + public DragDropController(DragItemContainer dragItemContainer) { + mDragItemContainer = dragItemContainer; + } + + /** @return True if the drag is started, false if the drag is cancelled for some reason. */ + boolean handleDragStarted(View v, int x, int y) { + int screenX = x; + int screenY = y; + // The coordinates in dragEvent of DragEvent.ACTION_DRAG_STARTED before NYC is window-related. + // This is fixed in NYC. + if (VERSION.SDK_INT >= VERSION_CODES.N) { + v.getLocationOnScreen(mLocationOnScreen); + screenX = x + mLocationOnScreen[0]; + screenY = y + mLocationOnScreen[1]; + } + final PhoneFavoriteSquareTileView tileView = + mDragItemContainer.getViewForLocation(screenX, screenY); + if (tileView == null) { + return false; + } + for (int i = 0; i < mOnDragDropListeners.size(); i++) { + mOnDragDropListeners.get(i).onDragStarted(screenX, screenY, tileView); + } + + return true; + } + + public void handleDragHovered(View v, int x, int y) { + v.getLocationOnScreen(mLocationOnScreen); + final int screenX = x + mLocationOnScreen[0]; + final int screenY = y + mLocationOnScreen[1]; + final PhoneFavoriteSquareTileView view = + mDragItemContainer.getViewForLocation(screenX, screenY); + for (int i = 0; i < mOnDragDropListeners.size(); i++) { + mOnDragDropListeners.get(i).onDragHovered(screenX, screenY, view); + } + } + + public void handleDragFinished(int x, int y, boolean isRemoveView) { + if (isRemoveView) { + for (int i = 0; i < mOnDragDropListeners.size(); i++) { + mOnDragDropListeners.get(i).onDroppedOnRemove(); + } + } + + for (int i = 0; i < mOnDragDropListeners.size(); i++) { + mOnDragDropListeners.get(i).onDragFinished(x, y); + } + } + + public void addOnDragDropListener(OnDragDropListener listener) { + if (!mOnDragDropListeners.contains(listener)) { + mOnDragDropListeners.add(listener); + } + } + + public void removeOnDragDropListener(OnDragDropListener listener) { + if (mOnDragDropListeners.contains(listener)) { + mOnDragDropListeners.remove(listener); + } + } + + /** + * Callback interface used to retrieve views based on the current touch coordinates of the drag + * event. The {@link DragItemContainer} houses the draggable views that this {@link + * DragDropController} controls. + */ + public interface DragItemContainer { + + PhoneFavoriteSquareTileView getViewForLocation(int x, int y); + } +} diff --git a/java/com/android/dialer/app/list/ListsFragment.java b/java/com/android/dialer/app/list/ListsFragment.java new file mode 100644 index 000000000..725ad3001 --- /dev/null +++ b/java/com/android/dialer/app/list/ListsFragment.java @@ -0,0 +1,587 @@ +/* + * Copyright (C) 2013 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.app.list; + +import android.app.Activity; +import android.app.Fragment; +import android.app.FragmentManager; +import android.content.SharedPreferences; +import android.database.ContentObserver; +import android.database.Cursor; +import android.os.Bundle; +import android.os.Handler; +import android.os.Trace; +import android.preference.PreferenceManager; +import android.provider.VoicemailContract; +import android.support.annotation.Nullable; +import android.support.v13.app.FragmentPagerAdapter; +import android.support.v4.view.ViewPager; +import android.support.v4.view.ViewPager.OnPageChangeListener; +import android.support.v7.app.ActionBar; +import android.support.v7.app.AppCompatActivity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import com.android.contacts.common.list.ViewPagerTabs; +import com.android.dialer.app.R; +import com.android.dialer.app.calllog.CallLogFragment; +import com.android.dialer.app.calllog.CallLogNotificationsHelper; +import com.android.dialer.app.calllog.VisualVoicemailCallLogFragment; +import com.android.dialer.app.voicemail.error.VoicemailStatusCorruptionHandler; +import com.android.dialer.app.voicemail.error.VoicemailStatusCorruptionHandler.Source; +import com.android.dialer.app.widget.ActionBarController; +import com.android.dialer.common.LogUtil; +import com.android.dialer.database.CallLogQueryHandler; +import com.android.dialer.logging.Logger; +import com.android.dialer.logging.nano.DialerImpression; +import com.android.dialer.logging.nano.ScreenEvent; +import com.android.dialer.util.ViewUtil; +import com.android.dialer.voicemailstatus.VisualVoicemailEnabledChecker; +import com.android.dialer.voicemailstatus.VoicemailStatusHelper; +import com.android.dialer.voicemailstatus.VoicemailStatusHelperImpl; +import java.util.ArrayList; +import java.util.List; + +/** + * Fragment that is used as the main screen of the Dialer. + * + * <p>Contains a ViewPager that contains various contact lists like the Speed Dial list and the All + * Contacts list. This will also eventually contain the logic that allows sliding the ViewPager + * containing the lists up above the search bar and pin it against the top of the screen. + */ +public class ListsFragment extends Fragment + implements ViewPager.OnPageChangeListener, CallLogQueryHandler.Listener { + + /** Every fragment in the list show implement this interface. */ + public interface ListsPage { + + /** + * Called when the page is resumed, including selecting the page or activity resume. Note: This + * is called before the page fragment is attached to a activity. + * + * @param activity the activity hosting the ListFragment + */ + void onPageResume(@Nullable Activity activity); + + /** + * Called when the page is paused, including selecting another page or activity pause. Note: + * This is called after the page fragment is detached from a activity. + * + * @param activity the activity hosting the ListFragment + */ + void onPagePause(@Nullable Activity activity); + } + + public static final int TAB_INDEX_SPEED_DIAL = 0; + public static final int TAB_INDEX_HISTORY = 1; + public static final int TAB_INDEX_ALL_CONTACTS = 2; + public static final int TAB_INDEX_VOICEMAIL = 3; + public static final int TAB_COUNT_DEFAULT = 3; + public static final int TAB_COUNT_WITH_VOICEMAIL = 4; + private static final String TAG = "ListsFragment"; + private ActionBar mActionBar; + private ViewPager mViewPager; + private ViewPagerTabs mViewPagerTabs; + private ViewPagerAdapter mViewPagerAdapter; + private RemoveView mRemoveView; + private View mRemoveViewContent; + private SpeedDialFragment mSpeedDialFragment; + private CallLogFragment mHistoryFragment; + private AllContactsFragment mAllContactsFragment; + private CallLogFragment mVoicemailFragment; + private ListsPage mCurrentPage; + private SharedPreferences mPrefs; + private boolean mHasActiveVoicemailProvider; + private boolean mHasFetchedVoicemailStatus; + private boolean mShowVoicemailTabAfterVoicemailStatusIsFetched; + private VoicemailStatusHelper mVoicemailStatusHelper; + private ArrayList<OnPageChangeListener> mOnPageChangeListeners = + new ArrayList<OnPageChangeListener>(); + private String[] mTabTitles; + private int[] mTabIcons; + /** The position of the currently selected tab. */ + private int mTabIndex = TAB_INDEX_SPEED_DIAL; + + private CallLogQueryHandler mCallLogQueryHandler; + + private final ContentObserver mVoicemailStatusObserver = + new ContentObserver(new Handler()) { + @Override + public void onChange(boolean selfChange) { + super.onChange(selfChange); + mCallLogQueryHandler.fetchVoicemailStatus(); + } + }; + + @Override + public void onCreate(Bundle savedInstanceState) { + LogUtil.d("ListsFragment.onCreate", null); + Trace.beginSection(TAG + " onCreate"); + super.onCreate(savedInstanceState); + + mVoicemailStatusHelper = new VoicemailStatusHelperImpl(); + mHasFetchedVoicemailStatus = false; + + mPrefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); + mHasActiveVoicemailProvider = + mPrefs.getBoolean( + VisualVoicemailEnabledChecker.PREF_KEY_HAS_ACTIVE_VOICEMAIL_PROVIDER, false); + + Trace.endSection(); + } + + @Override + public void onResume() { + LogUtil.d("ListsFragment.onResume", null); + Trace.beginSection(TAG + " onResume"); + super.onResume(); + + mActionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + if (getUserVisibleHint()) { + sendScreenViewForCurrentPosition(); + } + + // Fetch voicemail status to determine if we should show the voicemail tab. + mCallLogQueryHandler = + new CallLogQueryHandler(getActivity(), getActivity().getContentResolver(), this); + mCallLogQueryHandler.fetchVoicemailStatus(); + mCallLogQueryHandler.fetchMissedCallsUnreadCount(); + Trace.endSection(); + mCurrentPage = getListsPage(mViewPager.getCurrentItem()); + if (mCurrentPage != null) { + mCurrentPage.onPageResume(getActivity()); + } + } + + @Override + public void onPause() { + LogUtil.d("ListsFragment.onPause", null); + if (mCurrentPage != null) { + mCurrentPage.onPagePause(getActivity()); + } + super.onPause(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + mViewPager.removeOnPageChangeListener(this); + } + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + LogUtil.d("ListsFragment.onCreateView", null); + Trace.beginSection(TAG + " onCreateView"); + Trace.beginSection(TAG + " inflate view"); + final View parentView = inflater.inflate(R.layout.lists_fragment, container, false); + Trace.endSection(); + Trace.beginSection(TAG + " setup views"); + mViewPager = (ViewPager) parentView.findViewById(R.id.lists_pager); + mViewPagerAdapter = new ViewPagerAdapter(getChildFragmentManager()); + mViewPager.setAdapter(mViewPagerAdapter); + mViewPager.setOffscreenPageLimit(TAB_COUNT_WITH_VOICEMAIL - 1); + mViewPager.addOnPageChangeListener(this); + showTab(TAB_INDEX_SPEED_DIAL); + + mTabTitles = new String[TAB_COUNT_WITH_VOICEMAIL]; + mTabTitles[TAB_INDEX_SPEED_DIAL] = getResources().getString(R.string.tab_speed_dial); + mTabTitles[TAB_INDEX_HISTORY] = getResources().getString(R.string.tab_history); + mTabTitles[TAB_INDEX_ALL_CONTACTS] = getResources().getString(R.string.tab_all_contacts); + mTabTitles[TAB_INDEX_VOICEMAIL] = getResources().getString(R.string.tab_voicemail); + + mTabIcons = new int[TAB_COUNT_WITH_VOICEMAIL]; + mTabIcons[TAB_INDEX_SPEED_DIAL] = R.drawable.ic_grade_24dp; + mTabIcons[TAB_INDEX_HISTORY] = R.drawable.ic_schedule_24dp; + mTabIcons[TAB_INDEX_ALL_CONTACTS] = R.drawable.ic_people_24dp; + mTabIcons[TAB_INDEX_VOICEMAIL] = R.drawable.ic_voicemail_24dp; + + mViewPagerTabs = (ViewPagerTabs) parentView.findViewById(R.id.lists_pager_header); + mViewPagerTabs.configureTabIcons(mTabIcons); + mViewPagerTabs.setViewPager(mViewPager); + addOnPageChangeListener(mViewPagerTabs); + + mRemoveView = (RemoveView) parentView.findViewById(R.id.remove_view); + mRemoveViewContent = parentView.findViewById(R.id.remove_view_content); + + getActivity() + .getContentResolver() + .registerContentObserver( + VoicemailContract.Status.CONTENT_URI, true, mVoicemailStatusObserver); + + Trace.endSection(); + Trace.endSection(); + return parentView; + } + + @Override + public void onDestroy() { + getActivity().getContentResolver().unregisterContentObserver(mVoicemailStatusObserver); + super.onDestroy(); + } + + public void addOnPageChangeListener(OnPageChangeListener onPageChangeListener) { + if (!mOnPageChangeListeners.contains(onPageChangeListener)) { + mOnPageChangeListeners.add(onPageChangeListener); + } + } + + /** + * Shows the tab with the specified index. If the voicemail tab index is specified, but the + * voicemail status hasn't been fetched, it will try to show the tab after the voicemail status + * has been fetched. + */ + public void showTab(int index) { + if (index == TAB_INDEX_VOICEMAIL) { + if (mHasActiveVoicemailProvider) { + Logger.get(getContext()).logImpression(DialerImpression.Type.VVM_TAB_VISIBLE); + mViewPager.setCurrentItem(getRtlPosition(TAB_INDEX_VOICEMAIL)); + } else if (!mHasFetchedVoicemailStatus) { + // Try to show the voicemail tab after the voicemail status returns. + mShowVoicemailTabAfterVoicemailStatusIsFetched = true; + } + } else if (index < getTabCount()) { + mViewPager.setCurrentItem(getRtlPosition(index)); + } + } + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + mTabIndex = getRtlPosition(position); + + final int count = mOnPageChangeListeners.size(); + for (int i = 0; i < count; i++) { + mOnPageChangeListeners.get(i).onPageScrolled(position, positionOffset, positionOffsetPixels); + } + } + + @Override + public void onPageSelected(int position) { + LogUtil.i("ListsFragment.onPageSelected", "position: %d", position); + mTabIndex = getRtlPosition(position); + + // Show the tab which has been selected instead. + mShowVoicemailTabAfterVoicemailStatusIsFetched = false; + + final int count = mOnPageChangeListeners.size(); + for (int i = 0; i < count; i++) { + mOnPageChangeListeners.get(i).onPageSelected(position); + } + sendScreenViewForCurrentPosition(); + + if (mCurrentPage != null) { + mCurrentPage.onPagePause(getActivity()); + } + mCurrentPage = getListsPage(position); + if (mCurrentPage != null) { + mCurrentPage.onPageResume(getActivity()); + } + } + + @Override + public void onPageScrollStateChanged(int state) { + final int count = mOnPageChangeListeners.size(); + for (int i = 0; i < count; i++) { + mOnPageChangeListeners.get(i).onPageScrollStateChanged(state); + } + } + + @Override + public void onVoicemailStatusFetched(Cursor statusCursor) { + mHasFetchedVoicemailStatus = true; + + if (getActivity() == null || getActivity().isFinishing()) { + return; + } + + VoicemailStatusCorruptionHandler.maybeFixVoicemailStatus( + getContext(), statusCursor, Source.Activity); + + // Update mHasActiveVoicemailProvider, which controls the number of tabs displayed. + boolean hasActiveVoicemailProvider = + mVoicemailStatusHelper.getNumberActivityVoicemailSources(statusCursor) > 0; + if (hasActiveVoicemailProvider != mHasActiveVoicemailProvider) { + mHasActiveVoicemailProvider = hasActiveVoicemailProvider; + mViewPagerAdapter.notifyDataSetChanged(); + + if (hasActiveVoicemailProvider) { + mViewPagerTabs.updateTab(TAB_INDEX_VOICEMAIL); + } else { + mViewPagerTabs.removeTab(TAB_INDEX_VOICEMAIL); + removeVoicemailFragment(); + } + + mPrefs + .edit() + .putBoolean( + VisualVoicemailEnabledChecker.PREF_KEY_HAS_ACTIVE_VOICEMAIL_PROVIDER, + hasActiveVoicemailProvider) + .commit(); + } + + if (hasActiveVoicemailProvider) { + mCallLogQueryHandler.fetchVoicemailUnreadCount(); + } + + if (mHasActiveVoicemailProvider && mShowVoicemailTabAfterVoicemailStatusIsFetched) { + mShowVoicemailTabAfterVoicemailStatusIsFetched = false; + showTab(TAB_INDEX_VOICEMAIL); + } + } + + @Override + public void onVoicemailUnreadCountFetched(Cursor cursor) { + if (getActivity() == null || getActivity().isFinishing() || cursor == null) { + return; + } + + int count = 0; + try { + count = cursor.getCount(); + } finally { + cursor.close(); + } + + mViewPagerTabs.setUnreadCount(count, TAB_INDEX_VOICEMAIL); + mViewPagerTabs.updateTab(TAB_INDEX_VOICEMAIL); + } + + @Override + public void onMissedCallsUnreadCountFetched(Cursor cursor) { + if (getActivity() == null || getActivity().isFinishing() || cursor == null) { + return; + } + + int count = 0; + try { + count = cursor.getCount(); + } finally { + cursor.close(); + } + + mViewPagerTabs.setUnreadCount(count, TAB_INDEX_HISTORY); + mViewPagerTabs.updateTab(TAB_INDEX_HISTORY); + } + + @Override + public boolean onCallsFetched(Cursor statusCursor) { + // Return false; did not take ownership of cursor + return false; + } + + public int getCurrentTabIndex() { + return mTabIndex; + } + + /** + * External method to update unread count because the unread count changes when the user expands a + * voicemail in the call log or when the user expands an unread call in the call history tab. + */ + public void updateTabUnreadCounts() { + if (mCallLogQueryHandler != null) { + mCallLogQueryHandler.fetchMissedCallsUnreadCount(); + if (mHasActiveVoicemailProvider) { + mCallLogQueryHandler.fetchVoicemailUnreadCount(); + } + } + } + + /** External method to mark all missed calls as read. */ + public void markMissedCallsAsReadAndRemoveNotifications() { + if (mCallLogQueryHandler != null) { + mCallLogQueryHandler.markMissedCallsAsRead(); + CallLogNotificationsHelper.removeMissedCallNotifications(getActivity()); + } + } + + public void showRemoveView(boolean show) { + mRemoveViewContent.setVisibility(show ? View.VISIBLE : View.GONE); + mRemoveView.setAlpha(show ? 0 : 1); + mRemoveView.animate().alpha(show ? 1 : 0).start(); + } + + public boolean shouldShowActionBar() { + // TODO: Update this based on scroll state. + return mActionBar != null; + } + + public SpeedDialFragment getSpeedDialFragment() { + return mSpeedDialFragment; + } + + public RemoveView getRemoveView() { + return mRemoveView; + } + + public int getTabCount() { + return mViewPagerAdapter.getCount(); + } + + private int getRtlPosition(int position) { + if (ViewUtil.isRtl()) { + return mViewPagerAdapter.getCount() - 1 - position; + } + return position; + } + + public void sendScreenViewForCurrentPosition() { + if (!isResumed()) { + return; + } + + int screenType; + switch (getCurrentTabIndex()) { + case TAB_INDEX_SPEED_DIAL: + screenType = ScreenEvent.Type.SPEED_DIAL; + break; + case TAB_INDEX_HISTORY: + screenType = ScreenEvent.Type.CALL_LOG; + break; + case TAB_INDEX_ALL_CONTACTS: + screenType = ScreenEvent.Type.ALL_CONTACTS; + break; + case TAB_INDEX_VOICEMAIL: + screenType = ScreenEvent.Type.VOICEMAIL_LOG; + break; + default: + return; + } + Logger.get(getActivity()).logScreenView(screenType, getActivity()); + } + + private void removeVoicemailFragment() { + if (mVoicemailFragment != null) { + getChildFragmentManager() + .beginTransaction() + .remove(mVoicemailFragment) + .commitAllowingStateLoss(); + mVoicemailFragment = null; + } + } + + private ListsPage getListsPage(int position) { + switch (getRtlPosition(position)) { + case TAB_INDEX_SPEED_DIAL: + return mSpeedDialFragment; + case TAB_INDEX_HISTORY: + return mHistoryFragment; + case TAB_INDEX_ALL_CONTACTS: + return mAllContactsFragment; + case TAB_INDEX_VOICEMAIL: + return mVoicemailFragment; + } + throw new IllegalStateException("No fragment at position " + position); + } + + public interface HostInterface { + + ActionBarController getActionBarController(); + } + + public class ViewPagerAdapter extends FragmentPagerAdapter { + + private final List<Fragment> mFragments = new ArrayList<>(); + + public ViewPagerAdapter(FragmentManager fm) { + super(fm); + for (int i = 0; i < TAB_COUNT_WITH_VOICEMAIL; i++) { + mFragments.add(null); + } + } + + @Override + public long getItemId(int position) { + return getRtlPosition(position); + } + + @Override + public Fragment getItem(int position) { + LogUtil.d("ViewPagerAdapter.getItem", "position: %d", position); + switch (getRtlPosition(position)) { + case TAB_INDEX_SPEED_DIAL: + if (mSpeedDialFragment == null) { + mSpeedDialFragment = new SpeedDialFragment(); + } + return mSpeedDialFragment; + case TAB_INDEX_HISTORY: + if (mHistoryFragment == null) { + mHistoryFragment = new CallLogFragment(); + } + return mHistoryFragment; + case TAB_INDEX_ALL_CONTACTS: + if (mAllContactsFragment == null) { + mAllContactsFragment = new AllContactsFragment(); + } + return mAllContactsFragment; + case TAB_INDEX_VOICEMAIL: + if (mVoicemailFragment == null) { + mVoicemailFragment = new VisualVoicemailCallLogFragment(); + LogUtil.v( + "ViewPagerAdapter.getItem", + "new VisualVoicemailCallLogFragment: %s", + mVoicemailFragment); + } + return mVoicemailFragment; + } + throw new IllegalStateException("No fragment at position " + position); + } + + @Override + public Fragment instantiateItem(ViewGroup container, int position) { + LogUtil.d("ViewPagerAdapter.instantiateItem", "position: %d", position); + // On rotation the FragmentManager handles rotation. Therefore getItem() isn't called. + // Copy the fragments that the FragmentManager finds so that we can store them in + // instance variables for later. + final Fragment fragment = (Fragment) super.instantiateItem(container, position); + if (fragment instanceof SpeedDialFragment) { + mSpeedDialFragment = (SpeedDialFragment) fragment; + } else if (fragment instanceof CallLogFragment && position == TAB_INDEX_HISTORY) { + mHistoryFragment = (CallLogFragment) fragment; + } else if (fragment instanceof AllContactsFragment) { + mAllContactsFragment = (AllContactsFragment) fragment; + } else if (fragment instanceof CallLogFragment && position == TAB_INDEX_VOICEMAIL) { + mVoicemailFragment = (CallLogFragment) fragment; + LogUtil.v("ViewPagerAdapter.instantiateItem", mVoicemailFragment.toString()); + } + mFragments.set(position, fragment); + return fragment; + } + + /** + * When {@link android.support.v4.view.PagerAdapter#notifyDataSetChanged} is called, this method + * is called on all pages to determine whether they need to be recreated. When the voicemail tab + * is removed, the view needs to be recreated by returning POSITION_NONE. If + * notifyDataSetChanged is called for some other reason, the voicemail tab is recreated only if + * it is active. All other tabs do not need to be recreated and POSITION_UNCHANGED is returned. + */ + @Override + public int getItemPosition(Object object) { + return !mHasActiveVoicemailProvider && mFragments.indexOf(object) == TAB_INDEX_VOICEMAIL + ? POSITION_NONE + : POSITION_UNCHANGED; + } + + @Override + public int getCount() { + return mHasActiveVoicemailProvider ? TAB_COUNT_WITH_VOICEMAIL : TAB_COUNT_DEFAULT; + } + + @Override + public CharSequence getPageTitle(int position) { + return mTabTitles[position]; + } + } +} diff --git a/java/com/android/dialer/app/list/OnDragDropListener.java b/java/com/android/dialer/app/list/OnDragDropListener.java new file mode 100644 index 000000000..b71c7fef6 --- /dev/null +++ b/java/com/android/dialer/app/list/OnDragDropListener.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2016 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.app.list; + +/** + * Classes that want to receive callbacks in response to drag events should implement this + * interface. + */ +public interface OnDragDropListener { + + /** + * Called when a drag is started. + * + * @param x X-coordinate of the drag event + * @param y Y-coordinate of the drag event + * @param view The contact tile which the drag was started on + */ + void onDragStarted(int x, int y, PhoneFavoriteSquareTileView view); + + /** + * Called when a drag is in progress and the user moves the dragged contact to a location. + * + * @param x X-coordinate of the drag event + * @param y Y-coordinate of the drag event + * @param view Contact tile in the ListView which is currently being displaced by the dragged + * contact + */ + void onDragHovered(int x, int y, PhoneFavoriteSquareTileView view); + + /** + * Called when a drag is completed (whether by dropping it somewhere or simply by dragging the + * contact off the screen) + * + * @param x X-coordinate of the drag event + * @param y Y-coordinate of the drag event + */ + void onDragFinished(int x, int y); + + /** + * Called when a contact has been dropped on the remove view, indicating that the user wants to + * remove this contact. + */ + void onDroppedOnRemove(); +} diff --git a/java/com/android/dialer/app/list/OnListFragmentScrolledListener.java b/java/com/android/dialer/app/list/OnListFragmentScrolledListener.java new file mode 100644 index 000000000..a76f3b527 --- /dev/null +++ b/java/com/android/dialer/app/list/OnListFragmentScrolledListener.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2013 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.app.list; + +/* + * Interface to provide callback to activity when a child fragment is scrolled + */ +public interface OnListFragmentScrolledListener { + + void onListFragmentScrollStateChange(int scrollState); + + void onListFragmentScroll(int firstVisibleItem, int visibleItemCount, int totalItemCount); +} diff --git a/java/com/android/dialer/app/list/PhoneFavoriteListView.java b/java/com/android/dialer/app/list/PhoneFavoriteListView.java new file mode 100644 index 000000000..9516f0611 --- /dev/null +++ b/java/com/android/dialer/app/list/PhoneFavoriteListView.java @@ -0,0 +1,315 @@ +/* + * Copyright (C) 2012 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.app.list; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.os.Handler; +import android.util.AttributeSet; +import android.util.Log; +import android.view.DragEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.widget.GridView; +import android.widget.ImageView; +import com.android.dialer.app.R; +import com.android.dialer.app.list.DragDropController.DragItemContainer; + +/** Viewgroup that presents the user's speed dial contacts in a grid. */ +public class PhoneFavoriteListView extends GridView + implements OnDragDropListener, DragItemContainer { + + public static final String LOG_TAG = PhoneFavoriteListView.class.getSimpleName(); + final int[] mLocationOnScreen = new int[2]; + private final long SCROLL_HANDLER_DELAY_MILLIS = 5; + private final int DRAG_SCROLL_PX_UNIT = 25; + private final float DRAG_SHADOW_ALPHA = 0.7f; + /** + * {@link #mTopScrollBound} and {@link mBottomScrollBound} will be offseted to the top / bottom by + * {@link #getHeight} * {@link #BOUND_GAP_RATIO} pixels. + */ + private final float BOUND_GAP_RATIO = 0.2f; + + private float mTouchSlop; + private int mTopScrollBound; + private int mBottomScrollBound; + private int mLastDragY; + private Handler mScrollHandler; + private final Runnable mDragScroller = + new Runnable() { + @Override + public void run() { + if (mLastDragY <= mTopScrollBound) { + smoothScrollBy(-DRAG_SCROLL_PX_UNIT, (int) SCROLL_HANDLER_DELAY_MILLIS); + } else if (mLastDragY >= mBottomScrollBound) { + smoothScrollBy(DRAG_SCROLL_PX_UNIT, (int) SCROLL_HANDLER_DELAY_MILLIS); + } + mScrollHandler.postDelayed(this, SCROLL_HANDLER_DELAY_MILLIS); + } + }; + private boolean mIsDragScrollerRunning = false; + private int mTouchDownForDragStartX; + private int mTouchDownForDragStartY; + private Bitmap mDragShadowBitmap; + private ImageView mDragShadowOverlay; + private final AnimatorListenerAdapter mDragShadowOverAnimatorListener = + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + if (mDragShadowBitmap != null) { + mDragShadowBitmap.recycle(); + mDragShadowBitmap = null; + } + mDragShadowOverlay.setVisibility(GONE); + mDragShadowOverlay.setImageBitmap(null); + } + }; + private View mDragShadowParent; + private int mAnimationDuration; + // X and Y offsets inside the item from where the user grabbed to the + // child's left coordinate. This is used to aid in the drawing of the drag shadow. + private int mTouchOffsetToChildLeft; + private int mTouchOffsetToChildTop; + private int mDragShadowLeft; + private int mDragShadowTop; + private DragDropController mDragDropController = new DragDropController(this); + + public PhoneFavoriteListView(Context context) { + this(context, null); + } + + public PhoneFavoriteListView(Context context, AttributeSet attrs) { + this(context, attrs, -1); + } + + public PhoneFavoriteListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + mAnimationDuration = context.getResources().getInteger(R.integer.fade_duration); + mTouchSlop = ViewConfiguration.get(context).getScaledPagingTouchSlop(); + mDragDropController.addOnDragDropListener(this); + } + + @Override + protected void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + mTouchSlop = ViewConfiguration.get(getContext()).getScaledPagingTouchSlop(); + } + + /** + * TODO: This is all swipe to remove code (nothing to do with drag to remove). This should be + * cleaned up and removed once drag to remove becomes the only way to remove contacts. + */ + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + mTouchDownForDragStartX = (int) ev.getX(); + mTouchDownForDragStartY = (int) ev.getY(); + } + + return super.onInterceptTouchEvent(ev); + } + + @Override + public boolean onDragEvent(DragEvent event) { + final int action = event.getAction(); + final int eX = (int) event.getX(); + final int eY = (int) event.getY(); + switch (action) { + case DragEvent.ACTION_DRAG_STARTED: + { + if (!PhoneFavoriteTileView.DRAG_PHONE_FAVORITE_TILE.equals(event.getLocalState())) { + // Ignore any drag events that were not propagated by long pressing + // on a {@link PhoneFavoriteTileView} + return false; + } + if (!mDragDropController.handleDragStarted(this, eX, eY)) { + return false; + } + break; + } + case DragEvent.ACTION_DRAG_LOCATION: + mLastDragY = eY; + mDragDropController.handleDragHovered(this, eX, eY); + // Kick off {@link #mScrollHandler} if it's not started yet. + if (!mIsDragScrollerRunning + && + // And if the distance traveled while dragging exceeds the touch slop + (Math.abs(mLastDragY - mTouchDownForDragStartY) >= 4 * mTouchSlop)) { + mIsDragScrollerRunning = true; + ensureScrollHandler(); + mScrollHandler.postDelayed(mDragScroller, SCROLL_HANDLER_DELAY_MILLIS); + } + break; + case DragEvent.ACTION_DRAG_ENTERED: + final int boundGap = (int) (getHeight() * BOUND_GAP_RATIO); + mTopScrollBound = (getTop() + boundGap); + mBottomScrollBound = (getBottom() - boundGap); + break; + case DragEvent.ACTION_DRAG_EXITED: + case DragEvent.ACTION_DRAG_ENDED: + case DragEvent.ACTION_DROP: + ensureScrollHandler(); + mScrollHandler.removeCallbacks(mDragScroller); + mIsDragScrollerRunning = false; + // Either a successful drop or it's ended with out drop. + if (action == DragEvent.ACTION_DROP || action == DragEvent.ACTION_DRAG_ENDED) { + mDragDropController.handleDragFinished(eX, eY, false); + } + break; + default: + break; + } + // This ListView will consume the drag events on behalf of its children. + return true; + } + + public void setDragShadowOverlay(ImageView overlay) { + mDragShadowOverlay = overlay; + mDragShadowParent = (View) mDragShadowOverlay.getParent(); + } + + /** Find the view under the pointer. */ + private View getViewAtPosition(int x, int y) { + final int count = getChildCount(); + View child; + for (int childIdx = 0; childIdx < count; childIdx++) { + child = getChildAt(childIdx); + if (y >= child.getTop() + && y <= child.getBottom() + && x >= child.getLeft() + && x <= child.getRight()) { + return child; + } + } + return null; + } + + private void ensureScrollHandler() { + if (mScrollHandler == null) { + mScrollHandler = getHandler(); + } + } + + public DragDropController getDragDropController() { + return mDragDropController; + } + + @Override + public void onDragStarted(int x, int y, PhoneFavoriteSquareTileView tileView) { + if (mDragShadowOverlay == null) { + return; + } + + mDragShadowOverlay.clearAnimation(); + mDragShadowBitmap = createDraggedChildBitmap(tileView); + if (mDragShadowBitmap == null) { + return; + } + + tileView.getLocationOnScreen(mLocationOnScreen); + mDragShadowLeft = mLocationOnScreen[0]; + mDragShadowTop = mLocationOnScreen[1]; + + // x and y are the coordinates of the on-screen touch event. Using these + // and the on-screen location of the tileView, calculate the difference between + // the position of the user's finger and the position of the tileView. These will + // be used to offset the location of the drag shadow so that it appears that the + // tileView is positioned directly under the user's finger. + mTouchOffsetToChildLeft = x - mDragShadowLeft; + mTouchOffsetToChildTop = y - mDragShadowTop; + + mDragShadowParent.getLocationOnScreen(mLocationOnScreen); + mDragShadowLeft -= mLocationOnScreen[0]; + mDragShadowTop -= mLocationOnScreen[1]; + + mDragShadowOverlay.setImageBitmap(mDragShadowBitmap); + mDragShadowOverlay.setVisibility(VISIBLE); + mDragShadowOverlay.setAlpha(DRAG_SHADOW_ALPHA); + + mDragShadowOverlay.setX(mDragShadowLeft); + mDragShadowOverlay.setY(mDragShadowTop); + } + + @Override + public void onDragHovered(int x, int y, PhoneFavoriteSquareTileView tileView) { + // Update the drag shadow location. + mDragShadowParent.getLocationOnScreen(mLocationOnScreen); + mDragShadowLeft = x - mTouchOffsetToChildLeft - mLocationOnScreen[0]; + mDragShadowTop = y - mTouchOffsetToChildTop - mLocationOnScreen[1]; + // Draw the drag shadow at its last known location if the drag shadow exists. + if (mDragShadowOverlay != null) { + mDragShadowOverlay.setX(mDragShadowLeft); + mDragShadowOverlay.setY(mDragShadowTop); + } + } + + @Override + public void onDragFinished(int x, int y) { + if (mDragShadowOverlay != null) { + mDragShadowOverlay.clearAnimation(); + mDragShadowOverlay + .animate() + .alpha(0.0f) + .setDuration(mAnimationDuration) + .setListener(mDragShadowOverAnimatorListener) + .start(); + } + } + + @Override + public void onDroppedOnRemove() {} + + private Bitmap createDraggedChildBitmap(View view) { + view.setDrawingCacheEnabled(true); + final Bitmap cache = view.getDrawingCache(); + + Bitmap bitmap = null; + if (cache != null) { + try { + bitmap = cache.copy(Bitmap.Config.ARGB_8888, false); + } catch (final OutOfMemoryError e) { + Log.w(LOG_TAG, "Failed to copy bitmap from Drawing cache", e); + bitmap = null; + } + } + + view.destroyDrawingCache(); + view.setDrawingCacheEnabled(false); + + return bitmap; + } + + @Override + public PhoneFavoriteSquareTileView getViewForLocation(int x, int y) { + getLocationOnScreen(mLocationOnScreen); + // Calculate the X and Y coordinates of the drag event relative to the view + final int viewX = x - mLocationOnScreen[0]; + final int viewY = y - mLocationOnScreen[1]; + final View child = getViewAtPosition(viewX, viewY); + + if (!(child instanceof PhoneFavoriteSquareTileView)) { + return null; + } + + return (PhoneFavoriteSquareTileView) child; + } +} diff --git a/java/com/android/dialer/app/list/PhoneFavoriteSquareTileView.java b/java/com/android/dialer/app/list/PhoneFavoriteSquareTileView.java new file mode 100644 index 000000000..5a18d039b --- /dev/null +++ b/java/com/android/dialer/app/list/PhoneFavoriteSquareTileView.java @@ -0,0 +1,119 @@ +/* + +* 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.app.list; + +import android.content.Context; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.QuickContact; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageButton; +import android.widget.TextView; +import com.android.contacts.common.list.ContactEntry; +import com.android.dialer.app.R; +import com.android.dialer.compat.CompatUtils; + +/** Displays the contact's picture overlaid with their name and number type in a tile. */ +public class PhoneFavoriteSquareTileView extends PhoneFavoriteTileView { + + private static final String TAG = PhoneFavoriteSquareTileView.class.getSimpleName(); + + private final float mHeightToWidthRatio; + + private ImageButton mSecondaryButton; + + private ContactEntry mContactEntry; + + public PhoneFavoriteSquareTileView(Context context, AttributeSet attrs) { + super(context, attrs); + + mHeightToWidthRatio = + getResources().getFraction(R.dimen.contact_tile_height_to_width_ratio, 1, 1); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + final TextView nameView = (TextView) findViewById(R.id.contact_tile_name); + nameView.setElegantTextHeight(false); + final TextView phoneTypeView = (TextView) findViewById(R.id.contact_tile_phone_type); + phoneTypeView.setElegantTextHeight(false); + mSecondaryButton = (ImageButton) findViewById(R.id.contact_tile_secondary_button); + } + + @Override + protected int getApproximateImageSize() { + // The picture is the full size of the tile (minus some padding, but we can be generous) + return getWidth(); + } + + private void launchQuickContact() { + if (CompatUtils.hasPrioritizedMimeType()) { + QuickContact.showQuickContact( + getContext(), + PhoneFavoriteSquareTileView.this, + getLookupUri(), + null, + Phone.CONTENT_ITEM_TYPE); + } else { + QuickContact.showQuickContact( + getContext(), + PhoneFavoriteSquareTileView.this, + getLookupUri(), + QuickContact.MODE_LARGE, + null); + } + } + + @Override + public void loadFromContact(ContactEntry entry) { + super.loadFromContact(entry); + if (entry != null) { + mSecondaryButton.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + launchQuickContact(); + } + }); + } + mContactEntry = entry; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int width = MeasureSpec.getSize(widthMeasureSpec); + final int height = (int) (mHeightToWidthRatio * width); + final int count = getChildCount(); + for (int i = 0; i < count; i++) { + getChildAt(i) + .measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); + } + setMeasuredDimension(width, height); + } + + @Override + protected String getNameForView(ContactEntry contactEntry) { + return contactEntry.getPreferredDisplayName(); + } + + public ContactEntry getContactEntry() { + return mContactEntry; + } +} diff --git a/java/com/android/dialer/app/list/PhoneFavoriteTileView.java b/java/com/android/dialer/app/list/PhoneFavoriteTileView.java new file mode 100644 index 000000000..db89cf3dc --- /dev/null +++ b/java/com/android/dialer/app/list/PhoneFavoriteTileView.java @@ -0,0 +1,155 @@ +/* + +* 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.app.list; + +import android.content.ClipData; +import android.content.Context; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest; +import com.android.contacts.common.MoreContactUtils; +import com.android.contacts.common.list.ContactEntry; +import com.android.contacts.common.list.ContactTileView; +import com.android.dialer.app.R; + +/** + * A light version of the {@link com.android.contacts.common.list.ContactTileView} that is used in + * Dialtacts for frequently called contacts. Slightly different behavior from superclass when you + * tap it, you want to call the frequently-called number for the contact, even if that is not the + * default number for that contact. This abstract class is the super class to both the row and tile + * view. + */ +public abstract class PhoneFavoriteTileView extends ContactTileView { + + // Constant to pass to the drag event so that the drag action only happens when a phone favorite + // tile is long pressed. + static final String DRAG_PHONE_FAVORITE_TILE = "PHONE_FAVORITE_TILE"; + private static final String TAG = PhoneFavoriteTileView.class.getSimpleName(); + private static final boolean DEBUG = false; + // These parameters instruct the photo manager to display the default image/letter at 70% of + // its normal size, and vertically offset upwards 12% towards the top of the letter tile, to + // make room for the contact name and number label at the bottom of the image. + private static final float DEFAULT_IMAGE_LETTER_OFFSET = -0.12f; + private static final float DEFAULT_IMAGE_LETTER_SCALE = 0.70f; + // Dummy clip data object that is attached to drag shadows so that text views + // don't crash with an NPE if the drag shadow is released in their bounds + private static final ClipData EMPTY_CLIP_DATA = ClipData.newPlainText("", ""); + /** View that contains the transparent shadow that is overlaid on top of the contact image. */ + private View mShadowOverlay; + /** Users' most frequent phone number. */ + private String mPhoneNumberString; + + public PhoneFavoriteTileView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mShadowOverlay = findViewById(R.id.shadow_overlay); + + setOnLongClickListener( + new OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + final PhoneFavoriteTileView view = (PhoneFavoriteTileView) v; + // NOTE The drag shadow is handled in the ListView. + view.startDrag( + EMPTY_CLIP_DATA, new View.DragShadowBuilder(), DRAG_PHONE_FAVORITE_TILE, 0); + return true; + } + }); + } + + @Override + public void loadFromContact(ContactEntry entry) { + super.loadFromContact(entry); + // Set phone number to null in case we're reusing the view. + mPhoneNumberString = null; + if (entry != null) { + // Grab the phone-number to call directly. See {@link onClick()}. + mPhoneNumberString = entry.phoneNumber; + + // If this is a blank entry, don't show anything. + // TODO krelease: Just hide the view for now. For this to truly look like an empty row + // the entire ContactTileRow needs to be hidden. + if (entry == ContactEntry.BLANK_ENTRY) { + setVisibility(View.INVISIBLE); + } else { + final ImageView starIcon = (ImageView) findViewById(R.id.contact_star_icon); + starIcon.setVisibility(entry.isFavorite ? View.VISIBLE : View.GONE); + setVisibility(View.VISIBLE); + } + } + } + + @Override + protected boolean isDarkTheme() { + return false; + } + + @Override + protected OnClickListener createClickListener() { + return new OnClickListener() { + @Override + public void onClick(View v) { + if (mListener == null) { + return; + } + if (TextUtils.isEmpty(mPhoneNumberString)) { + // Copy "superclass" implementation + mListener.onContactSelected( + getLookupUri(), MoreContactUtils.getTargetRectFromView(PhoneFavoriteTileView.this)); + } else { + // When you tap a frequently-called contact, you want to + // call them at the number that you usually talk to them + // at (i.e. the one displayed in the UI), regardless of + // whether that's their default number. + mListener.onCallNumberDirectly(mPhoneNumberString); + } + } + }; + } + + @Override + protected DefaultImageRequest getDefaultImageRequest(String displayName, String lookupKey) { + return new DefaultImageRequest( + displayName, + lookupKey, + ContactPhotoManager.TYPE_DEFAULT, + DEFAULT_IMAGE_LETTER_SCALE, + DEFAULT_IMAGE_LETTER_OFFSET, + false); + } + + @Override + protected void configureViewForImage(boolean isDefaultImage) { + // Hide the shadow overlay if the image is a default image (i.e. colored letter tile) + if (mShadowOverlay != null) { + mShadowOverlay.setVisibility(isDefaultImage ? View.GONE : View.VISIBLE); + } + } + + @Override + protected boolean isContactPhotoCircular() { + // Unlike Contacts' tiles, the Dialer's favorites tiles are square. + return false; + } +} diff --git a/java/com/android/dialer/app/list/PhoneFavoritesTileAdapter.java b/java/com/android/dialer/app/list/PhoneFavoritesTileAdapter.java new file mode 100644 index 000000000..c692ecac7 --- /dev/null +++ b/java/com/android/dialer/app/list/PhoneFavoritesTileAdapter.java @@ -0,0 +1,627 @@ +/* + * Copyright (C) 2013 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.app.list; + +import android.content.ContentProviderOperation; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.OperationApplicationException; +import android.content.res.Resources; +import android.database.Cursor; +import android.net.Uri; +import android.os.RemoteException; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.PinnedPositions; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; +import android.util.Log; +import android.util.LongSparseArray; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.ContactTileLoaderFactory; +import com.android.contacts.common.list.ContactEntry; +import com.android.contacts.common.list.ContactTileView; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.dialer.app.R; +import com.android.dialer.shortcuts.ShortcutRefresher; +import com.google.common.collect.ComparisonChain; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedList; +import java.util.List; +import java.util.PriorityQueue; + +/** Also allows for a configurable number of columns as well as a maximum row of tiled contacts. */ +public class PhoneFavoritesTileAdapter extends BaseAdapter implements OnDragDropListener { + + // Pinned positions start from 1, so there are a total of 20 maximum pinned contacts + private static final int PIN_LIMIT = 21; + private static final String TAG = PhoneFavoritesTileAdapter.class.getSimpleName(); + private static final boolean DEBUG = false; + /** + * The soft limit on how many contact tiles to show. NOTE This soft limit would not restrict the + * number of starred contacts to show, rather 1. If the count of starred contacts is less than + * this limit, show 20 tiles total. 2. If the count of starred contacts is more than or equal to + * this limit, show all starred tiles and no frequents. + */ + private static final int TILES_SOFT_LIMIT = 20; + /** Contact data stored in cache. This is used to populate the associated view. */ + private ArrayList<ContactEntry> mContactEntries = null; + + private int mNumFrequents; + private int mNumStarred; + + private ContactTileView.Listener mListener; + private OnDataSetChangedForAnimationListener mDataSetChangedListener; + private Context mContext; + private Resources mResources; + private ContactsPreferences mContactsPreferences; + private final Comparator<ContactEntry> mContactEntryComparator = + new Comparator<ContactEntry>() { + @Override + public int compare(ContactEntry lhs, ContactEntry rhs) { + return ComparisonChain.start() + .compare(lhs.pinned, rhs.pinned) + .compare(getPreferredSortName(lhs), getPreferredSortName(rhs)) + .result(); + } + + private String getPreferredSortName(ContactEntry contactEntry) { + if (mContactsPreferences.getSortOrder() == ContactsPreferences.SORT_ORDER_PRIMARY + || TextUtils.isEmpty(contactEntry.nameAlternative)) { + return contactEntry.namePrimary; + } + return contactEntry.nameAlternative; + } + }; + /** Back up of the temporarily removed Contact during dragging. */ + private ContactEntry mDraggedEntry = null; + /** Position of the temporarily removed contact in the cache. */ + private int mDraggedEntryIndex = -1; + /** New position of the temporarily removed contact in the cache. */ + private int mDropEntryIndex = -1; + /** New position of the temporarily entered contact in the cache. */ + private int mDragEnteredEntryIndex = -1; + + private boolean mAwaitingRemove = false; + private boolean mDelayCursorUpdates = false; + private ContactPhotoManager mPhotoManager; + + /** Indicates whether a drag is in process. */ + private boolean mInDragging = false; + + public PhoneFavoritesTileAdapter( + Context context, + ContactTileView.Listener listener, + OnDataSetChangedForAnimationListener dataSetChangedListener) { + mDataSetChangedListener = dataSetChangedListener; + mListener = listener; + mContext = context; + mResources = context.getResources(); + mContactsPreferences = new ContactsPreferences(mContext); + mNumFrequents = 0; + mContactEntries = new ArrayList<>(); + } + + void setPhotoLoader(ContactPhotoManager photoLoader) { + mPhotoManager = photoLoader; + } + + /** + * Indicates whether a drag is in process. + * + * @param inDragging Boolean variable indicating whether there is a drag in process. + */ + private void setInDragging(boolean inDragging) { + mDelayCursorUpdates = inDragging; + mInDragging = inDragging; + } + + void refreshContactsPreferences() { + mContactsPreferences.refreshValue(ContactsPreferences.DISPLAY_ORDER_KEY); + mContactsPreferences.refreshValue(ContactsPreferences.SORT_ORDER_KEY); + } + + /** + * Gets the number of frequents from the passed in cursor. + * + * <p>This methods is needed so the GroupMemberTileAdapter can override this. + * + * @param cursor The cursor to get number of frequents from. + */ + private void saveNumFrequentsFromCursor(Cursor cursor) { + mNumFrequents = cursor.getCount() - mNumStarred; + } + + /** + * Creates {@link ContactTileView}s for each item in {@link Cursor}. + * + * <p>Else use {@link ContactTileLoaderFactory} + */ + void setContactCursor(Cursor cursor) { + if (!mDelayCursorUpdates && cursor != null && !cursor.isClosed()) { + mNumStarred = getNumStarredContacts(cursor); + if (mAwaitingRemove) { + mDataSetChangedListener.cacheOffsetsForDatasetChange(); + } + + saveNumFrequentsFromCursor(cursor); + saveCursorToCache(cursor); + // cause a refresh of any views that rely on this data + notifyDataSetChanged(); + // about to start redraw + mDataSetChangedListener.onDataSetChangedForAnimation(); + } + } + + /** + * Saves the cursor data to the cache, to speed up UI changes. + * + * @param cursor Returned cursor from {@link ContactTileLoaderFactory} with data to populate the + * view. + */ + private void saveCursorToCache(Cursor cursor) { + mContactEntries.clear(); + + if (cursor == null) { + return; + } + + final LongSparseArray<Object> duplicates = new LongSparseArray<>(cursor.getCount()); + + // Track the length of {@link #mContactEntries} and compare to {@link #TILES_SOFT_LIMIT}. + int counter = 0; + + // The cursor should not be closed since this is invoked from a CursorLoader. + if (cursor.moveToFirst()) { + int starredColumn = cursor.getColumnIndexOrThrow(Contacts.STARRED); + int contactIdColumn = cursor.getColumnIndexOrThrow(Phone.CONTACT_ID); + int photoUriColumn = cursor.getColumnIndexOrThrow(Contacts.PHOTO_URI); + int lookupKeyColumn = cursor.getColumnIndexOrThrow(Contacts.LOOKUP_KEY); + int pinnedColumn = cursor.getColumnIndexOrThrow(Contacts.PINNED); + int nameColumn = cursor.getColumnIndexOrThrow(Contacts.DISPLAY_NAME_PRIMARY); + int nameAlternativeColumn = cursor.getColumnIndexOrThrow(Contacts.DISPLAY_NAME_ALTERNATIVE); + int isDefaultNumberColumn = cursor.getColumnIndexOrThrow(Phone.IS_SUPER_PRIMARY); + int phoneTypeColumn = cursor.getColumnIndexOrThrow(Phone.TYPE); + int phoneLabelColumn = cursor.getColumnIndexOrThrow(Phone.LABEL); + int phoneNumberColumn = cursor.getColumnIndexOrThrow(Phone.NUMBER); + do { + final int starred = cursor.getInt(starredColumn); + final long id; + + // We display a maximum of TILES_SOFT_LIMIT contacts, or the total number of starred + // whichever is greater. + if (starred < 1 && counter >= TILES_SOFT_LIMIT) { + break; + } else { + id = cursor.getLong(contactIdColumn); + } + + final ContactEntry existing = (ContactEntry) duplicates.get(id); + if (existing != null) { + // Check if the existing number is a default number. If not, clear the phone number + // and label fields so that the disambiguation dialog will show up. + if (!existing.isDefaultNumber) { + existing.phoneLabel = null; + existing.phoneNumber = null; + } + continue; + } + + final String photoUri = cursor.getString(photoUriColumn); + final String lookupKey = cursor.getString(lookupKeyColumn); + final int pinned = cursor.getInt(pinnedColumn); + final String name = cursor.getString(nameColumn); + final String nameAlternative = cursor.getString(nameAlternativeColumn); + final boolean isStarred = cursor.getInt(starredColumn) > 0; + final boolean isDefaultNumber = cursor.getInt(isDefaultNumberColumn) > 0; + + final ContactEntry contact = new ContactEntry(); + + contact.id = id; + contact.namePrimary = + (!TextUtils.isEmpty(name)) ? name : mResources.getString(R.string.missing_name); + contact.nameAlternative = + (!TextUtils.isEmpty(nameAlternative)) + ? nameAlternative + : mResources.getString(R.string.missing_name); + contact.nameDisplayOrder = mContactsPreferences.getDisplayOrder(); + contact.photoUri = (photoUri != null ? Uri.parse(photoUri) : null); + contact.lookupKey = lookupKey; + contact.lookupUri = + ContentUris.withAppendedId( + Uri.withAppendedPath(Contacts.CONTENT_LOOKUP_URI, lookupKey), id); + contact.isFavorite = isStarred; + contact.isDefaultNumber = isDefaultNumber; + + // Set phone number and label + final int phoneNumberType = cursor.getInt(phoneTypeColumn); + final String phoneNumberCustomLabel = cursor.getString(phoneLabelColumn); + contact.phoneLabel = + (String) Phone.getTypeLabel(mResources, phoneNumberType, phoneNumberCustomLabel); + contact.phoneNumber = cursor.getString(phoneNumberColumn); + + contact.pinned = pinned; + mContactEntries.add(contact); + + duplicates.put(id, contact); + + counter++; + } while (cursor.moveToNext()); + } + + mAwaitingRemove = false; + + arrangeContactsByPinnedPosition(mContactEntries); + + ShortcutRefresher.refresh(mContext, mContactEntries); + notifyDataSetChanged(); + } + + /** Iterates over the {@link Cursor} Returns position of the first NON Starred Contact */ + private int getNumStarredContacts(Cursor cursor) { + if (cursor == null) { + return 0; + } + + if (cursor.moveToFirst()) { + int starredColumn = cursor.getColumnIndex(Contacts.STARRED); + do { + if (cursor.getInt(starredColumn) == 0) { + return cursor.getPosition(); + } + } while (cursor.moveToNext()); + } + // There are not NON Starred contacts in cursor + // Set divider position to end + return cursor.getCount(); + } + + /** Returns the number of frequents that will be displayed in the list. */ + int getNumFrequents() { + return mNumFrequents; + } + + @Override + public int getCount() { + if (mContactEntries == null) { + return 0; + } + + return mContactEntries.size(); + } + + /** + * Returns an ArrayList of the {@link ContactEntry}s that are to appear on the row for the given + * position. + */ + @Override + public ContactEntry getItem(int position) { + return mContactEntries.get(position); + } + + /** + * For the top row of tiled contacts, the item id is the position of the row of contacts. For + * frequent contacts, the item id is the maximum number of rows of tiled contacts + the actual + * contact id. Since contact ids are always greater than 0, this guarantees that all items within + * this adapter will always have unique ids. + */ + @Override + public long getItemId(int position) { + return getItem(position).id; + } + + @Override + public boolean hasStableIds() { + return true; + } + + @Override + public boolean areAllItemsEnabled() { + return true; + } + + @Override + public boolean isEnabled(int position) { + return getCount() > 0; + } + + @Override + public void notifyDataSetChanged() { + if (DEBUG) { + Log.v(TAG, "notifyDataSetChanged"); + } + super.notifyDataSetChanged(); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (DEBUG) { + Log.v(TAG, "get view for " + String.valueOf(position)); + } + + PhoneFavoriteTileView tileView = null; + + if (convertView instanceof PhoneFavoriteTileView) { + tileView = (PhoneFavoriteTileView) convertView; + } + + if (tileView == null) { + tileView = + (PhoneFavoriteTileView) View.inflate(mContext, R.layout.phone_favorite_tile_view, null); + } + tileView.setPhotoManager(mPhotoManager); + tileView.setListener(mListener); + tileView.loadFromContact(getItem(position)); + return tileView; + } + + @Override + public int getViewTypeCount() { + return ViewTypes.COUNT; + } + + @Override + public int getItemViewType(int position) { + return ViewTypes.TILE; + } + + /** + * Temporarily removes a contact from the list for UI refresh. Stores data for this contact in the + * back-up variable. + * + * @param index Position of the contact to be removed. + */ + private void popContactEntry(int index) { + if (isIndexInBound(index)) { + mDraggedEntry = mContactEntries.get(index); + mDraggedEntryIndex = index; + mDragEnteredEntryIndex = index; + markDropArea(mDragEnteredEntryIndex); + } + } + + /** + * @param itemIndex Position of the contact in {@link #mContactEntries}. + * @return True if the given index is valid for {@link #mContactEntries}. + */ + boolean isIndexInBound(int itemIndex) { + return itemIndex >= 0 && itemIndex < mContactEntries.size(); + } + + /** + * Mark the tile as drop area by given the item index in {@link #mContactEntries}. + * + * @param itemIndex Position of the contact in {@link #mContactEntries}. + */ + private void markDropArea(int itemIndex) { + if (mDraggedEntry != null + && isIndexInBound(mDragEnteredEntryIndex) + && isIndexInBound(itemIndex)) { + mDataSetChangedListener.cacheOffsetsForDatasetChange(); + // Remove the old placeholder item and place the new placeholder item. + mContactEntries.remove(mDragEnteredEntryIndex); + mDragEnteredEntryIndex = itemIndex; + mContactEntries.add(mDragEnteredEntryIndex, ContactEntry.BLANK_ENTRY); + ContactEntry.BLANK_ENTRY.id = mDraggedEntry.id; + mDataSetChangedListener.onDataSetChangedForAnimation(); + notifyDataSetChanged(); + } + } + + /** Drops the temporarily removed contact to the desired location in the list. */ + private void handleDrop() { + boolean changed = false; + if (mDraggedEntry != null) { + if (isIndexInBound(mDragEnteredEntryIndex) && mDragEnteredEntryIndex != mDraggedEntryIndex) { + // Don't add the ContactEntry here (to prevent a double animation from occuring). + // When we receive a new cursor the list of contact entries will automatically be + // populated with the dragged ContactEntry at the correct spot. + mDropEntryIndex = mDragEnteredEntryIndex; + mContactEntries.set(mDropEntryIndex, mDraggedEntry); + mDataSetChangedListener.cacheOffsetsForDatasetChange(); + changed = true; + } else if (isIndexInBound(mDraggedEntryIndex)) { + // If {@link #mDragEnteredEntryIndex} is invalid, + // falls back to the original position of the contact. + mContactEntries.remove(mDragEnteredEntryIndex); + mContactEntries.add(mDraggedEntryIndex, mDraggedEntry); + mDropEntryIndex = mDraggedEntryIndex; + notifyDataSetChanged(); + } + + if (changed && mDropEntryIndex < PIN_LIMIT) { + final ArrayList<ContentProviderOperation> operations = + getReflowedPinningOperations(mContactEntries, mDraggedEntryIndex, mDropEntryIndex); + if (!operations.isEmpty()) { + // update the database here with the new pinned positions + try { + mContext.getContentResolver().applyBatch(ContactsContract.AUTHORITY, operations); + } catch (RemoteException | OperationApplicationException e) { + Log.e(TAG, "Exception thrown when pinning contacts", e); + } + } + } + mDraggedEntry = null; + } + } + + /** + * Used when a contact is removed from speeddial. This will both unstar and set pinned position of + * the contact to PinnedPosition.DEMOTED so that it doesn't show up anymore in the favorites list. + */ + private void unstarAndUnpinContact(Uri contactUri) { + final ContentValues values = new ContentValues(2); + values.put(Contacts.STARRED, false); + values.put(Contacts.PINNED, PinnedPositions.DEMOTED); + mContext.getContentResolver().update(contactUri, values, null, null); + } + + /** + * Given a list of contacts that each have pinned positions, rearrange the list (destructive) such + * that all pinned contacts are in their defined pinned positions, and unpinned contacts take the + * spaces between those pinned contacts. Demoted contacts should not appear in the resulting list. + * + * <p>This method also updates the pinned positions of pinned contacts so that they are all unique + * positive integers within range from 0 to toArrange.size() - 1. This is because when the contact + * entries are read from the database, it is possible for them to have overlapping pin positions + * due to sync or modifications by third party apps. + */ + @VisibleForTesting + private void arrangeContactsByPinnedPosition(ArrayList<ContactEntry> toArrange) { + final PriorityQueue<ContactEntry> pinnedQueue = + new PriorityQueue<>(PIN_LIMIT, mContactEntryComparator); + + final List<ContactEntry> unpinnedContacts = new LinkedList<>(); + + final int length = toArrange.size(); + for (int i = 0; i < length; i++) { + final ContactEntry contact = toArrange.get(i); + // Decide whether the contact is hidden(demoted), pinned, or unpinned + if (contact.pinned > PIN_LIMIT || contact.pinned == PinnedPositions.UNPINNED) { + unpinnedContacts.add(contact); + } else if (contact.pinned > PinnedPositions.DEMOTED) { + // Demoted or contacts with negative pinned positions are ignored. + // Pinned contacts go into a priority queue where they are ranked by pinned + // position. This is required because the contacts provider does not return + // contacts ordered by pinned position. + pinnedQueue.add(contact); + } + } + + final int maxToPin = Math.min(PIN_LIMIT, pinnedQueue.size() + unpinnedContacts.size()); + + toArrange.clear(); + for (int i = 1; i < maxToPin + 1; i++) { + if (!pinnedQueue.isEmpty() && pinnedQueue.peek().pinned <= i) { + final ContactEntry toPin = pinnedQueue.poll(); + toPin.pinned = i; + toArrange.add(toPin); + } else if (!unpinnedContacts.isEmpty()) { + toArrange.add(unpinnedContacts.remove(0)); + } + } + + // If there are still contacts in pinnedContacts at this point, it means that the pinned + // positions of these pinned contacts exceed the actual number of contacts in the list. + // For example, the user had 10 frequents, starred and pinned one of them at the last spot, + // and then cleared frequents. Contacts in this situation should become unpinned. + while (!pinnedQueue.isEmpty()) { + final ContactEntry entry = pinnedQueue.poll(); + entry.pinned = PinnedPositions.UNPINNED; + toArrange.add(entry); + } + + // Any remaining unpinned contacts that weren't in the gaps between the pinned contacts + // now just get appended to the end of the list. + toArrange.addAll(unpinnedContacts); + } + + /** + * Given an existing list of contact entries and a single entry that is to be pinned at a + * particular position, return a list of {@link ContentProviderOperation}s that contains new + * pinned positions for all contacts that are forced to be pinned at new positions, trying as much + * as possible to keep pinned contacts at their original location. + * + * <p>At this point in time the pinned position of each contact in the list has already been + * updated by {@link #arrangeContactsByPinnedPosition}, so we can assume that all pinned + * positions(within {@link #PIN_LIMIT} are unique positive integers. + */ + @VisibleForTesting + private ArrayList<ContentProviderOperation> getReflowedPinningOperations( + ArrayList<ContactEntry> list, int oldPos, int newPinPos) { + final ArrayList<ContentProviderOperation> positions = new ArrayList<>(); + final int lowerBound = Math.min(oldPos, newPinPos); + final int upperBound = Math.max(oldPos, newPinPos); + for (int i = lowerBound; i <= upperBound; i++) { + final ContactEntry entry = list.get(i); + + // Pinned positions in the database start from 1 instead of being zero-indexed like + // arrays, so offset by 1. + final int databasePinnedPosition = i + 1; + if (entry.pinned == databasePinnedPosition) { + continue; + } + + final Uri uri = Uri.withAppendedPath(Contacts.CONTENT_URI, String.valueOf(entry.id)); + final ContentValues values = new ContentValues(); + values.put(Contacts.PINNED, databasePinnedPosition); + positions.add(ContentProviderOperation.newUpdate(uri).withValues(values).build()); + } + return positions; + } + + @Override + public void onDragStarted(int x, int y, PhoneFavoriteSquareTileView view) { + setInDragging(true); + final int itemIndex = mContactEntries.indexOf(view.getContactEntry()); + popContactEntry(itemIndex); + } + + @Override + public void onDragHovered(int x, int y, PhoneFavoriteSquareTileView view) { + if (view == null) { + // The user is hovering over a view that is not a contact tile, no need to do + // anything here. + return; + } + final int itemIndex = mContactEntries.indexOf(view.getContactEntry()); + if (mInDragging + && mDragEnteredEntryIndex != itemIndex + && isIndexInBound(itemIndex) + && itemIndex < PIN_LIMIT + && itemIndex >= 0) { + markDropArea(itemIndex); + } + } + + @Override + public void onDragFinished(int x, int y) { + setInDragging(false); + // A contact has been dragged to the RemoveView in order to be unstarred, so simply wait + // for the new contact cursor which will cause the UI to be refreshed without the unstarred + // contact. + if (!mAwaitingRemove) { + handleDrop(); + } + } + + @Override + public void onDroppedOnRemove() { + if (mDraggedEntry != null) { + unstarAndUnpinContact(mDraggedEntry.lookupUri); + mAwaitingRemove = true; + } + } + + interface OnDataSetChangedForAnimationListener { + + void onDataSetChangedForAnimation(long... idsInPlace); + + void cacheOffsetsForDatasetChange(); + } + + private static class ViewTypes { + + static final int TILE = 0; + static final int COUNT = 1; + } +} diff --git a/java/com/android/dialer/app/list/RegularSearchFragment.java b/java/com/android/dialer/app/list/RegularSearchFragment.java new file mode 100644 index 000000000..26959539b --- /dev/null +++ b/java/com/android/dialer/app/list/RegularSearchFragment.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2013 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.app.list; + +import static android.Manifest.permission.READ_CONTACTS; + +import android.app.Activity; +import android.content.pm.PackageManager; +import android.support.v13.app.FragmentCompat; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import com.android.contacts.common.list.ContactEntryListAdapter; +import com.android.contacts.common.list.PinnedHeaderListView; +import com.android.dialer.app.R; +import com.android.dialer.app.widget.EmptyContentView; +import com.android.dialer.app.widget.EmptyContentView.OnEmptyViewActionButtonClickedListener; +import com.android.dialer.callintent.nano.CallInitiationType; +import com.android.dialer.phonenumbercache.CachedNumberLookupService; +import com.android.dialer.phonenumbercache.PhoneNumberCache; +import com.android.dialer.util.PermissionsUtil; + +public class RegularSearchFragment extends SearchFragment + implements OnEmptyViewActionButtonClickedListener, + FragmentCompat.OnRequestPermissionsResultCallback { + + public static final int PERMISSION_REQUEST_CODE = 1; + + private static final int SEARCH_DIRECTORY_RESULT_LIMIT = 5; + protected String mPermissionToRequest; + + public RegularSearchFragment() { + configureDirectorySearch(); + } + + public void configureDirectorySearch() { + setDirectorySearchEnabled(true); + setDirectoryResultLimit(SEARCH_DIRECTORY_RESULT_LIMIT); + } + + @Override + protected void onCreateView(LayoutInflater inflater, ViewGroup container) { + super.onCreateView(inflater, container); + ((PinnedHeaderListView) getListView()).setScrollToSectionOnHeaderTouch(true); + } + + @Override + protected ContactEntryListAdapter createListAdapter() { + RegularSearchListAdapter adapter = new RegularSearchListAdapter(getActivity()); + adapter.setDisplayPhotos(true); + adapter.setUseCallableUri(usesCallableUri()); + adapter.setListener(this); + return adapter; + } + + @Override + protected void cacheContactInfo(int position) { + CachedNumberLookupService cachedNumberLookupService = + PhoneNumberCache.get(getContext()).getCachedNumberLookupService(); + if (cachedNumberLookupService != null) { + final RegularSearchListAdapter adapter = (RegularSearchListAdapter) getAdapter(); + cachedNumberLookupService.addContact( + getContext(), adapter.getContactInfo(cachedNumberLookupService, position)); + } + } + + @Override + protected void setupEmptyView() { + if (mEmptyView != null && getActivity() != null) { + final int imageResource; + final int actionLabelResource; + final int descriptionResource; + final OnEmptyViewActionButtonClickedListener listener; + if (!PermissionsUtil.hasPermission(getActivity(), READ_CONTACTS)) { + imageResource = R.drawable.empty_contacts; + actionLabelResource = R.string.permission_single_turn_on; + descriptionResource = R.string.permission_no_search; + listener = this; + mPermissionToRequest = READ_CONTACTS; + } else { + imageResource = EmptyContentView.NO_IMAGE; + actionLabelResource = EmptyContentView.NO_LABEL; + descriptionResource = EmptyContentView.NO_LABEL; + listener = null; + mPermissionToRequest = null; + } + + mEmptyView.setImage(imageResource); + mEmptyView.setActionLabel(actionLabelResource); + mEmptyView.setDescription(descriptionResource); + if (listener != null) { + mEmptyView.setActionClickedListener(listener); + } + } + } + + @Override + public void onEmptyViewActionButtonClicked() { + final Activity activity = getActivity(); + if (activity == null) { + return; + } + + if (READ_CONTACTS.equals(mPermissionToRequest)) { + FragmentCompat.requestPermissions( + this, new String[] {mPermissionToRequest}, PERMISSION_REQUEST_CODE); + } + } + + @Override + public void onRequestPermissionsResult( + int requestCode, String[] permissions, int[] grantResults) { + if (requestCode == PERMISSION_REQUEST_CODE) { + setupEmptyView(); + if (grantResults != null + && grantResults.length == 1 + && PackageManager.PERMISSION_GRANTED == grantResults[0]) { + PermissionsUtil.notifyPermissionGranted(getActivity(), permissions[0]); + } + } + } + + @Override + protected int getCallInitiationType(boolean isRemoteDirectory) { + return isRemoteDirectory + ? CallInitiationType.Type.REMOTE_DIRECTORY + : CallInitiationType.Type.REGULAR_SEARCH; + } + + public interface CapabilityChecker { + + boolean isNearbyPlacesSearchEnabled(); + } +} diff --git a/java/com/android/dialer/app/list/RegularSearchListAdapter.java b/java/com/android/dialer/app/list/RegularSearchListAdapter.java new file mode 100644 index 000000000..94544d2db --- /dev/null +++ b/java/com/android/dialer/app/list/RegularSearchListAdapter.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2013 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.app.list; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.text.TextUtils; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.compat.DirectoryCompat; +import com.android.contacts.common.list.DirectoryPartition; +import com.android.dialer.phonenumbercache.CachedNumberLookupService; +import com.android.dialer.phonenumbercache.CachedNumberLookupService.CachedContactInfo; +import com.android.dialer.phonenumbercache.ContactInfo; +import com.android.dialer.phonenumberutil.PhoneNumberHelper; +import com.android.dialer.util.CallUtil; + +/** List adapter to display regular search results. */ +public class RegularSearchListAdapter extends DialerPhoneNumberListAdapter { + + protected boolean mIsQuerySipAddress; + + public RegularSearchListAdapter(Context context) { + super(context); + setShortcutEnabled(SHORTCUT_CREATE_NEW_CONTACT, false); + setShortcutEnabled(SHORTCUT_ADD_TO_EXISTING_CONTACT, false); + } + + public CachedContactInfo getContactInfo(CachedNumberLookupService lookupService, int position) { + ContactInfo info = new ContactInfo(); + CachedContactInfo cacheInfo = lookupService.buildCachedContactInfo(info); + final Cursor item = (Cursor) getItem(position); + if (item != null) { + final DirectoryPartition partition = + (DirectoryPartition) getPartition(getPartitionForPosition(position)); + final long directoryId = partition.getDirectoryId(); + final boolean isExtendedDirectory = isExtendedDirectory(directoryId); + + info.name = item.getString(PhoneQuery.DISPLAY_NAME); + info.type = item.getInt(PhoneQuery.PHONE_TYPE); + info.label = item.getString(PhoneQuery.PHONE_LABEL); + info.number = item.getString(PhoneQuery.PHONE_NUMBER); + final String photoUriStr = item.getString(PhoneQuery.PHOTO_URI); + info.photoUri = photoUriStr == null ? null : Uri.parse(photoUriStr); + /* + * An extended directory is custom directory in the app, but not a directory provided by + * framework. So it can't be USER_TYPE_WORK. + * + * When a search result is selected, RegularSearchFragment calls getContactInfo and + * cache the resulting @{link ContactInfo} into local db. Set usertype to USER_TYPE_WORK + * only if it's NOT extended directory id and is enterprise directory. + */ + info.userType = + !isExtendedDirectory && DirectoryCompat.isEnterpriseDirectoryId(directoryId) + ? ContactsUtils.USER_TYPE_WORK + : ContactsUtils.USER_TYPE_CURRENT; + + cacheInfo.setLookupKey(item.getString(PhoneQuery.LOOKUP_KEY)); + + final String sourceName = partition.getLabel(); + if (isExtendedDirectory) { + cacheInfo.setExtendedSource(sourceName, directoryId); + } else { + cacheInfo.setDirectorySource(sourceName, directoryId); + } + } + return cacheInfo; + } + + @Override + public String getFormattedQueryString() { + if (mIsQuerySipAddress) { + // Return unnormalized SIP address + return getQueryString(); + } + return super.getFormattedQueryString(); + } + + @Override + public void setQueryString(String queryString) { + // Don't show actions if the query string contains a letter. + final boolean showNumberShortcuts = + !TextUtils.isEmpty(getFormattedQueryString()) && hasDigitsInQueryString(); + mIsQuerySipAddress = PhoneNumberHelper.isUriNumber(queryString); + + if (isChanged(showNumberShortcuts)) { + notifyDataSetChanged(); + } + super.setQueryString(queryString); + } + + protected boolean isChanged(boolean showNumberShortcuts) { + boolean changed = false; + changed |= setShortcutEnabled(SHORTCUT_DIRECT_CALL, showNumberShortcuts || mIsQuerySipAddress); + changed |= setShortcutEnabled(SHORTCUT_SEND_SMS_MESSAGE, showNumberShortcuts); + changed |= + setShortcutEnabled( + SHORTCUT_MAKE_VIDEO_CALL, showNumberShortcuts && CallUtil.isVideoEnabled(getContext())); + return changed; + } + + /** Whether there is at least one digit in the query string. */ + private boolean hasDigitsInQueryString() { + String queryString = getQueryString(); + int length = queryString.length(); + for (int i = 0; i < length; i++) { + if (Character.isDigit(queryString.charAt(i))) { + return true; + } + } + return false; + } +} diff --git a/java/com/android/dialer/app/list/RemoveView.java b/java/com/android/dialer/app/list/RemoveView.java new file mode 100644 index 000000000..3b917db43 --- /dev/null +++ b/java/com/android/dialer/app/list/RemoveView.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2016 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.app.list; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.DragEvent; +import android.view.accessibility.AccessibilityEvent; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import com.android.dialer.app.R; + +public class RemoveView extends FrameLayout { + + DragDropController mDragDropController; + TextView mRemoveText; + ImageView mRemoveIcon; + int mUnhighlightedColor; + int mHighlightedColor; + Drawable mRemoveDrawable; + + public RemoveView(Context context) { + super(context); + } + + public RemoveView(Context context, AttributeSet attrs) { + this(context, attrs, -1); + } + + public RemoveView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected void onFinishInflate() { + mRemoveText = (TextView) findViewById(R.id.remove_view_text); + mRemoveIcon = (ImageView) findViewById(R.id.remove_view_icon); + final Resources r = getResources(); + mUnhighlightedColor = r.getColor(R.color.remove_text_color); + mHighlightedColor = r.getColor(R.color.remove_highlighted_text_color); + mRemoveDrawable = r.getDrawable(R.drawable.ic_remove); + } + + public void setDragDropController(DragDropController controller) { + mDragDropController = controller; + } + + @Override + public boolean onDragEvent(DragEvent event) { + final int action = event.getAction(); + switch (action) { + case DragEvent.ACTION_DRAG_ENTERED: + // TODO: This is temporary solution and should be removed once accessibility for + // drag and drop is supported by framework(b/26871588). + sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT); + setAppearanceHighlighted(); + break; + case DragEvent.ACTION_DRAG_EXITED: + setAppearanceNormal(); + break; + case DragEvent.ACTION_DRAG_LOCATION: + if (mDragDropController != null) { + mDragDropController.handleDragHovered(this, (int) event.getX(), (int) event.getY()); + } + break; + case DragEvent.ACTION_DROP: + sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT); + if (mDragDropController != null) { + mDragDropController.handleDragFinished((int) event.getX(), (int) event.getY(), true); + } + setAppearanceNormal(); + break; + } + return true; + } + + private void setAppearanceNormal() { + mRemoveText.setTextColor(mUnhighlightedColor); + mRemoveIcon.setColorFilter(mUnhighlightedColor); + invalidate(); + } + + private void setAppearanceHighlighted() { + mRemoveText.setTextColor(mHighlightedColor); + mRemoveIcon.setColorFilter(mHighlightedColor); + invalidate(); + } +} diff --git a/java/com/android/dialer/app/list/SearchFragment.java b/java/com/android/dialer/app/list/SearchFragment.java new file mode 100644 index 000000000..4a7d48ae4 --- /dev/null +++ b/java/com/android/dialer/app/list/SearchFragment.java @@ -0,0 +1,425 @@ +/* + * Copyright (C) 2013 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.app.list; + +import android.animation.Animator; +import android.animation.AnimatorInflater; +import android.animation.AnimatorListenerAdapter; +import android.app.Activity; +import android.app.DialogFragment; +import android.content.Intent; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Interpolator; +import android.widget.AbsListView; +import android.widget.AbsListView.OnScrollListener; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.Space; +import com.android.contacts.common.list.ContactEntryListAdapter; +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.FabUtil; +import com.android.dialer.animation.AnimUtils; +import com.android.dialer.app.R; +import com.android.dialer.app.dialpad.DialpadFragment.ErrorDialogFragment; +import com.android.dialer.app.widget.DialpadSearchEmptyContentView; +import com.android.dialer.app.widget.EmptyContentView; +import com.android.dialer.callintent.nano.CallSpecificAppData; +import com.android.dialer.common.LogUtil; +import com.android.dialer.util.DialerUtils; +import com.android.dialer.util.IntentUtil; +import com.android.dialer.util.PermissionsUtil; + +public class SearchFragment extends PhoneNumberPickerFragment { + + protected EmptyContentView mEmptyView; + private OnListFragmentScrolledListener mActivityScrollListener; + private View.OnTouchListener mActivityOnTouchListener; + /* + * Stores the untouched user-entered string that is used to populate the add to contacts + * intent. + */ + private String mAddToContactNumber; + private int mActionBarHeight; + private int mShadowHeight; + private int mPaddingTop; + private int mShowDialpadDuration; + private int mHideDialpadDuration; + /** + * Used to resize the list view containing search results so that it fits the available space + * above the dialpad. Does not have a user-visible effect in regular touch usage (since the + * dialpad hides that portion of the ListView anyway), but improves usability in accessibility + * mode. + */ + private Space mSpacer; + + private HostInterface mActivity; + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + + setQuickContactEnabled(true); + setAdjustSelectionBoundsEnabled(false); + setDarkTheme(false); + setPhotoPosition(ContactListItemView.getDefaultPhotoPosition(false /* opposite */)); + setUseCallableUri(true); + + try { + mActivityScrollListener = (OnListFragmentScrolledListener) activity; + } catch (ClassCastException e) { + LogUtil.v( + "SearchFragment.onAttach", + activity.toString() + + " doesn't implement OnListFragmentScrolledListener. " + + "Ignoring."); + } + } + + @Override + public void onStart() { + super.onStart(); + if (isSearchMode()) { + getAdapter().setHasHeader(0, false); + } + + mActivity = (HostInterface) getActivity(); + + final Resources res = getResources(); + mActionBarHeight = mActivity.getActionBarHeight(); + mShadowHeight = res.getDrawable(R.drawable.search_shadow).getIntrinsicHeight(); + mPaddingTop = res.getDimensionPixelSize(R.dimen.search_list_padding_top); + mShowDialpadDuration = res.getInteger(R.integer.dialpad_slide_in_duration); + mHideDialpadDuration = res.getInteger(R.integer.dialpad_slide_out_duration); + + final ListView listView = getListView(); + + if (mEmptyView == null) { + if (this instanceof SmartDialSearchFragment) { + mEmptyView = new DialpadSearchEmptyContentView(getActivity()); + } else { + mEmptyView = new EmptyContentView(getActivity()); + } + ((ViewGroup) getListView().getParent()).addView(mEmptyView); + getListView().setEmptyView(mEmptyView); + setupEmptyView(); + } + + listView.setBackgroundColor(res.getColor(R.color.background_dialer_results)); + listView.setClipToPadding(false); + setVisibleScrollbarEnabled(false); + + //Turn of accessibility live region as the list constantly update itself and spam messages. + listView.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_NONE); + ContentChangedFilter.addToParent(listView); + + listView.setOnScrollListener( + new OnScrollListener() { + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + if (mActivityScrollListener != null) { + mActivityScrollListener.onListFragmentScrollStateChange(scrollState); + } + } + + @Override + public void onScroll( + AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {} + }); + if (mActivityOnTouchListener != null) { + listView.setOnTouchListener(mActivityOnTouchListener); + } + + updatePosition(false /* animate */); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + FabUtil.addBottomPaddingToListViewForFab(getListView(), getResources()); + } + + @Override + public Animator onCreateAnimator(int transit, boolean enter, int nextAnim) { + Animator animator = null; + if (nextAnim != 0) { + animator = AnimatorInflater.loadAnimator(getActivity(), nextAnim); + } + if (animator != null) { + final View view = getView(); + final int oldLayerType = view.getLayerType(); + animator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + view.setLayerType(oldLayerType, null); + } + }); + } + return animator; + } + + @Override + protected void setSearchMode(boolean flag) { + super.setSearchMode(flag); + // This hides the "All contacts with phone numbers" header in the search fragment + final ContactEntryListAdapter adapter = getAdapter(); + if (adapter != null) { + adapter.setHasHeader(0, false); + } + } + + public void setAddToContactNumber(String addToContactNumber) { + mAddToContactNumber = addToContactNumber; + } + + /** + * Return true if phone number is prohibited by a value - + * (R.string.config_prohibited_phone_number_regexp) in the config files. False otherwise. + */ + public boolean checkForProhibitedPhoneNumber(String number) { + // Regular expression prohibiting manual phone call. Can be empty i.e. "no rule". + String prohibitedPhoneNumberRegexp = + getResources().getString(R.string.config_prohibited_phone_number_regexp); + + // "persist.radio.otaspdial" is a temporary hack needed for one carrier's automated + // test equipment. + if (number != null + && !TextUtils.isEmpty(prohibitedPhoneNumberRegexp) + && number.matches(prohibitedPhoneNumberRegexp)) { + LogUtil.i( + "SearchFragment.checkForProhibitedPhoneNumber", + "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"); + } + + return true; + } + return false; + } + + @Override + protected ContactEntryListAdapter createListAdapter() { + DialerPhoneNumberListAdapter adapter = new DialerPhoneNumberListAdapter(getActivity()); + adapter.setDisplayPhotos(true); + adapter.setUseCallableUri(super.usesCallableUri()); + adapter.setListener(this); + return adapter; + } + + @Override + protected void onItemClick(int position, long id) { + final DialerPhoneNumberListAdapter adapter = (DialerPhoneNumberListAdapter) getAdapter(); + final int shortcutType = adapter.getShortcutTypeFromPosition(position); + final OnPhoneNumberPickerActionListener listener; + final Intent intent; + final String number; + + LogUtil.i("SearchFragment.onItemClick", "shortcutType: " + shortcutType); + + switch (shortcutType) { + case DialerPhoneNumberListAdapter.SHORTCUT_INVALID: + super.onItemClick(position, id); + break; + case DialerPhoneNumberListAdapter.SHORTCUT_DIRECT_CALL: + number = adapter.getQueryString(); + listener = getOnPhoneNumberPickerListener(); + if (listener != null && !checkForProhibitedPhoneNumber(number)) { + CallSpecificAppData callSpecificAppData = new CallSpecificAppData(); + callSpecificAppData.callInitiationType = + getCallInitiationType(false /* isRemoteDirectory */); + callSpecificAppData.positionOfSelectedSearchResult = position; + callSpecificAppData.charactersInSearchString = + getQueryString() == null ? 0 : getQueryString().length(); + listener.onPickPhoneNumber(number, false /* isVideoCall */, callSpecificAppData); + } + break; + case DialerPhoneNumberListAdapter.SHORTCUT_CREATE_NEW_CONTACT: + number = + TextUtils.isEmpty(mAddToContactNumber) + ? adapter.getFormattedQueryString() + : mAddToContactNumber; + intent = IntentUtil.getNewContactIntent(number); + DialerUtils.startActivityWithErrorToast(getActivity(), intent); + break; + case DialerPhoneNumberListAdapter.SHORTCUT_ADD_TO_EXISTING_CONTACT: + number = + TextUtils.isEmpty(mAddToContactNumber) + ? adapter.getFormattedQueryString() + : mAddToContactNumber; + intent = IntentUtil.getAddToExistingContactIntent(number); + DialerUtils.startActivityWithErrorToast( + getActivity(), intent, R.string.add_contact_not_available); + break; + case DialerPhoneNumberListAdapter.SHORTCUT_SEND_SMS_MESSAGE: + number = adapter.getFormattedQueryString(); + intent = IntentUtil.getSendSmsIntent(number); + DialerUtils.startActivityWithErrorToast(getActivity(), intent); + break; + case DialerPhoneNumberListAdapter.SHORTCUT_MAKE_VIDEO_CALL: + number = + TextUtils.isEmpty(mAddToContactNumber) ? adapter.getQueryString() : mAddToContactNumber; + listener = getOnPhoneNumberPickerListener(); + if (listener != null && !checkForProhibitedPhoneNumber(number)) { + CallSpecificAppData callSpecificAppData = new CallSpecificAppData(); + callSpecificAppData.callInitiationType = + getCallInitiationType(false /* isRemoteDirectory */); + callSpecificAppData.positionOfSelectedSearchResult = position; + callSpecificAppData.charactersInSearchString = + getQueryString() == null ? 0 : getQueryString().length(); + listener.onPickPhoneNumber(number, true /* isVideoCall */, callSpecificAppData); + } + break; + } + } + + /** + * Updates the position and padding of the search fragment, depending on whether the dialpad is + * shown. This can be optionally animated. + */ + public void updatePosition(boolean animate) { + if (mActivity == null) { + // Activity will be set in onStart, and this method will be called again + return; + } + + // Use negative shadow height instead of 0 to account for the 9-patch's shadow. + int startTranslationValue = + mActivity.isDialpadShown() ? mActionBarHeight - mShadowHeight : -mShadowHeight; + int endTranslationValue = 0; + // Prevents ListView from being translated down after a rotation when the ActionBar is up. + if (animate || mActivity.isActionBarShowing()) { + endTranslationValue = mActivity.isDialpadShown() ? 0 : mActionBarHeight - mShadowHeight; + } + if (animate) { + // If the dialpad will be shown, then this animation involves sliding the list up. + final boolean slideUp = mActivity.isDialpadShown(); + + Interpolator interpolator = slideUp ? AnimUtils.EASE_IN : AnimUtils.EASE_OUT; + int duration = slideUp ? mShowDialpadDuration : mHideDialpadDuration; + getView().setTranslationY(startTranslationValue); + getView() + .animate() + .translationY(endTranslationValue) + .setInterpolator(interpolator) + .setDuration(duration) + .setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + if (!slideUp) { + resizeListView(); + } + } + + @Override + public void onAnimationEnd(Animator animation) { + if (slideUp) { + resizeListView(); + } + } + }); + + } else { + getView().setTranslationY(endTranslationValue); + resizeListView(); + } + + // There is padding which should only be applied when the dialpad is not shown. + int paddingTop = mActivity.isDialpadShown() ? 0 : mPaddingTop; + final ListView listView = getListView(); + listView.setPaddingRelative( + listView.getPaddingStart(), + paddingTop, + listView.getPaddingEnd(), + listView.getPaddingBottom()); + } + + public void resizeListView() { + if (mSpacer == null) { + return; + } + int spacerHeight = mActivity.isDialpadShown() ? mActivity.getDialpadHeight() : 0; + if (spacerHeight != mSpacer.getHeight()) { + final LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) mSpacer.getLayoutParams(); + lp.height = spacerHeight; + mSpacer.setLayoutParams(lp); + } + } + + @Override + protected void startLoading() { + if (getActivity() == null) { + return; + } + + if (PermissionsUtil.hasContactsPermissions(getActivity())) { + super.startLoading(); + } else if (TextUtils.isEmpty(getQueryString())) { + // Clear out any existing call shortcuts. + final DialerPhoneNumberListAdapter adapter = (DialerPhoneNumberListAdapter) getAdapter(); + adapter.disableAllShortcuts(); + } else { + // The contact list is not going to change (we have no results since permissions are + // denied), but the shortcuts might because of the different query, so update the + // list. + getAdapter().notifyDataSetChanged(); + } + + setupEmptyView(); + } + + public void setOnTouchListener(View.OnTouchListener onTouchListener) { + mActivityOnTouchListener = onTouchListener; + } + + @Override + protected View inflateView(LayoutInflater inflater, ViewGroup container) { + final LinearLayout parent = (LinearLayout) super.inflateView(inflater, container); + final int orientation = getResources().getConfiguration().orientation; + if (orientation == Configuration.ORIENTATION_PORTRAIT) { + mSpacer = new Space(getActivity()); + parent.addView( + mSpacer, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0)); + } + return parent; + } + + protected void setupEmptyView() {} + + public interface HostInterface { + + boolean isActionBarShowing(); + + boolean isDialpadShown(); + + int getDialpadHeight(); + + int getActionBarHideOffset(); + + int getActionBarHeight(); + } +} diff --git a/java/com/android/dialer/app/list/SmartDialNumberListAdapter.java b/java/com/android/dialer/app/list/SmartDialNumberListAdapter.java new file mode 100644 index 000000000..566a15d53 --- /dev/null +++ b/java/com/android/dialer/app/list/SmartDialNumberListAdapter.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2013 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.app.list; + +import android.content.Context; +import android.database.Cursor; +import android.support.annotation.NonNull; +import android.telephony.PhoneNumberUtils; +import android.text.TextUtils; +import android.util.Log; +import com.android.contacts.common.list.ContactListItemView; +import com.android.dialer.app.dialpad.SmartDialCursorLoader; +import com.android.dialer.smartdial.SmartDialMatchPosition; +import com.android.dialer.smartdial.SmartDialNameMatcher; +import com.android.dialer.smartdial.SmartDialPrefix; +import com.android.dialer.util.CallUtil; +import java.util.ArrayList; + +/** List adapter to display the SmartDial search results. */ +public class SmartDialNumberListAdapter extends DialerPhoneNumberListAdapter { + + private static final String TAG = SmartDialNumberListAdapter.class.getSimpleName(); + private static final boolean DEBUG = false; + + @NonNull private final SmartDialNameMatcher mNameMatcher; + + public SmartDialNumberListAdapter(Context context) { + super(context); + mNameMatcher = new SmartDialNameMatcher("", SmartDialPrefix.getMap()); + setShortcutEnabled(SmartDialNumberListAdapter.SHORTCUT_DIRECT_CALL, false); + + if (DEBUG) { + Log.v(TAG, "Constructing List Adapter"); + } + } + + /** Sets query for the SmartDialCursorLoader. */ + public void configureLoader(SmartDialCursorLoader loader) { + if (DEBUG) { + Log.v(TAG, "Configure Loader with query" + getQueryString()); + } + + if (getQueryString() == null) { + loader.configureQuery(""); + mNameMatcher.setQuery(""); + } else { + loader.configureQuery(getQueryString()); + mNameMatcher.setQuery(PhoneNumberUtils.normalizeNumber(getQueryString())); + } + } + + /** + * Sets highlight options for a List item in the SmartDial search results. + * + * @param view ContactListItemView where the result will be displayed. + * @param cursor Object containing information of the associated List item. + */ + @Override + protected void setHighlight(ContactListItemView view, Cursor cursor) { + view.clearHighlightSequences(); + + if (mNameMatcher.matches(cursor.getString(PhoneQuery.DISPLAY_NAME))) { + final ArrayList<SmartDialMatchPosition> nameMatches = mNameMatcher.getMatchPositions(); + for (SmartDialMatchPosition match : nameMatches) { + view.addNameHighlightSequence(match.start, match.end); + if (DEBUG) { + Log.v( + TAG, + cursor.getString(PhoneQuery.DISPLAY_NAME) + + " " + + mNameMatcher.getQuery() + + " " + + String.valueOf(match.start)); + } + } + } + + final SmartDialMatchPosition numberMatch = + mNameMatcher.matchesNumber(cursor.getString(PhoneQuery.PHONE_NUMBER)); + if (numberMatch != null) { + view.addNumberHighlightSequence(numberMatch.start, numberMatch.end); + } + } + + @Override + public void setQueryString(String queryString) { + final boolean showNumberShortcuts = !TextUtils.isEmpty(getFormattedQueryString()); + boolean changed = false; + changed |= setShortcutEnabled(SHORTCUT_CREATE_NEW_CONTACT, showNumberShortcuts); + changed |= setShortcutEnabled(SHORTCUT_ADD_TO_EXISTING_CONTACT, showNumberShortcuts); + changed |= setShortcutEnabled(SHORTCUT_SEND_SMS_MESSAGE, showNumberShortcuts); + changed |= + setShortcutEnabled( + SHORTCUT_MAKE_VIDEO_CALL, showNumberShortcuts && CallUtil.isVideoEnabled(getContext())); + if (changed) { + notifyDataSetChanged(); + } + super.setQueryString(queryString); + } + + public void setShowEmptyListForNullQuery(boolean show) { + mNameMatcher.setShouldMatchEmptyQuery(!show); + } +} diff --git a/java/com/android/dialer/app/list/SmartDialSearchFragment.java b/java/com/android/dialer/app/list/SmartDialSearchFragment.java new file mode 100644 index 000000000..c783d3ac3 --- /dev/null +++ b/java/com/android/dialer/app/list/SmartDialSearchFragment.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2013 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.app.list; + +import static android.Manifest.permission.CALL_PHONE; + +import android.app.Activity; +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; +import android.support.v13.app.FragmentCompat; +import com.android.contacts.common.list.ContactEntryListAdapter; +import com.android.dialer.app.R; +import com.android.dialer.app.dialpad.SmartDialCursorLoader; +import com.android.dialer.app.widget.EmptyContentView; +import com.android.dialer.callintent.nano.CallInitiationType; +import com.android.dialer.util.PermissionsUtil; + +/** Implements a fragment to load and display SmartDial search results. */ +public class SmartDialSearchFragment extends SearchFragment + implements EmptyContentView.OnEmptyViewActionButtonClickedListener, + FragmentCompat.OnRequestPermissionsResultCallback { + + private static final String TAG = SmartDialSearchFragment.class.getSimpleName(); + + private static final int CALL_PHONE_PERMISSION_REQUEST_CODE = 1; + + /** Creates a SmartDialListAdapter to display and operate on search results. */ + @Override + protected ContactEntryListAdapter createListAdapter() { + SmartDialNumberListAdapter adapter = new SmartDialNumberListAdapter(getActivity()); + adapter.setUseCallableUri(super.usesCallableUri()); + adapter.setQuickContactEnabled(true); + adapter.setShowEmptyListForNullQuery(getShowEmptyListForNullQuery()); + // Set adapter's query string to restore previous instance state. + adapter.setQueryString(getQueryString()); + adapter.setListener(this); + return adapter; + } + + /** Creates a SmartDialCursorLoader object to load query results. */ + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + // Smart dialing does not support Directory Load, falls back to normal search instead. + if (id == getDirectoryLoaderId()) { + return super.onCreateLoader(id, args); + } else { + final SmartDialNumberListAdapter adapter = (SmartDialNumberListAdapter) getAdapter(); + SmartDialCursorLoader loader = new SmartDialCursorLoader(super.getContext()); + loader.setShowEmptyListForNullQuery(getShowEmptyListForNullQuery()); + adapter.configureLoader(loader); + return loader; + } + } + + @Override + protected void setupEmptyView() { + if (mEmptyView != null && getActivity() != null) { + if (!PermissionsUtil.hasPermission(getActivity(), CALL_PHONE)) { + mEmptyView.setImage(R.drawable.empty_contacts); + mEmptyView.setActionLabel(R.string.permission_single_turn_on); + mEmptyView.setDescription(R.string.permission_place_call); + mEmptyView.setActionClickedListener(this); + } else { + mEmptyView.setImage(EmptyContentView.NO_IMAGE); + mEmptyView.setActionLabel(EmptyContentView.NO_LABEL); + mEmptyView.setDescription(EmptyContentView.NO_LABEL); + } + } + } + + @Override + public void onEmptyViewActionButtonClicked() { + final Activity activity = getActivity(); + if (activity == null) { + return; + } + + FragmentCompat.requestPermissions( + this, new String[] {CALL_PHONE}, CALL_PHONE_PERMISSION_REQUEST_CODE); + } + + @Override + public void onRequestPermissionsResult( + int requestCode, String[] permissions, int[] grantResults) { + if (requestCode == CALL_PHONE_PERMISSION_REQUEST_CODE) { + setupEmptyView(); + } + } + + @Override + protected int getCallInitiationType(boolean isRemoteDirectory) { + return CallInitiationType.Type.SMART_DIAL; + } + + public boolean isShowingPermissionRequest() { + return mEmptyView != null && mEmptyView.isShowingContent(); + } + + @Override + public void setShowEmptyListForNullQuery(boolean show) { + if (getAdapter() != null) { + ((SmartDialNumberListAdapter) getAdapter()).setShowEmptyListForNullQuery(show); + } + super.setShowEmptyListForNullQuery(show); + } +} diff --git a/java/com/android/dialer/app/list/SpeedDialFragment.java b/java/com/android/dialer/app/list/SpeedDialFragment.java new file mode 100644 index 000000000..8e0f89028 --- /dev/null +++ b/java/com/android/dialer/app/list/SpeedDialFragment.java @@ -0,0 +1,512 @@ +/* + * Copyright (C) 2013 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.app.list; + +import static android.Manifest.permission.READ_CONTACTS; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.app.Activity; +import android.app.Fragment; +import android.app.LoaderManager; +import android.content.CursorLoader; +import android.content.Loader; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import android.os.Trace; +import android.support.annotation.Nullable; +import android.support.v13.app.FragmentCompat; +import android.support.v4.util.LongSparseArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AnimationUtils; +import android.view.animation.LayoutAnimationController; +import android.widget.AbsListView; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.FrameLayout; +import android.widget.FrameLayout.LayoutParams; +import android.widget.ImageView; +import android.widget.ListView; +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.ContactTileLoaderFactory; +import com.android.contacts.common.list.ContactTileView; +import com.android.contacts.common.list.OnPhoneNumberPickerActionListener; +import com.android.dialer.app.R; +import com.android.dialer.app.list.ListsFragment.ListsPage; +import com.android.dialer.app.widget.EmptyContentView; +import com.android.dialer.callintent.nano.CallInitiationType; +import com.android.dialer.callintent.nano.CallSpecificAppData; +import com.android.dialer.common.LogUtil; +import com.android.dialer.util.PermissionsUtil; +import com.android.dialer.util.ViewUtil; +import java.util.ArrayList; + +/** This fragment displays the user's favorite/frequent contacts in a grid. */ +public class SpeedDialFragment extends Fragment + implements ListsPage, + OnItemClickListener, + PhoneFavoritesTileAdapter.OnDataSetChangedForAnimationListener, + EmptyContentView.OnEmptyViewActionButtonClickedListener, + FragmentCompat.OnRequestPermissionsResultCallback { + + private static final int READ_CONTACTS_PERMISSION_REQUEST_CODE = 1; + + /** + * By default, the animation code assumes that all items in a list view are of the same height + * when animating new list items into view (e.g. from the bottom of the screen into view). This + * can cause incorrect translation offsets when a item that is larger or smaller than other list + * item is removed from the list. This key is used to provide the actual height of the removed + * object so that the actual translation appears correct to the user. + */ + private static final long KEY_REMOVED_ITEM_HEIGHT = Long.MAX_VALUE; + + private static final String TAG = "SpeedDialFragment"; + private static final boolean DEBUG = false; + /** Used with LoaderManager. */ + private static final int LOADER_ID_CONTACT_TILE = 1; + + private final LongSparseArray<Integer> mItemIdTopMap = new LongSparseArray<>(); + private final LongSparseArray<Integer> mItemIdLeftMap = new LongSparseArray<>(); + private final ContactTileView.Listener mContactTileAdapterListener = + new ContactTileAdapterListener(); + private final LoaderManager.LoaderCallbacks<Cursor> mContactTileLoaderListener = + new ContactTileLoaderListener(); + private final ScrollListener mScrollListener = new ScrollListener(); + private int mAnimationDuration; + private OnPhoneNumberPickerActionListener mPhoneNumberPickerActionListener; + private OnListFragmentScrolledListener mActivityScrollListener; + private PhoneFavoritesTileAdapter mContactTileAdapter; + private View mParentView; + private PhoneFavoriteListView mListView; + private View mContactTileFrame; + /** Layout used when there are no favorites. */ + private EmptyContentView mEmptyView; + + @Override + public void onCreate(Bundle savedState) { + if (DEBUG) { + LogUtil.d("SpeedDialFragment.onCreate", null); + } + Trace.beginSection(TAG + " onCreate"); + super.onCreate(savedState); + + // 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 PhoneFavoritesTileAdapter(getActivity(), mContactTileAdapterListener, this); + mContactTileAdapter.setPhotoLoader(ContactPhotoManager.getInstance(getActivity())); + mAnimationDuration = getResources().getInteger(R.integer.fade_duration); + Trace.endSection(); + } + + @Override + public void onResume() { + Trace.beginSection(TAG + " onResume"); + super.onResume(); + if (mContactTileAdapter != null) { + mContactTileAdapter.refreshContactsPreferences(); + } + if (PermissionsUtil.hasContactsPermissions(getActivity())) { + if (getLoaderManager().getLoader(LOADER_ID_CONTACT_TILE) == null) { + getLoaderManager().initLoader(LOADER_ID_CONTACT_TILE, null, mContactTileLoaderListener); + + } else { + getLoaderManager().getLoader(LOADER_ID_CONTACT_TILE).forceLoad(); + } + + mEmptyView.setDescription(R.string.speed_dial_empty); + mEmptyView.setActionLabel(R.string.speed_dial_empty_add_favorite_action); + } else { + mEmptyView.setDescription(R.string.permission_no_speeddial); + mEmptyView.setActionLabel(R.string.permission_single_turn_on); + } + Trace.endSection(); + } + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + Trace.beginSection(TAG + " onCreateView"); + mParentView = inflater.inflate(R.layout.speed_dial_fragment, container, false); + + mListView = (PhoneFavoriteListView) mParentView.findViewById(R.id.contact_tile_list); + mListView.setOnItemClickListener(this); + mListView.setVerticalScrollBarEnabled(false); + mListView.setVerticalScrollbarPosition(View.SCROLLBAR_POSITION_RIGHT); + mListView.setScrollBarStyle(ListView.SCROLLBARS_OUTSIDE_OVERLAY); + mListView.getDragDropController().addOnDragDropListener(mContactTileAdapter); + + final ImageView dragShadowOverlay = + (ImageView) getActivity().findViewById(R.id.contact_tile_drag_shadow_overlay); + mListView.setDragShadowOverlay(dragShadowOverlay); + + mEmptyView = (EmptyContentView) mParentView.findViewById(R.id.empty_list_view); + mEmptyView.setImage(R.drawable.empty_speed_dial); + mEmptyView.setActionClickedListener(this); + + mContactTileFrame = mParentView.findViewById(R.id.contact_tile_frame); + + final LayoutAnimationController controller = + new LayoutAnimationController( + AnimationUtils.loadAnimation(getActivity(), android.R.anim.fade_in)); + controller.setDelay(0); + mListView.setLayoutAnimation(controller); + mListView.setAdapter(mContactTileAdapter); + + mListView.setOnScrollListener(mScrollListener); + mListView.setFastScrollEnabled(false); + mListView.setFastScrollAlwaysVisible(false); + + //prevent content changes of the list from firing accessibility events. + mListView.setAccessibilityLiveRegion(View.ACCESSIBILITY_LIVE_REGION_NONE); + ContentChangedFilter.addToParent(mListView); + + Trace.endSection(); + return mParentView; + } + + public boolean hasFrequents() { + if (mContactTileAdapter == null) { + return false; + } + return mContactTileAdapter.getNumFrequents() > 0; + } + + /* package */ void setEmptyViewVisibility(final boolean visible) { + final int previousVisibility = mEmptyView.getVisibility(); + final int emptyViewVisibility = visible ? View.VISIBLE : View.GONE; + final int listViewVisibility = visible ? View.GONE : View.VISIBLE; + + if (previousVisibility != emptyViewVisibility) { + final FrameLayout.LayoutParams params = (LayoutParams) mContactTileFrame.getLayoutParams(); + params.height = visible ? LayoutParams.WRAP_CONTENT : LayoutParams.MATCH_PARENT; + mContactTileFrame.setLayoutParams(params); + mEmptyView.setVisibility(emptyViewVisibility); + mListView.setVisibility(listViewVisibility); + } + } + + @Override + public void onStart() { + super.onStart(); + + final Activity activity = getActivity(); + + try { + mActivityScrollListener = (OnListFragmentScrolledListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException( + activity.toString() + " must implement OnListFragmentScrolledListener"); + } + + try { + OnDragDropListener listener = (OnDragDropListener) activity; + mListView.getDragDropController().addOnDragDropListener(listener); + ((HostInterface) activity).setDragDropController(mListView.getDragDropController()); + } catch (ClassCastException e) { + throw new ClassCastException( + activity.toString() + " must implement OnDragDropListener and HostInterface"); + } + + try { + mPhoneNumberPickerActionListener = (OnPhoneNumberPickerActionListener) activity; + } catch (ClassCastException e) { + throw new ClassCastException( + activity.toString() + " must implement PhoneFavoritesFragment.listener"); + } + + // 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. + if (PermissionsUtil.hasContactsPermissions(activity)) { + getLoaderManager().initLoader(LOADER_ID_CONTACT_TILE, null, mContactTileLoaderListener); + } else { + setEmptyViewVisibility(true); + } + } + + /** + * {@inheritDoc} + * + * <p>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) { + LogUtil.e( + "SpeedDialFragment.onItemClick", + "event for unexpected position. The position " + + position + + " is before \"all\" section. Ignored."); + } + } + + /** + * Cache the current view offsets into memory. Once a relayout of views in the ListView has + * happened due to a dataset change, the cached offsets are used to create animations that slide + * views from their previous positions to their new ones, to give the appearance that the views + * are sliding into their new positions. + */ + private void saveOffsets(int removedItemHeight) { + final int firstVisiblePosition = mListView.getFirstVisiblePosition(); + if (DEBUG) { + LogUtil.d("SpeedDialFragment.saveOffsets", "Child count : " + mListView.getChildCount()); + } + for (int i = 0; i < mListView.getChildCount(); i++) { + final View child = mListView.getChildAt(i); + final int position = firstVisiblePosition + i; + // Since we are getting the position from mListView and then querying + // mContactTileAdapter, its very possible that things are out of sync + // and we might index out of bounds. Let's make sure that this doesn't happen. + if (!mContactTileAdapter.isIndexInBound(position)) { + continue; + } + final long itemId = mContactTileAdapter.getItemId(position); + if (DEBUG) { + LogUtil.d( + "SpeedDialFragment.saveOffsets", + "Saving itemId: " + itemId + " for listview child " + i + " Top: " + child.getTop()); + } + mItemIdTopMap.put(itemId, child.getTop()); + mItemIdLeftMap.put(itemId, child.getLeft()); + } + mItemIdTopMap.put(KEY_REMOVED_ITEM_HEIGHT, removedItemHeight); + } + + /* + * Performs animations for the gridView + */ + private void animateGridView(final long... idsInPlace) { + if (mItemIdTopMap.size() == 0) { + // Don't do animations if the database is being queried for the first time and + // the previous item offsets have not been cached, or the user hasn't done anything + // (dragging, swiping etc) that requires an animation. + return; + } + + ViewUtil.doOnPreDraw( + mListView, + true, + new Runnable() { + @Override + public void run() { + + final int firstVisiblePosition = mListView.getFirstVisiblePosition(); + final AnimatorSet animSet = new AnimatorSet(); + final ArrayList<Animator> animators = new ArrayList<Animator>(); + for (int i = 0; i < mListView.getChildCount(); i++) { + final View child = mListView.getChildAt(i); + int position = firstVisiblePosition + i; + + // Since we are getting the position from mListView and then querying + // mContactTileAdapter, its very possible that things are out of sync + // and we might index out of bounds. Let's make sure that this doesn't happen. + if (!mContactTileAdapter.isIndexInBound(position)) { + continue; + } + + final long itemId = mContactTileAdapter.getItemId(position); + + if (containsId(idsInPlace, itemId)) { + animators.add(ObjectAnimator.ofFloat(child, "alpha", 0.0f, 1.0f)); + break; + } else { + Integer startTop = mItemIdTopMap.get(itemId); + Integer startLeft = mItemIdLeftMap.get(itemId); + final int top = child.getTop(); + final int left = child.getLeft(); + int deltaX = 0; + int deltaY = 0; + + if (startLeft != null) { + if (startLeft != left) { + deltaX = startLeft - left; + animators.add(ObjectAnimator.ofFloat(child, "translationX", deltaX, 0.0f)); + } + } + + if (startTop != null) { + if (startTop != top) { + deltaY = startTop - top; + animators.add(ObjectAnimator.ofFloat(child, "translationY", deltaY, 0.0f)); + } + } + + if (DEBUG) { + LogUtil.d( + "SpeedDialFragment.onPreDraw", + "Found itemId: " + + itemId + + " for listview child " + + i + + " Top: " + + top + + " Delta: " + + deltaY); + } + } + } + + if (animators.size() > 0) { + animSet.setDuration(mAnimationDuration).playTogether(animators); + animSet.start(); + } + + mItemIdTopMap.clear(); + mItemIdLeftMap.clear(); + } + }); + } + + private boolean containsId(long[] ids, long target) { + // Linear search on array is fine because this is typically only 0-1 elements long + for (int i = 0; i < ids.length; i++) { + if (ids[i] == target) { + return true; + } + } + return false; + } + + @Override + public void onDataSetChangedForAnimation(long... idsInPlace) { + animateGridView(idsInPlace); + } + + @Override + public void cacheOffsetsForDatasetChange() { + saveOffsets(0); + } + + @Override + public void onEmptyViewActionButtonClicked() { + final Activity activity = getActivity(); + if (activity == null) { + return; + } + + if (!PermissionsUtil.hasPermission(activity, READ_CONTACTS)) { + FragmentCompat.requestPermissions( + this, new String[] {READ_CONTACTS}, READ_CONTACTS_PERMISSION_REQUEST_CODE); + } else { + // Switch tabs + ((HostInterface) activity).showAllContactsTab(); + } + } + + @Override + public void onRequestPermissionsResult( + int requestCode, String[] permissions, int[] grantResults) { + if (requestCode == READ_CONTACTS_PERMISSION_REQUEST_CODE) { + if (grantResults.length == 1 && PackageManager.PERMISSION_GRANTED == grantResults[0]) { + PermissionsUtil.notifyPermissionGranted(getActivity(), READ_CONTACTS); + } + } + } + + @Override + public void onPageResume(@Nullable Activity activity) { + LogUtil.i("SpeedDialFragment.onPageResume", null); + } + + @Override + public void onPagePause(@Nullable Activity activity) { + LogUtil.i("SpeedDialFragment.onPagePause", null); + } + + public interface HostInterface { + + void setDragDropController(DragDropController controller); + + void showAllContactsTab(); + } + + private class ContactTileLoaderListener implements LoaderManager.LoaderCallbacks<Cursor> { + + @Override + public CursorLoader onCreateLoader(int id, Bundle args) { + if (DEBUG) { + LogUtil.d("ContactTileLoaderListener.onCreateLoader", null); + } + return ContactTileLoaderFactory.createStrequentPhoneOnlyLoader(getActivity()); + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor data) { + if (DEBUG) { + LogUtil.d("ContactTileLoaderListener.onLoadFinished", null); + } + mContactTileAdapter.setContactCursor(data); + setEmptyViewVisibility(mContactTileAdapter.getCount() == 0); + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) { + if (DEBUG) { + LogUtil.d("ContactTileLoaderListener.onLoaderReset", null); + } + } + } + + private class ContactTileAdapterListener implements ContactTileView.Listener { + + @Override + public void onContactSelected(Uri contactUri, Rect targetRect) { + if (mPhoneNumberPickerActionListener != null) { + CallSpecificAppData callSpecificAppData = new CallSpecificAppData(); + callSpecificAppData.callInitiationType = CallInitiationType.Type.SPEED_DIAL; + mPhoneNumberPickerActionListener.onPickDataUri( + contactUri, false /* isVideoCall */, callSpecificAppData); + } + } + + @Override + public void onCallNumberDirectly(String phoneNumber) { + if (mPhoneNumberPickerActionListener != null) { + CallSpecificAppData callSpecificAppData = new CallSpecificAppData(); + callSpecificAppData.callInitiationType = CallInitiationType.Type.SPEED_DIAL; + mPhoneNumberPickerActionListener.onPickPhoneNumber( + phoneNumber, false /* isVideoCall */, callSpecificAppData); + } + } + } + + private class ScrollListener implements ListView.OnScrollListener { + + @Override + public void onScroll( + AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { + if (mActivityScrollListener != null) { + mActivityScrollListener.onListFragmentScroll( + firstVisibleItem, visibleItemCount, totalItemCount); + } + } + + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + mActivityScrollListener.onListFragmentScrollStateChange(scrollState); + } + } +} |