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