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.compat.AggregationSuggestionsCompat;
42import com.android.contacts.model.ValuesDelta;
43import com.android.contacts.model.account.AccountWithDataSet;
44
45import com.google.common.base.MoreObjects;
46import com.google.common.collect.Lists;
47
48import java.util.ArrayList;
49import java.util.Arrays;
50import java.util.List;
51
52/**
53 * Runs asynchronous queries to obtain aggregation suggestions in the as-you-type mode.
54 */
55public class AggregationSuggestionEngine extends HandlerThread {
56    public interface Listener {
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
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 {
86        private SuggestionContentObserver(Handler handler) {
87            super(handler);
88        }
89
90        @Override
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
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
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
139    public void setContactId(long contactId) {
140        if (contactId != mContactId) {
141            mContactId = contactId;
142            reset();
143        }
144    }
145
146    public void setAccountFilter(AccountWithDataSet account) {
147        mAccountFilter = account;
148    }
149
150    public void setListener(Listener listener) {
151        mListener = listener;
152    }
153
154    @Override
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
167    public void reset() {
168        Handler handler = getHandler();
169        handler.removeMessages(MESSAGE_NAME_CHANGE);
170        handler.sendEmptyMessage(MESSAGE_RESET);
171    }
172
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
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
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
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
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
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
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
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
374    public int getSuggestedContactCount() {
375        return mDataCursor != null ? mDataCursor.getCount() : 0;
376    }
377
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