QuickContactActivity.java revision 8a0dd0f08ab11e9c6dad386e4411aeeb4c0cd73f
1/*
2 * Copyright (C) 2009 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.contacts.quickcontact;
18
19import com.android.contacts.Collapser;
20import com.android.contacts.ContactLoader;
21import com.android.contacts.R;
22import com.android.contacts.model.AccountTypeManager;
23import com.android.contacts.model.DataKind;
24import com.android.contacts.util.DataStatus;
25import com.android.contacts.util.ImageViewDrawableSetter;
26import com.android.contacts.util.SchedulingUtils;
27import com.google.common.base.Preconditions;
28import com.google.common.collect.Lists;
29
30import android.app.Activity;
31import android.app.Fragment;
32import android.app.FragmentManager;
33import android.app.LoaderManager.LoaderCallbacks;
34import android.content.ActivityNotFoundException;
35import android.content.ContentUris;
36import android.content.ContentValues;
37import android.content.Context;
38import android.content.Entity;
39import android.content.Entity.NamedContentValues;
40import android.content.Intent;
41import android.content.Loader;
42import android.content.pm.PackageManager;
43import android.graphics.Rect;
44import android.graphics.drawable.Drawable;
45import android.net.Uri;
46import android.os.Bundle;
47import android.os.Handler;
48import android.provider.ContactsContract.CommonDataKinds.Email;
49import android.provider.ContactsContract.CommonDataKinds.Im;
50import android.provider.ContactsContract.CommonDataKinds.Phone;
51import android.provider.ContactsContract.CommonDataKinds.SipAddress;
52import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
53import android.provider.ContactsContract.CommonDataKinds.Website;
54import android.provider.ContactsContract.Contacts;
55import android.provider.ContactsContract.Data;
56import android.provider.ContactsContract.QuickContact;
57import android.provider.ContactsContract.RawContacts;
58import android.support.v13.app.FragmentPagerAdapter;
59import android.support.v4.view.ViewPager;
60import android.support.v4.view.ViewPager.SimpleOnPageChangeListener;
61import android.text.TextUtils;
62import android.util.Log;
63import android.view.MotionEvent;
64import android.view.View;
65import android.view.View.OnClickListener;
66import android.view.ViewGroup;
67import android.view.WindowManager;
68import android.widget.HorizontalScrollView;
69import android.widget.ImageButton;
70import android.widget.ImageView;
71import android.widget.RelativeLayout;
72import android.widget.TextView;
73import android.widget.Toast;
74
75import java.util.HashMap;
76import java.util.HashSet;
77import java.util.List;
78import java.util.Set;
79
80// TODO: Save selected tab index during rotation
81
82/**
83 * Mostly translucent {@link Activity} that shows QuickContact dialog. It loads
84 * data asynchronously, and then shows a popup with details centered around
85 * {@link Intent#getSourceBounds()}.
86 */
87public class QuickContactActivity extends Activity {
88    private static final String TAG = "QuickContact";
89
90    private static final boolean TRACE_LAUNCH = false;
91    private static final String TRACE_TAG = "quickcontact";
92    private static final int POST_DRAW_WAIT_DURATION = 60;
93
94    @SuppressWarnings("deprecation")
95    private static final String LEGACY_AUTHORITY = android.provider.Contacts.AUTHORITY;
96
97    private Uri mLookupUri;
98    private String[] mExcludeMimes;
99    private List<String> mSortedActionMimeTypes = Lists.newArrayList();
100
101    private boolean mHasFinishedAnimatingIn = false;
102    private boolean mHasStartedAnimatingOut = false;
103
104    private FloatingChildLayout mFloatingLayout;
105
106    private View mPhotoContainer;
107    private ViewGroup mTrack;
108    private HorizontalScrollView mTrackScroller;
109    private View mSelectedTabRectangle;
110    private View mLineAfterTrack;
111
112    private ImageButton mOpenDetailsButton;
113    private ImageButton mOpenDetailsPushLayerButton;
114    private ViewPager mListPager;
115
116    private final ImageViewDrawableSetter mPhotoSetter = new ImageViewDrawableSetter();
117
118    /**
119     * Keeps the default action per mimetype. Empty if no default actions are set
120     */
121    private HashMap<String, Action> mDefaultsMap = new HashMap<String, Action>();
122
123    /**
124     * Set of {@link Action} that are associated with the aggregate currently
125     * displayed by this dialog, represented as a map from {@link String}
126     * MIME-type to a list of {@link Action}.
127     */
128    private ActionMultiMap mActions = new ActionMultiMap();
129
130    /**
131     * {@link #LEADING_MIMETYPES} and {@link #TRAILING_MIMETYPES} are used to sort MIME-types.
132     *
133     * <p>The MIME-types in {@link #LEADING_MIMETYPES} appear in the front of the dialog,
134     * in the order specified here.</p>
135     *
136     * <p>The ones in {@link #TRAILING_MIMETYPES} appear in the end of the dialog, in the order
137     * specified here.</p>
138     *
139     * <p>The rest go between them, in the order in the array.</p>
140     */
141    private static final List<String> LEADING_MIMETYPES = Lists.newArrayList(
142            Phone.CONTENT_ITEM_TYPE, SipAddress.CONTENT_ITEM_TYPE, Email.CONTENT_ITEM_TYPE);
143
144    /** See {@link #LEADING_MIMETYPES}. */
145    private static final List<String> TRAILING_MIMETYPES = Lists.newArrayList(
146            StructuredPostal.CONTENT_ITEM_TYPE, Website.CONTENT_ITEM_TYPE);
147
148    /** Id for the background loader */
149    private static final int LOADER_ID = 0;
150
151    @Override
152    protected void onCreate(Bundle icicle) {
153        super.onCreate(icicle);
154
155        // Show QuickContact in front of soft input
156        getWindow().setFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM,
157                WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
158
159        setContentView(R.layout.quickcontact_activity);
160
161        mFloatingLayout = (FloatingChildLayout) findViewById(R.id.floating_layout);
162        mTrack = (ViewGroup) findViewById(R.id.track);
163        mTrackScroller = (HorizontalScrollView) findViewById(R.id.track_scroller);
164        mOpenDetailsButton = (ImageButton) findViewById(R.id.open_details_button);
165        mOpenDetailsPushLayerButton = (ImageButton) findViewById(R.id.open_details_push_layer);
166        mListPager = (ViewPager) findViewById(R.id.item_list_pager);
167        mSelectedTabRectangle = findViewById(R.id.selected_tab_rectangle);
168        mLineAfterTrack = findViewById(R.id.line_after_track);
169
170        mFloatingLayout.setOnOutsideTouchListener(new View.OnTouchListener() {
171            @Override
172            public boolean onTouch(View v, MotionEvent event) {
173                return handleOutsideTouch();
174            }
175        });
176
177        final OnClickListener openDetailsClickHandler = new OnClickListener() {
178            @Override
179            public void onClick(View v) {
180                final Intent intent = new Intent(Intent.ACTION_VIEW, mLookupUri);
181                intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
182                startActivity(intent);
183                hide(false);
184            }
185        };
186        mOpenDetailsButton.setOnClickListener(openDetailsClickHandler);
187        mOpenDetailsPushLayerButton.setOnClickListener(openDetailsClickHandler);
188        mListPager.setAdapter(new ViewPagerAdapter(getFragmentManager()));
189        mListPager.setOnPageChangeListener(new PageChangeListener());
190
191        show();
192    }
193
194    private void show() {
195
196        if (TRACE_LAUNCH) {
197            android.os.Debug.startMethodTracing(TRACE_TAG);
198        }
199
200        final Intent intent = getIntent();
201
202        Uri lookupUri = intent.getData();
203
204        // Check to see whether it comes from the old version.
205        if (LEGACY_AUTHORITY.equals(lookupUri.getAuthority())) {
206            final long rawContactId = ContentUris.parseId(lookupUri);
207            lookupUri = RawContacts.getContactLookupUri(getContentResolver(),
208                    ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId));
209        }
210
211        mLookupUri = Preconditions.checkNotNull(lookupUri, "missing lookupUri");
212
213        // Read requested parameters for displaying
214        final Rect targetScreen = intent.getSourceBounds();
215        Preconditions.checkNotNull(targetScreen, "missing targetScreen");
216        mFloatingLayout.setChildTargetScreen(targetScreen);
217
218        mExcludeMimes = intent.getStringArrayExtra(QuickContact.EXTRA_EXCLUDE_MIMES);
219
220        // find and prepare correct header view
221        mPhotoContainer = findViewById(R.id.photo_container);
222        setHeaderNameText(R.id.name, R.string.missing_name);
223
224        getLoaderManager().initLoader(LOADER_ID, null, mLoaderCallbacks);
225    }
226
227    private boolean handleOutsideTouch() {
228        if (!mHasFinishedAnimatingIn) return false;
229        if (mHasStartedAnimatingOut) return false;
230
231        mHasStartedAnimatingOut = true;
232        hide(true);
233        return true;
234    }
235
236    private void hide(boolean withAnimation) {
237        // cancel any pending queries
238        getLoaderManager().destroyLoader(LOADER_ID);
239
240        if (withAnimation) {
241            mFloatingLayout.hideChild(new Runnable() {
242                @Override
243                public void run() {
244                    // Wait until the final animation frame has been drawn, otherwise
245                    // there is jank as the framework transitions to the next Activity.
246                    SchedulingUtils.doAfterDraw(mFloatingLayout, new Runnable() {
247                        @Override
248                        public void run() {
249                            // Unfortunately, we need to also use postDelayed() to wait a moment
250                            // for the frame to be drawn, else the framework's activity-transition
251                            // animation will kick in before the final frame is available to it.
252                            // This seems unavoidable.  The problem isn't merely that there is no
253                            // post-draw listener API; if that were so, it would be sufficient to
254                            // call post() instead of postDelayed().
255                            new Handler().postDelayed(new Runnable() {
256                                @Override
257                                public void run() {
258                                    finish();
259                                }
260                            }, POST_DRAW_WAIT_DURATION);
261                        }
262                    });
263                }
264            });
265        } else {
266            mFloatingLayout.hideChild(null);
267            finish();
268        }
269    }
270
271    @Override
272    public void onBackPressed() {
273        hide(true);
274    }
275
276    /** Assign this string to the view if it is not empty. */
277    private void setHeaderNameText(int id, int resId) {
278        setHeaderNameText(id, getText(resId));
279    }
280
281    /** Assign this string to the view if it is not empty. */
282    private void setHeaderNameText(int id, CharSequence value) {
283        final View view = mPhotoContainer.findViewById(id);
284        if (view instanceof TextView) {
285            if (!TextUtils.isEmpty(value)) {
286                ((TextView)view).setText(value);
287            }
288        }
289    }
290
291    /**
292     * Check if the given MIME-type appears in the list of excluded MIME-types
293     * that the most-recent caller requested.
294     */
295    private boolean isMimeExcluded(String mimeType) {
296        if (mExcludeMimes == null) return false;
297        for (String excludedMime : mExcludeMimes) {
298            if (TextUtils.equals(excludedMime, mimeType)) {
299                return true;
300            }
301        }
302        return false;
303    }
304
305    /**
306     * Handle the result from the ContactLoader
307     */
308    private void bindData(ContactLoader.Result data) {
309        final ResolveCache cache = ResolveCache.getInstance(this);
310        final Context context = this;
311
312        mOpenDetailsButton.setVisibility(isMimeExcluded(Contacts.CONTENT_ITEM_TYPE) ? View.GONE
313                : View.VISIBLE);
314
315        mDefaultsMap.clear();
316
317        final AccountTypeManager accountTypes = AccountTypeManager.getInstance(
318                context.getApplicationContext());
319        final ImageView photoView = (ImageView) mPhotoContainer.findViewById(R.id.photo);
320        mPhotoSetter.setupContactPhoto(data, photoView);
321
322        for (Entity entity : data.getEntities()) {
323            final ContentValues entityValues = entity.getEntityValues();
324            final String accountType = entityValues.getAsString(RawContacts.ACCOUNT_TYPE);
325            final String dataSet = entityValues.getAsString(RawContacts.DATA_SET);
326            for (NamedContentValues subValue : entity.getSubValues()) {
327                final ContentValues entryValues = subValue.values;
328                final String mimeType = entryValues.getAsString(Data.MIMETYPE);
329
330                // Skip this data item if MIME-type excluded
331                if (isMimeExcluded(mimeType)) continue;
332
333                final long dataId = entryValues.getAsLong(Data._ID);
334                final Integer primary = entryValues.getAsInteger(Data.IS_PRIMARY);
335                final boolean isPrimary = primary != null && primary != 0;
336                final Integer superPrimary = entryValues.getAsInteger(Data.IS_SUPER_PRIMARY);
337                final boolean isSuperPrimary = superPrimary != null && superPrimary != 0;
338
339                final DataKind kind =
340                        accountTypes.getKindOrFallback(accountType, dataSet, mimeType);
341
342                if (kind != null) {
343                    // Build an action for this data entry, find a mapping to a UI
344                    // element, build its summary from the cursor, and collect it
345                    // along with all others of this MIME-type.
346                    final Action action = new DataAction(context, mimeType, kind, dataId,
347                            entryValues);
348                    final boolean wasAdded = considerAdd(action, cache, isSuperPrimary);
349                    if (wasAdded) {
350                        // Remember the default
351                        if (isSuperPrimary || (isPrimary && (mDefaultsMap.get(mimeType) == null))) {
352                            mDefaultsMap.put(mimeType, action);
353                        }
354                    }
355                }
356
357                // Handle Email rows with presence data as Im entry
358                final DataStatus status = data.getStatuses().get(dataId);
359                if (status != null && Email.CONTENT_ITEM_TYPE.equals(mimeType)) {
360                    final DataKind imKind = accountTypes.getKindOrFallback(accountType, dataSet,
361                            Im.CONTENT_ITEM_TYPE);
362                    if (imKind != null) {
363                        final DataAction action = new DataAction(context, Im.CONTENT_ITEM_TYPE,
364                                imKind, dataId, entryValues);
365                        action.setPresence(status.getPresence());
366                        considerAdd(action, cache, isSuperPrimary);
367                    }
368                }
369            }
370        }
371
372        // Collapse Action Lists (remove e.g. duplicate e-mail addresses from different sources)
373        for (List<Action> actionChildren : mActions.values()) {
374            Collapser.collapseList(actionChildren);
375        }
376
377        setHeaderNameText(R.id.name, data.getDisplayName());
378
379        // All the mime-types to add.
380        final Set<String> containedTypes = new HashSet<String>(mActions.keySet());
381        mSortedActionMimeTypes.clear();
382        // First, add LEADING_MIMETYPES, which are most common.
383        for (String mimeType : LEADING_MIMETYPES) {
384            if (containedTypes.contains(mimeType)) {
385                mSortedActionMimeTypes.add(mimeType);
386                containedTypes.remove(mimeType);
387            }
388        }
389
390        // Add all the remaining ones that are not TRAILING
391        for (String mimeType : containedTypes.toArray(new String[containedTypes.size()])) {
392            if (!TRAILING_MIMETYPES.contains(mimeType)) {
393                mSortedActionMimeTypes.add(mimeType);
394                containedTypes.remove(mimeType);
395            }
396        }
397
398        // Then, add TRAILING_MIMETYPES, which are least common.
399        for (String mimeType : TRAILING_MIMETYPES) {
400            if (containedTypes.contains(mimeType)) {
401                containedTypes.remove(mimeType);
402                mSortedActionMimeTypes.add(mimeType);
403            }
404        }
405
406        // Add buttons for each mimetype
407        mTrack.removeAllViews();
408        for (String mimeType : mSortedActionMimeTypes) {
409            final View actionView = inflateAction(mimeType, cache, mTrack);
410            mTrack.addView(actionView);
411        }
412
413        final boolean hasData = !mSortedActionMimeTypes.isEmpty();
414        mTrackScroller.setVisibility(hasData ? View.VISIBLE : View.GONE);
415        mSelectedTabRectangle.setVisibility(hasData ? View.VISIBLE : View.GONE);
416        mLineAfterTrack.setVisibility(hasData ? View.VISIBLE : View.GONE);
417        mListPager.setVisibility(hasData ? View.VISIBLE : View.GONE);
418    }
419
420    /**
421     * Consider adding the given {@link Action}, which will only happen if
422     * {@link PackageManager} finds an application to handle
423     * {@link Action#getIntent()}.
424     * @param action the action to handle
425     * @param resolveCache cache of applications that can handle actions
426     * @param front indicates whether to add the action to the front of the list
427     * @return true if action has been added
428     */
429    private boolean considerAdd(Action action, ResolveCache resolveCache, boolean front) {
430        if (resolveCache.hasResolve(action)) {
431            mActions.put(action.getMimeType(), action, front);
432            return true;
433        }
434        return false;
435    }
436
437    /**
438     * Inflate the in-track view for the action of the given MIME-type, collapsing duplicate values.
439     * Will use the icon provided by the {@link DataKind}.
440     */
441    private View inflateAction(String mimeType, ResolveCache resolveCache, ViewGroup root) {
442        final CheckableImageView typeView = (CheckableImageView) getLayoutInflater().inflate(
443                R.layout.quickcontact_track_button, root, false);
444
445        List<Action> children = mActions.get(mimeType);
446        typeView.setTag(mimeType);
447        final Action firstInfo = children.get(0);
448
449        // Set icon and listen for clicks
450        final CharSequence descrip = resolveCache.getDescription(firstInfo);
451        final Drawable icon = resolveCache.getIcon(firstInfo);
452        typeView.setChecked(false);
453        typeView.setContentDescription(descrip);
454        typeView.setImageDrawable(icon);
455        typeView.setOnClickListener(mTypeViewClickListener);
456
457        return typeView;
458    }
459
460    private CheckableImageView getActionViewAt(int position) {
461        return (CheckableImageView) mTrack.getChildAt(position);
462    }
463
464    @Override
465    public void onAttachFragment(Fragment fragment) {
466        final QuickContactListFragment listFragment = (QuickContactListFragment) fragment;
467        listFragment.setListener(mListFragmentListener);
468    }
469
470    private LoaderCallbacks<ContactLoader.Result> mLoaderCallbacks =
471            new LoaderCallbacks<ContactLoader.Result>() {
472        @Override
473        public void onLoaderReset(Loader<ContactLoader.Result> loader) {
474        }
475
476        @Override
477        public void onLoadFinished(Loader<ContactLoader.Result> loader, ContactLoader.Result data) {
478            if (isFinishing()) {
479                hide(false);
480                return;
481            }
482            if (data.isError()) {
483                // This shouldn't ever happen, so throw an exception. The {@link ContactLoader}
484                // should log the actual exception.
485                throw new IllegalStateException("Failed to load contact", data.getException());
486            }
487            if (data.isNotFound()) {
488                Log.i(TAG, "No contact found: " + ((ContactLoader)loader).getLookupUri());
489                Toast.makeText(QuickContactActivity.this, R.string.invalidContactMessage,
490                        Toast.LENGTH_LONG).show();
491                hide(false);
492                return;
493            }
494
495            bindData(data);
496
497            if (TRACE_LAUNCH) {
498                android.os.Debug.stopMethodTracing();
499            }
500
501            // Data bound and ready, pull curtain to show. Put this on the Handler to ensure
502            // that the layout passes are completed
503            SchedulingUtils.doAfterLayout(mFloatingLayout, new Runnable() {
504                @Override
505                public void run() {
506                    mFloatingLayout.showChild(new Runnable() {
507                        @Override
508                        public void run() {
509                            mHasFinishedAnimatingIn = true;
510                        }
511                    });
512                }
513            });
514        }
515
516        @Override
517        public Loader<ContactLoader.Result> onCreateLoader(int id, Bundle args) {
518            if (mLookupUri == null) {
519                Log.wtf(TAG, "Lookup uri wasn't initialized. Loader was started too early");
520            }
521            return new ContactLoader(getApplicationContext(), mLookupUri);
522        }
523    };
524
525    /** A type (e.g. Call/Addresses was clicked) */
526    private final OnClickListener mTypeViewClickListener = new OnClickListener() {
527        @Override
528        public void onClick(View view) {
529            final CheckableImageView actionView = (CheckableImageView)view;
530            final String mimeType = (String) actionView.getTag();
531            int index = mSortedActionMimeTypes.indexOf(mimeType);
532            mListPager.setCurrentItem(index, true);
533        }
534    };
535
536    private class ViewPagerAdapter extends FragmentPagerAdapter {
537        public ViewPagerAdapter(FragmentManager fragmentManager) {
538            super(fragmentManager);
539        }
540
541        @Override
542        public Fragment getItem(int position) {
543            QuickContactListFragment fragment = new QuickContactListFragment();
544            final String mimeType = mSortedActionMimeTypes.get(position);
545            final List<Action> actions = mActions.get(mimeType);
546            fragment.setActions(actions);
547            return fragment;
548        }
549
550        @Override
551        public int getCount() {
552            return mSortedActionMimeTypes.size();
553        }
554    }
555
556    private class PageChangeListener extends SimpleOnPageChangeListener {
557        @Override
558        public void onPageSelected(int position) {
559            final CheckableImageView actionView = getActionViewAt(position);
560            mTrackScroller.requestChildRectangleOnScreen(actionView,
561                    new Rect(0, 0, actionView.getWidth(), actionView.getHeight()), false);
562        }
563
564        @Override
565        public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
566            final RelativeLayout.LayoutParams layoutParams =
567                    (RelativeLayout.LayoutParams) mSelectedTabRectangle.getLayoutParams();
568            final int width = mSelectedTabRectangle.getWidth();
569            layoutParams.leftMargin = (int) ((position + positionOffset) * width);
570            mSelectedTabRectangle.setLayoutParams(layoutParams);
571        }
572    }
573
574    private final QuickContactListFragment.Listener mListFragmentListener =
575            new QuickContactListFragment.Listener() {
576        @Override
577        public void onOutsideClick() {
578            // If there is no background, we want to dismiss, because to the user it seems
579            // like he had touched outside. If the ViewPager is solid however, those taps
580            // must be ignored
581            final boolean isTransparent = mListPager.getBackground() == null;
582            if (isTransparent) handleOutsideTouch();
583        }
584
585        @Override
586        public void onItemClicked(final Action action, final boolean alternate) {
587            final Runnable startAppRunnable = new Runnable() {
588                @Override
589                public void run() {
590                    try {
591                        startActivity(alternate ? action.getAlternateIntent() : action.getIntent());
592                    } catch (ActivityNotFoundException e) {
593                        Toast.makeText(QuickContactActivity.this, R.string.quickcontact_missing_app,
594                                Toast.LENGTH_SHORT).show();
595                    }
596
597                    hide(false);
598                }
599            };
600            // Defer the action to make the window properly repaint
601            new Handler().post(startAppRunnable);
602        }
603    };
604}
605