blob: 3bcb3acd48a38c7a9c243bb42d6e81516bfc575c [file] [log] [blame]
/*
* 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;
}
}