Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 661834dd authored by Christine Chen's avatar Christine Chen
Browse files

Add SmartDial database for the Dialer app.

- Creates a database helper to create a smartdial database for the Dialer app.
- Queries all rows in the Contact database and copies related columns to
  the smart dial database.
- Create another prefix database to contain all prefixes of a contact.
- During keypad input, the prefix databse is queried to find contact
  suggestions, and suggestions are ranked by the usage data and contact
  status (starred, primary contact, etc.)
- Created unit test for the SmartDial database insertion and prefix
  computing functions.

Change-Id: I4d7c3b3bcc52dd6efa4d6e69d3f1687c3abaeb69
parent 68a5a264
Loading
Loading
Loading
Loading
+826 −0

File added.

Preview size limit exceeded, changes collapsed.

+12 −39
Original line number Diff line number Diff line
@@ -26,7 +26,6 @@ import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.res.Resources;
import android.database.ContentObserver;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
@@ -35,7 +34,6 @@ import android.media.ToneGenerator;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.os.RemoteException;
import android.os.ServiceManager;
import android.os.SystemProperties;
@@ -62,12 +60,10 @@ 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;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.PopupMenu;
import android.widget.RelativeLayout;
@@ -82,6 +78,7 @@ import com.android.contacts.common.util.StopWatch;
import com.android.dialer.DialtactsActivity;
import com.android.dialer.R;
import com.android.dialer.SpecialCharSequenceMgr;
import com.android.dialer.database.DialerDatabaseHelper;
import com.android.dialer.interactions.PhoneNumberInteraction;
import com.android.dialer.util.OrientationUtil;
import com.android.internal.telephony.ITelephony;
@@ -156,8 +153,6 @@ public class DialpadFragment extends Fragment
     */
    private SmartDialController mSmartDialAdapter;

    private SmartDialCache mSmartDialCache;

    /**
     * Use latin character map by default
     */
@@ -169,6 +164,8 @@ public class DialpadFragment extends Fragment
     */
    private boolean mSmartDialEnabled = false;

    private DialerDatabaseHelper mDialerDatabaseHelper;

    /**
     * Regular expression prohibiting manual phone call. Can be empty, which means "no rule".
     */
@@ -300,6 +297,10 @@ public class DialpadFragment extends Fragment
        mFirstLaunch = true;
        mContactsPrefs = new ContactsPreferences(getActivity());
        mCurrentCountryIso = GeoUtil.getCurrentCountryIso(getActivity());

        mDialerDatabaseHelper = DialerDatabaseHelper.getInstance(getActivity());
        SmartDialPrefix.initializeNanpSettings(getActivity());

        try {
            mHaptic.init(getActivity(),
                         getResources().getBoolean(R.bool.config_enable_dialer_key_vibration));
@@ -1653,20 +1654,6 @@ public class DialpadFragment extends Fragment
        return intent;
    }

    @Override
    public void setUserVisibleHint(boolean isVisibleToUser) {
        super.setUserVisibleHint(isVisibleToUser);
        if (mSmartDialEnabled && isVisibleToUser && mSmartDialCache != null) {
            // This is called every time the dialpad fragment comes into view. The first
            // time the dialer is launched, mSmartDialEnabled is always false as it has not been
            // read from settings(in onResume) yet at the point where setUserVisibleHint is called
            // for the first time, so the caching on first launch will happen in onResume instead.
            // This covers only the case where the dialer is launched in the call log or
            // contacts tab, and then the user swipes to the dialpad.
            mSmartDialCache.cacheIfNeeded(false);
        }
    }

    private String mLastDigitsForSmartDial;

    private void loadSmartDialEntries() {
@@ -1675,11 +1662,6 @@ public class DialpadFragment extends Fragment
            return;
        }

        if (mSmartDialCache == null) {
            Log.e(TAG, "Trying to load smart dialing entries from a null cache");
            return;
        }

        // Update only when the digits have changed.
        final String digits = SmartDialNameMatcher.normalizeNumber(mDigits.getText().toString(),
                mSmartDialMap);
