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 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