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 17 package com.android.contacts.quickcontact; 18 19 import android.app.Activity; 20 import android.app.Fragment; 21 import android.app.FragmentManager; 22 import android.app.LoaderManager.LoaderCallbacks; 23 import android.content.ActivityNotFoundException; 24 import android.content.ContentUris; 25 import android.content.Context; 26 import android.content.Intent; 27 import android.content.Loader; 28 import android.content.pm.PackageManager; 29 import android.graphics.Rect; 30 import android.graphics.drawable.Drawable; 31 import android.net.Uri; 32 import android.os.Bundle; 33 import android.os.Handler; 34 import android.provider.ContactsContract.CommonDataKinds.Email; 35 import android.provider.ContactsContract.CommonDataKinds.Phone; 36 import android.provider.ContactsContract.CommonDataKinds.SipAddress; 37 import android.provider.ContactsContract.CommonDataKinds.StructuredPostal; 38 import android.provider.ContactsContract.CommonDataKinds.Website; 39 import android.provider.ContactsContract.Contacts; 40 import android.provider.ContactsContract.QuickContact; 41 import android.provider.ContactsContract.RawContacts; 42 import android.support.v13.app.FragmentPagerAdapter; 43 import android.support.v4.view.ViewPager; 44 import android.support.v4.view.ViewPager.SimpleOnPageChangeListener; 45 import android.text.TextUtils; 46 import android.util.Log; 47 import android.view.MotionEvent; 48 import android.view.View; 49 import android.view.View.OnClickListener; 50 import android.view.ViewGroup; 51 import android.view.WindowManager; 52 import android.widget.HorizontalScrollView; 53 import android.widget.ImageButton; 54 import android.widget.ImageView; 55 import android.widget.RelativeLayout; 56 import android.widget.TextView; 57 import android.widget.Toast; 58 59 import com.android.contacts.Collapser; 60 import com.android.contacts.R; 61 import com.android.contacts.model.Contact; 62 import com.android.contacts.model.ContactLoader; 63 import com.android.contacts.model.RawContact; 64 import com.android.contacts.model.dataitem.DataItem; 65 import com.android.contacts.model.dataitem.DataKind; 66 import com.android.contacts.model.dataitem.EmailDataItem; 67 import com.android.contacts.model.dataitem.ImDataItem; 68 import com.android.contacts.util.Constants; 69 import com.android.contacts.util.DataStatus; 70 import com.android.contacts.util.ImageViewDrawableSetter; 71 import com.android.contacts.util.SchedulingUtils; 72 import com.android.contacts.util.StopWatch; 73 import com.google.common.base.Preconditions; 74 import com.google.common.collect.Lists; 75 76 import java.util.HashMap; 77 import java.util.HashSet; 78 import java.util.List; 79 import 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 */ 88 public 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