@@ -1691,7 +1673,7 @@ public class DialpadFragment extends Fragment
        if (digits.length() < 1) {
            mSmartDialAdapter.clear();
        } else {
            final SmartDialLoaderTask task = new SmartDialLoaderTask(this, digits, mSmartDialCache);
            final SmartDialLoaderTask task = new SmartDialLoaderTask(this, digits, getActivity());
            task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new String[] {});
        }
    }
@@ -1708,24 +1690,15 @@ public class DialpadFragment extends Fragment
        // Handle smart dialing related state
        if (mSmartDialEnabled) {
            mSmartDialContainer.setVisibility(View.VISIBLE);
            mSmartDialCache = SmartDialCache.getInstance(getActivity(),
                    mContactsPrefs.getDisplayOrder(), mSmartDialMap);
            // Don't force recache if this is the first time onResume is being called, since
            // caching should already happen in setUserVisibleHint.
            if (!mFirstLaunch || getUserVisibleHint()) {
                // This forced recache covers the cases where the dialer was running before and
                // was brought back into the foreground, or the dialer was launched for the first
                // time and displays the dialpad fragment immediately. If the dialpad fragment
                // hasn't actually become visible throughout the entire activity's lifecycle, it
                // is possible that caching hasn't happened yet. In this case, we can force a
                // recache anyway, since we are not worried about startup performance anymore.
                mSmartDialCache.cacheIfNeeded(true);

            if (DEBUG) {
                Log.w(TAG, "Creating smart dial database");
            }
            mDialerDatabaseHelper.startSmartDialUpdateThread();
        } else {
            if (mSmartDialContainer != null) {
                mSmartDialContainer.setVisibility(View.GONE);
            }
            mSmartDialCache = null;
        }
    }

+0 −408
Original line number Diff line number Diff line
/*
 * 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.SmartDialController.LOG_TAG;

import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.net.Uri;
import android.preference.PreferenceManager;
import android.provider.ContactsContract;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.provider.ContactsContract.Contacts;
import android.provider.ContactsContract.Data;
import android.provider.ContactsContract.Directory;
import android.telephony.TelephonyManager;
import android.text.TextUtils;
import android.util.Log;

import com.android.contacts.common.util.StopWatch;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;

import java.util.Comparator;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Cache object used to cache Smart Dial contacts that handles various states of the cache at the
 * point in time when getContacts() is called
 * 1) Cache is currently empty and there is no caching thread running - getContacts() starts a
 * caching thread and returns the cache when completed
 * 2) The cache is currently empty, but a caching thread has been started - getContacts() waits
 * till the existing caching thread is completed before immediately returning the cache
 * 3) The cache has already been populated, and there is no caching thread running - getContacts()
 * returns the existing cache immediately
 * 4) The cache has already been populated, but there is another caching thread running (due to
 * a forced cache refresh due to content updates - getContacts() returns the existing cache
 * immediately
 */
public class SmartDialCache {

    public static class ContactNumber {
        public final String displayName;
        public final String lookupKey;
        public final long id;
        public final int affinity;
        public final String phoneNumber;

        public ContactNumber(long id, String displayName, String phoneNumber, String lookupKey,
                int affinity) {
            this.displayName = displayName;
            this.lookupKey = lookupKey;
            this.id = id;
            this.affinity = affinity;
            this.phoneNumber = phoneNumber;
        }
    }

    public static interface PhoneQuery {

       Uri URI = Phone.CONTENT_URI.buildUpon().
               appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
               String.valueOf(Directory.DEFAULT)).
               appendQueryParameter(ContactsContract.REMOVE_DUPLICATE_ENTRIES, "true").
               build();

       final String[] PROJECTION_PRIMARY = new String[] {
            Phone._ID,                          // 0
            Phone.TYPE,                         // 1
            Phone.LABEL,                        // 2
            Phone.NUMBER,                       // 3
            Phone.CONTACT_ID,                   // 4
            Phone.LOOKUP_KEY,                   // 5
            Phone.DISPLAY_NAME_PRIMARY,         // 6
        };

        final String[] PROJECTION_ALTERNATIVE = new String[] {
            Phone._ID,                          // 0
            Phone.TYPE,                         // 1
            Phone.LABEL,                        // 2
            Phone.NUMBER,                       // 3
            Phone.CONTACT_ID,                   // 4
            Phone.LOOKUP_KEY,                   // 5
            Phone.DISPLAY_NAME_ALTERNATIVE,     // 6
        };

