1/*
2 * Copyright (C) 2012 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.calendar.event;
18
19import android.content.ContentResolver;
20import android.content.ContentUris;
21import android.content.Context;
22import android.database.Cursor;
23import android.graphics.Bitmap;
24import android.graphics.BitmapFactory;
25import android.net.Uri;
26import android.os.AsyncTask;
27import android.provider.CalendarContract.Events;
28import android.provider.ContactsContract.CommonDataKinds;
29import android.provider.ContactsContract.Contacts;
30import android.provider.ContactsContract.RawContacts;
31import android.text.TextUtils;
32import android.util.Log;
33import android.view.LayoutInflater;
34import android.view.View;
35import android.view.ViewGroup;
36import android.widget.ArrayAdapter;
37import android.widget.Filter;
38import android.widget.Filterable;
39import android.widget.ImageView;
40import android.widget.TextView;
41
42import com.android.calendar.R;
43
44import java.io.InputStream;
45import java.util.ArrayList;
46import java.util.HashMap;
47import java.util.HashSet;
48import java.util.List;
49import java.util.Map;
50import java.util.TreeSet;
51import java.util.concurrent.ExecutionException;
52
53// TODO: limit length of dropdown to stop at the soft keyboard
54// TODO: history icon resize asset
55
56/**
57 * An adapter for autocomplete of the location field in edit-event view.
58 */
59public class EventLocationAdapter extends ArrayAdapter<EventLocationAdapter.Result>
60        implements Filterable {
61    private static final String TAG = "EventLocationAdapter";
62
63    /**
64     * Internal class for containing info for an item in the auto-complete results.
65     */
66    public static class Result {
67        private final String mName;
68        private final String mAddress;
69
70        // The default image resource for the icon.  This will be null if there should
71        // be no icon (if multiple listings for a contact, only the first one should have the
72        // photo icon).
73        private final Integer mDefaultIcon;
74
75        // The contact photo to use for the icon.  This will override the default icon.
76        private final Uri mContactPhotoUri;
77
78        public Result(String displayName, String address, Integer defaultIcon,
79                Uri contactPhotoUri) {
80            this.mName = displayName;
81            this.mAddress = address;
82            this.mDefaultIcon = defaultIcon;
83            this.mContactPhotoUri = contactPhotoUri;
84        }
85
86        /**
87         * This is the autocompleted text.
88         */
89        @Override
90        public String toString() {
91            return mAddress;
92        }
93    }
94    private static ArrayList<Result> EMPTY_LIST = new ArrayList<Result>();
95
96    // Constants for contacts query:
97    // SELECT ... FROM view_data data WHERE ((data1 LIKE 'input%' OR data1 LIKE '%input%' OR
98    // display_name LIKE 'input%' OR display_name LIKE '%input%' )) ORDER BY display_name ASC
99    private static final String[] CONTACTS_PROJECTION = new String[] {
100        CommonDataKinds.StructuredPostal._ID,
101        Contacts.DISPLAY_NAME,
102        CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS,
103        RawContacts.CONTACT_ID,
104        Contacts.PHOTO_ID,
105    };
106    private static final int CONTACTS_INDEX_ID = 0;
107    private static final int CONTACTS_INDEX_DISPLAY_NAME = 1;
108    private static final int CONTACTS_INDEX_ADDRESS = 2;
109    private static final int CONTACTS_INDEX_CONTACT_ID = 3;
110    private static final int CONTACTS_INDEX_PHOTO_ID = 4;
111    // TODO: Only query visible contacts?
112    private static final String CONTACTS_WHERE = new StringBuilder()
113            .append("(")
114            .append(CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS)
115            .append(" LIKE ? OR ")
116            .append(CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS)
117            .append(" LIKE ? OR ")
118            .append(Contacts.DISPLAY_NAME)
119            .append(" LIKE ? OR ")
120            .append(Contacts.DISPLAY_NAME)
121            .append(" LIKE ? )")
122            .toString();
123
124    // Constants for recent locations query (in Events table):
125    // SELECT ... FROM view_events WHERE (eventLocation LIKE 'input%') ORDER BY _id DESC
126    private static final String[] EVENT_PROJECTION = new String[] {
127        Events._ID,
128        Events.EVENT_LOCATION,
129        Events.VISIBLE,
130    };
131    private static final int EVENT_INDEX_ID = 0;
132    private static final int EVENT_INDEX_LOCATION = 1;
133    private static final int EVENT_INDEX_VISIBLE = 2;
134    private static final String LOCATION_WHERE = Events.VISIBLE + "=? AND "
135            + Events.EVENT_LOCATION + " LIKE ?";
136    private static final int MAX_LOCATION_SUGGESTIONS = 4;
137
138    private final ContentResolver mResolver;
139    private final LayoutInflater mInflater;
140    private final ArrayList<Result> mResultList = new ArrayList<Result>();
141
142    // The cache for contacts photos.  We don't have to worry about clearing this, as a
143    // new adapter is created for every edit event.
144    private final Map<Uri, Bitmap> mPhotoCache = new HashMap<Uri, Bitmap>();
145
146    /**
147     * Constructor.
148     */
149    public EventLocationAdapter(Context context) {
150        super(context, R.layout.location_dropdown_item, EMPTY_LIST);
151
152        mResolver = context.getContentResolver();
153        mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
154    }
155
156    @Override
157    public int getCount() {
158        return mResultList.size();
159    }
160
161    @Override
162    public Result getItem(int index) {
163        if (index < mResultList.size()) {
164            return mResultList.get(index);
165        } else {
166            return null;
167        }
168    }
169
170    @Override
171    public View getView(final int position, final View convertView, final ViewGroup parent) {
172        View view = convertView;
173        if (view == null) {
174            view = mInflater.inflate(R.layout.location_dropdown_item, parent, false);
175        }
176        final Result result = getItem(position);
177        if (result == null) {
178            return view;
179        }
180
181        // Update the display name in the item in auto-complete list.
182        TextView nameView = (TextView) view.findViewById(R.id.location_name);
183        if (nameView != null) {
184            if (result.mName == null) {
185                nameView.setVisibility(View.GONE);
186            } else {
187                nameView.setVisibility(View.VISIBLE);
188                nameView.setText(result.mName);
189            }
190        }
191
192        // Update the address line.
193        TextView addressView = (TextView) view.findViewById(R.id.location_address);
194        if (addressView != null) {
195            addressView.setText(result.mAddress);
196        }
197
198        // Update the icon.
199        final ImageView imageView = (ImageView) view.findViewById(R.id.icon);
200        if (imageView != null) {
201            if (result.mDefaultIcon == null) {
202                imageView.setVisibility(View.INVISIBLE);
203            } else {
204                imageView.setVisibility(View.VISIBLE);
205                imageView.setImageResource(result.mDefaultIcon);
206
207                // Save the URI on the view, so we can check against it later when updating
208                // the image.  Otherwise the async image update with using 'convertView' above
209                // resulted in the wrong list items being updated.
210                imageView.setTag(result.mContactPhotoUri);
211                if (result.mContactPhotoUri != null) {
212                    Bitmap cachedPhoto = mPhotoCache.get(result.mContactPhotoUri);
213                    if (cachedPhoto != null) {
214                        // Use photo in cache.
215                        imageView.setImageBitmap(cachedPhoto);
216                    } else {
217                        // Asynchronously load photo and update.
218                        asyncLoadPhotoAndUpdateView(result.mContactPhotoUri, imageView);
219                    }
220                }
221            }
222        }
223        return view;
224    }
225
226    // TODO: Refactor to share code with ContactsAsyncHelper.
227    private void asyncLoadPhotoAndUpdateView(final Uri contactPhotoUri,
228            final ImageView imageView) {
229        AsyncTask<Void, Void, Bitmap> photoUpdaterTask =
230                new AsyncTask<Void, Void, Bitmap>() {
231            @Override
232            protected Bitmap doInBackground(Void... params) {
233                Bitmap photo = null;
234                InputStream imageStream = Contacts.openContactPhotoInputStream(
235                        mResolver, contactPhotoUri);
236                if (imageStream != null) {
237                    photo = BitmapFactory.decodeStream(imageStream);
238                    mPhotoCache.put(contactPhotoUri, photo);
239                }
240                return photo;
241            }
242
243            @Override
244            public void onPostExecute(Bitmap photo) {
245                // The View may have already been reused (because using 'convertView' above), so
246                // we must check the URI is as expected before setting the icon, or we may be
247                // setting the icon in other items.
248                if (photo != null && imageView.getTag() == contactPhotoUri) {
249                    imageView.setImageBitmap(photo);
250                }
251            }
252        }.execute();
253    }
254
255    /**
256     * Return filter for matching against contacts info and recent locations.
257     */
258    @Override
259    public Filter getFilter() {
260        return new LocationFilter();
261    }
262
263    /**
264     * Filter implementation for matching the input string against contacts info and
265     * recent locations.
266     */
267    public class LocationFilter extends Filter {
268
269        @Override
270        protected FilterResults performFiltering(CharSequence constraint) {
271            long startTime = System.currentTimeMillis();
272            final String filter = constraint == null ? "" : constraint.toString();
273            if (filter.isEmpty()) {
274                return null;
275            }
276
277            // Start the recent locations query (async).
278            AsyncTask<Void, Void, List<Result>> locationsQueryTask =
279                    new AsyncTask<Void, Void, List<Result>>() {
280                @Override
281                protected List<Result> doInBackground(Void... params) {
282                    return queryRecentLocations(mResolver, filter);
283                }
284            }.execute();
285
286            // Perform the contacts query (sync).
287            HashSet<String> contactsAddresses = new HashSet<String>();
288            List<Result> contacts = queryContacts(mResolver, filter, contactsAddresses);
289
290            ArrayList<Result> resultList = new ArrayList<Result>();
291            try {
292                // Wait for the locations query.
293                List<Result> recentLocations = locationsQueryTask.get();
294
295                // Add the matched recent locations to returned results.  If a match exists in
296                // both the recent locations query and the contacts addresses, only display it
297                // as a contacts match.
298                for (Result recentLocation : recentLocations) {
299                    if (recentLocation.mAddress != null &&
300                            !contactsAddresses.contains(recentLocation.mAddress)) {
301                        resultList.add(recentLocation);
302                    }
303                }
304            } catch (ExecutionException e) {
305                Log.e(TAG, "Failed waiting for locations query results.", e);
306            } catch (InterruptedException e) {
307                Log.e(TAG, "Failed waiting for locations query results.", e);
308            }
309
310            // Add all the contacts matches to returned results.
311            if (contacts != null) {
312                resultList.addAll(contacts);
313            }
314
315            // Log the processing duration.
316            if (Log.isLoggable(TAG, Log.DEBUG)) {
317                long duration = System.currentTimeMillis() - startTime;
318                StringBuilder msg = new StringBuilder();
319                msg.append("Autocomplete of ").append(constraint);
320                msg.append(": location query match took ").append(duration).append("ms ");
321                msg.append("(").append(resultList.size()).append(" results)");
322                Log.d(TAG, msg.toString());
323            }
324
325            final FilterResults filterResults = new FilterResults();
326            filterResults.values = resultList;
327            filterResults.count = resultList.size();
328            return filterResults;
329        }
330
331        @Override
332        protected void publishResults(CharSequence constraint, FilterResults results) {
333            mResultList.clear();
334            if (results != null && results.count > 0) {
335                mResultList.addAll((ArrayList<Result>) results.values);
336                notifyDataSetChanged();
337            } else {
338                notifyDataSetInvalidated();
339            }
340        }
341    }
342
343    /**
344     * Matches the input string against contacts names and addresses.
345     *
346     * @param resolver The content resolver.
347     * @param input The user-typed input string.
348     * @param addressesRetVal The addresses in the returned result are also returned here
349     *     for faster lookup.  Pass in an empty set.
350     * @return Ordered list of all the matched results.  If there are multiple address matches
351     *     for the same contact, they will be listed together in individual items, with only
352     *     the first item containing a name/icon.
353     */
354    private static List<Result> queryContacts(ContentResolver resolver, String input,
355            HashSet<String> addressesRetVal) {
356        String where = null;
357        String[] whereArgs = null;
358
359        // Match any word in contact name or address.
360        if (!TextUtils.isEmpty(input)) {
361            where = CONTACTS_WHERE;
362            String param1 = input + "%";
363            String param2 = "% " + input + "%";
364            whereArgs = new String[] {param1, param2, param1, param2};
365        }
366
367        // Perform the query.
368        Cursor c = resolver.query(CommonDataKinds.StructuredPostal.CONTENT_URI,
369                CONTACTS_PROJECTION, where, whereArgs, Contacts.DISPLAY_NAME + " ASC");
370
371        // Process results.  Group together addresses for the same contact.
372        try {
373            Map<String, List<Result>> nameToAddresses = new HashMap<String, List<Result>>();
374            c.moveToPosition(-1);
375            while (c.moveToNext()) {
376                String name = c.getString(CONTACTS_INDEX_DISPLAY_NAME);
377                String address = c.getString(CONTACTS_INDEX_ADDRESS);
378                if (name != null) {
379
380                    List<Result> addressesForName = nameToAddresses.get(name);
381                    Result result;
382                    if (addressesForName == null) {
383                        // Determine if there is a photo for the icon.
384                        Uri contactPhotoUri = null;
385                        if (c.getLong(CONTACTS_INDEX_PHOTO_ID) > 0) {
386                            contactPhotoUri = ContentUris.withAppendedId(Contacts.CONTENT_URI,
387                                    c.getLong(CONTACTS_INDEX_CONTACT_ID));
388                        }
389
390                        // First listing for a distinct contact should have the name/icon.
391                        addressesForName = new ArrayList<Result>();
392                        nameToAddresses.put(name, addressesForName);
393                        result = new Result(name, address, R.drawable.ic_contact_picture,
394                                contactPhotoUri);
395                    } else {
396                        // Do not include name/icon in subsequent listings for the same contact.
397                        result = new Result(null, address, null, null);
398                    }
399
400                    addressesForName.add(result);
401                    addressesRetVal.add(address);
402                }
403            }
404
405            // Return the list of results.
406            List<Result> allResults = new ArrayList<Result>();
407            for (List<Result> result : nameToAddresses.values()) {
408                allResults.addAll(result);
409            }
410            return allResults;
411
412        } finally {
413            if (c != null) {
414                c.close();
415            }
416        }
417    }
418
419    /**
420     * Matches the input string against recent locations.
421     */
422    private static List<Result> queryRecentLocations(ContentResolver resolver, String input) {
423        // TODO: also match each word in the address?
424        String filter = input == null ? "" : input + "%";
425        if (filter.isEmpty()) {
426            return null;
427        }
428
429        // Query all locations prefixed with the constraint.  There is no way to insert
430        // 'DISTINCT' or 'GROUP BY' to get rid of dupes, so use post-processing to
431        // remove dupes.  We will order query results by descending event ID to show
432        // results that were most recently inputed.
433        Cursor c = resolver.query(Events.CONTENT_URI, EVENT_PROJECTION, LOCATION_WHERE,
434                new String[] { "1", filter }, Events._ID + " DESC");
435        try {
436            List<Result> recentLocations = null;
437            if (c != null) {
438                // Post process query results.
439                recentLocations = processLocationsQueryResults(c);
440            }
441            return recentLocations;
442        } finally {
443            if (c != null) {
444                c.close();
445            }
446        }
447    }
448
449    /**
450     * Post-process the query results to return the first MAX_LOCATION_SUGGESTIONS
451     * unique locations in alphabetical order.
452     *
453     * TODO: Refactor to share code with the recent titles auto-complete.
454     */
455    private static List<Result> processLocationsQueryResults(Cursor cursor) {
456        TreeSet<String> locations = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER);
457        cursor.moveToPosition(-1);
458
459        // Remove dupes.
460        while ((locations.size() < MAX_LOCATION_SUGGESTIONS) && cursor.moveToNext()) {
461            String location = cursor.getString(EVENT_INDEX_LOCATION).trim();
462            locations.add(location);
463        }
464
465        // Copy the sorted results.
466        List<Result> results = new ArrayList<Result>();
467        for (String location : locations) {
468            results.add(new Result(null, location, R.drawable.ic_history_holo_light, null));
469        }
470        return results;
471    }
472}
473