diff options
20 files changed, 1542 insertions, 64 deletions
diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 7c39ee5db..502184c63 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -51,10 +51,8 @@ <application android:label="@string/applicationLabel" - android:icon="@mipmap/ic_launcher_contacts" - android:taskAffinity="android.task.contacts" - android:hardwareAccelerated="true" - > + android:icon="@mipmap/ic_launcher_phone" + android:hardwareAccelerated="true"> <!-- The entrance point for Phone UI. stateAlwaysHidden is set to suppress keyboard show up on @@ -68,7 +66,6 @@ android:icon="@mipmap/ic_launcher_phone" android:screenOrientation="nosensor" android:enabled="@*android:bool/config_voice_capable" - android:taskAffinity="android.task.contacts.phone" android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"> <intent-filter> <action android:name="android.intent.action.DIAL" /> @@ -130,7 +127,6 @@ android:theme="@style/CallDetailActivityTheme" android:screenOrientation="portrait" android:icon="@mipmap/ic_launcher_phone" - android:taskAffinity="android.task.contacts.phone" > <intent-filter> <action android:name="android.intent.action.VIEW"/> diff --git a/res/layout/dialpad_fragment.xml b/res/layout/dialpad_fragment.xml index 6423638a3..e672551bb 100644 --- a/res/layout/dialpad_fragment.xml +++ b/res/layout/dialpad_fragment.xml @@ -56,13 +56,23 @@ android:src="@drawable/ic_dial_action_delete" /> </LinearLayout> + <View style="@style/DialpadHorizontalSeparator"/> + + <!-- Smard dial suggestion section --> + <GridView + android:id="@+id/dialpad_smartdial_list" + android:layout_width="match_parent" + android:layout_height="42sp" + android:columnWidth="0dp" + android:numColumns="3" + android:stretchMode="columnWidth" + android:gravity="center" + android:background="@drawable/dialpad_background"/> + <!-- Keypad section --> <include layout="@layout/dialpad" /> - <View - android:layout_width="match_parent" - android:layout_height="@dimen/dialpad_vertical_margin" - android:background="#66000000"/> + <View style="@style/DialpadHorizontalSeparator"/> <!-- left and right paddings will be modified by the code. See DialpadFragment. --> <FrameLayout diff --git a/res/layout/dialpad_smartdial_item.xml b/res/layout/dialpad_smartdial_item.xml new file mode 100644 index 000000000..eed257035 --- /dev/null +++ b/res/layout/dialpad_smartdial_item.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2012 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. +--> + +<com.android.dialer.dialpad.SmartDialTextView + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/contact_name" + android:layout_width="match_parent" + android:layout_height="42sp" + android:padding="@dimen/smartdial_suggestions_padding" + android:textColor="#39caff" + android:textSize="16sp" + android:singleLine="true" + android:ellipsize="none" + android:gravity="center" + /> diff --git a/res/mipmap-hdpi/ic_launcher_phone.png b/res/mipmap-hdpi/ic_launcher_phone.png Binary files differnew file mode 100644 index 000000000..5a3dff1f3 --- /dev/null +++ b/res/mipmap-hdpi/ic_launcher_phone.png diff --git a/res/mipmap-mdpi/ic_launcher_phone.png b/res/mipmap-mdpi/ic_launcher_phone.png Binary files differnew file mode 100644 index 000000000..9ea0d8c8b --- /dev/null +++ b/res/mipmap-mdpi/ic_launcher_phone.png diff --git a/res/mipmap-xhdpi/ic_launcher_phone.png b/res/mipmap-xhdpi/ic_launcher_phone.png Binary files differnew file mode 100644 index 000000000..e97836cdf --- /dev/null +++ b/res/mipmap-xhdpi/ic_launcher_phone.png diff --git a/res/mipmap-xxhdpi/ic_launcher_phone.png b/res/mipmap-xxhdpi/ic_launcher_phone.png Binary files differnew file mode 100644 index 000000000..1594e4ec3 --- /dev/null +++ b/res/mipmap-xxhdpi/ic_launcher_phone.png diff --git a/res/values/colors.xml b/res/values/colors.xml index 9f3e3a2b0..2828725ac 100644 --- a/res/values/colors.xml +++ b/res/values/colors.xml @@ -18,5 +18,7 @@ <!-- Secondary text color in the Phone app --> <color name="dialtacts_secondary_text_color">#888888</color> + <color name="smartdial_confidence_drawable_color">#39caff</color> + <color name="smartdial_highlighted_text_color">#ffffff</color> </resources> diff --git a/res/values/dimens.xml b/res/values/dimens.xml index 2c8d59662..4034f7303 100644 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -50,4 +50,9 @@ <!-- Min with of fake menu buttons, which should be same as ActionBar's one --> <dimen name="fake_menu_button_min_width">56dip</dimen> + + <!-- Smart Dial --> + <dimen name="smartdial_suggestions_padding">4dp</dimen> + <dimen name="smartdial_suggestions_extra_padding">2dp</dimen> + <dimen name="smartdial_confidence_hint_text_size">27dp</dimen> </resources> diff --git a/res/values/styles.xml b/res/values/styles.xml index 6f36bf1d8..4f64cb37a 100644 --- a/res/values/styles.xml +++ b/res/values/styles.xml @@ -104,6 +104,12 @@ <item name="android:soundEffectsEnabled">false</item> </style> + <style name="DialpadHorizontalSeparator"> + <item name="android:layout_width">match_parent</item> + <item name="android:layout_height">@dimen/dialpad_vertical_margin</item> + <item name="android:background">#66000000</item> + </style> + <style name="DialtactsActionBarStyle" parent="android:Widget.Holo.ActionBar"> <item name="android:backgroundSplit">@null</item> <item name="android:backgroundStacked">@drawable/ab_stacked_opaque_dark_holo</item> diff --git a/src/com/android/dialer/DialtactsActivity.java b/src/com/android/dialer/DialtactsActivity.java index 12b29a8ad..0edf19a3b 100644 --- a/src/com/android/dialer/DialtactsActivity.java +++ b/src/com/android/dialer/DialtactsActivity.java @@ -828,7 +828,7 @@ public class DialtactsActivity extends TransactionSafeActivity implements View.O if (mViewPager.getCurrentItem() == TAB_INDEX_DIALER) { if (mDialpadFragment != null) { - mDialpadFragment.configureScreenFromIntent(newIntent); + mDialpadFragment.setStartedFromNewIntent(true); } else { Log.e(TAG, "DialpadFragment isn't ready yet when the tab is already selected."); } diff --git a/src/com/android/dialer/calllog/CallLogFragment.java b/src/com/android/dialer/calllog/CallLogFragment.java index eb0b371b2..86a383aa9 100644 --- a/src/com/android/dialer/calllog/CallLogFragment.java +++ b/src/com/android/dialer/calllog/CallLogFragment.java @@ -77,8 +77,6 @@ public class CallLogFragment extends ListFragment /** Whether there is at least one voicemail source installed. */ private boolean mVoicemailSourcesAvailable = false; - /** Whether we are currently filtering over voicemail. */ - private boolean mShowingVoicemailOnly = false; private VoicemailStatusHelper mVoicemailStatusHelper; private View mStatusMessageView; @@ -317,10 +315,6 @@ public class CallLogFragment extends ListFragment public void startCallsQuery() { mAdapter.setLoading(true); mCallLogQueryHandler.fetchCalls(mCallTypeFilter); - if (mShowingVoicemailOnly) { - mShowingVoicemailOnly = false; - getActivity().invalidateOptionsMenu(); - } } private void startVoicemailStatusQuery() { @@ -340,10 +334,49 @@ public class CallLogFragment extends ListFragment // menu items are ready if the first item is non-null. if (itemDeleteAll != null) { itemDeleteAll.setEnabled(mAdapter != null && !mAdapter.isEmpty()); - menu.findItem(R.id.show_voicemails_only).setVisible(mVoicemailSourcesAvailable); + + showAllFilterMenuOptions(menu); + hideCurrentFilterMenuOption(menu); + + // Only hide if not available. Let the above calls handle showing. + if (!mVoicemailSourcesAvailable) { + menu.findItem(R.id.show_voicemails_only).setVisible(false); + } + } + } + + private void hideCurrentFilterMenuOption(Menu menu) { + MenuItem item = null; + switch (mCallTypeFilter) { + case CallLogQueryHandler.CALL_TYPE_ALL: + item = menu.findItem(R.id.show_all_calls); + break; + case Calls.INCOMING_TYPE: + item = menu.findItem(R.id.show_incoming_only); + break; + case Calls.OUTGOING_TYPE: + item = menu.findItem(R.id.show_outgoing_only); + break; + case Calls.MISSED_TYPE: + item = menu.findItem(R.id.show_missed_only); + break; + case Calls.VOICEMAIL_TYPE: + menu.findItem(R.id.show_voicemails_only); + break; + } + if (item != null) { + item.setVisible(false); } } + private void showAllFilterMenuOptions(Menu menu) { + menu.findItem(R.id.show_all_calls).setVisible(true); + menu.findItem(R.id.show_incoming_only).setVisible(true); + menu.findItem(R.id.show_outgoing_only).setVisible(true); + menu.findItem(R.id.show_missed_only).setVisible(true); + menu.findItem(R.id.show_voicemails_only).setVisible(true); + } + @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { @@ -375,7 +408,6 @@ public class CallLogFragment extends ListFragment registerPhoneCallReceiver(); mCallLogQueryHandler.fetchCalls(Calls.VOICEMAIL_TYPE); updateFilterTypeAndHeader(Calls.VOICEMAIL_TYPE); - mShowingVoicemailOnly = true; return true; case R.id.show_all_calls: @@ -383,7 +415,6 @@ public class CallLogFragment extends ListFragment unregisterPhoneCallReceiver(); mCallLogQueryHandler.fetchCalls(CallLogQueryHandler.CALL_TYPE_ALL); updateFilterTypeAndHeader(CallLogQueryHandler.CALL_TYPE_ALL); - mShowingVoicemailOnly = false; return true; default: diff --git a/src/com/android/dialer/dialpad/DialpadFragment.java b/src/com/android/dialer/dialpad/DialpadFragment.java index 2b77b4619..f7a9056f5 100644 --- a/src/com/android/dialer/dialpad/DialpadFragment.java +++ b/src/com/android/dialer/dialpad/DialpadFragment.java @@ -31,6 +31,7 @@ import android.graphics.BitmapFactory; import android.media.AudioManager; import android.media.ToneGenerator; import android.net.Uri; +import android.os.AsyncTask; import android.os.Bundle; import android.os.RemoteException; import android.os.ServiceManager; @@ -39,6 +40,7 @@ import android.provider.Contacts.Intents.Insert; import android.provider.Contacts.People; import android.provider.Contacts.Phones; import android.provider.Contacts.PhonesColumns; +import android.provider.ContactsContract.Contacts; import android.provider.Settings; import android.telephony.PhoneNumberUtils; import android.telephony.PhoneStateListener; @@ -59,6 +61,7 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; +import android.widget.AbsListView; import android.widget.AdapterView; import android.widget.BaseAdapter; import android.widget.EditText; @@ -68,9 +71,12 @@ import android.widget.PopupMenu; import android.widget.TextView; import com.android.contacts.common.CallUtil; +import com.android.contacts.common.activity.TransactionSafeActivity; +import com.android.dialer.interactions.PhoneNumberInteraction; import com.android.contacts.common.GeoUtil; import com.android.contacts.common.util.PhoneNumberFormatter; import com.android.contacts.common.util.StopWatch; +import com.android.contacts.common.preference.ContactsPreferences; import com.android.dialer.DialtactsActivity; import com.android.dialer.R; import com.android.dialer.SpecialCharSequenceMgr; @@ -79,6 +85,9 @@ import com.android.internal.telephony.ITelephony; import com.android.phone.common.CallLogAsync; import com.android.phone.common.HapticFeedback; +import java.util.ArrayList; +import java.util.List; + /** * Fragment that displays a twelve-key phone dialpad. */ @@ -87,7 +96,8 @@ public class DialpadFragment extends Fragment View.OnLongClickListener, View.OnKeyListener, AdapterView.OnItemClickListener, TextWatcher, PopupMenu.OnMenuItemClickListener, - DialpadImageButton.OnPressedListener { + DialpadImageButton.OnPressedListener, + SmartDialLoaderTask.SmartDialLoaderCallback { private static final String TAG = DialpadFragment.class.getSimpleName(); private static final boolean DEBUG = DialtactsActivity.DEBUG; @@ -104,6 +114,8 @@ public class DialpadFragment extends Fragment /** Stream type used to play the DTMF tones off call, and mapped to the volume control keys */ private static final int DIAL_TONE_STREAM_TYPE = AudioManager.STREAM_DTMF; + private ContactsPreferences mContactsPrefs; + /** * View (usually FrameLayout) containing mDigits field. This can be null, in which mDigits * isn't enclosed by the container. @@ -131,6 +143,15 @@ public class DialpadFragment extends Fragment private ListView mDialpadChooser; private DialpadChooserAdapter mDialpadChooserAdapter; + /** Will be set only if the view has the smart dialing section. */ + private AbsListView mSmartDialList; + + /** + * Adapter for {@link #mSmartDialList}. + * Will be set only if the view has the smart dialing section. + */ + private SmartDialAdapter mSmartDialAdapter; + /** * Regular expression prohibiting manual phone call. Can be empty, which means "no rule". */ @@ -149,6 +170,7 @@ public class DialpadFragment extends Fragment // Vibration (haptic feedback) for dialer key presses. private final HapticFeedback mHaptic = new HapticFeedback(); + private boolean mNeedToCacheSmartDial = false; /** Identifier for the "Add Call" intent extra. */ private static final String ADD_CALL_MODE_KEY = "add_call_mode"; @@ -199,6 +221,8 @@ public class DialpadFragment extends Fragment */ private boolean mDigitsFilledByIntent; + private boolean mStartedFromNewIntent = false; + private static final String PREF_DIGITS_FILLED_BY_INTENT = "pref_digits_filled_by_intent"; /** @@ -246,14 +270,16 @@ public class DialpadFragment extends Fragment } updateDialAndDeleteButtonEnabledState(); + loadSmartDialEntries(); } @Override public void onCreate(Bundle state) { super.onCreate(state); + mContactsPrefs = new ContactsPreferences(getActivity()); mCurrentCountryIso = GeoUtil.getCurrentCountryIso(getActivity()); - + mNeedToCacheSmartDial = true; try { mHaptic.init(getActivity(), getResources().getBoolean(R.bool.config_enable_dialer_key_vibration)); @@ -269,6 +295,13 @@ public class DialpadFragment extends Fragment if (state != null) { mDigitsFilledByIntent = state.getBoolean(PREF_DIGITS_FILLED_BY_INTENT); } + + // Start caching contacts to use for smart dialling only if the dialpad fragment is visible + if (getUserVisibleHint()) { + SmartDialLoaderTask.startCacheContactsTaskIfNeeded( + getActivity(), mContactsPrefs.getDisplayOrder()); + mNeedToCacheSmartDial = false; + } } @Override @@ -334,7 +367,13 @@ public class DialpadFragment extends Fragment mDialpadChooser = (ListView) fragmentView.findViewById(R.id.dialpadChooser); mDialpadChooser.setOnItemClickListener(this); - configureScreenFromIntent(getActivity().getIntent()); + // Smart dial + mSmartDialList = (AbsListView) fragmentView.findViewById(R.id.dialpad_smartdial_list); + if (mSmartDialList != null) { + mSmartDialAdapter = new SmartDialAdapter(getActivity()); + mSmartDialList.setAdapter(mSmartDialAdapter); + mSmartDialList.setOnItemClickListener(new OnSmartDialItemClick()); + } return fragmentView; } @@ -392,45 +431,11 @@ public class DialpadFragment extends Fragment } /** - * @see #showDialpadChooser(boolean) + * Determines whether an add call operation is requested. + * + * @param intent The intent. + * @return {@literal true} if add call operation was requested. {@literal false} otherwise. */ - private static boolean needToShowDialpadChooser(Intent intent, boolean isAddCallMode) { - final String action = intent.getAction(); - - boolean needToShowDialpadChooser = false; - - if (Intent.ACTION_DIAL.equals(action) || Intent.ACTION_VIEW.equals(action)) { - Uri uri = intent.getData(); - if (uri == null) { - // ACTION_DIAL or ACTION_VIEW with no data. - // This behaves basically like ACTION_MAIN: If there's - // already an active call, bring up an intermediate UI to - // make the user confirm what they really want to do. - // Be sure *not* to show the dialpad chooser if this is an - // explicit "Add call" action, though. - if (!isAddCallMode && phoneIsInUse()) { - needToShowDialpadChooser = true; - } - } - } else if (Intent.ACTION_MAIN.equals(action)) { - // The MAIN action means we're bringing up a blank dialer - // (e.g. by selecting the Home shortcut, or tabbing over from - // Contacts or Call log.) - // - // At this point, IF there's already an active call, there's a - // good chance that the user got here accidentally (but really - // wanted the in-call dialpad instead). So we bring up an - // intermediate UI to make the user confirm what they really - // want to do. - if (phoneIsInUse()) { - // Log.i(TAG, "resolveIntent(): phone is in use; showing dialpad chooser!"); - needToShowDialpadChooser = true; - } - } - - return needToShowDialpadChooser; - } - private static boolean isAddCallMode(Intent intent) { final String action = intent.getAction(); if (Intent.ACTION_DIAL.equals(action) || Intent.ACTION_VIEW.equals(action)) { @@ -445,7 +450,7 @@ public class DialpadFragment extends Fragment * Checks the given Intent and changes dialpad's UI state. For example, if the Intent requires * the screen to enter "Add Call" mode, this method will show correct UI for the mode. */ - public void configureScreenFromIntent(Intent intent) { + private void configureScreenFromIntent(Intent intent) { if (!isLayoutReady()) { // This happens typically when parent's Activity#onNewIntent() is called while // Fragment#onCreateView() isn't called yet, and thus we cannot configure Views at @@ -458,16 +463,37 @@ public class DialpadFragment extends Fragment boolean needToShowDialpadChooser = false; + // Be sure *not* to show the dialpad chooser if this is an + // explicit "Add call" action, though. final boolean isAddCallMode = isAddCallMode(intent); if (!isAddCallMode) { + + // Don't show the chooser when called via onNewIntent() and phone number is present. + // i.e. User clicks a telephone link from gmail for example. + // In this case, we want to show the dialpad with the phone number. final boolean digitsFilled = fillDigitsIfNecessary(intent); - if (!digitsFilled) { - needToShowDialpadChooser = needToShowDialpadChooser(intent, isAddCallMode); + if (!(mStartedFromNewIntent && digitsFilled)) { + + final String action = intent.getAction(); + if (Intent.ACTION_DIAL.equals(action) || Intent.ACTION_VIEW.equals(action) + || Intent.ACTION_MAIN.equals(action)) { + // If there's already an active call, bring up an intermediate UI to + // make the user confirm what they really want to do. + if (phoneIsInUse()) { + needToShowDialpadChooser = true; + } + } + } } + showDialpadChooser(needToShowDialpadChooser); } + public void setStartedFromNewIntent(boolean value) { + mStartedFromNewIntent = value; + } + /** * Sets formatted digits to digits field. */ @@ -501,6 +527,13 @@ public class DialpadFragment extends Fragment } @Override + public void onStart() { + super.onStart(); + configureScreenFromIntent(getActivity().getIntent()); + setStartedFromNewIntent(false); + } + + @Override public void onResume() { super.onResume(); @@ -1115,6 +1148,11 @@ public class DialpadFragment extends Fragment } } + private String getCallOrigin() { + return (getActivity() instanceof DialtactsActivity) ? + ((DialtactsActivity) getActivity()).getCallOrigin() : null; + } + private void handleDialButtonClickWithEmptyDigits() { if (phoneIsCdma() && phoneIsOffhook()) { // This is really CDMA specific. On GSM is it possible @@ -1637,4 +1675,59 @@ public class DialpadFragment extends Fragment intent.putExtra(EXTRA_SEND_EMPTY_FLASH, true); return intent; } + + @Override + public void setUserVisibleHint(boolean isVisibleToUser) { + super.setUserVisibleHint(isVisibleToUser); + if (isVisibleToUser && mNeedToCacheSmartDial) { + SmartDialLoaderTask.startCacheContactsTaskIfNeeded( + getActivity(), mContactsPrefs.getDisplayOrder()); + mNeedToCacheSmartDial = false; + } + } + + private String mLastDigitsForSmartDial; + + private void loadSmartDialEntries() { + if (mSmartDialAdapter == null) { + // No smart dial views. Landscape? + return; + } + + // Update only when the digits have changed. + final String digits = SmartDialNameMatcher.normalizeNumber(mDigits.getText().toString()); + if (TextUtils.equals(digits, mLastDigitsForSmartDial)) { + return; + } + mLastDigitsForSmartDial = digits; + + if (digits.length() < 2) { + mSmartDialAdapter.clear(); + } else { + final SmartDialLoaderTask task = new SmartDialLoaderTask(this, digits); + // don't execute this in serial, otherwise we have to wait too long for results + task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new String[] {}); + } + } + + @Override + public void setSmartDialAdapterEntries(List<SmartDialEntry> data) { + if (data == null || data.isEmpty()) { + // No results found. Keep the last results. + return; + } + mSmartDialAdapter.setEntries(data); + } + + private class OnSmartDialItemClick implements AdapterView.OnItemClickListener { + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + final SmartDialEntry entry = (SmartDialEntry) view.getTag(); + if (entry == null) return; // just in case. + + mClearDigitsOnStop = true; + PhoneNumberInteraction.startInteractionForPhoneCall( + (TransactionSafeActivity) getActivity(), entry.contactUri, getCallOrigin()); + } + } } diff --git a/src/com/android/dialer/dialpad/SmartDialAdapter.java b/src/com/android/dialer/dialpad/SmartDialAdapter.java new file mode 100644 index 000000000..9330909e7 --- /dev/null +++ b/src/com/android/dialer/dialpad/SmartDialAdapter.java @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.dialpad; + +import com.android.contacts.R; +import com.google.common.collect.Lists; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.CharacterStyle; +import android.text.style.ForegroundColorSpan; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; + +import java.util.List; + +public class SmartDialAdapter extends BaseAdapter { + public static final String LOG_TAG = "SmartDial"; + private final LayoutInflater mInflater; + + private List<SmartDialEntry> mEntries; + private static Drawable mHighConfidenceHint; + + private final int mHighlightedTextColor; + + public SmartDialAdapter(Context context) { + mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + final Resources res = context.getResources(); + mHighConfidenceHint = SmartDialTextView.getHighConfidenceHintDrawable( + res, res.getDimension(R.dimen.smartdial_confidence_hint_text_size), + res.getColor(R.color.smartdial_confidence_drawable_color)); + mHighlightedTextColor = res.getColor(R.color.smartdial_highlighted_text_color); + clear(); + } + + /** Remove all entries. */ + public void clear() { + mEntries = Lists.newArrayList(); + notifyDataSetChanged(); + } + + /** Set entries. */ + public void setEntries(List<SmartDialEntry> entries) { + if (entries == null) throw new IllegalArgumentException(); + mEntries = entries; + + if (mEntries.size() <= 1) { + // add a null entry to push the single entry into the middle + mEntries.add(0, null); + } else if (mEntries.size() >= 2){ + // swap the 1st and 2nd entries so that the highest confidence match goes into the + // middle + final SmartDialEntry temp = mEntries.get(0); + mEntries.set(0, mEntries.get(1)); + mEntries.set(1, temp); + } + + notifyDataSetChanged(); + } + + @Override + public boolean isEnabled(int position) { + return !(mEntries.get(position) == null); + } + + @Override + public boolean areAllItemsEnabled() { + return false; + } + + @Override + public int getCount() { + return mEntries.size(); + } + + @Override + public Object getItem(int position) { + return mEntries.get(position); + } + + @Override + public long getItemId(int position) { + return position; // Just use the position as the ID, so it's not stable. + } + + @Override + public boolean hasStableIds() { + return false; // Not stable because we just use the position as the ID. + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + final SmartDialTextView view; + if (convertView == null) { + view = (SmartDialTextView) mInflater.inflate( + R.layout.dialpad_smartdial_item, parent, false); + } else { + view = (SmartDialTextView) convertView; + } + // Set the display name with highlight. + + final SmartDialEntry item = mEntries.get(position); + + if (item == null) { + // Clear the text in case the view was reused. + view.setText(""); + // Empty view. We use this to force a single entry to be in the middle + return view; + } + final SpannableString displayName = new SpannableString(item.displayName); + for (final SmartDialMatchPosition p : item.matchPositions) { + final int matchStart = p.start; + final int matchEnd = p.end; + if (matchStart < matchEnd) { + // Create a new ForegroundColorSpan for each section of the name to highlight, + // otherwise multiple highlights won't work. + try { + displayName.setSpan( + new ForegroundColorSpan(mHighlightedTextColor), matchStart, matchEnd, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } catch (final IndexOutOfBoundsException e) { + Log.wtf(LOG_TAG, + "Invalid match positions provided - [" + matchStart + "," + + matchEnd + "] for display name: " + item.displayName); + } + } + } + + if (position == 1) { + view.setCompoundDrawablesWithIntrinsicBounds( + null, null, null, mHighConfidenceHint); + // Hack to align text in this view with text in other views without the + // overflow drawable + view.setCompoundDrawablePadding(-mHighConfidenceHint.getIntrinsicHeight()); + } else { + view.setCompoundDrawablesWithIntrinsicBounds( + null, null, null, null); + } + + + view.setText(displayName); + view.setTag(item); + + return view; + } +} diff --git a/src/com/android/dialer/dialpad/SmartDialEntry.java b/src/com/android/dialer/dialpad/SmartDialEntry.java new file mode 100644 index 000000000..101678de0 --- /dev/null +++ b/src/com/android/dialer/dialpad/SmartDialEntry.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.dialpad; + +import android.net.Uri; + +import java.util.ArrayList; + +public class SmartDialEntry { + /** Display name for the contact. */ + public final CharSequence displayName; + public final Uri contactUri; + + public final ArrayList<SmartDialMatchPosition> matchPositions; + + public SmartDialEntry(CharSequence displayName, Uri contactUri, + ArrayList<SmartDialMatchPosition> matchPositions) { + this.displayName = displayName; + this.contactUri = contactUri; + this.matchPositions = matchPositions; + } +} diff --git a/src/com/android/dialer/dialpad/SmartDialLoaderTask.java b/src/com/android/dialer/dialpad/SmartDialLoaderTask.java new file mode 100644 index 000000000..832183785 --- /dev/null +++ b/src/com/android/dialer/dialpad/SmartDialLoaderTask.java @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.dialpad; + +import static com.android.dialer.dialpad.SmartDialAdapter.LOG_TAG; + +import com.google.common.collect.Lists; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.telephony.PhoneNumberUtils; +import android.util.Log; + +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.contacts.common.util.StopWatch; + +import java.util.ArrayList; +import java.util.List; + +/** + * AsyncTask that performs one of two functions depending on which constructor is used. + * If {@link #SmartDialLoaderTask(Context context, int nameDisplayOrder)} is used, the task + * caches all contacts with a phone number into the static variable {@link #sContactsCache}. + * If {@link #SmartDialLoaderTask(SmartDialLoaderCallback callback, String query)} is used, the + * task searches through the cache to return the top 3 contacts(ranked by confidence) that match + * the query, then passes it back to the {@link SmartDialLoaderCallback} through a callback + * function. + */ +// TODO: Make the cache a singleton class and refactor to fix possible concurrency issues in the +// future +public class SmartDialLoaderTask extends AsyncTask<String, Integer, List<SmartDialEntry>> { + + private class Contact { + final String mDisplayName; + final String mLookupKey; + final long mId; + + public Contact(long id, String displayName, String lookupKey) { + mDisplayName = displayName; + mLookupKey = lookupKey; + mId = id; + } + } + + public interface SmartDialLoaderCallback { + void setSmartDialAdapterEntries(List<SmartDialEntry> list); + } + + static private final boolean DEBUG = true; // STOPSHIP change to false. + + private static final int MAX_ENTRIES = 3; + + private static List<Contact> sContactsCache; + + private final boolean mCacheOnly; + + private final SmartDialLoaderCallback mCallback; + + private final Context mContext; + /** + * See {@link ContactsPreferences#getDisplayOrder()}. + * {@link ContactsContract.Preferences#DISPLAY_ORDER_PRIMARY} (first name first) + * {@link ContactsContract.Preferences#DISPLAY_ORDER_ALTERNATIVE} (last name first) + */ + private final int mNameDisplayOrder; + + private final SmartDialNameMatcher mNameMatcher; + + // cache only constructor + private SmartDialLoaderTask(Context context, int nameDisplayOrder) { + this.mNameDisplayOrder = nameDisplayOrder; + this.mContext = context; + // we're just caching contacts so no need to initialize a SmartDialNameMatcher or callback + this.mNameMatcher = null; + this.mCallback = null; + this.mCacheOnly = true; + } + + public SmartDialLoaderTask(SmartDialLoaderCallback callback, String query) { + this.mCallback = callback; + this.mContext = null; + this.mCacheOnly = false; + this.mNameDisplayOrder = 0; + this.mNameMatcher = new SmartDialNameMatcher(PhoneNumberUtils.normalizeNumber(query)); + } + + @Override + protected List<SmartDialEntry> doInBackground(String... params) { + if (mCacheOnly) { + cacheContacts(); + return Lists.newArrayList(); + } + + return getContactMatches(); + } + + @Override + protected void onPostExecute(List<SmartDialEntry> result) { + if (mCallback != null) { + mCallback.setSmartDialAdapterEntries(result); + } + } + + /** Query used for loadByContactName */ + private interface ContactQuery { + Uri URI = Contacts.CONTENT_URI.buildUpon() + // Visible contact only + //.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, "0") + .build(); + String[] PROJECTION = new String[] { + Contacts._ID, + Contacts.DISPLAY_NAME, + Contacts.LOOKUP_KEY + }; + String[] PROJECTION_ALTERNATIVE = new String[] { + Contacts._ID, + Contacts.DISPLAY_NAME_ALTERNATIVE, + Contacts.LOOKUP_KEY + }; + + int COLUMN_ID = 0; + int COLUMN_DISPLAY_NAME = 1; + int COLUMN_LOOKUP_KEY = 2; + + String SELECTION = + //Contacts.IN_VISIBLE_GROUP + "=1 and " + + Contacts.HAS_PHONE_NUMBER + "=1"; + + String ORDER_BY = Contacts.LAST_TIME_CONTACTED + " DESC"; + } + + public static void startCacheContactsTaskIfNeeded(Context context, int displayOrder) { + if (sContactsCache != null) { + // contacts have already been cached, just return + return; + } + final SmartDialLoaderTask task = + new SmartDialLoaderTask(context, displayOrder); + task.execute(); + } + + /** + * Caches the contacts into an in memory array list. This is called once at startup and should + * not be cancelled. + */ + private void cacheContacts() { + final StopWatch stopWatch = DEBUG ? StopWatch.start("SmartDial Cache") : null; + if (sContactsCache != null) { + // contacts have already been cached, just return + stopWatch.stopAndLog("SmartDial Already Cached", 0); + return; + } + if (mContext == null) { + if (DEBUG) { + stopWatch.stopAndLog("Invalid context", 0); + } + return; + } + final Cursor c = mContext.getContentResolver().query(ContactQuery.URI, + (mNameDisplayOrder == ContactsContract.Preferences.DISPLAY_ORDER_PRIMARY) + ? ContactQuery.PROJECTION : ContactQuery.PROJECTION_ALTERNATIVE, + ContactQuery.SELECTION, null, + ContactQuery.ORDER_BY); + if (c == null) { + if (DEBUG) { + stopWatch.stopAndLog("Query Failure", 0); + } + return; + } + sContactsCache = Lists.newArrayListWithCapacity(c.getCount()); + try { + c.moveToPosition(-1); + while (c.moveToNext()) { + final String displayName = c.getString(ContactQuery.COLUMN_DISPLAY_NAME); + final long id = c.getLong(ContactQuery.COLUMN_ID); + final String lookupKey = c.getString(ContactQuery.COLUMN_LOOKUP_KEY); + sContactsCache.add(new Contact(id, displayName, lookupKey)); + } + } finally { + c.close(); + if (DEBUG) { + stopWatch.stopAndLog("SmartDial Cache", 0); + } + } + } + + /** + * Loads all visible contacts with phone numbers and check if their display names match the + * query. Return at most {@link #MAX_ENTRIES} {@link SmartDialEntry}'s for the matching + * contacts. + */ + private ArrayList<SmartDialEntry> getContactMatches() { + final StopWatch stopWatch = DEBUG ? StopWatch.start(LOG_TAG + " Start Match") : null; + if (sContactsCache == null) { + // contacts should have been cached by this point in time, but in case they + // are not, we go ahead and cache them into memory. + if (DEBUG) { + Log.d(LOG_TAG, "empty cache"); + } + cacheContacts(); + // TODO: if sContactsCache is still null at this point we should try to recache + } + if (DEBUG) { + Log.d(LOG_TAG, "Size of cache: " + sContactsCache.size()); + } + final ArrayList<SmartDialEntry> outList = Lists.newArrayList(); + if (sContactsCache == null) { + return outList; + } + int count = 0; + for (int i = 0; i < sContactsCache.size(); i++) { + final Contact contact = sContactsCache.get(i); + final String displayName = contact.mDisplayName; + + if (!mNameMatcher.matches(displayName)) { + continue; + } + // Matched; create SmartDialEntry. + @SuppressWarnings("unchecked") + final SmartDialEntry entry = new SmartDialEntry( + contact.mDisplayName, + Contacts.getLookupUri(contact.mId, contact.mLookupKey), + (ArrayList<SmartDialMatchPosition>) mNameMatcher.getMatchPositions().clone() + ); + outList.add(entry); + count++; + if (count >= MAX_ENTRIES) { + break; + } + } + if (DEBUG) { + stopWatch.stopAndLog(LOG_TAG + " Match Complete", 0); + } + return outList; + } +} diff --git a/src/com/android/dialer/dialpad/SmartDialMatchPosition.java b/src/com/android/dialer/dialpad/SmartDialMatchPosition.java new file mode 100644 index 000000000..3d248ccb2 --- /dev/null +++ b/src/com/android/dialer/dialpad/SmartDialMatchPosition.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.dialpad; + +import static com.android.dialer.dialpad.SmartDialAdapter.LOG_TAG; + +import android.util.Log; + +import java.util.ArrayList; + +/** + * Stores information about a range of characters matched in a display name The integers + * start and end indicate that the range start to end (exclusive) correspond to some characters + * in the query. Used by {@link SmartDialAdapter} to highlight certain parts of the contact's + * display name to indicate that those ranges matched the user's query. + */ +class SmartDialMatchPosition { + public int start; + public int end; + + public SmartDialMatchPosition(int start, int end) { + this.start = start; + this.end = end; + } + + private void advance(int toAdvance) { + this.start += toAdvance; + this.end += toAdvance; + } + + /** + * Used by {@link SmartDialNameMatcher} to advance the positions of a match position found in + * a sub query. + * + * @param inList ArrayList of SmartDialMatchPositions to modify. + * @param toAdvance Offset to modify by. + */ + public static void advanceMatchPositions(ArrayList<SmartDialMatchPosition> inList, + int toAdvance) { + for (int i = 0; i < inList.size(); i++) { + inList.get(i).advance(toAdvance); + } + } + + /** + * Used mainly for debug purposes. Displays contents of an ArrayList of SmartDialMatchPositions. + * + * @param list ArrayList of SmartDialMatchPositions to print out in a human readable fashion. + */ + public static void print(ArrayList<SmartDialMatchPosition> list) { + for (int i = 0; i < list.size(); i ++) { + SmartDialMatchPosition m = list.get(i); + Log.d(LOG_TAG, "[" + m.start + "," + m.end + "]"); + } + } +} diff --git a/src/com/android/dialer/dialpad/SmartDialNameMatcher.java b/src/com/android/dialer/dialpad/SmartDialNameMatcher.java new file mode 100644 index 000000000..1253a566d --- /dev/null +++ b/src/com/android/dialer/dialpad/SmartDialNameMatcher.java @@ -0,0 +1,552 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.dialpad; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Lists; + +import com.android.contacts.test.NeededForTesting; + +import java.text.Normalizer; +import java.util.ArrayList; + +/** + * {@link #SmartDialNameMatcher} contains utility functions to remove accents from accented + * characters and normalize a phone number. It also contains the matching logic that determines if + * a contact's display name matches a numeric query. The boolean variable + * {@link #ALLOW_INITIAL_MATCH} controls the behavior of the matching logic and determines + * whether we allow matches like 57 - (J)ohn (S)mith. + */ +public class SmartDialNameMatcher { + + private final String mQuery; + + private static final char[] LETTERS_TO_DIGITS = { + '2', '2', '2', // A,B,C -> 2 + '3', '3', '3', // D,E,F -> 3 + '4', '4', '4', // G,H,I -> 4 + '5', '5', '5', // J,K,L -> 5 + '6', '6', '6', // M,N,O -> 6 + '7', '7', '7', '7', // P,Q,R,S -> 7 + '8', '8', '8', // T,U,V -> 8 + '9', '9', '9', '9' // W,X,Y,Z -> 9 + }; + + /* + * The switch statement in this function was generated using the python code: + * from unidecode import unidecode + * for i in range(192, 564): + * char = unichr(i) + * decoded = unidecode(char) + * # Unicode characters that decompose into multiple characters i.e. + * # into ss are not supported for now + * if (len(decoded) == 1 and decoded.isalpha()): + * print "case '" + char + "': return '" + unidecode(char) + "';" + * + * This gives us a way to map characters containing accents/diacritics to their + * alphabetic equivalents. The unidecode library can be found at: + * http://pypi.python.org/pypi/Unidecode/0.04.1 + */ + private static char remapAccentedChars(char c) { + switch (c) { + case 'À': return 'A'; + case 'Á': return 'A'; + case 'Â': return 'A'; + case 'Ã': return 'A'; + case 'Ä': return 'A'; + case 'Å': return 'A'; + case 'Ç': return 'C'; + case 'È': return 'E'; + case 'É': return 'E'; + case 'Ê': return 'E'; + case 'Ë': return 'E'; + case 'Ì': return 'I'; + case 'Í': return 'I'; + case 'Î': return 'I'; + case 'Ï': return 'I'; + case 'Ð': return 'D'; + case 'Ñ': return 'N'; + case 'Ò': return 'O'; + case 'Ó': return 'O'; + case 'Ô': return 'O'; + case 'Õ': return 'O'; + case 'Ö': return 'O'; + case '×': return 'x'; + case 'Ø': return 'O'; + case 'Ù': return 'U'; + case 'Ú': return 'U'; + case 'Û': return 'U'; + case 'Ü': return 'U'; + case 'Ý': return 'U'; + case 'à': return 'a'; + case 'á': return 'a'; + case 'â': return 'a'; + case 'ã': return 'a'; + case 'ä': return 'a'; + case 'å': return 'a'; + case 'ç': return 'c'; + case 'è': return 'e'; + case 'é': return 'e'; + case 'ê': return 'e'; + case 'ë': return 'e'; + case 'ì': return 'i'; + case 'í': return 'i'; + case 'î': return 'i'; + case 'ï': return 'i'; + case 'ð': return 'd'; + case 'ñ': return 'n'; + case 'ò': return 'o'; + case 'ó': return 'o'; + case 'ô': return 'o'; + case 'õ': return 'o'; + case 'ö': return 'o'; + case 'ø': return 'o'; + case 'ù': return 'u'; + case 'ú': return 'u'; + case 'û': return 'u'; + case 'ü': return 'u'; + case 'ý': return 'y'; + case 'ÿ': return 'y'; + case 'Ā': return 'A'; + case 'ā': return 'a'; + case 'Ă': return 'A'; + case 'ă': return 'a'; + case 'Ą': return 'A'; + case 'ą': return 'a'; + case 'Ć': return 'C'; + case 'ć': return 'c'; + case 'Ĉ': return 'C'; + case 'ĉ': return 'c'; + case 'Ċ': return 'C'; + case 'ċ': return 'c'; + case 'Č': return 'C'; + case 'č': return 'c'; + case 'Ď': return 'D'; + case 'ď': return 'd'; + case 'Đ': return 'D'; + case 'đ': return 'd'; + case 'Ē': return 'E'; + case 'ē': return 'e'; + case 'Ĕ': return 'E'; + case 'ĕ': return 'e'; + case 'Ė': return 'E'; + case 'ė': return 'e'; + case 'Ę': return 'E'; + case 'ę': return 'e'; + case 'Ě': return 'E'; + case 'ě': return 'e'; + case 'Ĝ': return 'G'; + case 'ĝ': return 'g'; + case 'Ğ': return 'G'; + case 'ğ': return 'g'; + case 'Ġ': return 'G'; + case 'ġ': return 'g'; + case 'Ģ': return 'G'; + case 'ģ': return 'g'; + case 'Ĥ': return 'H'; + case 'ĥ': return 'h'; + case 'Ħ': return 'H'; + case 'ħ': return 'h'; + case 'Ĩ': return 'I'; + case 'ĩ': return 'i'; + case 'Ī': return 'I'; + case 'ī': return 'i'; + case 'Ĭ': return 'I'; + case 'ĭ': return 'i'; + case 'Į': return 'I'; + case 'į': return 'i'; + case 'İ': return 'I'; + case 'ı': return 'i'; + case 'Ĵ': return 'J'; + case 'ĵ': return 'j'; + case 'Ķ': return 'K'; + case 'ķ': return 'k'; + case 'ĸ': return 'k'; + case 'Ĺ': return 'L'; + case 'ĺ': return 'l'; + case 'Ļ': return 'L'; + case 'ļ': return 'l'; + case 'Ľ': return 'L'; + case 'ľ': return 'l'; + case 'Ŀ': return 'L'; + case 'ŀ': return 'l'; + case 'Ł': return 'L'; + case 'ł': return 'l'; + case 'Ń': return 'N'; + case 'ń': return 'n'; + case 'Ņ': return 'N'; + case 'ņ': return 'n'; + case 'Ň': return 'N'; + case 'ň': return 'n'; + case 'Ō': return 'O'; + case 'ō': return 'o'; + case 'Ŏ': return 'O'; + case 'ŏ': return 'o'; + case 'Ő': return 'O'; + case 'ő': return 'o'; + case 'Ŕ': return 'R'; + case 'ŕ': return 'r'; + case 'Ŗ': return 'R'; + case 'ŗ': return 'r'; + case 'Ř': return 'R'; + case 'ř': return 'r'; + case 'Ś': return 'S'; + case 'ś': return 's'; + case 'Ŝ': return 'S'; + case 'ŝ': return 's'; + case 'Ş': return 'S'; + case 'ş': return 's'; + case 'Š': return 'S'; + case 'š': return 's'; + case 'Ţ': return 'T'; + case 'ţ': return 't'; + case 'Ť': return 'T'; + case 'ť': return 't'; + case 'Ŧ': return 'T'; + case 'ŧ': return 't'; + case 'Ũ': return 'U'; + case 'ũ': return 'u'; + case 'Ū': return 'U'; + case 'ū': return 'u'; + case 'Ŭ': return 'U'; + case 'ŭ': return 'u'; + case 'Ů': return 'U'; + case 'ů': return 'u'; + case 'Ű': return 'U'; + case 'ű': return 'u'; + case 'Ų': return 'U'; + case 'ų': return 'u'; + case 'Ŵ': return 'W'; + case 'ŵ': return 'w'; + case 'Ŷ': return 'Y'; + case 'ŷ': return 'y'; + case 'Ÿ': return 'Y'; + case 'Ź': return 'Z'; + case 'ź': return 'z'; + case 'Ż': return 'Z'; + case 'ż': return 'z'; + case 'Ž': return 'Z'; + case 'ž': return 'z'; + case 'ſ': return 's'; + case 'ƀ': return 'b'; + case 'Ɓ': return 'B'; + case 'Ƃ': return 'B'; + case 'ƃ': return 'b'; + case 'Ɔ': return 'O'; + case 'Ƈ': return 'C'; + case 'ƈ': return 'c'; + case 'Ɖ': return 'D'; + case 'Ɗ': return 'D'; + case 'Ƌ': return 'D'; + case 'ƌ': return 'd'; + case 'ƍ': return 'd'; + case 'Ɛ': return 'E'; + case 'Ƒ': return 'F'; + case 'ƒ': return 'f'; + case 'Ɠ': return 'G'; + case 'Ɣ': return 'G'; + case 'Ɩ': return 'I'; + case 'Ɨ': return 'I'; + case 'Ƙ': return 'K'; + case 'ƙ': return 'k'; + case 'ƚ': return 'l'; + case 'ƛ': return 'l'; + case 'Ɯ': return 'W'; + case 'Ɲ': return 'N'; + case 'ƞ': return 'n'; + case 'Ɵ': return 'O'; + case 'Ơ': return 'O'; + case 'ơ': return 'o'; + case 'Ƥ': return 'P'; + case 'ƥ': return 'p'; + case 'ƫ': return 't'; + case 'Ƭ': return 'T'; + case 'ƭ': return 't'; + case 'Ʈ': return 'T'; + case 'Ư': return 'U'; + case 'ư': return 'u'; + case 'Ʊ': return 'Y'; + case 'Ʋ': return 'V'; + case 'Ƴ': return 'Y'; + case 'ƴ': return 'y'; + case 'Ƶ': return 'Z'; + case 'ƶ': return 'z'; + case 'ƿ': return 'w'; + case 'Ǎ': return 'A'; + case 'ǎ': return 'a'; + case 'Ǐ': return 'I'; + case 'ǐ': return 'i'; + case 'Ǒ': return 'O'; + case 'ǒ': return 'o'; + case 'Ǔ': return 'U'; + case 'ǔ': return 'u'; + case 'Ǖ': return 'U'; + case 'ǖ': return 'u'; + case 'Ǘ': return 'U'; + case 'ǘ': return 'u'; + case 'Ǚ': return 'U'; + case 'ǚ': return 'u'; + case 'Ǜ': return 'U'; + case 'ǜ': return 'u'; + case 'Ǟ': return 'A'; + case 'ǟ': return 'a'; + case 'Ǡ': return 'A'; + case 'ǡ': return 'a'; + case 'Ǥ': return 'G'; + case 'ǥ': return 'g'; + case 'Ǧ': return 'G'; + case 'ǧ': return 'g'; + case 'Ǩ': return 'K'; + case 'ǩ': return 'k'; + case 'Ǫ': return 'O'; + case 'ǫ': return 'o'; + case 'Ǭ': return 'O'; + case 'ǭ': return 'o'; + case 'ǰ': return 'j'; + case 'Dz': return 'D'; + case 'Ǵ': return 'G'; + case 'ǵ': return 'g'; + case 'Ƿ': return 'W'; + case 'Ǹ': return 'N'; + case 'ǹ': return 'n'; + case 'Ǻ': return 'A'; + case 'ǻ': return 'a'; + case 'Ǿ': return 'O'; + case 'ǿ': return 'o'; + case 'Ȁ': return 'A'; + case 'ȁ': return 'a'; + case 'Ȃ': return 'A'; + case 'ȃ': return 'a'; + case 'Ȅ': return 'E'; + case 'ȅ': return 'e'; + case 'Ȇ': return 'E'; + case 'ȇ': return 'e'; + case 'Ȉ': return 'I'; + case 'ȉ': return 'i'; + case 'Ȋ': return 'I'; + case 'ȋ': return 'i'; + case 'Ȍ': return 'O'; + case 'ȍ': return 'o'; + case 'Ȏ': return 'O'; + case 'ȏ': return 'o'; + case 'Ȑ': return 'R'; + case 'ȑ': return 'r'; + case 'Ȓ': return 'R'; + case 'ȓ': return 'r'; + case 'Ȕ': return 'U'; + case 'ȕ': return 'u'; + case 'Ȗ': return 'U'; + case 'ȗ': return 'u'; + case 'Ș': return 'S'; + case 'ș': return 's'; + case 'Ț': return 'T'; + case 'ț': return 't'; + case 'Ȝ': return 'Y'; + case 'ȝ': return 'y'; + case 'Ȟ': return 'H'; + case 'ȟ': return 'h'; + case 'Ȥ': return 'Z'; + case 'ȥ': return 'z'; + case 'Ȧ': return 'A'; + case 'ȧ': return 'a'; + case 'Ȩ': return 'E'; + case 'ȩ': return 'e'; + case 'Ȫ': return 'O'; + case 'ȫ': return 'o'; + case 'Ȭ': return 'O'; + case 'ȭ': return 'o'; + case 'Ȯ': return 'O'; + case 'ȯ': return 'o'; + case 'Ȱ': return 'O'; + case 'ȱ': return 'o'; + case 'Ȳ': return 'Y'; + case 'ȳ': return 'y'; + default: return c; + } + } + + private final ArrayList<SmartDialMatchPosition> mMatchPositions = Lists.newArrayList(); + + public SmartDialNameMatcher(String query) { + mQuery = query; + } + + /** + * Strips all accented characters in a name and converts them to their alphabetic equivalents. + * + * @param name Name we want to remove accented characters from. + * @return Name without accents in characters + */ + public static String stripDiacritics(String name) { + // NFD stands for normalization form D - Canonical Decomposition + // This means that for all characters with diacritics, e.g. ä, we decompose them into + // two characters, the first being the alphabetic equivalent, and the second being a + // a character that represents the diacritic. + + final String normalized = Normalizer.normalize(name, Normalizer.Form.NFD); + final StringBuilder stripped = new StringBuilder(); + for (int i = 0; i < normalized.length(); i++) { + // This pass through the string strips out all the diacritics by checking to see + // if they are in this list here: + // http://www.fileformat.info/info/unicode/category/Mn/list.htm + if (Character.getType(normalized.charAt(i)) != Character.NON_SPACING_MARK) { + stripped.append(normalized.charAt(i)); + } + } + return stripped.toString(); + } + + /** + * Strips a phone number of unnecessary characters (zeros, ones, spaces, dashes, etc.) + * + * @param number Phone number we want to normalize + * @return Phone number consisting of digits from 2-9 + */ + public static String normalizeNumber(String number) { + final StringBuilder s = new StringBuilder(); + for (int i = 0; i < number.length(); i++) { + char ch = number.charAt(i); + if (ch >= '2' && ch <= '9') { + s.append(ch); + } + } + return s.toString(); + } + + /** + * This function iterates through each token in the display name, trying to match the query + * to the numeric equivalent of the token. + * + * A token is defined as a range in the display name delimited by whitespace. For example, + * the display name "Phillips Thomas Jr" contains three tokens: "phillips", "thomas", and "jr". + * + * A match must begin at the start of a token. + * For example, typing 846(Tho) would match "Phillips Thomas", but 466(hom) would not. + * + * Also, a match can extend across tokens. + * For example, typing 37337(FredS) would match (Fred S)mith. + * + * @param displayName The normalized(no accented characters) display name we intend to match + * against. + * @param query The string of digits that we want to match the display name to. + * @param matchList An array list of {@link SmartDialMatchPosition}s that we add matched + * positions to. + * @return Returns true if a combination of the tokens in displayName match the query + * string contained in query. If the function returns true, matchList will contain an + * ArrayList of match positions. For now, matchList will contain a maximum of one match + * position. If we intend to support initial matching in the future, matchList could possibly + * contain more than one match position. + */ + @VisibleForTesting + boolean matchesCombination(String displayName, String query, + ArrayList<SmartDialMatchPosition> matchList) { + final int nameLength = displayName.length(); + final int queryLength = query.length(); + + if (nameLength < queryLength) { + return false; + } + + if (queryLength == 0) { + return false; + } + + // The current character index in displayName + // E.g. 3 corresponds to 'd' in "Fred Smith" + int nameStart = 0; + + // The current character in the query we are trying to match the displayName against + int queryStart = 0; + + // The start position of the current token we are inspecting + int tokenStart = 0; + + // The number of non-alphabetic characters we've encountered so far in the current match. + // E.g. if we've currently matched 3733764849 to (Fred Smith W)illiam, then the + // seperatorCount should be 2. This allows us to correctly calculate offsets for the match + // positions + int seperatorCount = 0; + + ArrayList<SmartDialMatchPosition> partial = new ArrayList<SmartDialMatchPosition>(); + + // Keep going until we reach the end of displayName + while (nameStart < nameLength && queryStart < queryLength) { + char ch = displayName.charAt(nameStart); + // Strip diacritics from accented characters if any + ch = remapAccentedChars(ch); + if ((ch >= 'A') && (ch <= 'Z')) { + // Simply change the ascii code to the lower case version instead of using + // toLowerCase for efficiency + ch += 32; + } + if ((ch >= 'a') && (ch <= 'z')) { + // a starts at index 0 + if (LETTERS_TO_DIGITS[ch - 'a'] != query.charAt(queryStart)) { + // we did not find a match + queryStart = 0; + seperatorCount = 0; + while (nameStart < nameLength && + !Character.isWhitespace(displayName.charAt(nameStart))) { + nameStart++; + } + nameStart++; + tokenStart = nameStart; + } else { + if (queryStart == queryLength - 1) { + + // As much as possible, we prioritize a full token match over a sub token + // one so if we find a full token match, we can return right away + matchList.add(new SmartDialMatchPosition( + tokenStart, queryLength + tokenStart + seperatorCount)); + return true; + } + nameStart++; + queryStart++; + // we matched the current character in the name against one in the query, + // continue and see if the rest of the characters match + } + } else { + // found a separator, we skip this character and continue to the next one + nameStart++; + if (queryStart == 0) { + // This means we found a separator before the start of a token, + // so we should increment the token's start position to reflect its true + // start position + tokenStart = nameStart; + } else { + // Otherwise this separator was found in the middle of a token being matched, + // so increase the separator count + seperatorCount++; + } + } + } + return false; + } + + public boolean matches(String displayName) { + mMatchPositions.clear(); + return matchesCombination(displayName, mQuery, mMatchPositions); + } + + public ArrayList<SmartDialMatchPosition> getMatchPositions() { + return mMatchPositions; + } + + public String getQuery() { + return mQuery; + } +} diff --git a/src/com/android/dialer/dialpad/SmartDialTextView.java b/src/com/android/dialer/dialpad/SmartDialTextView.java new file mode 100644 index 000000000..b9df48ff0 --- /dev/null +++ b/src/com/android/dialer/dialpad/SmartDialTextView.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.dialpad; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Paint.Align; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.util.Log; +import android.widget.TextView; + +import com.android.dialer.R; + +public class SmartDialTextView extends TextView { + + private final float mPadding; + private final float mExtraPadding; + private static final String HIGH_CONFIDENCE_HINT = "\u2026"; + + public SmartDialTextView(Context context) { + this(context, null); + } + + public SmartDialTextView(Context context, AttributeSet attrs) { + super(context, attrs); + mPadding = getResources().getDimension(R.dimen.smartdial_suggestions_padding); + mExtraPadding = getResources().getDimension(R.dimen.smartdial_suggestions_extra_padding); + } + + /** + * Returns a drawable that resembles a sideways overflow icon. Used to indicate the presence + * of a high confidence match. + * + * @param res Resources that we will use to create our BitmapDrawable with + * @param textSize Size of drawable to create + * @param color Color of drawable to create + * @return The drawable drawn according to the given parameters + */ + public static Drawable getHighConfidenceHintDrawable(final Resources res, final float textSize, + final int color) { + final Paint paint = new Paint(); + paint.setAntiAlias(true); + paint.setTextAlign(Align.CENTER); + paint.setTextSize(textSize); + paint.setColor(color); + final Rect bounds = new Rect(); + paint.getTextBounds(HIGH_CONFIDENCE_HINT, 0, HIGH_CONFIDENCE_HINT.length(), bounds); + final int width = bounds.width(); + final int height = bounds.height(); + final Bitmap buffer = Bitmap.createBitmap( + width, (height * 3 / 2), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(buffer); + canvas.drawText(HIGH_CONFIDENCE_HINT, width / 2, height, paint); + return new BitmapDrawable(res, buffer); + } + + @Override + protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) { + super.onTextChanged(text, start, lengthBefore, lengthAfter); + rescaleText(getWidth()); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + rescaleText(w); + } + + private void rescaleText(int w) { + if (w == 0) { + return; + } + setTextScaleX(1); + final Paint paint = getPaint(); + float width = w - 2 * mPadding - 2 * mExtraPadding; + + float ratio = width / paint.measureText(getText().toString()); + if (ratio < 1.0f) { + setTextScaleX(ratio); + } + } +} diff --git a/tests/src/com/android/dialer/SmartDialNameMatcherTest.java b/tests/src/com/android/dialer/SmartDialNameMatcherTest.java new file mode 100644 index 000000000..492e5b422 --- /dev/null +++ b/tests/src/com/android/dialer/SmartDialNameMatcherTest.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.dialpad; + +import android.test.suitebuilder.annotation.SmallTest; +import android.test.suitebuilder.annotation.Suppress; +import android.util.Log; + +import com.android.dialer.dialpad.SmartDialNameMatcher; + +import java.text.Normalizer; +import java.util.ArrayList; + +import junit.framework.TestCase; + +@SmallTest +public class SmartDialNameMatcherTest extends TestCase { + private static final String TAG = "SmartDialNameMatcherTest"; + + public void testMatches() { + // Test to ensure that all alphabetic characters are covered + checkMatches("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + "22233344455566677778889999" + "22233344455566677778889999", true, 0, 26 * 2); + // Should fail because of a mistyped 2 instead of 9 in the second last character + checkMatches("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + "22233344455566677778889999" + "22233344455566677778889929", false, 0, 0); + + // Basic name test + checkMatches("joe", "5", true, 0, 1); + checkMatches("joe", "56", true, 0, 2); + checkMatches("joe", "563", true, 0, 3); + + // Matches only word boundary. + checkMatches("joe", "63", false, 0, 0); + checkMatches("joe oe", "63", true, 4, 6); + + // Test for a match across word boundaries + checkMatches("joe oe", "56363", true, 0, 6); + } + + public void testMatches_repeatedLetters() { + checkMatches("aaaaaaaaaa", "2222222222", true, 0, 10); + // Fails because of one extra 2 + checkMatches("aaaaaaaaaa", "22222222222", false, 0, 0); + checkMatches("zzzzzzzzzz zzzzzzzzzz", "99999999999999999999", true, 0, 21); + } + + public void testMatches_repeatedSpaces() { + checkMatches("William J Smith", "9455426576", true, 0, 17); + checkMatches("William J Smith", "576", true, 12, 17); + // Fails because we start at non-word boundary + checkMatches("William J Smith", "6576", false, 0, 0); + } + + // TODO: Do we want to make these pass anymore? + @Suppress + public void testMatches_repeatedSeparators() { + // Simple match for single token + checkMatches("John,,,,,Doe", "5646", true, 0, 4); + // Match across tokens + checkMatches("John,,,,,Doe", "56463", true, 0, 10); + // Match token after chain of separators + checkMatches("John,,,,,Doe", "363", true, 9, 12); + } + + public void testMatches_umlaut() { + checkMatches("ÄÖÜäöü", "268268", true, 0, 6); + } + // TODO: Great if it was treated as "s" or "ss. Figure out if possible without prefix trie? + @Suppress + public void testMatches_germanSharpS() { + checkMatches("ß", "s", true, 0, 1); + checkMatches("ß", "ss", true, 0, 1); + } + + // TODO: Add this and make it work + @Suppress + public void testMatches_greek() { + // http://en.wikipedia.org/wiki/Greek_alphabet + fail("Greek letters aren't supported yet."); + } + + // TODO: Add this and make it work + @Suppress + public void testMatches_cyrillic() { + // http://en.wikipedia.org/wiki/Cyrillic_script + fail("Cyrillic letters aren't supported yet."); + } + + private void checkMatches(String displayName, String query, boolean expectedMatches, + int expectedMatchStart, int expectedMatchEnd) { + final SmartDialNameMatcher matcher = new SmartDialNameMatcher(query); + final ArrayList<SmartDialMatchPosition> matchPositions = + new ArrayList<SmartDialMatchPosition>(); + final boolean matches = matcher.matchesCombination( + displayName, query, matchPositions); + Log.d(TAG, "query=" + query + " text=" + displayName + + " nfd=" + Normalizer.normalize(displayName, Normalizer.Form.NFD) + + " nfc=" + Normalizer.normalize(displayName, Normalizer.Form.NFC) + + " nfkd=" + Normalizer.normalize(displayName, Normalizer.Form.NFKD) + + " nfkc=" + Normalizer.normalize(displayName, Normalizer.Form.NFKC) + + " matches=" + matches); + assertEquals("matches", expectedMatches, matches); + if (matches) { + assertEquals("start", expectedMatchStart, matchPositions.get(0).start); + assertEquals("end", expectedMatchEnd, matchPositions.get(0).end); + } + } +} |