QuickContactActivity.java revision 02477604d7cc803b6a7de10b45ed176c4e70f312
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.animation.ArgbEvaluator;
20import android.animation.ObjectAnimator;
21import android.app.Activity;
22import android.app.Fragment;
23import android.app.LoaderManager.LoaderCallbacks;
24import android.content.ActivityNotFoundException;
25import android.content.ContentUris;
26import android.content.Intent;
27import android.content.Loader;
28import android.content.pm.PackageManager;
29import android.graphics.Bitmap;
30import android.graphics.Color;
31import android.graphics.drawable.BitmapDrawable;
32import android.graphics.drawable.ColorDrawable;
33import android.graphics.drawable.Drawable;
34import android.graphics.PorterDuff;
35import android.graphics.PorterDuffColorFilter;
36import android.net.Uri;
37import android.os.AsyncTask;
38import android.os.Bundle;
39import android.os.Trace;
40import android.provider.ContactsContract;
41import android.provider.ContactsContract.CommonDataKinds.Email;
42import android.provider.ContactsContract.CommonDataKinds.Phone;
43import android.provider.ContactsContract.CommonDataKinds.SipAddress;
44import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
45import android.provider.ContactsContract.CommonDataKinds.Website;
46import android.provider.ContactsContract.Contacts;
47import android.provider.ContactsContract.QuickContact;
48import android.provider.ContactsContract.RawContacts;
49import android.support.v7.graphics.Palette;
50import android.text.TextUtils;
51import android.util.Log;
52import android.view.Menu;
53import android.view.MenuItem;
54import android.view.MenuInflater;
55import android.view.View;
56import android.view.View.OnClickListener;
57import android.view.WindowManager;
58import android.widget.ImageView;
59import android.widget.Toast;
60import android.widget.Toolbar;
61
62import com.android.contacts.ContactSaveService;
63import com.android.contacts.ContactsActivity;
64import com.android.contacts.common.Collapser;
65import com.android.contacts.R;
66import com.android.contacts.common.editor.SelectAccountDialogFragment;
67import com.android.contacts.common.lettertiles.LetterTileDrawable;
68import com.android.contacts.common.list.ShortcutIntentBuilder;
69import com.android.contacts.common.list.ShortcutIntentBuilder.OnShortcutIntentCreatedListener;
70import com.android.contacts.common.model.AccountTypeManager;
71import com.android.contacts.common.model.Contact;
72import com.android.contacts.common.model.ContactLoader;
73import com.android.contacts.common.model.RawContact;
74import com.android.contacts.common.model.account.AccountType;
75import com.android.contacts.common.model.account.AccountWithDataSet;
76import com.android.contacts.common.model.dataitem.DataItem;
77import com.android.contacts.common.model.dataitem.DataKind;
78import com.android.contacts.common.model.dataitem.EmailDataItem;
79import com.android.contacts.common.model.dataitem.ImDataItem;
80import com.android.contacts.common.model.dataitem.PhoneDataItem;
81import com.android.contacts.common.util.DataStatus;
82import com.android.contacts.detail.ContactDetailDisplayUtils;
83import com.android.contacts.interactions.ContactDeletionInteraction;
84import com.android.contacts.interactions.ContactInteraction;
85import com.android.contacts.interactions.SmsInteractionsLoader;
86import com.android.contacts.quickcontact.ExpandingEntryCardView.Entry;
87import com.android.contacts.util.ImageViewDrawableSetter;
88import com.android.contacts.util.SchedulingUtils;
89import com.android.contacts.widget.MultiShrinkScroller;
90import com.android.contacts.widget.MultiShrinkScroller.MultiShrinkScrollerListener;
91
92import com.google.common.base.Preconditions;
93import com.google.common.collect.Lists;
94
95import java.util.ArrayList;
96import java.util.Collections;
97import java.util.Comparator;
98import java.util.HashMap;
99import java.util.HashSet;
100import java.util.List;
101import java.util.Map;
102import java.util.Set;
103
104/**
105 * Mostly translucent {@link Activity} that shows QuickContact dialog. It loads
106 * data asynchronously, and then shows a popup with details centered around
107 * {@link Intent#getSourceBounds()}.
108 */
109public class QuickContactActivity extends ContactsActivity {
110
111    /**
112     * QuickContacts immediately takes up the full screen. All possible information is shown.
113     * This value for {@link android.provider.ContactsContract.QuickContact#EXTRA_MODE}
114     * should only be used by the Contacts app.
115     */
116    public static final int MODE_FULLY_EXPANDED = 4;
117
118    private static final String TAG = "QuickContact";
119
120    private static final int ANIMATION_SLIDE_OPEN_DURATION = 250;
121    private static final int ANIMATION_STATUS_BAR_COLOR_CHANGE_DURATION = 75;
122    private static final int REQUEST_CODE_CONTACT_EDITOR_ACTIVITY = 1;
123    private static final float SYSTEM_BAR_BRIGHTNESS_FACTOR = 0.7f;
124    private static final int SHIM_COLOR = Color.argb(0x7F, 0, 0, 0);
125
126    /** This is the Intent action to install a shortcut in the launcher. */
127    private static final String ACTION_INSTALL_SHORTCUT =
128            "com.android.launcher.action.INSTALL_SHORTCUT";
129
130    @SuppressWarnings("deprecation")
131    private static final String LEGACY_AUTHORITY = android.provider.Contacts.AUTHORITY;
132
133    private Uri mLookupUri;
134    private String[] mExcludeMimes;
135    private int mExtraMode;
136    private int mStatusBarColor;
137    private boolean mHasAlreadyBeenOpened;
138
139    private ImageView mPhotoView;
140    private ExpandingEntryCardView mCommunicationCard;
141    private ExpandingEntryCardView mRecentCard;
142    private MultiShrinkScroller mScroller;
143    private SelectAccountDialogFragmentListener mSelectAccountFragmentListener;
144    private AsyncTask<Void, Void, Void> mEntriesAndActionsTask;
145
146    private static final int MIN_NUM_COMMUNICATION_ENTRIES_SHOWN = 3;
147    private static final int MIN_NUM_COLLAPSED_RECENT_ENTRIES_SHOWN = 3;
148
149    private Contact mContactData;
150    private ContactLoader mContactLoader;
151
152    private PorterDuffColorFilter mColorFilter;
153    List<Drawable> mDrawablesToTint;
154
155    private final ImageViewDrawableSetter mPhotoSetter = new ImageViewDrawableSetter();
156
157    /**
158     * Keeps the default action per mimetype. Empty if no default actions are set
159     */
160    private HashMap<String, Action> mDefaultsMap = new HashMap<String, Action>();
161
162    /**
163     * Set of {@link Action} that are associated with the aggregate currently
164     * displayed by this dialog, represented as a map from {@link String}
165     * MIME-type to a list of {@link Action}.
166     */
167    private ActionMultiMap mActions = new ActionMultiMap();
168
169    /**
170     * {@link #LEADING_MIMETYPES} and {@link #TRAILING_MIMETYPES} are used to sort MIME-types.
171     *
172     * <p>The MIME-types in {@link #LEADING_MIMETYPES} appear in the front of the dialog,
173     * in the order specified here.</p>
174     *
175     * <p>The ones in {@link #TRAILING_MIMETYPES} appear in the end of the dialog, in the order
176     * specified here.</p>
177     *
178     * <p>The rest go between them, in the order in the array.</p>
179     */
180    private static final List<String> LEADING_MIMETYPES = Lists.newArrayList(
181            Phone.CONTENT_ITEM_TYPE, SipAddress.CONTENT_ITEM_TYPE, Email.CONTENT_ITEM_TYPE);
182
183    /** See {@link #LEADING_MIMETYPES}. */
184    private static final List<String> TRAILING_MIMETYPES = Lists.newArrayList(
185            StructuredPostal.CONTENT_ITEM_TYPE, Website.CONTENT_ITEM_TYPE);
186
187    /** Id for the background contact loader */
188    private static final int LOADER_CONTACT_ID = 0;
189
190    /** Id for the background Sms Loader */
191    private static final int LOADER_SMS_ID = 1;
192    private static final String KEY_LOADER_EXTRA_SMS_PHONES =
193            QuickContactActivity.class.getCanonicalName() + ".KEY_LOADER_EXTRA_SMS_PHONES";
194    private static final int MAX_SMS_RETRIEVE = 3;
195
196    private static final int[] mRecentLoaderIds = new int[LOADER_SMS_ID];
197    private Map<Integer, List<ContactInteraction>> mRecentLoaderResults;
198
199    private static final String FRAGMENT_TAG_SELECT_ACCOUNT = "select_account_fragment";
200
201    final OnClickListener mEntryClickHandler = new OnClickListener() {
202        @Override
203        public void onClick(View v) {
204            Log.i(TAG, "mEntryClickHandler onClick");
205            Object intent = v.getTag();
206            if (intent == null || !(intent instanceof Intent)) {
207                return;
208            }
209            startActivity((Intent) intent);
210        }
211    };
212
213    /**
214     * Headless fragment used to handle account selection callbacks invoked from
215     * {@link DirectoryContactUtil}.
216     */
217    public static class SelectAccountDialogFragmentListener extends Fragment
218            implements SelectAccountDialogFragment.Listener {
219
220        private QuickContactActivity mQuickContactActivity;
221
222        public SelectAccountDialogFragmentListener() {}
223
224        @Override
225        public void onAccountChosen(AccountWithDataSet account, Bundle extraArgs) {
226            DirectoryContactUtil.createCopy(mQuickContactActivity.mContactData.getContentValues(),
227                    account, mQuickContactActivity);
228        }
229
230        @Override
231        public void onAccountSelectorCancelled() {}
232
233        /**
234         * Set the parent activity. Since rotation can cause this fragment to be used across
235         * more than one activity instance, we need to explicitly set this value instead
236         * of making this class non-static.
237         */
238        public void setQuickContactActivity(QuickContactActivity quickContactActivity) {
239            mQuickContactActivity = quickContactActivity;
240        }
241    }
242
243    final MultiShrinkScrollerListener mMultiShrinkScrollerListener
244            = new MultiShrinkScrollerListener() {
245        @Override
246        public void onScrolledOffBottom() {
247            onBackPressed();
248        }
249
250        @Override
251        public void onEnterFullscreen() {
252            updateStatusBarColor();
253        }
254
255        @Override
256        public void onExitFullscreen() {
257            updateStatusBarColor();
258        }
259    };
260
261    @Override
262    protected void onCreate(Bundle savedInstanceState) {
263        Trace.beginSection("onCreate()");
264        super.onCreate(savedInstanceState);
265
266        getWindow().setStatusBarColor(Color.TRANSPARENT);
267        // Since we can't disable Window animations from the Launcher, we can minimize the
268        // silliness of the animation by setting the navigation bar transparent.
269        getWindow().setNavigationBarColor(Color.TRANSPARENT);
270
271        processIntent(getIntent());
272
273        // Show QuickContact in front of soft input
274        getWindow().setFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM,
275                WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
276
277        setContentView(R.layout.quickcontact_activity);
278
279        mCommunicationCard = (ExpandingEntryCardView) findViewById(R.id.communication_card);
280        mRecentCard = (ExpandingEntryCardView) findViewById(R.id.recent_card);
281        mScroller = (MultiShrinkScroller) findViewById(R.id.multiscroller);
282
283        mCommunicationCard.setOnClickListener(mEntryClickHandler);
284        mCommunicationCard.setTitle(getResources().getString(R.string.communication_card_title));
285        mCommunicationCard.setExpandButtonText(
286        getResources().getString(R.string.expanding_entry_card_view_see_all));
287
288        mRecentCard.setOnClickListener(mEntryClickHandler);
289        mRecentCard.setTitle(getResources().getString(R.string.recent_card_title));
290
291        mPhotoView = (ImageView) findViewById(R.id.photo);
292
293        final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
294        setActionBar(toolbar);
295        setHeaderNameText(R.string.missing_name);
296
297        mHasAlreadyBeenOpened = savedInstanceState != null;
298
299        final ColorDrawable windowShim = new ColorDrawable(SHIM_COLOR);
300        getWindow().setBackgroundDrawable(windowShim);
301        if (!mHasAlreadyBeenOpened) {
302            final int duration = getResources().getInteger(android.R.integer.config_shortAnimTime);
303            ObjectAnimator.ofInt(windowShim, "alpha", 0, 0xFF).setDuration(duration).start();
304        }
305
306        if (mScroller != null) {
307            mScroller.initialize(mMultiShrinkScrollerListener);
308            if (mHasAlreadyBeenOpened) {
309                mScroller.setVisibility(View.VISIBLE);
310                mScroller.setScroll(mScroller.getScrollNeededToBeFullScreen());
311            } else {
312                mScroller.setVisibility(View.GONE);
313            }
314        }
315
316        mDrawablesToTint = new ArrayList<>();
317        mSelectAccountFragmentListener= (SelectAccountDialogFragmentListener) getFragmentManager()
318                .findFragmentByTag(FRAGMENT_TAG_SELECT_ACCOUNT);
319        if (mSelectAccountFragmentListener == null) {
320            mSelectAccountFragmentListener = new SelectAccountDialogFragmentListener();
321            getFragmentManager().beginTransaction().add(0, mSelectAccountFragmentListener,
322                    FRAGMENT_TAG_SELECT_ACCOUNT).commit();
323            mSelectAccountFragmentListener.setRetainInstance(true);
324        }
325        mSelectAccountFragmentListener.setQuickContactActivity(this);
326
327        Trace.endSection();
328    }
329
330    protected void onActivityResult(int requestCode, int resultCode,
331            Intent data) {
332        if (requestCode == REQUEST_CODE_CONTACT_EDITOR_ACTIVITY &&
333                resultCode == ContactDeletionInteraction.RESULT_CODE_DELETED) {
334            // The contact that we were showing has been deleted.
335            finish();
336        }
337    }
338
339    @Override
340    protected void onNewIntent(Intent intent) {
341        super.onNewIntent(intent);
342        mHasAlreadyBeenOpened = true;
343        processIntent(intent);
344    }
345
346    private void processIntent(Intent intent) {
347        Uri lookupUri = intent.getData();
348
349        // Check to see whether it comes from the old version.
350        if (lookupUri != null && LEGACY_AUTHORITY.equals(lookupUri.getAuthority())) {
351            final long rawContactId = ContentUris.parseId(lookupUri);
352            lookupUri = RawContacts.getContactLookupUri(getContentResolver(),
353                    ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId));
354        }
355        mExtraMode = getIntent().getIntExtra(QuickContact.EXTRA_MODE,
356                QuickContact.MODE_LARGE);
357        final Uri oldLookupUri = mLookupUri;
358
359        mLookupUri = Preconditions.checkNotNull(lookupUri, "missing lookupUri");
360        mExcludeMimes = intent.getStringArrayExtra(QuickContact.EXTRA_EXCLUDE_MIMES);
361        if (oldLookupUri == null) {
362            mContactLoader = (ContactLoader) getLoaderManager().initLoader(
363                    LOADER_CONTACT_ID, null, mLoaderContactCallbacks);
364        } else if (oldLookupUri != mLookupUri) {
365            // After copying a directory contact, the contact URI changes. Therefore,
366            // we need to restart the loader and reload the new contact.
367            mContactLoader = (ContactLoader) getLoaderManager().restartLoader(
368                    LOADER_CONTACT_ID, null, mLoaderContactCallbacks);
369        }
370    }
371
372    private void runEntranceAnimation() {
373        if (mHasAlreadyBeenOpened) {
374            return;
375        }
376        mHasAlreadyBeenOpened = true;
377        final int bottomScroll = mScroller.getScrollUntilOffBottom() - 1;
378        final ObjectAnimator scrollAnimation
379                = ObjectAnimator.ofInt(mScroller, "scroll", -bottomScroll,
380                mExtraMode != MODE_FULLY_EXPANDED ? 0 : mScroller.getScrollNeededToBeFullScreen());
381        scrollAnimation.setDuration(ANIMATION_SLIDE_OPEN_DURATION);
382        scrollAnimation.start();
383    }
384
385    /** Assign this string to the view if it is not empty. */
386    private void setHeaderNameText(int resId) {
387        getActionBar().setTitle(getText(resId));
388    }
389
390    /** Assign this string to the view if it is not empty. */
391    private void setHeaderNameText(CharSequence value) {
392        if (!TextUtils.isEmpty(value)) {
393            getActionBar().setTitle(value);
394        }
395    }
396
397    /**
398     * Check if the given MIME-type appears in the list of excluded MIME-types
399     * that the most-recent caller requested.
400     */
401    private boolean isMimeExcluded(String mimeType) {
402        if (mExcludeMimes == null) return false;
403        for (String excludedMime : mExcludeMimes) {
404            if (TextUtils.equals(excludedMime, mimeType)) {
405                return true;
406            }
407        }
408        return false;
409    }
410
411    /**
412     * Handle the result from the ContactLoader
413     */
414    private void bindContactData(final Contact data) {
415        Trace.beginSection("bindContactData");
416        mContactData = data;
417        invalidateOptionsMenu();
418
419        mDefaultsMap.clear();
420
421        Trace.endSection();
422        Trace.beginSection("Set display photo & name");
423
424        mPhotoSetter.setupContactPhoto(data, mPhotoView);
425        extractAndApplyTintFromPhotoViewAsynchronously();
426        setHeaderNameText(data.getDisplayName());
427
428        Trace.endSection();
429
430        final List<String> sortedActionMimeTypes = Lists.newArrayList();
431        // Maintain a list of phone numbers to pass into SmsInteractionsLoader
432        final List<String> phoneNumbers = Lists.newArrayList();
433        // List of Entry that makes up the ExpandingEntryCardView
434        final List<Entry> entries = Lists.newArrayList();
435
436        mEntriesAndActionsTask = new AsyncTask<Void, Void, Void>() {
437            @Override
438            protected Void doInBackground(Void... params) {
439                computeEntriesAndActions(data, phoneNumbers, sortedActionMimeTypes, entries);
440                return null;
441            }
442
443            @Override
444            protected void onPostExecute(Void aVoid) {
445                super.onPostExecute(aVoid);
446                // Check that original AsyncTask parameters are still valid and the activity
447                // is still running before binding to UI. A new intent could invalidate
448                // the results, for example.
449                if (data == mContactData && !isCancelled()) {
450                    bindEntriesAndActions(entries, phoneNumbers, sortedActionMimeTypes);
451                    showActivity();
452                }
453            }
454        };
455        mEntriesAndActionsTask.execute();
456    }
457
458    private void bindEntriesAndActions(List<Entry> entries,
459            List<String> phoneNumbers,
460            List<String> sortedActionMimeTypes) {
461        Trace.beginSection("start sms loader");
462
463        Bundle smsExtraBundle = new Bundle();
464        smsExtraBundle.putStringArray(KEY_LOADER_EXTRA_SMS_PHONES,
465                phoneNumbers.toArray(new String[phoneNumbers.size()]));
466        getLoaderManager().initLoader(
467                LOADER_SMS_ID,
468                smsExtraBundle,
469                mLoaderInteractionsCallbacks);
470
471        Trace.endSection();
472        Trace.beginSection("bind communicate card");
473
474        if (entries.size() > 0) {
475            mCommunicationCard.initialize(entries,
476                    /* numInitialVisibleEntries = */ MIN_NUM_COMMUNICATION_ENTRIES_SHOWN,
477                    /* isExpanded = */ false,
478                    /* themeColor = */ 0);
479        }
480
481        final boolean hasData = !sortedActionMimeTypes.isEmpty();
482        mCommunicationCard.setVisibility(hasData ? View.VISIBLE : View.GONE);
483
484        Trace.endSection();
485    }
486
487    private void showActivity() {
488        if (mScroller != null) {
489            mScroller.setVisibility(View.VISIBLE);
490            SchedulingUtils.doOnPreDraw(mScroller, /* drawNextFrame = */ false,
491                    new Runnable() {
492                        @Override
493                        public void run() {
494                            runEntranceAnimation();
495                        }
496                    });
497        }
498    }
499
500    private void computeEntriesAndActions(Contact data, List<String> phoneNumbers,
501            List<String> sortedActionMimeTypes, List<Entry> entries) {
502        Trace.beginSection("inflate entries and actions");
503
504        final ResolveCache cache = ResolveCache.getInstance(this);
505        for (RawContact rawContact : data.getRawContacts()) {
506            for (DataItem dataItem : rawContact.getDataItems()) {
507                final String mimeType = dataItem.getMimeType();
508                final AccountType accountType = rawContact.getAccountType(this);
509                final DataKind dataKind = AccountTypeManager.getInstance(this)
510                        .getKindOrFallback(accountType, mimeType);
511
512                if (dataItem instanceof PhoneDataItem) {
513                    phoneNumbers.add(((PhoneDataItem) dataItem).getNormalizedNumber());
514                }
515
516                // Skip this data item if MIME-type excluded
517                if (isMimeExcluded(mimeType)) continue;
518
519                final long dataId = dataItem.getId();
520                final boolean isPrimary = dataItem.isPrimary();
521                final boolean isSuperPrimary = dataItem.isSuperPrimary();
522
523                if (dataKind != null) {
524                    // Build an action for this data entry, find a mapping to a UI
525                    // element, build its summary from the cursor, and collect it
526                    // along with all others of this MIME-type.
527                    final Action action = new DataAction(getApplicationContext(),
528                            dataItem, dataKind);
529                    final boolean wasAdded = considerAdd(action, cache, isSuperPrimary);
530                    if (wasAdded) {
531                        // Remember the default
532                        if (isSuperPrimary || (isPrimary && (mDefaultsMap.get(mimeType) == null))) {
533                            mDefaultsMap.put(mimeType, action);
534                        }
535                    }
536                }
537
538                // Handle Email rows with presence data as Im entry
539                final DataStatus status = data.getStatuses().get(dataId);
540                if (status != null && dataItem instanceof EmailDataItem) {
541                    final EmailDataItem email = (EmailDataItem) dataItem;
542                    final ImDataItem im = ImDataItem.createFromEmail(email);
543                    if (dataKind != null) {
544                        final DataAction action = new DataAction(getApplicationContext(),
545                                im, dataKind);
546                        action.setPresence(status.getPresence());
547                        considerAdd(action, cache, isSuperPrimary);
548                    }
549                }
550            }
551        }
552
553        Trace.endSection();
554        Trace.beginSection("collapsing action list");
555
556        // Collapse Action Lists (remove e.g. duplicate e-mail addresses from different sources)
557        for (List<Action> actionChildren : mActions.values()) {
558            Collapser.collapseList(actionChildren);
559        }
560
561        Trace.endSection();
562        Trace.beginSection("sort mimetypes");
563
564        // All the mime-types to add.
565        final Set<String> containedTypes = new HashSet<String>(mActions.keySet());
566        // First, add LEADING_MIMETYPES, which are most common.
567        for (String mimeType : LEADING_MIMETYPES) {
568            if (containedTypes.contains(mimeType)) {
569                sortedActionMimeTypes.add(mimeType);
570                containedTypes.remove(mimeType);
571                entries.addAll(actionsToEntries(mActions.get(mimeType)));
572            }
573        }
574
575        // Add all the remaining ones that are not TRAILING
576        for (String mimeType : containedTypes.toArray(new String[containedTypes.size()])) {
577            if (!TRAILING_MIMETYPES.contains(mimeType)) {
578                sortedActionMimeTypes.add(mimeType);
579                containedTypes.remove(mimeType);
580                entries.addAll(actionsToEntries(mActions.get(mimeType)));
581            }
582        }
583
584        // Then, add TRAILING_MIMETYPES, which are least common.
585        for (String mimeType : TRAILING_MIMETYPES) {
586            if (containedTypes.contains(mimeType)) {
587                containedTypes.remove(mimeType);
588                sortedActionMimeTypes.add(mimeType);
589                entries.addAll(actionsToEntries(mActions.get(mimeType)));
590            }
591        }
592
593        Trace.endSection();
594    }
595
596    /**
597     * Asynchronously extract the most vibrant color from the PhotoView. Once extracted,
598     * apply this tint to {@link MultiShrinkScroller}. This operation takes about 20-30ms
599     * on a Nexus 5.
600     */
601    private void extractAndApplyTintFromPhotoViewAsynchronously() {
602        if (mScroller == null) {
603            return;
604        }
605        final Drawable imageViewDrawable = mPhotoView.getDrawable();
606        new AsyncTask<Void, Void, Integer>() {
607            @Override
608            protected Integer doInBackground(Void... params) {
609                if (imageViewDrawable instanceof BitmapDrawable) {
610                    final Bitmap bitmap = ((BitmapDrawable) imageViewDrawable).getBitmap();
611                    return colorFromBitmap(bitmap);
612                }
613                if (imageViewDrawable instanceof LetterTileDrawable) {
614                    // LetterTileDrawable doesn't normally draw unless it is visible. Therefore,
615                    // we need to directly ask it for its color via getColor(). We could directly
616                    // return this color. However, in the future Palette#generate() may incorporate
617                    // saturation boosting. So I want to use Palette#generate() for the sake of
618                    // consistency.
619                    final LetterTileDrawable tileDrawable = (LetterTileDrawable) imageViewDrawable;
620                    final int PALETTE_BITMAP_SIZE = 1;
621                    final Bitmap bitmap = Bitmap.createBitmap(PALETTE_BITMAP_SIZE,
622                            PALETTE_BITMAP_SIZE, Bitmap.Config.ARGB_8888);
623                    // If Palette can not extract a primary color, our UX person says we are better
624                    // off using the LetterTileDrawable's non vibrant color than falling back
625                    // to the app's default color.
626                    final int color = colorFromBitmap(bitmap);
627                    if (color == 0) {
628                        return tileDrawable.getColor();
629                    } else {
630                        return color;
631                    }
632                }
633                return 0;
634            }
635
636            @Override
637            protected void onPostExecute(Integer color) {
638                super.onPostExecute(color);
639                mColorFilter = new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP);
640                // Make sure the color is valid. Also check that the Photo has not changed. If it
641                // has changed, the new tint color needs to be extracted
642                if (color != 0 && imageViewDrawable == mPhotoView.getDrawable()) {
643                    // TODO: animate from the previous tint.
644                    mScroller.setHeaderTintColor(color);
645
646                    // Create a darker version of the actionbar color. HSV is device dependent
647                    // and not perceptually-linear. Therefore, we can't say mStatusBarColor is
648                    // 70% as bright as the action bar color. We can only say: it is a bit darker.
649                    final float hsvComponents[] = new float[3];
650                    Color.colorToHSV(color, hsvComponents);
651                    hsvComponents[2] *= SYSTEM_BAR_BRIGHTNESS_FACTOR;
652                    mStatusBarColor = Color.HSVToColor(hsvComponents);
653
654                    updateStatusBarColor();
655                    for (Drawable drawable : mDrawablesToTint) {
656                        applyThemeColorIfAvailable(drawable);
657                    }
658                    mDrawablesToTint.clear();
659                }
660            }
661        }.execute();
662    }
663
664    private void updateStatusBarColor() {
665        if (mScroller == null) {
666            return;
667        }
668        final int desiredStatusBarColor;
669        // Only use a custom status bar color if QuickContacts touches the top of the viewport.
670        if (mScroller.getScrollNeededToBeFullScreen() <= 0) {
671            desiredStatusBarColor = mStatusBarColor;
672        } else {
673            desiredStatusBarColor = Color.TRANSPARENT;
674        }
675        // Animate to the new color.
676        if (desiredStatusBarColor != getWindow().getStatusBarColor()) {
677            final ObjectAnimator animation = ObjectAnimator.ofInt(getWindow(), "statusBarColor",
678                    getWindow().getStatusBarColor(), desiredStatusBarColor);
679            animation.setDuration(ANIMATION_STATUS_BAR_COLOR_CHANGE_DURATION);
680            animation.setEvaluator(new ArgbEvaluator());
681            animation.start();
682        }
683    }
684
685    private int colorFromBitmap(Bitmap bitmap) {
686        // Author of Palette recommends using 24 colors when analyzing profile photos.
687        final int NUMBER_OF_PALETTE_COLORS = 24;
688        final Palette palette = Palette.generate(bitmap, NUMBER_OF_PALETTE_COLORS);
689        if (palette != null && palette.getVibrantColor() != null) {
690            return palette.getVibrantColor().getRgb();
691        }
692        return 0;
693    }
694
695    /**
696     * Consider adding the given {@link Action}, which will only happen if
697     * {@link PackageManager} finds an application to handle
698     * {@link Action#getIntent()}.
699     * @param action the action to handle
700     * @param resolveCache cache of applications that can handle actions
701     * @param front indicates whether to add the action to the front of the list
702     * @return true if action has been added
703     */
704    private boolean considerAdd(Action action, ResolveCache resolveCache, boolean front) {
705        if (resolveCache.hasResolve(action)) {
706            mActions.put(action.getMimeType(), action, front);
707            return true;
708        }
709        return false;
710    }
711
712    /**
713     * Converts a list of Action into a list of Entry
714     * @param actions The list of Action to convert
715     * @return The converted list of Entry
716     */
717    private List<Entry> actionsToEntries(List<Action> actions) {
718        List<Entry> entries = new ArrayList<>();
719        for (Action action :  actions) {
720            String header = null;
721            String body = null;
722            String footer = null;
723            Drawable icon = null;
724            switch (action.getMimeType()) {
725                case Phone.CONTENT_ITEM_TYPE:
726                    header = String.valueOf(action.getBody());
727                    footer = String.valueOf(action.getSubtitle());
728                    icon = applyThemeColorIfAvailable(
729                            getResources().getDrawable(R.drawable.ic_phone_24dp));
730                    break;
731                case Email.CONTENT_ITEM_TYPE:
732                    header = String.valueOf(action.getBody());
733                    footer = String.valueOf(action.getSubtitle());
734                    icon = applyThemeColorIfAvailable(
735                            getResources().getDrawable(R.drawable.ic_email_24dp));
736                    break;
737                case StructuredPostal.CONTENT_ITEM_TYPE:
738                    header = String.valueOf(action.getBody());
739                    footer = String.valueOf(action.getSubtitle());
740                    icon = applyThemeColorIfAvailable(
741                            getResources().getDrawable(R.drawable.ic_place_24dp));
742                    break;
743                default:
744                    header = String.valueOf(action.getSubtitle());
745                    footer = String.valueOf(action.getBody());
746                    icon = ResolveCache.getInstance(this).getIcon(action);
747            }
748            entries.add(new Entry(icon, header, body, footer, action.getIntent(),
749                    /* isEditable= */ false));
750
751            // Add SMS in addition to phone calls
752            if (action.getMimeType().equals(Phone.CONTENT_ITEM_TYPE)) {
753                entries.add(new Entry(applyThemeColorIfAvailable(getResources().getDrawable(
754                        R.drawable.ic_message_24dp)),
755                        getResources().getString(R.string.send_message), null, header,
756                        action.getAlternateIntent(), /* isEditable = */ false));
757            }
758        }
759        return entries;
760    }
761
762    private List<Entry> contactInteractionsToEntries(List<ContactInteraction> interactions) {
763        List<Entry> entries = new ArrayList<>();
764        for (ContactInteraction interaction : interactions) {
765            entries.add(new Entry(applyThemeColorIfAvailable(interaction.getIcon(this)),
766                    interaction.getViewHeader(this),
767                    interaction.getViewBody(this),
768                    interaction.getBodyIcon(this),
769                    interaction.getViewFooter(this),
770                    interaction.getFooterIcon(this),
771                    interaction.getIntent(),
772                    /* isEditable = */ false));
773        }
774        return entries;
775    }
776
777    private LoaderCallbacks<Contact> mLoaderContactCallbacks =
778            new LoaderCallbacks<Contact>() {
779        @Override
780        public void onLoaderReset(Loader<Contact> loader) {
781        }
782
783        @Override
784        public void onLoadFinished(Loader<Contact> loader, Contact data) {
785            Trace.beginSection("onLoadFinished()");
786
787            if (isFinishing()) {
788                return;
789            }
790            if (data.isError()) {
791                // This shouldn't ever happen, so throw an exception. The {@link ContactLoader}
792                // should log the actual exception.
793                throw new IllegalStateException("Failed to load contact", data.getException());
794            }
795            if (data.isNotFound()) {
796                if (mHasAlreadyBeenOpened) {
797                    finish();
798                } else {
799                    Log.i(TAG, "No contact found: " + ((ContactLoader)loader).getLookupUri());
800                    Toast.makeText(QuickContactActivity.this, R.string.invalidContactMessage,
801                            Toast.LENGTH_LONG).show();
802                }
803                return;
804            }
805
806            bindContactData(data);
807
808            Trace.endSection();
809        }
810
811        @Override
812        public Loader<Contact> onCreateLoader(int id, Bundle args) {
813            if (mLookupUri == null) {
814                Log.wtf(TAG, "Lookup uri wasn't initialized. Loader was started too early");
815            }
816            // Load all contact data. We need loadGroupMetaData=true to determine whether the
817            // contact is invisible. If it is, we need to display an "Add to Contacts" MenuItem.
818            return new ContactLoader(getApplicationContext(), mLookupUri,
819                    true /*loadGroupMetaData*/, false /*loadInvitableAccountTypes*/,
820                    false /*postViewNotification*/, true /*computeFormattedPhoneNumber*/);
821        }
822    };
823
824    @Override
825    public void onBackPressed() {
826        if (mScroller != null) {
827            // TODO: implement exit animation if the scroller isn't already off the screen
828            finish();
829        } else {
830            super.onBackPressed();
831        }
832    }
833
834    @Override
835    public void finish() {
836        super.finish();
837
838        // override transitions to skip the standard window animations
839        overridePendingTransition(0, 0);
840    }
841
842    private LoaderCallbacks<List<ContactInteraction>> mLoaderInteractionsCallbacks =
843            new LoaderCallbacks<List<ContactInteraction>>() {
844
845        @Override
846        public Loader<List<ContactInteraction>> onCreateLoader(int id, Bundle args) {
847            Log.v(TAG, "onCreateLoader");
848            Loader<List<ContactInteraction>> loader = null;
849            switch (id) {
850                case LOADER_SMS_ID:
851                    Log.v(TAG, "LOADER_SMS_ID");
852                    loader = new SmsInteractionsLoader(
853                            QuickContactActivity.this,
854                            args.getStringArray(KEY_LOADER_EXTRA_SMS_PHONES),
855                            MAX_SMS_RETRIEVE);
856                    break;
857            }
858            return loader;
859        }
860
861        @Override
862        public void onLoadFinished(Loader<List<ContactInteraction>> loader,
863                List<ContactInteraction> data) {
864            if (mRecentLoaderResults == null) {
865                mRecentLoaderResults = new HashMap<Integer, List<ContactInteraction>>();
866            }
867            mRecentLoaderResults.put(loader.getId(), data);
868
869            if (isAllRecentDataLoaded()) {
870                bindRecentData();
871            }
872        }
873
874        @Override
875        public void onLoaderReset(Loader<List<ContactInteraction>> loader) {
876            mRecentLoaderResults.remove(loader.getId());
877        }
878
879    };
880
881    private boolean isAllRecentDataLoaded() {
882        return mRecentLoaderResults.size() == mRecentLoaderIds.length;
883    }
884
885    private void bindRecentData() {
886        List<ContactInteraction> allInteractions = new ArrayList<>();
887        for (List<ContactInteraction> loaderInteractions : mRecentLoaderResults.values()) {
888            allInteractions.addAll(loaderInteractions);
889        }
890
891        // Sort the interactions by most recent
892        Collections.sort(allInteractions, new Comparator<ContactInteraction>() {
893            @Override
894            public int compare(ContactInteraction a, ContactInteraction b) {
895                return a.getInteractionDate() >= b.getInteractionDate() ? -1 : 1;
896            }
897        });
898
899        if (allInteractions.size() > 0) {
900            mRecentCard.initialize(contactInteractionsToEntries(allInteractions),
901                    /* numInitialVisibleEntries = */ MIN_NUM_COLLAPSED_RECENT_ENTRIES_SHOWN,
902                    /* isExpanded = */ false,
903                    /* themeColor = */ 0);
904            mRecentCard.setVisibility(View.VISIBLE);
905        }
906    }
907
908    @Override
909    protected void onStop() {
910        super.onStop();
911
912        if (mEntriesAndActionsTask != null) {
913            // Once the activity is stopped, we will no longer want to bind mEntriesAndActionsTask's
914            // results on the UI thread. In some circumstances Activities are killed without
915            // onStop() being called. This is not a problem, because in these circumstances
916            // the entire process will be killed.
917            mEntriesAndActionsTask.cancel(/* mayInterruptIfRunning = */ false);
918        }
919    }
920
921    /**
922     * Applies the theme color as extracted in
923     * {@link #extractAndApplyTintFromPhotoViewAsynchronously()} if available. If the color is not
924     * available, store a reference to the drawable to tint when a color becomes available.
925     */
926    private Drawable applyThemeColorIfAvailable(Drawable drawable) {
927        if (mColorFilter != null) {
928            drawable.setColorFilter(mColorFilter);
929        } else {
930            mDrawablesToTint.add(drawable);
931        }
932        return drawable;
933    }
934
935    /**
936     * Returns true if it is possible to edit the current contact.
937     */
938    private boolean isContactEditable() {
939        return mContactData != null && !mContactData.isDirectoryEntry();
940    }
941
942    private void editContact() {
943        final Intent intent = new Intent(Intent.ACTION_EDIT, mLookupUri);
944        mContactLoader.cacheResult();
945        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
946        startActivityForResult(intent, REQUEST_CODE_CONTACT_EDITOR_ACTIVITY);
947    }
948
949    private void toggleStar(MenuItem starredMenuItem) {
950        // Make sure there is a contact
951        if (mLookupUri != null) {
952            // Read the current starred value from the UI instead of using the last
953            // loaded state. This allows rapid tapping without writing the same
954            // value several times
955            final boolean isStarred = starredMenuItem.isChecked();
956
957            // To improve responsiveness, swap out the picture (and tag) in the UI already
958            ContactDetailDisplayUtils.configureStarredMenuItem(starredMenuItem,
959                    mContactData.isDirectoryEntry(), mContactData.isUserProfile(),
960                    !isStarred);
961
962            // Now perform the real save
963            Intent intent = ContactSaveService.createSetStarredIntent(
964                    QuickContactActivity.this, mLookupUri, !isStarred);
965            startService(intent);
966        }
967    }
968
969    /**
970     * Calls into the contacts provider to get a pre-authorized version of the given URI.
971     */
972    private Uri getPreAuthorizedUri(Uri uri) {
973        final Bundle uriBundle = new Bundle();
974        uriBundle.putParcelable(ContactsContract.Authorization.KEY_URI_TO_AUTHORIZE, uri);
975        final Bundle authResponse = getContentResolver().call(
976                ContactsContract.AUTHORITY_URI,
977                ContactsContract.Authorization.AUTHORIZATION_METHOD,
978                null,
979                uriBundle);
980        if (authResponse != null) {
981            return (Uri) authResponse.getParcelable(
982                    ContactsContract.Authorization.KEY_AUTHORIZED_URI);
983        } else {
984            return uri;
985        }
986    }
987    private void shareContact() {
988        final String lookupKey = mContactData.getLookupKey();
989        Uri shareUri = Uri.withAppendedPath(Contacts.CONTENT_VCARD_URI, lookupKey);
990        if (mContactData.isUserProfile()) {
991            // User is sharing the profile.  We don't want to force the receiver to have
992            // the highly-privileged READ_PROFILE permission, so we need to request a
993            // pre-authorized URI from the provider.
994            shareUri = getPreAuthorizedUri(shareUri);
995        }
996
997        final Intent intent = new Intent(Intent.ACTION_SEND);
998        intent.setType(Contacts.CONTENT_VCARD_TYPE);
999        intent.putExtra(Intent.EXTRA_STREAM, shareUri);
1000
1001        // Launch chooser to share contact via
1002        final CharSequence chooseTitle = getText(R.string.share_via);
1003        final Intent chooseIntent = Intent.createChooser(intent, chooseTitle);
1004
1005        try {
1006            this.startActivity(chooseIntent);
1007        } catch (ActivityNotFoundException ex) {
1008            Toast.makeText(this, R.string.share_error, Toast.LENGTH_SHORT).show();
1009        }
1010    }
1011
1012    /**
1013     * Creates a launcher shortcut with the current contact.
1014     */
1015    private void createLauncherShortcutWithContact() {
1016        final ShortcutIntentBuilder builder = new ShortcutIntentBuilder(this,
1017                new OnShortcutIntentCreatedListener() {
1018
1019                    @Override
1020                    public void onShortcutIntentCreated(Uri uri, Intent shortcutIntent) {
1021                        // Broadcast the shortcutIntent to the launcher to create a
1022                        // shortcut to this contact
1023                        shortcutIntent.setAction(ACTION_INSTALL_SHORTCUT);
1024                        QuickContactActivity.this.sendBroadcast(shortcutIntent);
1025
1026                        // Send a toast to give feedback to the user that a shortcut to this
1027                        // contact was added to the launcher.
1028                        Toast.makeText(QuickContactActivity.this,
1029                                R.string.createContactShortcutSuccessful,
1030                                Toast.LENGTH_SHORT).show();
1031                    }
1032
1033                });
1034        builder.createContactShortcutIntent(mLookupUri);
1035    }
1036
1037    @Override
1038    public boolean onCreateOptionsMenu(Menu menu) {
1039        MenuInflater inflater = getMenuInflater();
1040        inflater.inflate(R.menu.quickcontact, menu);
1041        return true;
1042    }
1043
1044    @Override
1045    public boolean onPrepareOptionsMenu(Menu menu) {
1046        if (mContactData != null) {
1047            final MenuItem starredMenuItem = menu.findItem(R.id.menu_star);
1048            ContactDetailDisplayUtils.configureStarredMenuItem(starredMenuItem,
1049                    mContactData.isDirectoryEntry(), mContactData.isUserProfile(),
1050                    mContactData.getStarred());
1051            // Configure edit MenuItem
1052            final MenuItem editMenuItem = menu.findItem(R.id.menu_edit);
1053            editMenuItem.setVisible(true);
1054            if (DirectoryContactUtil.isDirectoryContact(mContactData) || InvisibleContactUtil
1055                    .isInvisibleAndAddable(mContactData, this)) {
1056                editMenuItem.setIcon(R.drawable.ic_person_add_24dp);
1057            } else if (isContactEditable()) {
1058                editMenuItem.setIcon(R.drawable.ic_create_24dp);
1059            } else {
1060                editMenuItem.setVisible(false);
1061            }
1062        }
1063        return true;
1064    }
1065
1066    @Override
1067    public boolean onOptionsItemSelected(MenuItem item) {
1068        switch (item.getItemId()) {
1069            case R.id.menu_star:
1070                toggleStar(item);
1071                return true;
1072            case R.id.menu_edit:
1073                if (DirectoryContactUtil.isDirectoryContact(mContactData)) {
1074                    DirectoryContactUtil.addToMyContacts(mContactData, this, getFragmentManager(),
1075                            mSelectAccountFragmentListener);
1076                } else if (InvisibleContactUtil.isInvisibleAndAddable(mContactData, this)) {
1077                    InvisibleContactUtil.addToDefaultGroup(mContactData, this);
1078                } else if (isContactEditable()) {
1079                    editContact();
1080                }
1081                return true;
1082            case R.id.menu_share:
1083                shareContact();
1084                return true;
1085            case R.id.menu_create_contact_shortcut:
1086                createLauncherShortcutWithContact();
1087                return true;
1088            default:
1089                return super.onOptionsItemSelected(item);
1090        }
1091    }
1092}
1093