/* * Copyright (C) 2011 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.ex.chips; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import android.accounts.Account; import android.content.ContentResolver; import android.content.Context; import android.database.Cursor; import android.database.MatrixCursor; import android.net.Uri; import android.provider.ContactsContract; import android.provider.ContactsContract.CommonDataKinds.Phone; import android.provider.ContactsContract.Contacts; import android.text.TextUtils; import android.text.util.Rfc822Token; import android.text.util.Rfc822Tokenizer; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.CursorAdapter; import android.widget.ImageView; import android.widget.TextView; import com.android.ex.chips.BaseRecipientAdapter.DirectoryListQuery; import com.android.ex.chips.BaseRecipientAdapter.DirectorySearchParams; import com.android.ex.chips.Queries.Query; /** * RecipientAlternatesAdapter backs the RecipientEditTextView for managing contacts queried by email or by phone number. */ public class RecipientAlternatesAdapter extends CursorAdapter { static final int MAX_LOOKUPS =50; private final LayoutInflater mLayoutInflater; private final long mCurrentId; private int mCheckedItemPosition =-1; private final OnCheckedItemChangedListener mCheckedItemChangedListener; private static final String TAG ="RecipAlternates"; public static final int QUERY_TYPE_EMAIL =0; public static final int QUERY_TYPE_PHONE =1; private Query mQuery; public interface RecipientMatchCallback { public void matchesFound(Map<String,RecipientEntry> results); /** * Called with all addresses that could not be resolved to valid recipients. */ public void matchesNotFound(Set<String> unfoundAddresses); } public static void getMatchingRecipients(final Context context,final BaseRecipientAdapter adapter,final ArrayList<String> inAddresses,final Account account,final RecipientMatchCallback callback) { getMatchingRecipients(context,adapter,inAddresses,QUERY_TYPE_EMAIL,account,callback); } /** * Get a HashMap of address to RecipientEntry that contains all contact information for a contact with the provided * address, if one exists. This may block the UI, so run it in an async task. * * @param context * Context. * @param inAddresses * Array of addresses on which to perform the lookup. * @param callback * RecipientMatchCallback called when a match or matches are found. * @return HashMap<String,RecipientEntry> */ public static void getMatchingRecipients(final Context context,final BaseRecipientAdapter adapter,final ArrayList<String> inAddresses,final int addressType,final Account account,final RecipientMatchCallback callback) { Queries.Query query; if(addressType==QUERY_TYPE_EMAIL) query=Queries.EMAIL; else query=Queries.PHONE; final int addressesSize=Math.min(MAX_LOOKUPS,inAddresses.size()); final HashSet<String> addresses=new HashSet<String>(); final StringBuilder bindString=new StringBuilder(); // Create the "?" string and set up arguments. for(int i=0;i<addressesSize;i++) { final Rfc822Token[] tokens=Rfc822Tokenizer.tokenize(inAddresses.get(i).toLowerCase(Locale.getDefault())); addresses.add(tokens.length>0 ? tokens[0].getAddress() : inAddresses.get(i)); bindString.append("?"); if(i<addressesSize-1) bindString.append(","); } if(Log.isLoggable(TAG,Log.DEBUG)) Log.d(TAG,"Doing reverse lookup for "+addresses.toString()); final String[] addressArray=new String[addresses.size()]; addresses.toArray(addressArray); HashMap<String,RecipientEntry> recipientEntries=null; Cursor c=null; try { c=context.getContentResolver().query(query.getContentUri(),query.getProjection(),query.getProjection()[Queries.Query.DESTINATION]+" IN ("+bindString.toString()+")",addressArray,null); recipientEntries=processContactEntries(c); callback.matchesFound(recipientEntries); } finally { if(c!=null) c.close(); } // See if any entries did not resolve; if so, we need to check other // directories final Set<String> matchesNotFound=new HashSet<String>(); if(recipientEntries.size()<addresses.size()) { final List<DirectorySearchParams> paramsList; Cursor directoryCursor=null; try { directoryCursor=context.getContentResolver().query(DirectoryListQuery.URI,DirectoryListQuery.PROJECTION,null,null,null); if(directoryCursor==null) paramsList=null; else paramsList=BaseRecipientAdapter.setupOtherDirectories(context,directoryCursor,account); } finally { if(directoryCursor!=null) directoryCursor.close(); } // Run a directory query for each unmatched recipient. final HashSet<String> unresolvedAddresses=new HashSet<String>(); for(final String address : addresses) if(!recipientEntries.containsKey(address)) unresolvedAddresses.add(address); matchesNotFound.addAll(unresolvedAddresses); if(paramsList!=null) { Cursor directoryContactsCursor=null; for(final String unresolvedAddress : unresolvedAddresses) for(int i=0;i<paramsList.size();i++) try { directoryContactsCursor=doQuery(unresolvedAddress,1,paramsList.get(i).directoryId,account,context.getContentResolver(),query); } finally { if(directoryContactsCursor!=null&&directoryContactsCursor.getCount()==0) { directoryContactsCursor.close(); directoryContactsCursor=null; } else break; } } } // If no matches found in contact provider or the directories, try the extension // matcher. // todo (aalbert): This whole method needs to be in the adapter? if(adapter!=null) { final Map<String,RecipientEntry> entries=adapter.getMatchingRecipients(matchesNotFound); if(entries!=null&&entries.size()>0) { callback.matchesFound(entries); for(final String address : entries.keySet()) matchesNotFound.remove(address); } } callback.matchesNotFound(matchesNotFound); } private static HashMap<String,RecipientEntry> processContactEntries(final Cursor c) { final HashMap<String,RecipientEntry> recipientEntries=new HashMap<String,RecipientEntry>(); if(c!=null&&c.moveToFirst()) do { final String address=c.getString(Queries.Query.DESTINATION); final RecipientEntry newRecipientEntry=RecipientEntry.constructTopLevelEntry(c.getString(Queries.Query.NAME),c.getInt(Queries.Query.DISPLAY_NAME_SOURCE),c.getString(Queries.Query.DESTINATION),c.getInt(Queries.Query.DESTINATION_TYPE),c.getString(Queries.Query.DESTINATION_LABEL),c.getLong(Queries.Query.CONTACT_ID),c.getLong(Queries.Query.DATA_ID),c.getString(Queries.Query.PHOTO_THUMBNAIL_URI),true,false /* * isGalContact * TODO(skennedy) We should * look these up eventually */); /* * In certain situations, we may have two results for one address, where one of the results is just the * email address, and the other has a name and photo, so we want to use the better one. */ final RecipientEntry recipientEntry=getBetterRecipient(recipientEntries.get(address),newRecipientEntry); recipientEntries.put(address,recipientEntry); if(Log.isLoggable(TAG,Log.DEBUG)) Log.d(TAG,"Received reverse look up information for "+address+" RESULTS: "+" NAME : "+c.getString(Queries.Query.NAME)+" CONTACT ID : "+c.getLong(Queries.Query.CONTACT_ID)+" ADDRESS :"+c.getString(Queries.Query.DESTINATION)); } while(c.moveToNext()); return recipientEntries; } /** * Given two {@link RecipientEntry}s for the same email address, this will return the one that contains more * complete information for display purposes. Defaults to <code>entry2</code> if no significant differences are * found. */ static RecipientEntry getBetterRecipient(final RecipientEntry entry1,final RecipientEntry entry2) { // If only one has passed in, use it if(entry2==null) return entry1; if(entry1==null) return entry2; // If only one has a display name, use it if(!TextUtils.isEmpty(entry1.getDisplayName())&&TextUtils.isEmpty(entry2.getDisplayName())) return entry1; if(!TextUtils.isEmpty(entry2.getDisplayName())&&TextUtils.isEmpty(entry1.getDisplayName())) return entry2; // If only one has a display name that is not the same as the destination, use it if(!TextUtils.equals(entry1.getDisplayName(),entry1.getDestination())&&TextUtils.equals(entry2.getDisplayName(),entry2.getDestination())) return entry1; if(!TextUtils.equals(entry2.getDisplayName(),entry2.getDestination())&&TextUtils.equals(entry1.getDisplayName(),entry1.getDestination())) return entry2; // If only one has a photo, use it if((entry1.getPhotoThumbnailUri()!=null||entry1.getPhotoBytes()!=null)&&entry2.getPhotoThumbnailUri()==null&&entry2.getPhotoBytes()==null) return entry1; if((entry2.getPhotoThumbnailUri()!=null||entry2.getPhotoBytes()!=null)&&entry1.getPhotoThumbnailUri()==null&&entry1.getPhotoBytes()==null) return entry2; // Go with the second option as a default return entry2; } private static Cursor doQuery(final CharSequence constraint,final int limit,final Long directoryId,final Account account,final ContentResolver resolver,final Query query) { String constraintStr=constraint.toString(); final Uri.Builder builder; String selection=null; String[] selectionArgs=null; if(query!=Queries.PHONE) builder=query.getContentFilterUri().buildUpon().appendPath(constraintStr).appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,String.valueOf(limit+BaseRecipientAdapter.ALLOWANCE_FOR_DUPLICATES)); else { builder=query.getContentUri().buildUpon().appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,String.valueOf(limit+BaseRecipientAdapter.ALLOWANCE_FOR_DUPLICATES)); selection=Contacts.DISPLAY_NAME+" LIKE ? OR "+Phone.NUMBER+" LIKE ?"; constraintStr="%"+constraintStr+"%"; selectionArgs=new String[] {constraintStr,constraintStr}; } if(directoryId!=null) builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,String.valueOf(directoryId)); if(account!=null) { builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_NAME,account.name); builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_TYPE,account.type); } // final long start = System.currentTimeMillis(); final Uri uri=builder.build(); final Cursor cursor=resolver.query(uri,query.getProjection(),selection,selectionArgs,null); // final long end = System.currentTimeMillis(); // if (DEBUG) { // Log.d(TAG, "Time for autocomplete (query: " + constraint + ", directoryId: " + directoryId // + ", num_of_results: " + (cursor != null ? cursor.getCount() : "null") + "): " + (end - start) // + " ms"); // } return cursor; } public RecipientAlternatesAdapter(final Context context,final long contactId,final long currentId,final OnCheckedItemChangedListener listener) { this(context,contactId,currentId,QUERY_TYPE_EMAIL,listener); } public RecipientAlternatesAdapter(final Context context,final long contactId,final long currentId,// final int queryMode,final OnCheckedItemChangedListener listener) { super(context,getCursorForConstruction(context,contactId,queryMode),0); mLayoutInflater=LayoutInflater.from(context); mCurrentId=currentId; mCheckedItemChangedListener=listener; if(queryMode==QUERY_TYPE_EMAIL) mQuery=Queries.EMAIL; else if(queryMode==QUERY_TYPE_PHONE) mQuery=Queries.PHONE; else { mQuery=Queries.EMAIL; Log.e(TAG,"Unsupported query type: "+queryMode); } } public RecipientAlternatesAdapter(final Context context,final Cursor c,final long currentId,final int queryMode,final OnCheckedItemChangedListener listener) { super(context,c,0); mLayoutInflater=LayoutInflater.from(context); mCurrentId=currentId; mCheckedItemChangedListener=listener; if(queryMode==QUERY_TYPE_EMAIL) mQuery=Queries.EMAIL; else if(queryMode==QUERY_TYPE_PHONE) mQuery=Queries.PHONE; else { mQuery=Queries.EMAIL; Log.e(TAG,"Unsupported query type: "+queryMode); } } protected static Cursor getCursorForConstruction(final Context context,final long contactId,final int queryType) { final Cursor cursor; if(queryType==QUERY_TYPE_EMAIL) cursor=context.getContentResolver().query(Queries.EMAIL.getContentUri(),Queries.EMAIL.getProjection(),Queries.EMAIL.getProjection()[Queries.Query.CONTACT_ID]+" =?",new String[] {String.valueOf(contactId)},null); else cursor=context.getContentResolver().query(Queries.PHONE.getContentUri(),Queries.PHONE.getProjection(),Queries.PHONE.getProjection()[Queries.Query.CONTACT_ID]+" =?",new String[] {String.valueOf(contactId)},null); return removeDuplicateDestinations(cursor); } /** * @return a new cursor based on the given cursor with all duplicate destinations removed. * It's only intended to use for the alternate list, so... - This method ignores all other fields and dedupe * solely on the destination. Normally, if a cursor contains multiple contacts and they have the same * destination, we'd still want to show both. - This method creates a MatrixCursor, so all data will be kept * in memory. We wouldn't want to do this if the original cursor is large, but it's okay here because the * alternate list won't be that big. */ // Visible for testing /* package */static Cursor removeDuplicateDestinations(final Cursor original) { final MatrixCursor result=new MatrixCursor(original.getColumnNames(),original.getCount()); final HashSet<String> destinationsSeen=new HashSet<String>(); original.moveToPosition(-1); while(original.moveToNext()) { final String destination=original.getString(Query.DESTINATION); if(destinationsSeen.contains(destination)) continue; destinationsSeen.add(destination); result.addRow(new Object[] {original.getString(Query.NAME),original.getString(Query.DESTINATION),original.getInt(Query.DESTINATION_TYPE),original.getString(Query.DESTINATION_LABEL),original.getLong(Query.CONTACT_ID),original.getLong(Query.DATA_ID),original.getString(Query.PHOTO_THUMBNAIL_URI),original.getInt(Query.DISPLAY_NAME_SOURCE)}); } return result; } @Override public long getItemId(final int position) { final Cursor c=getCursor(); if(c.moveToPosition(position)) c.getLong(Queries.Query.DATA_ID); return -1; } public RecipientEntry getRecipientEntry(final int position) { final Cursor c=getCursor(); c.moveToPosition(position); return RecipientEntry.constructTopLevelEntry(c.getString(Queries.Query.NAME),c.getInt(Queries.Query.DISPLAY_NAME_SOURCE),c.getString(Queries.Query.DESTINATION),c.getInt(Queries.Query.DESTINATION_TYPE),c.getString(Queries.Query.DESTINATION_LABEL),c.getLong(Queries.Query.CONTACT_ID),c.getLong(Queries.Query.DATA_ID),c.getString(Queries.Query.PHOTO_THUMBNAIL_URI),true,false /* * isGalContact TODO(skennedy) We should * look these up eventually */); } @Override public View getView(final int position,View convertView,final ViewGroup parent) { final Cursor cursor=getCursor(); cursor.moveToPosition(position); if(convertView==null) convertView=newView(); if(cursor.getLong(Queries.Query.DATA_ID)==mCurrentId) { mCheckedItemPosition=position; if(mCheckedItemChangedListener!=null) mCheckedItemChangedListener.onCheckedItemChanged(mCheckedItemPosition); } bindView(convertView,convertView.getContext(),cursor); return convertView; } // TODO: this is VERY similar to the BaseRecipientAdapter. Can we combine // somehow? @Override public void bindView(final View view,final Context context,final Cursor cursor) { final int position=cursor.getPosition(); final TextView display=(TextView)view.findViewById(android.R.id.title); final ImageView imageView=(ImageView)view.findViewById(android.R.id.icon); final RecipientEntry entry=getRecipientEntry(position); if(position==0) { display.setText(cursor.getString(Queries.Query.NAME)); display.setVisibility(View.VISIBLE); // TODO: see if this needs to be done outside the main thread // as it may be too slow to get immediately. imageView.setImageURI(entry.getPhotoThumbnailUri()); imageView.setVisibility(View.VISIBLE); } else { display.setVisibility(View.GONE); imageView.setVisibility(View.GONE); } final TextView destination=(TextView)view.findViewById(android.R.id.text1); destination.setText(cursor.getString(Queries.Query.DESTINATION)); final TextView destinationType=(TextView)view.findViewById(android.R.id.text2); if(destinationType!=null) destinationType.setText(mQuery.getTypeLabel(context.getResources(),cursor.getInt(Queries.Query.DESTINATION_TYPE),cursor.getString(Queries.Query.DESTINATION_LABEL)).toString().toUpperCase(Locale.getDefault())); } @Override public View newView(final Context context,final Cursor cursor,final ViewGroup parent) { return newView(); } private View newView() { return mLayoutInflater.inflate(R.layout.chips_recipient_dropdown_item,null); } /* package */static interface OnCheckedItemChangedListener { public void onCheckedItemChanged(int position); } }