1/*
2 * Copyright (C) 2012 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.dialer.dialpad;
18
19import static com.android.dialer.dialpad.SmartDialController.LOG_TAG;
20
21import android.content.Context;
22import android.content.SharedPreferences;
23import android.database.Cursor;
24import android.net.Uri;
25import android.preference.PreferenceManager;
26import android.provider.ContactsContract;
27import android.provider.ContactsContract.CommonDataKinds.Phone;
28import android.provider.ContactsContract.Contacts;
29import android.provider.ContactsContract.Data;
30import android.provider.ContactsContract.Directory;
31import android.telephony.TelephonyManager;
32import android.text.TextUtils;
33import android.util.Log;
34
35import com.android.contacts.common.util.StopWatch;
36
37import com.google.common.annotations.VisibleForTesting;
38import com.google.common.base.Preconditions;
39
40import java.util.Comparator;
41import java.util.HashSet;
42import java.util.Set;
43import java.util.concurrent.atomic.AtomicInteger;
44
45/**
46 * Cache object used to cache Smart Dial contacts that handles various states of the cache at the
47 * point in time when getContacts() is called
48 * 1) Cache is currently empty and there is no caching thread running - getContacts() starts a
49 * caching thread and returns the cache when completed
50 * 2) The cache is currently empty, but a caching thread has been started - getContacts() waits
51 * till the existing caching thread is completed before immediately returning the cache
52 * 3) The cache has already been populated, and there is no caching thread running - getContacts()
53 * returns the existing cache immediately
54 * 4) The cache has already been populated, but there is another caching thread running (due to
55 * a forced cache refresh due to content updates - getContacts() returns the existing cache
56 * immediately
57 */
58public class SmartDialCache {
59
60    public static class ContactNumber {
61        public final String displayName;
62        public final String lookupKey;
63        public final long id;
64        public final int affinity;
65        public final String phoneNumber;
66
67        public ContactNumber(long id, String displayName, String phoneNumber, String lookupKey,
68                int affinity) {
69            this.displayName = displayName;
70            this.lookupKey = lookupKey;
71            this.id = id;
72            this.affinity = affinity;
73            this.phoneNumber = phoneNumber;
74        }
75    }
76
77    public static interface PhoneQuery {
78
79       Uri URI = Phone.CONTENT_URI.buildUpon().
80               appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
81               String.valueOf(Directory.DEFAULT)).
82               appendQueryParameter(ContactsContract.REMOVE_DUPLICATE_ENTRIES, "true").
83               build();
84
85       final String[] PROJECTION_PRIMARY = new String[] {
86            Phone._ID,                          // 0
87            Phone.TYPE,                         // 1
88            Phone.LABEL,                        // 2
89            Phone.NUMBER,                       // 3
90            Phone.CONTACT_ID,                   // 4
91            Phone.LOOKUP_KEY,                   // 5
92            Phone.DISPLAY_NAME_PRIMARY,         // 6
93        };
94
95        final String[] PROJECTION_ALTERNATIVE = new String[] {
96            Phone._ID,                          // 0
97            Phone.TYPE,                         // 1
98            Phone.LABEL,                        // 2
99            Phone.NUMBER,                       // 3
100            Phone.CONTACT_ID,                   // 4
101            Phone.LOOKUP_KEY,                   // 5
102            Phone.DISPLAY_NAME_ALTERNATIVE,     // 6
103        };
104
105        public static final int PHONE_ID           = 0;
106        public static final int PHONE_TYPE         = 1;
107        public static final int PHONE_LABEL        = 2;
108        public static final int PHONE_NUMBER       = 3;
109        public static final int PHONE_CONTACT_ID   = 4;
110        public static final int PHONE_LOOKUP_KEY   = 5;
111        public static final int PHONE_DISPLAY_NAME = 6;
112
113        // Current contacts - those contacted within the last 3 days (in milliseconds)
114        final static long LAST_TIME_USED_CURRENT_MS = 3L * 24 * 60 * 60 * 1000;
115
116        // Recent contacts - those contacted within the last 30 days (in milliseconds)
117        final static long LAST_TIME_USED_RECENT_MS = 30L * 24 * 60 * 60 * 1000;
118
119        final static String TIME_SINCE_LAST_USED_MS =
120                "(? - " + Data.LAST_TIME_USED + ")";
121
122        final static String SORT_BY_DATA_USAGE =
123                "(CASE WHEN " + TIME_SINCE_LAST_USED_MS + " < " + LAST_TIME_USED_CURRENT_MS +
124                " THEN 0 " +
125                " WHEN " + TIME_SINCE_LAST_USED_MS + " < " + LAST_TIME_USED_RECENT_MS +
126                " THEN 1 " +
127                " ELSE 2 END), " +
128                Data.TIMES_USED + " DESC";
129
130        // This sort order is similar to that used by the ContactsProvider when returning a list
131        // of frequently called contacts.
132        public static final String SORT_ORDER =
133                Contacts.STARRED + " DESC, "
134                + Data.IS_SUPER_PRIMARY + " DESC, "
135                + SORT_BY_DATA_USAGE + ", "
136                + Contacts.IN_VISIBLE_GROUP + " DESC, "
137                + Contacts.DISPLAY_NAME + ", "
138                + Data.CONTACT_ID + ", "
139                + Data.IS_PRIMARY + " DESC";
140    }
141
142    // Static set used to determine which countries use NANP numbers
143    public static Set<String> sNanpCountries = null;
144
145    private SmartDialTrie mContactsCache;
146    private static AtomicInteger mCacheStatus;
147    private final int mNameDisplayOrder;
148    private final Context mContext;
149    private final static Object mLock = new Object();
150
151    /** The country code of the user's sim card obtained by calling getSimCountryIso*/
152    private static final String PREF_USER_SIM_COUNTRY_CODE =
153            "DialtactsActivity_user_sim_country_code";
154    private static final String PREF_USER_SIM_COUNTRY_CODE_DEFAULT = null;
155
156    private static String sUserSimCountryCode = PREF_USER_SIM_COUNTRY_CODE_DEFAULT;
157    private static boolean sUserInNanpRegion = false;
158
159    public static final int CACHE_NEEDS_RECACHE = 1;
160    public static final int CACHE_IN_PROGRESS = 2;
161    public static final int CACHE_COMPLETED = 3;
162
163    private static final boolean DEBUG = false;
164
165    private SmartDialCache(Context context, int nameDisplayOrder) {
166        mNameDisplayOrder = nameDisplayOrder;
167        Preconditions.checkNotNull(context, "Context must not be null");
168        mContext = context.getApplicationContext();
169        mCacheStatus = new AtomicInteger(CACHE_NEEDS_RECACHE);
170
171        final TelephonyManager manager = (TelephonyManager) context.getSystemService(
172                Context.TELEPHONY_SERVICE);
173        if (manager != null) {
174            sUserSimCountryCode = manager.getSimCountryIso();
175        }
176
177        final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
178
179        if (sUserSimCountryCode != null) {
180            // Update shared preferences with the latest country obtained from getSimCountryIso
181            prefs.edit().putString(PREF_USER_SIM_COUNTRY_CODE, sUserSimCountryCode).apply();
182        } else {
183            // Couldn't get the country from getSimCountryIso. Maybe we are in airplane mode.
184            // Try to load the settings, if any from SharedPreferences.
185            sUserSimCountryCode = prefs.getString(PREF_USER_SIM_COUNTRY_CODE,
186                    PREF_USER_SIM_COUNTRY_CODE_DEFAULT);
187        }
188
189        sUserInNanpRegion = isCountryNanp(sUserSimCountryCode);
190
191    }
192
193    private static SmartDialCache instance;
194
195    /**
196     * Returns an instance of SmartDialCache.
197     *
198     * @param context A context that provides a valid ContentResolver.
199     * @param nameDisplayOrder One of the two name display order integer constants (1 or 2) as saved
200     *        in settings under the key
201     *        {@link android.provider.ContactsContract.Preferences#DISPLAY_ORDER}.
202     * @return An instance of SmartDialCache
203     */
204    public static synchronized SmartDialCache getInstance(Context context, int nameDisplayOrder) {
205        if (instance == null) {
206            instance = new SmartDialCache(context, nameDisplayOrder);
207        }
208        return instance;
209    }
210
211    /**
212     * Performs a database query, iterates through the returned cursor and saves the retrieved
213     * contacts to a local cache.
214     */
215    private void cacheContacts(Context context) {
216        mCacheStatus.set(CACHE_IN_PROGRESS);
217        synchronized(mLock) {
218            if (DEBUG) {
219                Log.d(LOG_TAG, "Starting caching thread");
220            }
221            final StopWatch stopWatch = DEBUG ? StopWatch.start("SmartDial Cache") : null;
222            final String millis = String.valueOf(System.currentTimeMillis());
223            final Cursor c = context.getContentResolver().query(PhoneQuery.URI,
224                    (mNameDisplayOrder == ContactsContract.Preferences.DISPLAY_ORDER_PRIMARY)
225                        ? PhoneQuery.PROJECTION_PRIMARY : PhoneQuery.PROJECTION_ALTERNATIVE,
226                    null, new String[] {millis, millis},
227                    PhoneQuery.SORT_ORDER);
228            if (DEBUG) {
229                stopWatch.lap("SmartDial query complete");
230            }
231            if (c == null) {
232                Log.w(LOG_TAG, "SmartDial query received null for cursor");
233                if (DEBUG) {
234                    stopWatch.stopAndLog("SmartDial query received null for cursor", 0);
235                }
236                mCacheStatus.getAndSet(CACHE_NEEDS_RECACHE);
237                return;
238            }
239            final SmartDialTrie cache = new SmartDialTrie(
240                    SmartDialNameMatcher.LATIN_LETTERS_TO_DIGITS, sUserInNanpRegion);
241            try {
242                c.moveToPosition(-1);
243                int affinityCount = 0;
244                while (c.moveToNext()) {
245                    final String displayName = c.getString(PhoneQuery.PHONE_DISPLAY_NAME);
246                    final String phoneNumber = c.getString(PhoneQuery.PHONE_NUMBER);
247                    final long id = c.getLong(PhoneQuery.PHONE_CONTACT_ID);
248                    final String lookupKey = c.getString(PhoneQuery.PHONE_LOOKUP_KEY);
249                    cache.put(new ContactNumber(id, displayName, phoneNumber, lookupKey,
250                            affinityCount));
251                    affinityCount++;
252                }
253            } finally {
254                c.close();
255                mContactsCache = cache;
256                if (DEBUG) {
257                    stopWatch.stopAndLog("SmartDial caching completed", 0);
258                }
259            }
260        }
261        if (DEBUG) {
262            Log.d(LOG_TAG, "Caching thread completed");
263        }
264        mCacheStatus.getAndSet(CACHE_COMPLETED);
265    }
266
267    /**
268     * Returns the list of cached contacts. This is blocking so it should not be called from the UI
269     * thread. There are 4 possible scenarios:
270     *
271     * 1) Cache is currently empty and there is no caching thread running - getContacts() starts a
272     * caching thread and returns the cache when completed
273     * 2) The cache is currently empty, but a caching thread has been started - getContacts() waits
274     * till the existing caching thread is completed before immediately returning the cache
275     * 3) The cache has already been populated, and there is no caching thread running -
276     * getContacts() returns the existing cache immediately
277     * 4) The cache has already been populated, but there is another caching thread running (due to
278     * a forced cache refresh due to content updates - getContacts() returns the existing cache
279     * immediately
280     *
281     * @return List of already cached contacts, or an empty list if the caching failed for any
282     * reason.
283     */
284    public SmartDialTrie getContacts() {
285        // Either scenario 3 or 4 - This means just go ahead and return the existing cache
286        // immediately even if there is a caching thread currently running. We are guaranteed to
287        // have the newest value of mContactsCache at this point because it is volatile.
288        if (mContactsCache != null) {
289            return mContactsCache;
290        }
291        // At this point we are forced to wait for cacheContacts to complete in another thread(if
292        // one currently exists) because of mLock.
293        synchronized(mLock) {
294            // If mContactsCache is still null at this point, either there was never any caching
295            // process running, or it failed (Scenario 1). If so, just go ahead and try to cache
296            // the contacts again.
297            if (mContactsCache == null) {
298                cacheContacts(mContext);
299                return (mContactsCache == null) ? new SmartDialTrie() : mContactsCache;
300            } else {
301                // After waiting for the lock on mLock to be released, mContactsCache is now
302                // non-null due to the completion of the caching thread (Scenario 2). Go ahead
303                // and return the existing cache.
304                return mContactsCache;
305            }
306        }
307    }
308
309    /**
310     * Cache contacts only if there is a need to (forced cache refresh or no attempt to cache yet).
311     * This method is called in 2 places: whenever the DialpadFragment comes into view, and in
312     * onResume.
313     *
314     * @param forceRecache If true, force a cache refresh.
315     */
316
317    public void cacheIfNeeded(boolean forceRecache) {
318        if (DEBUG) {
319            Log.d("SmartDial", "cacheIfNeeded called with " + String.valueOf(forceRecache));
320        }
321        if (mCacheStatus.get() == CACHE_IN_PROGRESS) {
322            return;
323        }
324        if (forceRecache || mCacheStatus.get() == CACHE_NEEDS_RECACHE) {
325            // Because this method can be possibly be called multiple times in rapid succession,
326            // set the cache status even before starting a caching thread to avoid unnecessarily
327            // spawning extra threads.
328            mCacheStatus.set(CACHE_IN_PROGRESS);
329            startCachingThread();
330        }
331    }
332
333    private void startCachingThread() {
334        new Thread(new Runnable() {
335            @Override
336            public void run() {
337                cacheContacts(mContext);
338            }
339        }).start();
340    }
341
342    public static class ContactAffinityComparator implements Comparator<ContactNumber> {
343        @Override
344        public int compare(ContactNumber lhs, ContactNumber rhs) {
345            // Smaller affinity is better because they are numbered in ascending order in
346            // the order the contacts were returned from the ContactsProvider (sorted by
347            // frequency of use and time last used
348            return Integer.compare(lhs.affinity, rhs.affinity);
349        }
350
351    }
352
353    public boolean getUserInNanpRegion() {
354        return sUserInNanpRegion;
355    }
356
357    /**
358     * Indicates whether the given country uses NANP numbers
359     *
360     * @param country ISO 3166 country code (case doesn't matter)
361     * @return True if country uses NANP numbers (e.g. US, Canada), false otherwise
362     */
363    @VisibleForTesting
364    static boolean isCountryNanp(String country) {
365        if (TextUtils.isEmpty(country)) {
366            return false;
367        }
368        if (sNanpCountries == null) {
369            sNanpCountries = initNanpCountries();
370        }
371        return sNanpCountries.contains(country.toUpperCase());
372    }
373
374    private static Set<String> initNanpCountries() {
375        final HashSet<String> result = new HashSet<String>();
376        result.add("US"); // United States
377        result.add("CA"); // Canada
378        result.add("AS"); // American Samoa
379        result.add("AI"); // Anguilla
380        result.add("AG"); // Antigua and Barbuda
381        result.add("BS"); // Bahamas
382        result.add("BB"); // Barbados
383        result.add("BM"); // Bermuda
384        result.add("VG"); // British Virgin Islands
385        result.add("KY"); // Cayman Islands
386        result.add("DM"); // Dominica
387        result.add("DO"); // Dominican Republic
388        result.add("GD"); // Grenada
389        result.add("GU"); // Guam
390        result.add("JM"); // Jamaica
391        result.add("PR"); // Puerto Rico
392        result.add("MS"); // Montserrat
393        result.add("MP"); // Northern Mariana Islands
394        result.add("KN"); // Saint Kitts and Nevis
395        result.add("LC"); // Saint Lucia
396        result.add("VC"); // Saint Vincent and the Grenadines
397        result.add("TT"); // Trinidad and Tobago
398        result.add("TC"); // Turks and Caicos Islands
399        result.add("VI"); // U.S. Virgin Islands
400        return result;
401    }
402}
403