com.android.calendar.event.EventLocationAdapter.java Source code

Java tutorial

Introduction

Here is the source code for com.android.calendar.event.EventLocationAdapter.java

Source

/*
 * 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.Manifest;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.provider.CalendarContract.Events;
import android.provider.ContactsContract.CommonDataKinds;
import android.provider.ContactsContract.Contacts;
import android.provider.ContactsContract.RawContacts;
import android.support.v4.content.ContextCompat;
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 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;

import ws.xsoh.etar.R;

// 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";
    // 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 static ArrayList<Result> EMPTY_LIST = new ArrayList<Result>();
    private final Context mContext;
    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);

        mContext = context;
        mResolver = context.getContentResolver();
        mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    }

    /**
     * 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, Context context) {
        // TODO: also match each word in the address?
        String filter = input == null ? "" : input + "%";
        if (filter.isEmpty()) {
            return null;
        }

        if (Build.VERSION.SDK_INT >= 23 && ContextCompat.checkSelfPermission(context,
                Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) {
            //If permission is not granted then just return.
            Log.d(TAG, "Manifest.permission.READ_CALENDAR is not granted");
            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.
     * <p/>
     * 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;
    }

    @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();
    }

    /**
     * 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;
        }
    }

    /**
     * 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, mContext);
                }
            }.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();
            }
        }
    }
}