        public static final int PHONE_ID           = 0;
        public static final int PHONE_TYPE         = 1;
        public static final int PHONE_LABEL        = 2;
        public static final int PHONE_NUMBER       = 3;
        public static final int PHONE_CONTACT_ID   = 4;
        public static final int PHONE_LOOKUP_KEY   = 5;
        public static final int PHONE_DISPLAY_NAME = 6;

        // Current contacts - those contacted within the last 3 days (in milliseconds)
        final static long LAST_TIME_USED_CURRENT_MS = 3L * 24 * 60 * 60 * 1000;

        // Recent contacts - those contacted within the last 30 days (in milliseconds)
        final static long LAST_TIME_USED_RECENT_MS = 30L * 24 * 60 * 60 * 1000;

        final static String TIME_SINCE_LAST_USED_MS =
                "(? - " + Data.LAST_TIME_USED + ")";

        final static String SORT_BY_DATA_USAGE =
                "(CASE WHEN " + TIME_SINCE_LAST_USED_MS + " < " + LAST_TIME_USED_CURRENT_MS +
                " THEN 0 " +
                " WHEN " + TIME_SINCE_LAST_USED_MS + " < " + LAST_TIME_USED_RECENT_MS +
                " THEN 1 " +
                " ELSE 2 END), " +
                Data.TIMES_USED + " DESC";

