/* * Copyright (C) 2012 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.calendar.event; import android.content.ContentResolver; import android.content.ContentUris; import android.content.Context; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.AsyncTask; import android.provider.CalendarContract.Events; import android.provider.ContactsContract.CommonDataKinds; import android.provider.ContactsContract.Contacts; import android.provider.ContactsContract.RawContacts; import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; import android.widget.Filter; import android.widget.Filterable; import android.widget.ImageView; import android.widget.TextView; import com.android.calendar.R; import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.TreeSet; import java.util.concurrent.ExecutionException; // TODO: limit length of dropdown to stop at the soft keyboard // TODO: history icon resize asset /** * An adapter for autocomplete of the location field in edit-event view. */ public class EventLocationAdapter extends ArrayAdapter<EventLocationAdapter.Result> implements Filterable { private static final String TAG = "EventLocationAdapter"; /** * Internal class for containing info for an item in the auto-complete results. */ public static class Result { private final String mName; private final String mAddress; // The default image resource for the icon. This will be null if there should // be no icon (if multiple listings for a contact, only the first one should have the // photo icon). private final Integer mDefaultIcon; // The contact photo to use for the icon. This will override the default icon. private final Uri mContactPhotoUri; public Result(String displayName, String address, Integer defaultIcon, Uri contactPhotoUri) { this.mName = displayName; this.mAddress = address; this.mDefaultIcon = defaultIcon; this.mContactPhotoUri = contactPhotoUri; } /** * This is the autocompleted text. */ @Override public String toString() { return mAddress; } } private static ArrayList<Result> EMPTY_LIST = new ArrayList<Result>(); // Constants for contacts query: // SELECT ... FROM view_data data WHERE ((data1 LIKE 'input%' OR data1 LIKE '%input%' OR // display_name LIKE 'input%' OR display_name LIKE '%input%' )) ORDER BY display_name ASC private static final String[] CONTACTS_PROJECTION = new String[] { CommonDataKinds.StructuredPostal._ID, Contacts.DISPLAY_NAME, CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS, RawContacts.CONTACT_ID, Contacts.PHOTO_ID, }; private static final int CONTACTS_INDEX_ID = 0; private static final int CONTACTS_INDEX_DISPLAY_NAME = 1; private static final int CONTACTS_INDEX_ADDRESS = 2; private static final int CONTACTS_INDEX_CONTACT_ID = 3; private static final int CONTACTS_INDEX_PHOTO_ID = 4; // TODO: Only query visible contacts? private static final String CONTACTS_WHERE = new StringBuilder() .append("(") .append(CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS) .append(" LIKE ? OR ") .append(CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS) .append(" LIKE ? OR ") .append(Contacts.DISPLAY_NAME) .append(" LIKE ? OR ") .append(Contacts.DISPLAY_NAME) .append(" LIKE ? )") .toString(); // Constants for recent locations query (in Events table): // SELECT ... FROM view_events WHERE (eventLocation LIKE 'input%') ORDER BY _id DESC private static final String[] EVENT_PROJECTION = new String[] { Events._ID, Events.EVENT_LOCATION, Events.VISIBLE, }; private static final int EVENT_INDEX_ID = 0; private static final int EVENT_INDEX_LOCATION = 1; private static final int EVENT_INDEX_VISIBLE = 2; private static final String LOCATION_WHERE = Events.VISIBLE + "=? AND " + Events.EVENT_LOCATION + " LIKE ?"; private static final int MAX_LOCATION_SUGGESTIONS = 4; private final ContentResolver mResolver; private final LayoutInflater mInflater; private final ArrayList<Result> mResultList = new ArrayList<Result>(); // The cache for contacts photos. We don't have to worry about clearing this, as a // new adapter is created for every edit event. private final Map<Uri, Bitmap> mPhotoCache = new HashMap<Uri, Bitmap>(); /** * Constructor. */ public EventLocationAdapter(Context context) { super(context, R.layout.location_dropdown_item, EMPTY_LIST); mResolver = context.getContentResolver(); mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); } @Override public int getCount() { return mResultList.size(); } @Override public Result getItem(int index) { if (index < mResultList.size()) { return mResultList.get(index); } else { return null; } } @Override public View getView(final int position, final View convertView, final ViewGroup parent) { View view = convertView; if (view == null) { view = mInflater.inflate(R.layout.location_dropdown_item, parent, false); } final Result result = getItem(position); if (result == null) { return view; } // Update the display name in the item in auto-complete list. TextView nameView = (TextView) view.findViewById(R.id.location_name); if (nameView != null) { if (result.mName == null) { nameView.setVisibility(View.GONE); } else { nameView.setVisibility(View.VISIBLE); nameView.setText(result.mName); } } // Update the address line. TextView addressView = (TextView) view.findViewById(R.id.location_address); if (addressView != null) { addressView.setText(result.mAddress); } // Update the icon. final ImageView imageView = (ImageView) view.findViewById(R.id.icon); if (imageView != null) { if (result.mDefaultIcon == null) { imageView.setVisibility(View.INVISIBLE); } else { imageView.setVisibility(View.VISIBLE); imageView.setImageResource(result.mDefaultIcon); // Save the URI on the view, so we can check against it later when updating // the image. Otherwise the async image update with using 'convertView' above // resulted in the wrong list items being updated. imageView.setTag(result.mContactPhotoUri); if (result.mContactPhotoUri != null) { Bitmap cachedPhoto = mPhotoCache.get(result.mContactPhotoUri); if (cachedPhoto != null) { // Use photo in cache. imageView.setImageBitmap(cachedPhoto); } else { // Asynchronously load photo and update. asyncLoadPhotoAndUpdateView(result.mContactPhotoUri, imageView); } } } } return view; } // TODO: Refactor to share code with ContactsAsyncHelper. private void asyncLoadPhotoAndUpdateView(final Uri contactPhotoUri, final ImageView imageView) { AsyncTask<Void, Void, Bitmap> photoUpdaterTask = new AsyncTask<Void, Void, Bitmap>() { @Override protected Bitmap doInBackground(Void... params) { Bitmap photo = null; InputStream imageStream = Contacts.openContactPhotoInputStream( mResolver, contactPhotoUri); if (imageStream != null) { photo = BitmapFactory.decodeStream(imageStream); mPhotoCache.put(contactPhotoUri, photo); } return photo; } @Override public void onPostExecute(Bitmap photo) { // The View may have already been reused (because using 'convertView' above), so // we must check the URI is as expected before setting the icon, or we may be // setting the icon in other items. if (photo != null && imageView.getTag() == contactPhotoUri) { imageView.setImageBitmap(photo); } } }.execute(); } /** * Return filter for matching against contacts info and recent locations. */ @Override public Filter getFilter() { return new LocationFilter(); } /** * Filter implementation for matching the input string against contacts info and * recent locations. */ public class LocationFilter extends Filter { @Override protected FilterResults performFiltering(CharSequence constraint) { long startTime = System.currentTimeMillis(); final String filter = constraint == null ? "" : constraint.toString(); if (filter.isEmpty()) { return null; } // Start the recent locations query (async). AsyncTask<Void, Void, List<Result>> locationsQueryTask = new AsyncTask<Void, Void, List<Result>>() { @Override protected List<Result> doInBackground(Void... params) { return queryRecentLocations(mResolver, filter); } }.execute(); // Perform the contacts query (sync). HashSet<String> contactsAddresses = new HashSet<String>(); List<Result> contacts = queryContacts(mResolver, filter, contactsAddresses); ArrayList<Result> resultList = new ArrayList<Result>(); try { // Wait for the locations query. List<Result> recentLocations = locationsQueryTask.get(); // Add the matched recent locations to returned results. If a match exists in // both the recent locations query and the contacts addresses, only display it // as a contacts match. for (Result recentLocation : recentLocations) { if (recentLocation.mAddress != null && !contactsAddresses.contains(recentLocation.mAddress)) { resultList.add(recentLocation); } } } catch (ExecutionException e) { Log.e(TAG, "Failed waiting for locations query results.", e); } catch (InterruptedException e) { Log.e(TAG, "Failed waiting for locations query results.", e); } // Add all the contacts matches to returned results. if (contacts != null) { resultList.addAll(contacts); } // Log the processing duration. if (Log.isLoggable(TAG, Log.DEBUG)) { long duration = System.currentTimeMillis() - startTime; StringBuilder msg = new StringBuilder(); msg.append("Autocomplete of ").append(constraint); msg.append(": location query match took ").append(duration).append("ms "); msg.append("(").append(resultList.size()).append(" results)"); Log.d(TAG, msg.toString()); } final FilterResults filterResults = new FilterResults(); filterResults.values = resultList; filterResults.count = resultList.size(); return filterResults; } @Override protected void publishResults(CharSequence constraint, FilterResults results) { mResultList.clear(); if (results != null && results.count > 0) { mResultList.addAll((ArrayList<Result>) results.values); notifyDataSetChanged(); } else { notifyDataSetInvalidated(); } } } /** * Matches the input string against contacts names and addresses. * * @param resolver The content resolver. * @param input The user-typed input string. * @param addressesRetVal The addresses in the returned result are also returned here * for faster lookup. Pass in an empty set. * @return Ordered list of all the matched results. If there are multiple address matches * for the same contact, they will be listed together in individual items, with only * the first item containing a name/icon. */ private static List<Result> queryContacts(ContentResolver resolver, String input, HashSet<String> addressesRetVal) { String where = null; String[] whereArgs = null; // Match any word in contact name or address. if (!TextUtils.isEmpty(input)) { where = CONTACTS_WHERE; String param1 = input + "%"; String param2 = "% " + input + "%"; whereArgs = new String[] {param1, param2, param1, param2}; } // Perform the query. Cursor c = resolver.query(CommonDataKinds.StructuredPostal.CONTENT_URI, CONTACTS_PROJECTION, where, whereArgs, Contacts.DISPLAY_NAME + " ASC"); // Process results. Group together addresses for the same contact. try { Map<String, List<Result>> nameToAddresses = new HashMap<String, List<Result>>(); c.moveToPosition(-1); while (c.moveToNext()) { String name = c.getString(CONTACTS_INDEX_DISPLAY_NAME); String address = c.getString(CONTACTS_INDEX_ADDRESS); if (name != null) { List<Result> addressesForName = nameToAddresses.get(name); Result result; if (addressesForName == null) { // Determine if there is a photo for the icon. Uri contactPhotoUri = null; if (c.getLong(CONTACTS_INDEX_PHOTO_ID) > 0) { contactPhotoUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, c.getLong(CONTACTS_INDEX_CONTACT_ID)); } // First listing for a distinct contact should have the name/icon. addressesForName = new ArrayList<Result>(); nameToAddresses.put(name, addressesForName); result = new Result(name, address, R.drawable.ic_contact_picture, contactPhotoUri); } else { // Do not include name/icon in subsequent listings for the same contact. result = new Result(null, address, null, null); } addressesForName.add(result); addressesRetVal.add(address); } } // Return the list of results. List<Result> allResults = new ArrayList<Result>(); for (List<Result> result : nameToAddresses.values()) { allResults.addAll(result); } return allResults; } finally { if (c != null) { c.close(); } } } /** * Matches the input string against recent locations. */ private static List<Result> queryRecentLocations(ContentResolver resolver, String input) { // TODO: also match each word in the address? String filter = input == null ? "" : input + "%"; if (filter.isEmpty()) { return null; } // Query all locations prefixed with the constraint. There is no way to insert // 'DISTINCT' or 'GROUP BY' to get rid of dupes, so use post-processing to // remove dupes. We will order query results by descending event ID to show // results that were most recently inputed. Cursor c = resolver.query(Events.CONTENT_URI, EVENT_PROJECTION, LOCATION_WHERE, new String[] { "1", filter }, Events._ID + " DESC"); try { List<Result> recentLocations = null; if (c != null) { // Post process query results. recentLocations = processLocationsQueryResults(c); } return recentLocations; } finally { if (c != null) { c.close(); } } } /** * Post-process the query results to return the first MAX_LOCATION_SUGGESTIONS * unique locations in alphabetical order. * * TODO: Refactor to share code with the recent titles auto-complete. */ private static List<Result> processLocationsQueryResults(Cursor cursor) { TreeSet<String> locations = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER); cursor.moveToPosition(-1); // Remove dupes. while ((locations.size() < MAX_LOCATION_SUGGESTIONS) && cursor.moveToNext()) { String location = cursor.getString(EVENT_INDEX_LOCATION).trim(); locations.add(location); } // Copy the sorted results. List<Result> results = new ArrayList<Result>(); for (String location : locations) { results.add(new Result(null, location, R.drawable.ic_history_holo_light, null)); } return results; } }