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