        // This sort order is similar to that used by the ContactsProvider when returning a list
        // of frequently called contacts.
        public static final String SORT_ORDER =
                Contacts.STARRED + " DESC, "
                + Data.IS_SUPER_PRIMARY + " DESC, "
                + SORT_BY_DATA_USAGE + ", "
                + Contacts.IN_VISIBLE_GROUP + " DESC, "
                + Contacts.DISPLAY_NAME + ", "
                + Data.CONTACT_ID + ", "
                + Data.IS_PRIMARY + " DESC";
    }

    // Static set used to determine which countries use NANP numbers
    public static Set<String> sNanpCountries = null;

    private SmartDialTrie mContactsCache;
    private static AtomicInteger mCacheStatus;
    private final SmartDialMap mMap;
    private final int mNameDisplayOrder;
    private final Context mContext;
    private final static Object mLock = new Object();

    /** The country code of the user's sim card obtained by calling getSimCountryIso*/
    private static final String PREF_USER_SIM_COUNTRY_CODE =
            "DialtactsActivity_user_sim_country_code";
    private static final String PREF_USER_SIM_COUNTRY_CODE_DEFAULT = null;

    private static String sUserSimCountryCode = PREF_USER_SIM_COUNTRY_CODE_DEFAULT;
    private static boolean sUserInNanpRegion = false;

    public static final int CACHE_NEEDS_RECACHE = 1;
    public static final int CACHE_IN_PROGRESS = 2;
    public static final int CACHE_COMPLETED = 3;

    private static final boolean DEBUG = false;

    private SmartDialCache(Context context, int nameDisplayOrder, SmartDialMap map) {
        mNameDisplayOrder = nameDisplayOrder;
        mMap = map;
        Preconditions.checkNotNull(context, "Context must not be null");
        mContext = context.getApplicationContext();
        mCacheStatus = new AtomicInteger(CACHE_NEEDS_RECACHE);

        final TelephonyManager manager = (TelephonyManager) context.getSystemService(
                Context.TELEPHONY_SERVICE);
        if (manager != null) {
            sUserSimCountryCode = manager.getSimCountryIso();
        }

        final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);

        if (sUserSimCountryCode != null) {
            // Update shared preferences with the latest country obtained from getSimCountryIso
            prefs.edit().putString(PREF_USER_SIM_COUNTRY_CODE, sUserSimCountryCode).apply();
        } else {
            // Couldn't get the country from getSimCountryIso. Maybe we are in airplane mode.
            // Try to load the settings, if any from SharedPreferences.
            sUserSimCountryCode = prefs.getString(PREF_USER_SIM_COUNTRY_CODE,
                    PREF_USER_SIM_COUNTRY_CODE_DEFAULT);
        }

        sUserInNanpRegion = isCountryNanp(sUserSimCountryCode);

    }

    private static SmartDialCache instance;

    /**
     * Returns an instance of SmartDialCache.
     *
     * @param context A context that provides a valid ContentResolver.
     * @param nameDisplayOrder One of the two name display order integer constants (1 or 2) as saved
     *        in settings under the key
     *        {@link android.provider.ContactsContract.Preferences#DISPLAY_ORDER}.
     * @return An instance of SmartDialCache
     */
    public static synchronized SmartDialCache getInstance(Context context, int nameDisplayOrder,
            SmartDialMap map) {
        if (instance == null) {
            instance = new SmartDialCache(context, nameDisplayOrder, map);
        }
        return instance;
    }

    /**
     * Performs a database query, iterates through the returned cursor and saves the retrieved
     * contacts to a local cache.
     */
    private void cacheContacts(Context context) {
        mCacheStatus.set(CACHE_IN_PROGRESS);
        synchronized(mLock) {
            if (DEBUG) {
                Log.d(LOG_TAG, "Starting caching thread");
            }
            final StopWatch stopWatch = DEBUG ? StopWatch.start("SmartDial Cache") : null;
            final String millis = String.valueOf(System.currentTimeMillis());
            final Cursor c = context.getContentResolver().query(PhoneQuery.URI,
                    (mNameDisplayOrder == ContactsContract.Preferences.DISPLAY_ORDER_PRIMARY)
                        ? PhoneQuery.PROJECTION_PRIMARY : PhoneQuery.PROJECTION_ALTERNATIVE,
                    null, new String[] {millis, millis},
                    PhoneQuery.SORT_ORDER);
            if (DEBUG) {
                stopWatch.lap("SmartDial query complete");
            }
            if (c == null) {
                Log.w(LOG_TAG, "SmartDial query received null for cursor");
                if (DEBUG) {
                    stopWatch.stopAndLog("SmartDial query received null for cursor", 0);
                }
                mCacheStatus.getAndSet(CACHE_NEEDS_RECACHE);
                return;
            }
            final SmartDialTrie cache = new SmartDialTrie(mMap, sUserInNanpRegion);
            try {
                c.moveToPosition(-1);
                int affinityCount = 0;
                while (c.moveToNext()) {
                    final String displayName = c.getString(PhoneQuery.PHONE_DISPLAY_NAME);
                    final String phoneNumber = c.getString(PhoneQuery.PHONE_NUMBER);
                    final long id = c.getLong(PhoneQuery.PHONE_CONTACT_ID);
                    final String lookupKey = c.getString(PhoneQuery.PHONE_LOOKUP_KEY);
                    cache.put(new ContactNumber(id, displayName, phoneNumber, lookupKey,
                            affinityCount));
                    affinityCount++;
                }
            } finally {
                c.close();
                mContactsCache = cache;
                if (DEBUG) {
                    stopWatch.stopAndLog("SmartDial caching completed", 0);
                }
            }
        }
        if (DEBUG) {
            Log.d(LOG_TAG, "Caching thread completed");
        }
        mCacheStatus.getAndSet(CACHE_COMPLETED);
    }

    /**
     * Returns the list of cached contacts. This is blocking so it should not be called from the UI
     * thread. There are 4 possible scenarios:
     *
     * 1) Cache is currently empty and there is no caching thread running - getContacts() starts a
     * caching thread and returns the cache when completed
     * 2) The cache is currently empty, but a caching thread has been started - getContacts() waits
     * till the existing caching thread is completed before immediately returning the cache
     * 3) The cache has already been populated, and there is no caching thread running -
     * getContacts() returns the existing cache immediately
     * 4) The cache has already been populated, but there is another caching thread running (due to
     * a forced cache refresh due to content updates - getContacts() returns the existing cache
     * immediately
     *
     * @return List of already cached contacts, or an empty list if the caching failed for any
     * reason.
     */
    public SmartDialTrie getContacts() {
        // Either scenario 3 or 4 - This means just go ahead and return the existing cache
        // immediately even if there is a caching thread currently running. We are guaranteed to
        // have the newest value of mContactsCache at this point because it is volatile.
        if (mContactsCache != null) {
            return mContactsCache;
        }
        // At this point we are forced to wait for cacheContacts to complete in another thread(if
        // one currently exists) because of mLock.
        synchronized(mLock) {
            // If mContactsCache is still null at this point, either there was never any caching
            // process running, or it failed (Scenario 1). If so, just go ahead and try to cache
            // the contacts again.
            if (mContactsCache == null) {
                cacheContacts(mContext);
                return (mContactsCache == null) ? new SmartDialTrie() : mContactsCache;
            } else {
                // After waiting for the lock on mLock to be released, mContactsCache is now
                // non-null due to the completion of the caching thread (Scenario 2). Go ahead
                // and return the existing cache.
                return mContactsCache;
            }
        }
    }

    /**
     * Cache contacts only if there is a need to (forced cache refresh or no attempt to cache yet).
     * This method is called in 2 places: whenever the DialpadFragment comes into view, and in
     * onResume.
     *
     * @param forceRecache If true, force a cache refresh.
     */

    public void cacheIfNeeded(boolean forceRecache) {
        if (DEBUG) {
            Log.d("SmartDial", "cacheIfNeeded called with " + String.valueOf(forceRecache));
        }
        if (mCacheStatus.get() == CACHE_IN_PROGRESS) {
            return;
        }
        if (forceRecache || mCacheStatus.get() == CACHE_NEEDS_RECACHE) {
            // Because this method can be possibly be called multiple times in rapid succession,
            // set the cache status even before starting a caching thread to avoid unnecessarily
            // spawning extra threads.
            mCacheStatus.set(CACHE_IN_PROGRESS);
            startCachingThread();
        }
    }

    private void startCachingThread() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                cacheContacts(mContext);
            }
        }).start();
    }

    public static class ContactAffinityComparator implements Comparator<ContactNumber> {
        @Override
        public int compare(ContactNumber lhs, ContactNumber rhs) {
            // Smaller affinity is better because they are numbered in ascending order in
            // the order the contacts were returned from the ContactsProvider (sorted by
            // frequency of use and time last used
            return Integer.compare(lhs.affinity, rhs.affinity);
        }

    }

    public SmartDialMap getMap() {
        return mMap;
    }

    public boolean getUserInNanpRegion() {
        return sUserInNanpRegion;
    }

    /**
     * Indicates whether the given country uses NANP numbers
     *
     * @param country ISO 3166 country code (case doesn't matter)
     * @return True if country uses NANP numbers (e.g. US, Canada), false otherwise
     */
    @VisibleForTesting
    static boolean isCountryNanp(String country) {
        if (TextUtils.isEmpty(country)) {
            return false;
        }
        if (sNanpCountries == null) {
            sNanpCountries = initNanpCountries();
        }
        return sNanpCountries.contains(country.toUpperCase());
    }

    private static Set<String> initNanpCountries() {
        final HashSet<String> result = new HashSet<String>();
        result.add("US"); // United States
        result.add("CA"); // Canada
        result.add("AS"); // American Samoa
        result.add("AI"); // Anguilla
        result.add("AG"); // Antigua and Barbuda
        result.add("BS"); // Bahamas
        result.add("BB"); // Barbados
        result.add("BM"); // Bermuda
        result.add("VG"); // British Virgin Islands
        result.add("KY"); // Cayman Islands
        result.add("DM"); // Dominica
        result.add("DO"); // Dominican Republic
        result.add("GD"); // Grenada
        result.add("GU"); // Guam
        result.add("JM"); // Jamaica
        result.add("PR"); // Puerto Rico
        result.add("MS"); // Montserrat
        result.add("MP"); // Northern Mariana Islands
        result.add("KN"); // Saint Kitts and Nevis
        result.add("LC"); // Saint Lucia
        result.add("VC"); // Saint Vincent and the Grenadines
        result.add("TT"); // Trinidad and Tobago
        result.add("TC"); // Turks and Caicos Islands
        result.add("VI"); // U.S. Virgin Islands
        return result;
    }
}
+16 −79

File changed.

Preview size limit exceeded, changes collapsed.

+30 −19

File changed.

Preview size limit exceeded, changes collapsed.

Loading