/* * Copyright (C) 2009 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.contacts.quickcontact; import com.android.contacts.Collapser; import com.android.contacts.ContactPhotoManager; import com.android.contacts.R; import com.android.contacts.model.AccountTypeManager; import com.android.contacts.model.DataKind; import com.android.contacts.util.DataStatus; import com.android.contacts.util.NotifyingAsyncQueryHandler; import com.android.contacts.util.NotifyingAsyncQueryHandler.AsyncQueryListener; import com.google.common.base.Preconditions; import com.google.common.collect.Lists; import android.app.Activity; import android.app.Fragment; import android.app.FragmentManager; import android.content.ActivityNotFoundException; import android.content.ContentUris; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.res.AssetFileDescriptor; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; import android.provider.ContactsContract.CommonDataKinds.Email; import android.provider.ContactsContract.CommonDataKinds.Im; import android.provider.ContactsContract.CommonDataKinds.Phone; import android.provider.ContactsContract.CommonDataKinds.Photo; import android.provider.ContactsContract.CommonDataKinds.SipAddress; import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; import android.provider.ContactsContract.CommonDataKinds.Website; import android.provider.ContactsContract.Contacts; import android.provider.ContactsContract.Data; import android.provider.ContactsContract.DisplayPhoto; import android.provider.ContactsContract.QuickContact; import android.provider.ContactsContract.RawContacts; import android.support.v13.app.FragmentPagerAdapter; import android.support.v4.view.ViewPager; import android.support.v4.view.ViewPager.SimpleOnPageChangeListener; import android.text.TextUtils; import android.util.Log; import android.view.MotionEvent; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; import android.view.WindowManager; import android.widget.HorizontalScrollView; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.RelativeLayout; import android.widget.TextView; import android.widget.Toast; import java.io.IOException; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Set; // TODO: Save selected tab index during rotation /** * Mostly translucent {@link Activity} that shows QuickContact dialog. It loads * data asynchronously, and then shows a popup with details centered around * {@link Intent#getSourceBounds()}. */ public class QuickContactActivity extends Activity { private static final String TAG = "QuickContact"; private static final boolean TRACE_LAUNCH = false; private static final String TRACE_TAG = "quickcontact"; @SuppressWarnings("deprecation") private static final String LEGACY_AUTHORITY = android.provider.Contacts.AUTHORITY; private NotifyingAsyncQueryHandler mHandler; private Uri mLookupUri; private String[] mExcludeMimes; private List<String> mSortedActionMimeTypes = Lists.newArrayList(); private boolean mHasFinishedAnimatingIn = false; private boolean mHasStartedAnimatingOut = false; private FloatingChildLayout mFloatingLayout; private View mPhotoContainer; private ViewGroup mTrack; private HorizontalScrollView mTrackScroller; private View mSelectedTabRectangle; private View mLineAfterTrack; private ImageButton mOpenDetailsButton; private ImageButton mOpenDetailsPushLayerButton; private ViewPager mListPager; /** * Keeps the default action per mimetype. Empty if no default actions are set */ private HashMap<String, Action> mDefaultsMap = new HashMap<String, Action>(); /** * Set of {@link Action} that are associated with the aggregate currently * displayed by this dialog, represented as a map from {@link String} * MIME-type to a list of {@link Action}. */ private ActionMultiMap mActions = new ActionMultiMap(); /** * {@link #LEADING_MIMETYPES} and {@link #TRAILING_MIMETYPES} are used to sort MIME-types. * * <p>The MIME-types in {@link #LEADING_MIMETYPES} appear in the front of the dialog, * in the order specified here.</p> * * <p>The ones in {@link #TRAILING_MIMETYPES} appear in the end of the dialog, in the order * specified here.</p> * * <p>The rest go between them, in the order in the array.</p> */ private static final List<String> LEADING_MIMETYPES = Lists.newArrayList( Phone.CONTENT_ITEM_TYPE, SipAddress.CONTENT_ITEM_TYPE, Email.CONTENT_ITEM_TYPE); /** See {@link #LEADING_MIMETYPES}. */ private static final List<String> TRAILING_MIMETYPES = Lists.newArrayList( StructuredPostal.CONTENT_ITEM_TYPE, Website.CONTENT_ITEM_TYPE); /** Id for the background handler that loads the data */ private static final int HANDLER_ID_DATA = 1; @Override protected void onCreate(Bundle icicle) { super.onCreate(icicle); // Show QuickContact in front of soft input getWindow().setFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM, WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); setContentView(R.layout.quickcontact_activity); mFloatingLayout = (FloatingChildLayout) findViewById(R.id.floating_layout); mTrack = (ViewGroup) findViewById(R.id.track); mTrackScroller = (HorizontalScrollView) findViewById(R.id.track_scroller); mOpenDetailsButton = (ImageButton) findViewById(R.id.open_details_button); mOpenDetailsPushLayerButton = (ImageButton) findViewById(R.id.open_details_push_layer); mListPager = (ViewPager) findViewById(R.id.item_list_pager); mSelectedTabRectangle = findViewById(R.id.selected_tab_rectangle); mLineAfterTrack = findViewById(R.id.line_after_track); mFloatingLayout.setOnOutsideTouchListener(new View.OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { return handleOutsideTouch(); } }); final OnClickListener openDetailsClickHandler = new OnClickListener() { @Override public void onClick(View v) { final Intent intent = new Intent(Intent.ACTION_VIEW, mLookupUri); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); startActivity(intent); hide(false); } }; mOpenDetailsButton.setOnClickListener(openDetailsClickHandler); mOpenDetailsPushLayerButton.setOnClickListener(openDetailsClickHandler); mListPager.setAdapter(new ViewPagerAdapter(getFragmentManager())); mListPager.setOnPageChangeListener(new PageChangeListener()); mHandler = new NotifyingAsyncQueryHandler(this, mQueryListener); show(); } private void show() { if (TRACE_LAUNCH) { android.os.Debug.startMethodTracing(TRACE_TAG); } final Intent intent = getIntent(); Uri lookupUri = intent.getData(); // Check to see whether it comes from the old version. if (LEGACY_AUTHORITY.equals(lookupUri.getAuthority())) { final long rawContactId = ContentUris.parseId(lookupUri); lookupUri = RawContacts.getContactLookupUri(getContentResolver(), ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId)); } mLookupUri = Preconditions.checkNotNull(lookupUri, "missing lookupUri"); // Read requested parameters for displaying final Rect targetScreen = intent.getSourceBounds(); Preconditions.checkNotNull(targetScreen, "missing targetScreen"); mFloatingLayout.setChildTargetScreen(targetScreen); mExcludeMimes = intent.getStringArrayExtra(QuickContact.EXTRA_EXCLUDE_MIMES); // find and prepare correct header view mPhotoContainer = findViewById(R.id.photo_container); setHeaderNameText(R.id.name, R.string.missing_name); // Start background query for data, but only select photo rows when they // directly match the super-primary PHOTO_ID. final Uri dataUri = Uri.withAppendedPath(lookupUri, Contacts.Data.CONTENT_DIRECTORY); mHandler.cancelOperation(HANDLER_ID_DATA); // Select all data items of the contact (except for photos, where we only select the display // photo) mHandler.startQuery(HANDLER_ID_DATA, lookupUri, dataUri, DataQuery.PROJECTION, Data.MIMETYPE + "!=? OR (" + Data.MIMETYPE + "=? AND " + Data._ID + "=" + Contacts.PHOTO_ID + ")", new String[] { Photo.CONTENT_ITEM_TYPE, Photo.CONTENT_ITEM_TYPE }, null); } private boolean handleOutsideTouch() { if (!mHasFinishedAnimatingIn) return false; if (mHasStartedAnimatingOut) return false; mHasStartedAnimatingOut = true; hide(true); return true; } private void hide(boolean withAnimation) { // cancel any pending queries mHandler.cancelOperation(HANDLER_ID_DATA); if (withAnimation) { mFloatingLayout.hideChild(new Runnable() { @Override public void run() { finish(); } }); } else { mFloatingLayout.hideChild(null); finish(); } } @Override public void onBackPressed() { hide(true); } private final AsyncQueryListener mQueryListener = new AsyncQueryListener() { @Override public synchronized void onQueryComplete(int token, Object cookie, Cursor cursor) { try { if (isFinishing()) { hide(false); return; } else if (cursor == null || cursor.getCount() == 0) { Toast.makeText(QuickContactActivity.this, R.string.invalidContactMessage, Toast.LENGTH_LONG).show(); hide(false); return; } bindData(cursor); if (TRACE_LAUNCH) { android.os.Debug.stopMethodTracing(); } // Data bound and ready, pull curtain to show. Put this on the Handler to ensure // that the layout passes are completed mHandler.post(new Runnable() { @Override public void run() { mFloatingLayout.showChild(new Runnable() { @Override public void run() { mHasFinishedAnimatingIn = true; } }); } }); } finally { if (cursor != null) { cursor.close(); } } } }; /** Assign this string to the view if it is not empty. */ private void setHeaderNameText(int id, int resId) { setHeaderNameText(id, getText(resId)); } /** Assign this string to the view if it is not empty. */ private void setHeaderNameText(int id, CharSequence value) { final View view = mPhotoContainer.findViewById(id); if (view instanceof TextView) { if (!TextUtils.isEmpty(value)) { ((TextView)view).setText(value); } } } /** * Assign this string to the view (if found in {@link #mPhotoContainer}), or hiding this view * if there is no string. */ private void setHeaderText(int id, int resId) { setHeaderText(id, getText(resId)); } /** * Assign this string to the view (if found in {@link #mPhotoContainer}), or hiding this view * if there is no string. */ private void setHeaderText(int id, CharSequence value) { final View view = mPhotoContainer.findViewById(id); if (view instanceof TextView) { ((TextView)view).setText(value); view.setVisibility(TextUtils.isEmpty(value) ? View.GONE : View.VISIBLE); } } /** Assign this image to the view, if found in {@link #mPhotoContainer}. */ private void setHeaderImage(int id, Drawable drawable) { final View view = mPhotoContainer.findViewById(id); if (view instanceof ImageView) { ((ImageView)view).setImageDrawable(drawable); view.setVisibility(drawable == null ? View.GONE : View.VISIBLE); } } /** * Check if the given MIME-type appears in the list of excluded MIME-types * that the most-recent caller requested. */ private boolean isMimeExcluded(String mimeType) { if (mExcludeMimes == null) return false; for (String excludedMime : mExcludeMimes) { if (TextUtils.equals(excludedMime, mimeType)) { return true; } } return false; } /** * Handle the result from the {@link #TOKEN_DATA} query. */ private void bindData(Cursor cursor) { final ResolveCache cache = ResolveCache.getInstance(this); final Context context = this; mOpenDetailsButton.setVisibility(isMimeExcluded(Contacts.CONTENT_ITEM_TYPE) ? View.GONE : View.VISIBLE); mDefaultsMap.clear(); final DataStatus status = new DataStatus(); final AccountTypeManager accountTypes = AccountTypeManager.getInstance( context.getApplicationContext()); final ImageView photoView = (ImageView) mPhotoContainer.findViewById(R.id.photo); Bitmap photoBitmap = null; while (cursor.moveToNext()) { // Handle any social status updates from this row status.possibleUpdate(cursor); final String mimeType = cursor.getString(DataQuery.MIMETYPE); // Skip this data item if MIME-type excluded if (isMimeExcluded(mimeType)) continue; final long dataId = cursor.getLong(DataQuery._ID); final String accountType = cursor.getString(DataQuery.ACCOUNT_TYPE); final String dataSet = cursor.getString(DataQuery.DATA_SET); final boolean isPrimary = cursor.getInt(DataQuery.IS_PRIMARY) != 0; final boolean isSuperPrimary = cursor.getInt(DataQuery.IS_SUPER_PRIMARY) != 0; // Handle photos included as data row if (Photo.CONTENT_ITEM_TYPE.equals(mimeType)) { final int displayPhotoColumnIndex = cursor.getColumnIndex(Photo.PHOTO_FILE_ID); final boolean hasDisplayPhoto = !cursor.isNull(displayPhotoColumnIndex); if (hasDisplayPhoto) { final long displayPhotoId = cursor.getLong(displayPhotoColumnIndex); final Uri displayPhotoUri = ContentUris.withAppendedId( DisplayPhoto.CONTENT_URI, displayPhotoId); // Fetch and JPEG uncompress on the background thread new AsyncTask<Void, Void, Bitmap>() { @Override protected Bitmap doInBackground(Void... params) { try { AssetFileDescriptor fd = getContentResolver() .openAssetFileDescriptor(displayPhotoUri, "r"); return BitmapFactory.decodeStream(fd.createInputStream()); } catch (IOException e) { Log.e(TAG, "Error getting display photo. Ignoring, as we already " + "have the thumbnail", e); return null; } } @Override protected void onPostExecute(Bitmap result) { if (result == null) return; photoView.setImageBitmap(result); } }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[]) null); } final int photoColumnIndex = cursor.getColumnIndex(Photo.PHOTO); final byte[] photoBlob = cursor.getBlob(photoColumnIndex); if (photoBlob != null) { photoBitmap = BitmapFactory.decodeByteArray(photoBlob, 0, photoBlob.length); } continue; } final DataKind kind = accountTypes.getKindOrFallback(accountType, dataSet, mimeType); if (kind != null) { // Build an action for this data entry, find a mapping to a UI // element, build its summary from the cursor, and collect it // along with all others of this MIME-type. final Action action = new DataAction(context, mimeType, kind, dataId, cursor); final boolean wasAdded = considerAdd(action, cache); if (wasAdded) { // Remember the default if (isSuperPrimary || (isPrimary && (mDefaultsMap.get(mimeType) == null))) { mDefaultsMap.put(mimeType, action); } } } // Handle Email rows with presence data as Im entry final boolean hasPresence = !cursor.isNull(DataQuery.PRESENCE); if (hasPresence && Email.CONTENT_ITEM_TYPE.equals(mimeType)) { final DataKind imKind = accountTypes.getKindOrFallback(accountType, dataSet, Im.CONTENT_ITEM_TYPE); if (imKind != null) { final DataAction action = new DataAction(context, Im.CONTENT_ITEM_TYPE, imKind, dataId, cursor); considerAdd(action, cache); } } } // Collapse Action Lists (remove e.g. duplicate e-mail addresses from different sources) for (List<Action> actionChildren : mActions.values()) { Collapser.collapseList(actionChildren); } if (cursor.moveToLast()) { // Read contact name from last data row final String name = cursor.getString(DataQuery.DISPLAY_NAME); setHeaderNameText(R.id.name, name); } if (photoView != null) { // Place photo when discovered in data, otherwise show generic avatar if (photoBitmap != null) { photoView.setImageBitmap(photoBitmap); } else { photoView.setImageResource(ContactPhotoManager.getDefaultAvatarResId(true, false)); } } // All the mime-types to add. final Set<String> containedTypes = new HashSet<String>(mActions.keySet()); mSortedActionMimeTypes.clear(); // First, add LEADING_MIMETYPES, which are most common. for (String mimeType : LEADING_MIMETYPES) { if (containedTypes.contains(mimeType)) { mSortedActionMimeTypes.add(mimeType); containedTypes.remove(mimeType); } } // Add all the remaining ones that are not TRAILING for (String mimeType : containedTypes.toArray(new String[containedTypes.size()])) { if (!TRAILING_MIMETYPES.contains(mimeType)) { mSortedActionMimeTypes.add(mimeType); containedTypes.remove(mimeType); } } // Then, add TRAILING_MIMETYPES, which are least common. for (String mimeType : TRAILING_MIMETYPES) { if (containedTypes.contains(mimeType)) { containedTypes.remove(mimeType); mSortedActionMimeTypes.add(mimeType); } } // Add buttons for each mimetype for (String mimeType : mSortedActionMimeTypes) { final View actionView = inflateAction(mimeType, cache, mTrack); mTrack.addView(actionView); } final boolean hasData = !mSortedActionMimeTypes.isEmpty(); mTrackScroller.setVisibility(hasData ? View.VISIBLE : View.GONE); mSelectedTabRectangle.setVisibility(hasData ? View.VISIBLE : View.GONE); mLineAfterTrack.setVisibility(hasData ? View.VISIBLE : View.GONE); mListPager.setVisibility(hasData ? View.VISIBLE : View.GONE); } /** * Consider adding the given {@link Action}, which will only happen if * {@link PackageManager} finds an application to handle * {@link Action#getIntent()}. * @return true if action has been added */ private boolean considerAdd(Action action, ResolveCache resolveCache) { if (resolveCache.hasResolve(action)) { mActions.put(action.getMimeType(), action); return true; } return false; } /** * Inflate the in-track view for the action of the given MIME-type, collapsing duplicate values. * Will use the icon provided by the {@link DataKind}. */ private View inflateAction(String mimeType, ResolveCache resolveCache, ViewGroup root) { final CheckableImageView typeView = (CheckableImageView) getLayoutInflater().inflate( R.layout.quickcontact_track_button, root, false); List<Action> children = mActions.get(mimeType); typeView.setTag(mimeType); final Action firstInfo = children.get(0); // Set icon and listen for clicks final CharSequence descrip = resolveCache.getDescription(firstInfo); final Drawable icon = resolveCache.getIcon(firstInfo); typeView.setChecked(false); typeView.setContentDescription(descrip); typeView.setImageDrawable(icon); typeView.setOnClickListener(mTypeViewClickListener); return typeView; } private CheckableImageView getActionViewAt(int position) { return (CheckableImageView) mTrack.getChildAt(position); } @Override public void onAttachFragment(Fragment fragment) { final QuickContactListFragment listFragment = (QuickContactListFragment) fragment; listFragment.setListener(mListFragmentListener); } /** A type (e.g. Call/Addresses was clicked) */ private final OnClickListener mTypeViewClickListener = new OnClickListener() { @Override public void onClick(View view) { final CheckableImageView actionView = (CheckableImageView)view; final String mimeType = (String) actionView.getTag(); int index = mSortedActionMimeTypes.indexOf(mimeType); mListPager.setCurrentItem(index, true); } }; private class ViewPagerAdapter extends FragmentPagerAdapter { public ViewPagerAdapter(FragmentManager fragmentManager) { super(fragmentManager); } @Override public Fragment getItem(int position) { QuickContactListFragment fragment = new QuickContactListFragment(); final String mimeType = mSortedActionMimeTypes.get(position); final List<Action> actions = mActions.get(mimeType); fragment.setActions(actions); return fragment; } @Override public int getCount() { return mSortedActionMimeTypes.size(); } } private class PageChangeListener extends SimpleOnPageChangeListener { @Override public void onPageSelected(int position) { final CheckableImageView actionView = getActionViewAt(position); mTrackScroller.requestChildRectangleOnScreen(actionView, new Rect(0, 0, actionView.getWidth(), actionView.getHeight()), false); } @Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { final RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) mSelectedTabRectangle.getLayoutParams(); final int width = mSelectedTabRectangle.getWidth(); layoutParams.leftMargin = (int) ((position + positionOffset) * width); mSelectedTabRectangle.setLayoutParams(layoutParams); } } private final QuickContactListFragment.Listener mListFragmentListener = new QuickContactListFragment.Listener() { @Override public void onOutsideClick() { // If there is no background, we want to dismiss, because to the user it seems // like he had touched outside. If the ViewPager is solid however, those taps // must be ignored final boolean isTransparent = mListPager.getBackground() == null; if (isTransparent) handleOutsideTouch(); } @Override public void onItemClicked(final Action action, final boolean alternate) { final Runnable startAppRunnable = new Runnable() { @Override public void run() { try { startActivity(alternate ? action.getAlternateIntent() : action.getIntent()); } catch (ActivityNotFoundException e) { Toast.makeText(QuickContactActivity.this, R.string.quickcontact_missing_app, Toast.LENGTH_SHORT).show(); } hide(false); } }; // Defer the action to make the window properly repaint new Handler().post(startAppRunnable); } }; private interface DataQuery { final String[] PROJECTION = new String[] { Data._ID, RawContacts.ACCOUNT_TYPE, RawContacts.DATA_SET, Contacts.STARRED, Contacts.DISPLAY_NAME, Data.STATUS, Data.STATUS_RES_PACKAGE, Data.STATUS_ICON, Data.STATUS_LABEL, Data.STATUS_TIMESTAMP, Data.PRESENCE, Data.CHAT_CAPABILITY, Data.RES_PACKAGE, Data.MIMETYPE, Data.IS_PRIMARY, Data.IS_SUPER_PRIMARY, Data.RAW_CONTACT_ID, Data.DATA1, Data.DATA2, Data.DATA3, Data.DATA4, Data.DATA5, Data.DATA6, Data.DATA7, Data.DATA8, Data.DATA9, Data.DATA10, Data.DATA11, Data.DATA12, Data.DATA13, Data.DATA14, Data.DATA15, }; final int _ID = 0; final int ACCOUNT_TYPE = 1; final int DATA_SET = 2; final int STARRED = 3; final int DISPLAY_NAME = 4; final int STATUS = 5; final int STATUS_RES_PACKAGE = 6; final int STATUS_ICON = 7; final int STATUS_LABEL = 8; final int STATUS_TIMESTAMP = 9; final int PRESENCE = 10; final int CHAT_CAPABILITY = 11; final int RES_PACKAGE = 12; final int MIMETYPE = 13; final int IS_PRIMARY = 14; final int IS_SUPER_PRIMARY = 15; } }