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
17package com.android.contacts.editor;
18
19import android.content.ContentResolver;
20import android.content.Context;
21import android.database.ContentObserver;
22import android.database.Cursor;
23import android.net.Uri;
24import android.os.Build;
25import android.os.Handler;
26import android.os.HandlerThread;
27import android.os.Message;
28import android.os.Process;
29import android.provider.ContactsContract.CommonDataKinds.Email;
30import android.provider.ContactsContract.CommonDataKinds.Nickname;
31import android.provider.ContactsContract.CommonDataKinds.Phone;
32import android.provider.ContactsContract.CommonDataKinds.Photo;
33import android.provider.ContactsContract.CommonDataKinds.StructuredName;
34import android.provider.ContactsContract.Contacts;
35import android.provider.ContactsContract.Contacts.AggregationSuggestions;
36import android.provider.ContactsContract.Contacts.AggregationSuggestions.Builder;
37import android.provider.ContactsContract.Data;
38import android.provider.ContactsContract.RawContacts;
39import android.text.TextUtils;
40
41import com.android.contacts.common.model.ValuesDelta;
42import com.android.contacts.compat.AggregationSuggestionsCompat;
43import com.google.common.collect.Lists;
44
45import java.util.ArrayList;
46import java.util.Arrays;
47import java.util.List;
48
49/**
50 * Runs asynchronous queries to obtain aggregation suggestions in the as-you-type mode.
51 */
52public class AggregationSuggestionEngine extends HandlerThread {
53    public static final String TAG = "AggregationSuggestionEngine";
54
55    public interface Listener {
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
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
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 {
93        private SuggestionContentObserver(Handler handler) {
94            super(handler);
95        }
96
97        @Override
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
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
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
146    public void setContactId(long contactId) {
147        if (contactId != mContactId) {
148            mContactId = contactId;
149            reset();
150        }
151    }
152
153    public void setSuggestionsLimit(int suggestionsLimit) {
154        mSuggestionsLimit = suggestionsLimit;
155    }
156
157    public void setPruneInvisibleContacts (boolean pruneInvisibleContacts) {
158        mPruneInvisibleContacts = pruneInvisibleContacts;
159    }
160
161    public void setListener(Listener listener) {
162        mListener = listener;
163    }
164
165    @Override
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
178    public void reset() {
179        Handler handler = getHandler();
180        handler.removeMessages(MESSAGE_NAME_CHANGE);
181        handler.sendEmptyMessage(MESSAGE_RESET);
182    }
183
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
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
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
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
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
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
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
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
396    public int getSuggestedContactCount() {
397        return mDataCursor != null ? mDataCursor.getCount() : 0;
398    }
399
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
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