Home | History | Annotate | Download | only in event
      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