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 
17 package com.android.calendar.event;
18 
19 import android.content.ContentResolver;
20 import android.content.ContentUris;
21 import android.content.Context;
22 import android.database.Cursor;
23 import android.graphics.Bitmap;
24 import android.graphics.BitmapFactory;
25 import android.net.Uri;
26 import android.os.AsyncTask;
27 import android.provider.CalendarContract.Events;
28 import android.provider.ContactsContract.CommonDataKinds;
29 import android.provider.ContactsContract.Contacts;
30 import android.provider.ContactsContract.RawContacts;
31 import android.text.TextUtils;
32 import android.util.Log;
33 import android.view.LayoutInflater;
34 import android.view.View;
35 import android.view.ViewGroup;
36 import android.widget.ArrayAdapter;
37 import android.widget.Filter;
38 import android.widget.Filterable;
39 import android.widget.ImageView;
40 import android.widget.TextView;
41 
42 import com.android.calendar.R;
43 
44 import java.io.InputStream;
45 import java.util.ArrayList;
46 import java.util.HashMap;
47 import java.util.HashSet;
48 import java.util.List;
49 import java.util.Map;
50 import java.util.TreeSet;
51 import 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  */
59 public 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 
Result(String displayName, String address, Integer defaultIcon, Uri contactPhotoUri)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
toString()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      */
EventLocationAdapter(Context context)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
getCount()157     public int getCount() {
158         return mResultList.size();
159     }
160 
161     @Override
getItem(int index)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
getView(final int position, final View convertView, final ViewGroup parent)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.
asyncLoadPhotoAndUpdateView(final Uri contactPhotoUri, final ImageView imageView)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
getFilter()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
performFiltering(CharSequence constraint)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
publishResults(CharSequence constraint, FilterResults results)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      */
queryContacts(ContentResolver resolver, String input, HashSet<String> addressesRetVal)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      */
queryRecentLocations(ContentResolver resolver, String input)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      */
processLocationsQueryResults(Cursor cursor)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