1 /*
2  * Copyright (C) 2010 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 
17 package com.android.contacts.editor;
18 
19 import android.content.ContentResolver;
20 import android.content.Context;
21 import android.database.ContentObserver;
22 import android.database.Cursor;
23 import android.net.Uri;
24 import android.os.Build;
25 import android.os.Handler;
26 import android.os.HandlerThread;
27 import android.os.Message;
28 import android.os.Process;
29 import android.provider.ContactsContract.CommonDataKinds.Email;
30 import android.provider.ContactsContract.CommonDataKinds.Nickname;
31 import android.provider.ContactsContract.CommonDataKinds.Phone;
32 import android.provider.ContactsContract.CommonDataKinds.Photo;
33 import android.provider.ContactsContract.CommonDataKinds.StructuredName;
34 import android.provider.ContactsContract.Contacts;
35 import android.provider.ContactsContract.Contacts.AggregationSuggestions;
36 import android.provider.ContactsContract.Contacts.AggregationSuggestions.Builder;
37 import android.provider.ContactsContract.Data;
38 import android.provider.ContactsContract.RawContacts;
39 import android.text.TextUtils;
40 
41 import com.android.contacts.common.model.ValuesDelta;
42 import com.android.contacts.compat.AggregationSuggestionsCompat;
43 import com.google.common.collect.Lists;
44 
45 import java.util.ArrayList;
46 import java.util.Arrays;
47 import java.util.List;
48 
49 /**
50  * Runs asynchronous queries to obtain aggregation suggestions in the as-you-type mode.
51  */
52 public class AggregationSuggestionEngine extends HandlerThread {
53     public static final String TAG = "AggregationSuggestionEngine";
54 
55     public interface Listener {
onAggregationSuggestionChange()56         void onAggregationSuggestionChange();
57     }
58 
59     public static final class RawContact {
60         public long rawContactId;
61         public String accountType;
62         public String accountName;
63         public String dataSet;
64 
65         @Override
toString()66         public String toString() {
67             return "ID: " + rawContactId + " account: " + accountType + "/" + accountName
68                     + " dataSet: " + dataSet;
69         }
70     }
71 
72     public static final class Suggestion {
73 
74         public long contactId;
75         public long photoId;
76         public String lookupKey;
77         public String name;
78         public String phoneNumber;
79         public String emailAddress;
80         public String nickname;
81         public byte[] photo;
82         public List<RawContact> rawContacts;
83 
84         @Override
toString()85         public String toString() {
86             return "ID: " + contactId + " rawContacts: " + rawContacts + " name: " + name
87             + " phone: " + phoneNumber + " email: " + emailAddress + " nickname: "
88             + nickname + (photo != null ? " [has photo]" : "");
89         }
90     }
91 
92     private final class SuggestionContentObserver extends ContentObserver {
SuggestionContentObserver(Handler handler)93         private SuggestionContentObserver(Handler handler) {
94             super(handler);
95         }
96 
97         @Override
onChange(boolean selfChange)98         public void onChange(boolean selfChange) {
99             scheduleSuggestionLookup();
100         }
101     }
102 
103     private static final int MESSAGE_RESET = 0;
104     private static final int MESSAGE_NAME_CHANGE = 1;
105     private static final int MESSAGE_DATA_CURSOR = 2;
106 
107     private static final long SUGGESTION_LOOKUP_DELAY_MILLIS = 300;
108 
109     private final Context mContext;
110 
111     private long[] mSuggestedContactIds = new long[0];
112 
113     private Handler mMainHandler;
114     private Handler mHandler;
115     private long mContactId;
116     private Listener mListener;
117     private Cursor mDataCursor;
118     private ContentObserver mContentObserver;
119     private Uri mSuggestionsUri;
120     private int mSuggestionsLimit = 3;
121     private boolean mPruneInvisibleContacts = true;
122 
AggregationSuggestionEngine(Context context)123     public AggregationSuggestionEngine(Context context) {
124         super("AggregationSuggestions", Process.THREAD_PRIORITY_BACKGROUND);
125         mContext = context.getApplicationContext();
126         mMainHandler = new Handler() {
127             @Override
128             public void handleMessage(Message msg) {
129                 AggregationSuggestionEngine.this.deliverNotification((Cursor) msg.obj);
130             }
131         };
132     }
133 
getHandler()134     protected Handler getHandler() {
135         if (mHandler == null) {
136             mHandler = new Handler(getLooper()) {
137                 @Override
138                 public void handleMessage(Message msg) {
139                     AggregationSuggestionEngine.this.handleMessage(msg);
140                 }
141             };
142         }
143         return mHandler;
144     }
145 
setContactId(long contactId)146     public void setContactId(long contactId) {
147         if (contactId != mContactId) {
148             mContactId = contactId;
149             reset();
150         }
151     }
152 
setSuggestionsLimit(int suggestionsLimit)153     public void setSuggestionsLimit(int suggestionsLimit) {
154         mSuggestionsLimit = suggestionsLimit;
155     }
156 
setPruneInvisibleContacts(boolean pruneInvisibleContacts)157     public void setPruneInvisibleContacts (boolean pruneInvisibleContacts) {
158         mPruneInvisibleContacts = pruneInvisibleContacts;
159     }
160 
setListener(Listener listener)161     public void setListener(Listener listener) {
162         mListener = listener;
163     }
164 
165     @Override
quit()166     public boolean quit() {
167         if (mDataCursor != null) {
168             mDataCursor.close();
169         }
170         mDataCursor = null;
171         if (mContentObserver != null) {
172             mContext.getContentResolver().unregisterContentObserver(mContentObserver);
173             mContentObserver = null;
174         }
175         return super.quit();
176     }
177 
reset()178     public void reset() {
179         Handler handler = getHandler();
180         handler.removeMessages(MESSAGE_NAME_CHANGE);
181         handler.sendEmptyMessage(MESSAGE_RESET);
182     }
183 
onNameChange(ValuesDelta values)184     public void onNameChange(ValuesDelta values) {
185         mSuggestionsUri = buildAggregationSuggestionUri(values);
186         if (mSuggestionsUri != null) {
187             if (mContentObserver == null) {
188                 mContentObserver = new SuggestionContentObserver(getHandler());
189                 mContext.getContentResolver().registerContentObserver(
190                         Contacts.CONTENT_URI, true, mContentObserver);
191             }
192         } else if (mContentObserver != null) {
193             mContext.getContentResolver().unregisterContentObserver(mContentObserver);
194             mContentObserver = null;
195         }
196         scheduleSuggestionLookup();
197     }
198 
scheduleSuggestionLookup()199     protected void scheduleSuggestionLookup() {
200         Handler handler = getHandler();
201         handler.removeMessages(MESSAGE_NAME_CHANGE);
202 
203         if (mSuggestionsUri == null) {
204             return;
205         }
206 
207         Message msg = handler.obtainMessage(MESSAGE_NAME_CHANGE, mSuggestionsUri);
208         handler.sendMessageDelayed(msg, SUGGESTION_LOOKUP_DELAY_MILLIS);
209     }
210 
buildAggregationSuggestionUri(ValuesDelta values)211     private Uri buildAggregationSuggestionUri(ValuesDelta values) {
212         StringBuilder nameSb = new StringBuilder();
213         appendValue(nameSb, values, StructuredName.PREFIX);
214         appendValue(nameSb, values, StructuredName.GIVEN_NAME);
215         appendValue(nameSb, values, StructuredName.MIDDLE_NAME);
216         appendValue(nameSb, values, StructuredName.FAMILY_NAME);
217         appendValue(nameSb, values, StructuredName.SUFFIX);
218 
219         if (nameSb.length() == 0) {
220             appendValue(nameSb, values, StructuredName.DISPLAY_NAME);
221         }
222 
223         StringBuilder phoneticNameSb = new StringBuilder();
224         appendValue(phoneticNameSb, values, StructuredName.PHONETIC_FAMILY_NAME);
225         appendValue(phoneticNameSb, values, StructuredName.PHONETIC_MIDDLE_NAME);
226         appendValue(phoneticNameSb, values, StructuredName.PHONETIC_GIVEN_NAME);
227 
228         if (nameSb.length() == 0 && phoneticNameSb.length() == 0) {
229             return null;
230         }
231 
232         // AggregationSuggestions.Builder() became visible in API level 23, so use it if applicable.
233         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
234             final Builder uriBuilder = new AggregationSuggestions.Builder()
235                     .setLimit(mSuggestionsLimit)
236                     .setContactId(mContactId);
237             if (nameSb.length() != 0) {
238                 uriBuilder.addNameParameter(nameSb.toString());
239             }
240             if (phoneticNameSb.length() != 0) {
241                 uriBuilder.addNameParameter(phoneticNameSb.toString());
242             }
243             return uriBuilder.build();
244         }
245 
246         // For previous SDKs, use the backup plan.
247         final AggregationSuggestionsCompat.Builder uriBuilder =
248                 new AggregationSuggestionsCompat.Builder()
249                 .setLimit(mSuggestionsLimit)
250                 .setContactId(mContactId);
251         if (nameSb.length() != 0) {
252             uriBuilder.addNameParameter(nameSb.toString());
253         }
254         if (phoneticNameSb.length() != 0) {
255             uriBuilder.addNameParameter(phoneticNameSb.toString());
256         }
257         return uriBuilder.build();
258     }
259 
appendValue(StringBuilder sb, ValuesDelta values, String column)260     private void appendValue(StringBuilder sb, ValuesDelta values, String column) {
261         String value = values.getAsString(column);
262         if (!TextUtils.isEmpty(value)) {
263             if (sb.length() > 0) {
264                 sb.append(' ');
265             }
266             sb.append(value);
267         }
268     }
269 
handleMessage(Message msg)270     protected void handleMessage(Message msg) {
271         switch (msg.what) {
272             case MESSAGE_RESET:
273                 mSuggestedContactIds = new long[0];
274                 break;
275             case MESSAGE_NAME_CHANGE:
276                 loadAggregationSuggestions((Uri) msg.obj);
277                 break;
278         }
279     }
280 
281     private static final class DataQuery {
282 
283         public static final String SELECTION_PREFIX =
284                 Data.MIMETYPE + " IN ('"
285                     + Phone.CONTENT_ITEM_TYPE + "','"
286                     + Email.CONTENT_ITEM_TYPE + "','"
287                     + StructuredName.CONTENT_ITEM_TYPE + "','"
288                     + Nickname.CONTENT_ITEM_TYPE + "','"
289                     + Photo.CONTENT_ITEM_TYPE + "')"
290                 + " AND " + Data.CONTACT_ID + " IN (";
291 
292         public static final String[] COLUMNS = {
293             Data._ID,
294             Data.CONTACT_ID,
295             Data.LOOKUP_KEY,
296             Data.PHOTO_ID,
297             Data.DISPLAY_NAME,
298             Data.RAW_CONTACT_ID,
299             Data.MIMETYPE,
300             Data.DATA1,
301             Data.IS_SUPER_PRIMARY,
302             Photo.PHOTO,
303             RawContacts.ACCOUNT_TYPE,
304             RawContacts.ACCOUNT_NAME,
305             RawContacts.DATA_SET
306         };
307 
308         public static final int ID = 0;
309         public static final int CONTACT_ID = 1;
310         public static final int LOOKUP_KEY = 2;
311         public static final int PHOTO_ID = 3;
312         public static final int DISPLAY_NAME = 4;
313         public static final int RAW_CONTACT_ID = 5;
314         public static final int MIMETYPE = 6;
315         public static final int DATA1 = 7;
316         public static final int IS_SUPERPRIMARY = 8;
317         public static final int PHOTO = 9;
318         public static final int ACCOUNT_TYPE = 10;
319         public static final int ACCOUNT_NAME = 11;
320         public static final int DATA_SET = 12;
321     }
322 
loadAggregationSuggestions(Uri uri)323     private void loadAggregationSuggestions(Uri uri) {
324         ContentResolver contentResolver = mContext.getContentResolver();
325         Cursor cursor = contentResolver.query(uri, new String[]{Contacts._ID}, null, null, null);
326         if (cursor == null) {
327             return;
328         }
329         try {
330             // If a new request is pending, chuck the result of the previous request
331             if (getHandler().hasMessages(MESSAGE_NAME_CHANGE)) {
332                 return;
333             }
334 
335             boolean changed = updateSuggestedContactIds(cursor);
336             if (!changed) {
337                 return;
338             }
339 
340             StringBuilder sb = new StringBuilder(DataQuery.SELECTION_PREFIX);
341             int count = mSuggestedContactIds.length;
342             for (int i = 0; i < count; i++) {
343                 if (i > 0) {
344                     sb.append(',');
345                 }
346                 sb.append(mSuggestedContactIds[i]);
347             }
348             sb.append(')');
349             sb.toString();
350 
351             Cursor dataCursor = contentResolver.query(Data.CONTENT_URI,
352                     DataQuery.COLUMNS, sb.toString(), null, Data.CONTACT_ID);
353             if (dataCursor != null) {
354                 mMainHandler.sendMessage(mMainHandler.obtainMessage(MESSAGE_DATA_CURSOR, dataCursor));
355             }
356         } finally {
357             cursor.close();
358         }
359     }
360 
updateSuggestedContactIds(final Cursor cursor)361     private boolean updateSuggestedContactIds(final Cursor cursor) {
362         final int count = cursor.getCount();
363         boolean changed = count != mSuggestedContactIds.length;
364         final ArrayList<Long> newIds = new ArrayList<Long>(count);
365         while (cursor.moveToNext()) {
366             final long contactId = cursor.getLong(0);
367             if (!changed &&
368                     Arrays.binarySearch(mSuggestedContactIds, contactId) < 0) {
369                 changed = true;
370             }
371             newIds.add(contactId);
372         }
373 
374         if (changed) {
375             mSuggestedContactIds = new long[newIds.size()];
376             int i = 0;
377             for (final Long newId : newIds) {
378                 mSuggestedContactIds[i++] = newId;
379             }
380             Arrays.sort(mSuggestedContactIds);
381         }
382 
383         return changed;
384     }
385 
deliverNotification(Cursor dataCursor)386     protected void deliverNotification(Cursor dataCursor) {
387         if (mDataCursor != null) {
388             mDataCursor.close();
389         }
390         mDataCursor = dataCursor;
391         if (mListener != null) {
392             mListener.onAggregationSuggestionChange();
393         }
394     }
395 
getSuggestedContactCount()396     public int getSuggestedContactCount() {
397         return mDataCursor != null ? mDataCursor.getCount() : 0;
398     }
399 
getSuggestions()400     public List<Suggestion> getSuggestions() {
401         final ArrayList<Long> visibleContacts = new ArrayList<>();
402         if (mPruneInvisibleContacts) {
403             final Uri contactFilterUri = Data.CONTENT_URI.buildUpon()
404                     .appendQueryParameter(Data.VISIBLE_CONTACTS_ONLY, "true")
405                     .build();
406             final ContentResolver contentResolver = mContext.getContentResolver();
407             final Cursor contactCursor = contentResolver.query(contactFilterUri,
408                     new String[]{Data.CONTACT_ID}, null, null, null);
409             try {
410                 if (contactCursor != null) {
411                     while (contactCursor.moveToNext()) {
412                         final long contactId = contactCursor.getLong(0);
413                         visibleContacts.add(contactId);
414                     }
415                 }
416             } finally {
417                 contactCursor.close();
418             }
419 
420         }
421 
422         ArrayList<Suggestion> list = Lists.newArrayList();
423         if (mDataCursor != null) {
424             Suggestion suggestion = null;
425             long currentContactId = -1;
426             mDataCursor.moveToPosition(-1);
427             while (mDataCursor.moveToNext()) {
428                 long contactId = mDataCursor.getLong(DataQuery.CONTACT_ID);
429                 if (mPruneInvisibleContacts && !visibleContacts.contains(contactId)) {
430                     continue;
431                 }
432                 if (contactId != currentContactId) {
433                     suggestion = new Suggestion();
434                     suggestion.contactId = contactId;
435                     suggestion.name = mDataCursor.getString(DataQuery.DISPLAY_NAME);
436                     suggestion.lookupKey = mDataCursor.getString(DataQuery.LOOKUP_KEY);
437                     suggestion.rawContacts = Lists.newArrayList();
438                     list.add(suggestion);
439                     currentContactId = contactId;
440                 }
441 
442                 long rawContactId = mDataCursor.getLong(DataQuery.RAW_CONTACT_ID);
443                 if (!containsRawContact(suggestion, rawContactId)) {
444                     RawContact rawContact = new RawContact();
445                     rawContact.rawContactId = rawContactId;
446                     rawContact.accountName = mDataCursor.getString(DataQuery.ACCOUNT_NAME);
447                     rawContact.accountType = mDataCursor.getString(DataQuery.ACCOUNT_TYPE);
448                     rawContact.dataSet = mDataCursor.getString(DataQuery.DATA_SET);
449                     suggestion.rawContacts.add(rawContact);
450                 }
451 
452                 String mimetype = mDataCursor.getString(DataQuery.MIMETYPE);
453                 if (Phone.CONTENT_ITEM_TYPE.equals(mimetype)) {
454                     String data = mDataCursor.getString(DataQuery.DATA1);
455                     int superprimary = mDataCursor.getInt(DataQuery.IS_SUPERPRIMARY);
456                     if (!TextUtils.isEmpty(data)
457                             && (superprimary != 0 || suggestion.phoneNumber == null)) {
458                         suggestion.phoneNumber = data;
459                     }
460                 } else if (Email.CONTENT_ITEM_TYPE.equals(mimetype)) {
461                     String data = mDataCursor.getString(DataQuery.DATA1);
462                     int superprimary = mDataCursor.getInt(DataQuery.IS_SUPERPRIMARY);
463                     if (!TextUtils.isEmpty(data)
464                             && (superprimary != 0 || suggestion.emailAddress == null)) {
465                         suggestion.emailAddress = data;
466                     }
467                 } else if (Nickname.CONTENT_ITEM_TYPE.equals(mimetype)) {
468                     String data = mDataCursor.getString(DataQuery.DATA1);
469                     if (!TextUtils.isEmpty(data)) {
470                         suggestion.nickname = data;
471                     }
472                 } else if (Photo.CONTENT_ITEM_TYPE.equals(mimetype)) {
473                     long dataId = mDataCursor.getLong(DataQuery.ID);
474                     long photoId = mDataCursor.getLong(DataQuery.PHOTO_ID);
475                     if (dataId == photoId && !mDataCursor.isNull(DataQuery.PHOTO)) {
476                         suggestion.photo = mDataCursor.getBlob(DataQuery.PHOTO);
477                         suggestion.photoId = photoId;
478                     }
479                 }
480             }
481         }
482         return list;
483     }
484 
containsRawContact(Suggestion suggestion, long rawContactId)485     public boolean containsRawContact(Suggestion suggestion, long rawContactId) {
486         if (suggestion.rawContacts != null) {
487             int count = suggestion.rawContacts.size();
488             for (int i = 0; i < count; i++) {
489                 if (suggestion.rawContacts.get(i).rawContactId == rawContactId) {
490                     return true;
491                 }
492             }
493         }
494         return false;
495     }
496 }
497