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