1/*
2 * Copyright (C) 2009 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.providers.contacts;
18
19import android.app.SearchManager;
20import android.content.res.Resources;
21import android.database.Cursor;
22import android.database.MatrixCursor;
23import android.database.sqlite.SQLiteDatabase;
24import android.net.Uri;
25import android.provider.ContactsContract;
26import android.provider.ContactsContract.CommonDataKinds.Email;
27import android.provider.ContactsContract.CommonDataKinds.Organization;
28import android.provider.ContactsContract.CommonDataKinds.Phone;
29import android.provider.ContactsContract.Contacts;
30import android.provider.ContactsContract.Data;
31import android.provider.ContactsContract.SearchSnippetColumns;
32import android.provider.ContactsContract.StatusUpdates;
33import android.text.TextUtils;
34
35import com.android.i18n.phonenumbers.PhoneNumberUtil;
36import com.android.i18n.phonenumbers.NumberParseException;
37import com.android.providers.contacts.ContactsDatabaseHelper.AggregatedPresenceColumns;
38import com.android.providers.contacts.ContactsDatabaseHelper.ContactsColumns;
39import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
40import com.android.providers.contacts.ContactsDatabaseHelper.Views;
41
42import java.util.ArrayList;
43import java.util.Locale;
44
45/**
46 * Support for global search integration for Contacts.
47 */
48public class GlobalSearchSupport {
49
50    private static final String[] SEARCH_SUGGESTIONS_BASED_ON_PHONE_NUMBER_COLUMNS = {
51            "_id",
52            SearchManager.SUGGEST_COLUMN_TEXT_1,
53            SearchManager.SUGGEST_COLUMN_TEXT_2,
54            SearchManager.SUGGEST_COLUMN_ICON_1,
55            SearchManager.SUGGEST_COLUMN_INTENT_DATA,
56            SearchManager.SUGGEST_COLUMN_INTENT_ACTION,
57            SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
58    };
59
60    private static final String[] SEARCH_SUGGESTIONS_BASED_ON_NAME_COLUMNS = {
61            "_id",
62            SearchManager.SUGGEST_COLUMN_TEXT_1,
63            SearchManager.SUGGEST_COLUMN_TEXT_2,
64            SearchManager.SUGGEST_COLUMN_ICON_1,
65            SearchManager.SUGGEST_COLUMN_ICON_2,
66            SearchManager.SUGGEST_COLUMN_INTENT_DATA,
67            SearchManager.SUGGEST_COLUMN_SHORTCUT_ID,
68            SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA,
69            SearchManager.SUGGEST_COLUMN_LAST_ACCESS_HINT,
70    };
71
72    private static final char SNIPPET_START_MATCH = '\u0001';
73    private static final char SNIPPET_END_MATCH = '\u0001';
74    private static final String SNIPPET_ELLIPSIS = "\u2026";
75    private static final int SNIPPET_MAX_TOKENS = 5;
76
77    private static final String PRESENCE_SQL =
78        "(SELECT " + StatusUpdates.PRESENCE +
79        " FROM " + Tables.AGGREGATED_PRESENCE +
80        " WHERE " + AggregatedPresenceColumns.CONTACT_ID + "=" + ContactsColumns.CONCRETE_ID + ")";
81
82    // Current contacts - those contacted within the last 3 days (in seconds)
83    private static final long CURRENT_CONTACTS = 3 * 24 * 60 * 60;
84
85    // Recent contacts - those contacted within the last 30 days (in seconds)
86    private static final long RECENT_CONTACTS = 30 * 24 * 60 * 60;
87
88    private static final String TIME_SINCE_LAST_CONTACTED =
89            "(strftime('%s', 'now') - contacts." + Contacts.LAST_TIME_CONTACTED + "/1000)";
90
91    /*
92     * See {@link ContactsProvider2#EMAIL_FILTER_SORT_ORDER} for the discussion of this
93     * sorting order.
94     */
95    private static final String SORT_ORDER =
96        "(CASE WHEN contacts." + Contacts.STARRED + "=1 THEN 0 ELSE 1 END), "
97        + "(CASE WHEN " + TIME_SINCE_LAST_CONTACTED + " < " + CURRENT_CONTACTS + " THEN 0 "
98        + " WHEN " + TIME_SINCE_LAST_CONTACTED + " < " + RECENT_CONTACTS + " THEN 1 "
99        + " ELSE 2 END),"
100        + "contacts." + Contacts.TIMES_CONTACTED + " DESC, "
101        + "contacts." + Contacts.DISPLAY_NAME_PRIMARY + ", "
102        + "contacts." + Contacts._ID;
103
104    private static final String RECENTLY_CONTACTED =
105        TIME_SINCE_LAST_CONTACTED + " < " + RECENT_CONTACTS;
106
107    private static class SearchSuggestion {
108        long contactId;
109        String photoUri;
110        String lookupKey;
111        int presence = -1;
112        String text1;
113        String text2;
114        String icon1;
115        String icon2;
116        String filter;
117        String lastAccessTime;
118
119        @SuppressWarnings({"unchecked"})
120        public ArrayList asList(String[] projection) {
121            if (photoUri != null) {
122                icon1 = photoUri.toString();
123            } else {
124                icon1 = String.valueOf(com.android.internal.R.drawable.ic_contact_picture);
125            }
126
127            if (presence != -1) {
128                icon2 = String.valueOf(StatusUpdates.getPresenceIconResourceId(presence));
129            }
130
131            ArrayList<Object> list = new ArrayList<Object>();
132            if (projection == null) {
133                list.add(contactId);
134                list.add(text1);
135                list.add(text2);
136                list.add(icon1);
137                list.add(icon2);
138                list.add(buildUri());
139                list.add(lookupKey);
140                list.add(filter);
141                list.add(lastAccessTime);
142            } else {
143                for (int i = 0; i < projection.length; i++) {
144                    addColumnValue(list, projection[i]);
145                }
146            }
147            return list;
148        }
149
150        private void addColumnValue(ArrayList<Object> list, String column) {
151            if ("_id".equals(column)) {
152                list.add(contactId);
153            } else if (SearchManager.SUGGEST_COLUMN_TEXT_1.equals(column)) {
154                list.add(text1);
155            } else if (SearchManager.SUGGEST_COLUMN_TEXT_2.equals(column)) {
156                list.add(text2);
157            } else if (SearchManager.SUGGEST_COLUMN_ICON_1.equals(column)) {
158                list.add(icon1);
159            } else if (SearchManager.SUGGEST_COLUMN_ICON_2.equals(column)) {
160                list.add(icon2);
161            } else if (SearchManager.SUGGEST_COLUMN_INTENT_DATA.equals(column)) {
162                list.add(buildUri());
163            } else if (SearchManager.SUGGEST_COLUMN_INTENT_DATA_ID.equals(column)) {
164                list.add(lookupKey);
165            } else if (SearchManager.SUGGEST_COLUMN_SHORTCUT_ID.equals(column)) {
166                list.add(lookupKey);
167            } else if (SearchManager.SUGGEST_COLUMN_INTENT_EXTRA_DATA.equals(column)) {
168                list.add(filter);
169            } else if (SearchManager.SUGGEST_COLUMN_LAST_ACCESS_HINT.equals(column)) {
170                list.add(lastAccessTime);
171            } else {
172                throw new IllegalArgumentException("Invalid column name: " + column);
173            }
174        }
175
176        private String buildUri() {
177            return Contacts.getLookupUri(contactId, lookupKey).toString();
178        }
179    }
180
181    private final ContactsProvider2 mContactsProvider;
182    private final PhoneNumberUtil mPhoneNumberUtil;
183
184    @SuppressWarnings("all")
185    public GlobalSearchSupport(ContactsProvider2 contactsProvider) {
186        mContactsProvider = contactsProvider;
187        mPhoneNumberUtil = PhoneNumberUtil.getInstance();
188
189        // To ensure the data column position. This is dead code if properly configured.
190        if (Organization.COMPANY != Data.DATA1 || Phone.NUMBER != Data.DATA1
191                || Email.DATA != Data.DATA1) {
192            throw new AssertionError("Some of ContactsContract.CommonDataKinds class primary"
193                    + " data is not in DATA1 column");
194        }
195    }
196
197    public Cursor handleSearchSuggestionsQuery(
198            SQLiteDatabase db, Uri uri, String[] projection, String limit) {
199        final String searchClause;
200        final String selection;
201        if (uri.getPathSegments().size() <= 1) {
202            searchClause = null;
203            selection = RECENTLY_CONTACTED;
204        } else {
205            searchClause = uri.getLastPathSegment();
206            selection = null;
207        }
208
209        if (mContactsProvider.isPhone() && isPhoneNumber(searchClause)) {
210            return buildCursorForSearchSuggestionsBasedOnPhoneNumber(searchClause);
211        } else {
212            return buildCursorForSearchSuggestionsBasedOnFilter(
213                    db, projection, selection, searchClause, limit);
214        }
215    }
216
217    private boolean isPhoneNumber(String query) {
218        return mPhoneNumberUtil.isPossibleNumber(query, Locale.getDefault().getCountry());
219    }
220
221    /**
222     * Returns a search suggestions cursor for the contact bearing the provided lookup key.  If the
223     * lookup key cannot be found in the database, the contact name is decoded from the lookup key
224     * and used to re-identify the contact.  If the contact still cannot be found, an empty cursor
225     * is returned.
226     *
227     * <p>Note that if {@code lookupKey} is not a valid lookup key, an empty cursor is returned
228     * silently.  This would occur with old-style shortcuts that were created using the contact id
229     * instead of the lookup key.
230     */
231    public Cursor handleSearchShortcutRefresh(SQLiteDatabase db, String[] projection,
232            String lookupKey, String filter) {
233        long contactId;
234        try {
235            contactId = mContactsProvider.lookupContactIdByLookupKey(db, lookupKey);
236        } catch (IllegalArgumentException e) {
237            contactId = -1L;
238        }
239        return buildCursorForSearchSuggestionsBasedOnFilter(
240                db, projection, ContactsColumns.CONCRETE_ID + "=" + contactId, filter, null);
241    }
242
243    private boolean isVoiceCapable() {
244        // this copied from com.android.phone.PhoneApp.onCreate():
245
246        // "voice capable" flag.
247        // This flag currently comes from a resource (which is
248        // overrideable on a per-product basis):
249        return mContactsProvider.getContext().getResources()
250                .getBoolean(com.android.internal.R.bool.config_voice_capable);
251        // ...but this might eventually become a PackageManager "system
252        // feature" instead, in which case we'd do something like:
253        // return
254        //   getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY_VOICE_CALLS);
255    }
256
257    private Cursor buildCursorForSearchSuggestionsBasedOnPhoneNumber(String searchClause) {
258        MatrixCursor cursor = new MatrixCursor(SEARCH_SUGGESTIONS_BASED_ON_PHONE_NUMBER_COLUMNS);
259        Resources r = mContactsProvider.getContext().getResources();
260        String s;
261        int i;
262
263        if (isVoiceCapable()) {
264            ArrayList<Object> dialNumber = new ArrayList<Object>();
265            dialNumber.add(0);  // _id
266            s = r.getString(com.android.internal.R.string.dial_number_using, searchClause);
267            i = s.indexOf('\n');
268            if (i < 0) {
269                dialNumber.add(s);
270                dialNumber.add("");
271            } else {
272                dialNumber.add(s.substring(0, i));
273                dialNumber.add(s.substring(i + 1));
274            }
275            dialNumber.add(String.valueOf(com.android.internal.R.drawable.call_contact));
276            dialNumber.add("tel:" + searchClause);
277            dialNumber.add(ContactsContract.Intents.SEARCH_SUGGESTION_DIAL_NUMBER_CLICKED);
278            dialNumber.add(null);
279            cursor.addRow(dialNumber);
280        }
281
282        ArrayList<Object> createContact = new ArrayList<Object>();
283        createContact.add(1);  // _id
284        s = r.getString(com.android.internal.R.string.create_contact_using, searchClause);
285        i = s.indexOf('\n');
286        if (i < 0) {
287            createContact.add(s);
288            createContact.add("");
289        } else {
290            createContact.add(s.substring(0, i));
291            createContact.add(s.substring(i + 1));
292        }
293        createContact.add(String.valueOf(com.android.internal.R.drawable.create_contact));
294        createContact.add("tel:" + searchClause);
295        createContact.add(ContactsContract.Intents.SEARCH_SUGGESTION_CREATE_CONTACT_CLICKED);
296        createContact.add(SearchManager.SUGGEST_NEVER_MAKE_SHORTCUT);
297        cursor.addRow(createContact);
298
299        return cursor;
300    }
301
302    private Cursor buildCursorForSearchSuggestionsBasedOnFilter(SQLiteDatabase db,
303            String[] projection, String selection, String filter, String limit) {
304        MatrixCursor cursor = new MatrixCursor(
305                projection != null ? projection : SEARCH_SUGGESTIONS_BASED_ON_NAME_COLUMNS);
306        StringBuilder sb = new StringBuilder();
307        final boolean haveFilter = !TextUtils.isEmpty(filter);
308        sb.append("SELECT "
309                        + Contacts._ID + ", "
310                        + Contacts.LOOKUP_KEY + ", "
311                        + Contacts.PHOTO_THUMBNAIL_URI + ", "
312                        + Contacts.DISPLAY_NAME + ", "
313                        + PRESENCE_SQL + " AS " + Contacts.CONTACT_PRESENCE + ", "
314                        + Contacts.LAST_TIME_CONTACTED);
315        if (haveFilter) {
316            sb.append(", " + SearchSnippetColumns.SNIPPET);
317        }
318        sb.append(" FROM ");
319        sb.append(Views.CONTACTS);
320        sb.append(" AS contacts");
321        if (haveFilter) {
322            mContactsProvider.appendSearchIndexJoin(sb, filter, true,
323                    String.valueOf(SNIPPET_START_MATCH), String.valueOf(SNIPPET_END_MATCH),
324                    SNIPPET_ELLIPSIS, SNIPPET_MAX_TOKENS, false);
325        }
326        if (selection != null) {
327            sb.append(" WHERE ").append(selection);
328        }
329        sb.append(" ORDER BY " + SORT_ORDER);
330        if (limit != null) {
331            sb.append(" LIMIT " + limit);
332        }
333        Cursor c = db.rawQuery(sb.toString(), null);
334        SearchSuggestion suggestion = new SearchSuggestion();
335        suggestion.filter = filter;
336        try {
337            while (c.moveToNext()) {
338                suggestion.contactId = c.getLong(0);
339                suggestion.lookupKey = c.getString(1);
340                suggestion.photoUri = c.getString(2);
341                suggestion.text1 = c.getString(3);
342                suggestion.presence = c.isNull(4) ? -1 : c.getInt(4);
343                suggestion.lastAccessTime = c.getString(5);
344                if (haveFilter) {
345                    suggestion.text2 = shortenSnippet(c.getString(6));
346                }
347                cursor.addRow(suggestion.asList(projection));
348            }
349        } finally {
350            c.close();
351        }
352        return cursor;
353    }
354
355    private String shortenSnippet(final String snippet) {
356        if (snippet == null) {
357            return null;
358        }
359
360        int from = 0;
361        int to = snippet.length();
362        int start = snippet.indexOf(SNIPPET_START_MATCH);
363        if (start == -1) {
364            return null;
365        }
366
367        int firstNl = snippet.lastIndexOf('\n', start);
368        if (firstNl != -1) {
369            from = firstNl + 1;
370        }
371        int end = snippet.lastIndexOf(SNIPPET_END_MATCH);
372        if (end != -1) {
373            int lastNl = snippet.indexOf('\n', end);
374            if (lastNl != -1) {
375                to = lastNl;
376            }
377        }
378
379        StringBuilder sb = new StringBuilder();
380        for (int i = from; i < to; i++) {
381            char c = snippet.charAt(i);
382            if (c != SNIPPET_START_MATCH && c != SNIPPET_END_MATCH) {
383                sb.append(c);
384            }
385        }
386        return sb.toString();
387    }
388}
389