1 /* 2 * Copyright (C) 2011 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.detail; 18 19 import android.content.ContentUris; 20 import android.content.Context; 21 import android.content.pm.PackageManager; 22 import android.content.pm.PackageManager.NameNotFoundException; 23 import android.content.res.Resources; 24 import android.content.res.Resources.NotFoundException; 25 import android.graphics.drawable.Drawable; 26 import android.net.Uri; 27 import android.provider.ContactsContract; 28 import android.provider.ContactsContract.DisplayNameSources; 29 import android.provider.ContactsContract.StreamItems; 30 import android.text.Html; 31 import android.text.Html.ImageGetter; 32 import android.text.TextUtils; 33 import android.util.Log; 34 import android.view.LayoutInflater; 35 import android.view.MenuItem; 36 import android.view.View; 37 import android.view.ViewGroup; 38 import android.widget.ImageView; 39 import android.widget.ListView; 40 import android.widget.TextView; 41 42 import com.android.contacts.common.ContactPhotoManager; 43 import com.android.contacts.R; 44 import com.android.contacts.model.Contact; 45 import com.android.contacts.model.RawContact; 46 import com.android.contacts.model.dataitem.DataItem; 47 import com.android.contacts.model.dataitem.OrganizationDataItem; 48 import com.android.contacts.common.preference.ContactsPreferences; 49 import com.android.contacts.util.ContactBadgeUtil; 50 import com.android.contacts.util.HtmlUtils; 51 import com.android.contacts.util.MoreMath; 52 import com.android.contacts.util.StreamItemEntry; 53 import com.android.contacts.util.StreamItemPhotoEntry; 54 import com.google.common.annotations.VisibleForTesting; 55 import com.google.common.collect.Iterables; 56 57 import java.util.List; 58 59 /** 60 * This class contains utility methods to bind high-level contact details 61 * (meaning name, phonetic name, job, and attribution) from a 62 * {@link Contact} data object to appropriate {@link View}s. 63 */ 64 public class ContactDetailDisplayUtils { 65 private static final String TAG = "ContactDetailDisplayUtils"; 66 67 /** 68 * Tag object used for stream item photos. 69 */ 70 public static class StreamPhotoTag { 71 public final StreamItemEntry streamItem; 72 public final StreamItemPhotoEntry streamItemPhoto; 73 StreamPhotoTag(StreamItemEntry streamItem, StreamItemPhotoEntry streamItemPhoto)74 public StreamPhotoTag(StreamItemEntry streamItem, StreamItemPhotoEntry streamItemPhoto) { 75 this.streamItem = streamItem; 76 this.streamItemPhoto = streamItemPhoto; 77 } 78 getStreamItemPhotoUri()79 public Uri getStreamItemPhotoUri() { 80 final Uri.Builder builder = StreamItems.CONTENT_URI.buildUpon(); 81 ContentUris.appendId(builder, streamItem.getId()); 82 builder.appendPath(StreamItems.StreamItemPhotos.CONTENT_DIRECTORY); 83 ContentUris.appendId(builder, streamItemPhoto.getId()); 84 return builder.build(); 85 } 86 } 87 ContactDetailDisplayUtils()88 private ContactDetailDisplayUtils() { 89 // Disallow explicit creation of this class. 90 } 91 92 /** 93 * Returns the display name of the contact, using the current display order setting. 94 * Returns res/string/missing_name if there is no display name. 95 */ getDisplayName(Context context, Contact contactData)96 public static CharSequence getDisplayName(Context context, Contact contactData) { 97 CharSequence displayName = contactData.getDisplayName(); 98 CharSequence altDisplayName = contactData.getAltDisplayName(); 99 ContactsPreferences prefs = new ContactsPreferences(context); 100 CharSequence styledName = ""; 101 if (!TextUtils.isEmpty(displayName) && !TextUtils.isEmpty(altDisplayName)) { 102 if (prefs.getDisplayOrder() == ContactsContract.Preferences.DISPLAY_ORDER_PRIMARY) { 103 styledName = displayName; 104 } else { 105 styledName = altDisplayName; 106 } 107 } else { 108 styledName = context.getResources().getString(R.string.missing_name); 109 } 110 return styledName; 111 } 112 113 /** 114 * Returns the phonetic name of the contact or null if there isn't one. 115 */ getPhoneticName(Context context, Contact contactData)116 public static String getPhoneticName(Context context, Contact contactData) { 117 String phoneticName = contactData.getPhoneticName(); 118 if (!TextUtils.isEmpty(phoneticName)) { 119 return phoneticName; 120 } 121 return null; 122 } 123 124 /** 125 * Returns the attribution string for the contact, which may specify the contact directory that 126 * the contact came from. Returns null if there is none applicable. 127 */ getAttribution(Context context, Contact contactData)128 public static String getAttribution(Context context, Contact contactData) { 129 if (contactData.isDirectoryEntry()) { 130 String directoryDisplayName = contactData.getDirectoryDisplayName(); 131 String directoryType = contactData.getDirectoryType(); 132 String displayName = !TextUtils.isEmpty(directoryDisplayName) 133 ? directoryDisplayName 134 : directoryType; 135 return context.getString(R.string.contact_directory_description, displayName); 136 } 137 return null; 138 } 139 140 /** 141 * Returns the organization of the contact. If several organizations are given, 142 * the first one is used. Returns null if not applicable. 143 */ getCompany(Context context, Contact contactData)144 public static String getCompany(Context context, Contact contactData) { 145 final boolean displayNameIsOrganization = contactData.getDisplayNameSource() 146 == DisplayNameSources.ORGANIZATION; 147 for (RawContact rawContact : contactData.getRawContacts()) { 148 for (DataItem dataItem : Iterables.filter( 149 rawContact.getDataItems(), OrganizationDataItem.class)) { 150 OrganizationDataItem organization = (OrganizationDataItem) dataItem; 151 final String company = organization.getCompany(); 152 final String title = organization.getTitle(); 153 final String combined; 154 // We need to show company and title in a combined string. However, if the 155 // DisplayName is already the organization, it mirrors company or (if company 156 // is empty title). Make sure we don't show what's already shown as DisplayName 157 if (TextUtils.isEmpty(company)) { 158 combined = displayNameIsOrganization ? null : title; 159 } else { 160 if (TextUtils.isEmpty(title)) { 161 combined = displayNameIsOrganization ? null : company; 162 } else { 163 if (displayNameIsOrganization) { 164 combined = title; 165 } else { 166 combined = context.getString( 167 R.string.organization_company_and_title, 168 company, title); 169 } 170 } 171 } 172 173 if (!TextUtils.isEmpty(combined)) { 174 return combined; 175 } 176 } 177 } 178 return null; 179 } 180 181 /** 182 * Sets the starred state of this contact. 183 */ configureStarredImageView(ImageView starredView, boolean isDirectoryEntry, boolean isUserProfile, boolean isStarred)184 public static void configureStarredImageView(ImageView starredView, boolean isDirectoryEntry, 185 boolean isUserProfile, boolean isStarred) { 186 // Check if the starred state should be visible 187 if (!isDirectoryEntry && !isUserProfile) { 188 starredView.setVisibility(View.VISIBLE); 189 final int resId = isStarred 190 ? R.drawable.btn_star_on_normal_holo_light 191 : R.drawable.btn_star_off_normal_holo_light; 192 starredView.setImageResource(resId); 193 starredView.setTag(isStarred); 194 starredView.setContentDescription(starredView.getResources().getString( 195 isStarred ? R.string.menu_removeStar : R.string.menu_addStar)); 196 } else { 197 starredView.setVisibility(View.GONE); 198 } 199 } 200 201 /** 202 * Sets the starred state of this contact. 203 */ configureStarredMenuItem(MenuItem starredMenuItem, boolean isDirectoryEntry, boolean isUserProfile, boolean isStarred)204 public static void configureStarredMenuItem(MenuItem starredMenuItem, boolean isDirectoryEntry, 205 boolean isUserProfile, boolean isStarred) { 206 // Check if the starred state should be visible 207 if (!isDirectoryEntry && !isUserProfile) { 208 starredMenuItem.setVisible(true); 209 final int resId = isStarred 210 ? R.drawable.btn_star_on_normal_holo_dark 211 : R.drawable.btn_star_off_normal_holo_dark; 212 starredMenuItem.setIcon(resId); 213 starredMenuItem.setChecked(isStarred); 214 starredMenuItem.setTitle(isStarred ? R.string.menu_removeStar : R.string.menu_addStar); 215 } else { 216 starredMenuItem.setVisible(false); 217 } 218 } 219 220 /** 221 * Set the social snippet text. If there isn't one, then set the view to gone. 222 */ setSocialSnippet(Context context, Contact contactData, TextView statusView, ImageView statusPhotoView)223 public static void setSocialSnippet(Context context, Contact contactData, TextView statusView, 224 ImageView statusPhotoView) { 225 if (statusView == null) { 226 return; 227 } 228 229 CharSequence snippet = null; 230 String photoUri = null; 231 if (!contactData.getStreamItems().isEmpty()) { 232 StreamItemEntry firstEntry = contactData.getStreamItems().get(0); 233 snippet = HtmlUtils.fromHtml(context, firstEntry.getText()); 234 if (!firstEntry.getPhotos().isEmpty()) { 235 StreamItemPhotoEntry firstPhoto = firstEntry.getPhotos().get(0); 236 photoUri = firstPhoto.getPhotoUri(); 237 238 // If displaying an image, hide the snippet text. 239 snippet = null; 240 } 241 } 242 setDataOrHideIfNone(snippet, statusView); 243 if (photoUri != null) { 244 ContactPhotoManager.getInstance(context).loadPhoto( 245 statusPhotoView, Uri.parse(photoUri), -1, false, 246 ContactPhotoManager.DEFAULT_BLANK); 247 statusPhotoView.setVisibility(View.VISIBLE); 248 } else { 249 statusPhotoView.setVisibility(View.GONE); 250 } 251 } 252 253 /** Creates the view that represents a stream item. */ createStreamItemView(LayoutInflater inflater, Context context, View convertView, StreamItemEntry streamItem, View.OnClickListener photoClickListener)254 public static View createStreamItemView(LayoutInflater inflater, Context context, 255 View convertView, StreamItemEntry streamItem, View.OnClickListener photoClickListener) { 256 257 // Try to recycle existing views. 258 final View container; 259 if (convertView != null) { 260 container = convertView; 261 } else { 262 container = inflater.inflate(R.layout.stream_item_container, null, false); 263 } 264 265 final ContactPhotoManager contactPhotoManager = ContactPhotoManager.getInstance(context); 266 final List<StreamItemPhotoEntry> photos = streamItem.getPhotos(); 267 final int photoCount = photos.size(); 268 269 // Add the text part. 270 addStreamItemText(context, streamItem, container); 271 272 // Add images. 273 final ViewGroup imageRows = (ViewGroup) container.findViewById(R.id.stream_item_image_rows); 274 275 if (photoCount == 0) { 276 // This stream item only has text. 277 imageRows.setVisibility(View.GONE); 278 } else { 279 // This stream item has text and photos. 280 imageRows.setVisibility(View.VISIBLE); 281 282 // Number of image rows needed, which is cailing(photoCount / 2) 283 final int numImageRows = (photoCount + 1) / 2; 284 285 // Actual image rows. 286 final int numOldImageRows = imageRows.getChildCount(); 287 288 // Make sure we have enough stream_item_row_images. 289 if (numOldImageRows == numImageRows) { 290 // Great, we have the just enough number of rows... 291 292 } else if (numOldImageRows < numImageRows) { 293 // Need to add more image rows. 294 for (int i = numOldImageRows; i < numImageRows; i++) { 295 View imageRow = inflater.inflate(R.layout.stream_item_row_images, imageRows, 296 true); 297 } 298 } else { 299 // We have exceeding image rows. Hide them. 300 for (int i = numImageRows; i < numOldImageRows; i++) { 301 imageRows.getChildAt(i).setVisibility(View.GONE); 302 } 303 } 304 305 // Put images, two by two. 306 for (int i = 0; i < photoCount; i += 2) { 307 final View imageRow = imageRows.getChildAt(i / 2); 308 // Reused image rows may not visible, so make sure they're shown. 309 imageRow.setVisibility(View.VISIBLE); 310 311 // Show first image. 312 loadPhoto(contactPhotoManager, streamItem, photos.get(i), imageRow, 313 R.id.stream_item_first_image, photoClickListener); 314 final View secondContainer = imageRow.findViewById(R.id.second_image_container); 315 if (i + 1 < photoCount) { 316 // Show the second image too. 317 loadPhoto(contactPhotoManager, streamItem, photos.get(i + 1), imageRow, 318 R.id.stream_item_second_image, photoClickListener); 319 secondContainer.setVisibility(View.VISIBLE); 320 } else { 321 // Hide the second image, but it still has to occupy the space. 322 secondContainer.setVisibility(View.INVISIBLE); 323 } 324 } 325 } 326 327 return container; 328 } 329 330 /** Loads a photo into an image view. The image view is identified by the given id. */ loadPhoto(ContactPhotoManager contactPhotoManager, final StreamItemEntry streamItem, final StreamItemPhotoEntry streamItemPhoto, View photoContainer, int imageViewId, View.OnClickListener photoClickListener)331 private static void loadPhoto(ContactPhotoManager contactPhotoManager, 332 final StreamItemEntry streamItem, final StreamItemPhotoEntry streamItemPhoto, 333 View photoContainer, int imageViewId, View.OnClickListener photoClickListener) { 334 final View frame = photoContainer.findViewById(imageViewId); 335 final View pushLayerView = frame.findViewById(R.id.push_layer); 336 final ImageView imageView = (ImageView) frame.findViewById(R.id.image); 337 if (photoClickListener != null) { 338 pushLayerView.setOnClickListener(photoClickListener); 339 pushLayerView.setTag(new StreamPhotoTag(streamItem, streamItemPhoto)); 340 pushLayerView.setFocusable(true); 341 pushLayerView.setEnabled(true); 342 } else { 343 pushLayerView.setOnClickListener(null); 344 pushLayerView.setTag(null); 345 pushLayerView.setFocusable(false); 346 // setOnClickListener makes it clickable, so we need to overwrite it 347 pushLayerView.setClickable(false); 348 pushLayerView.setEnabled(false); 349 } 350 contactPhotoManager.loadPhoto(imageView, Uri.parse(streamItemPhoto.getPhotoUri()), -1, 351 false, ContactPhotoManager.DEFAULT_BLANK); 352 } 353 354 @VisibleForTesting addStreamItemText(Context context, StreamItemEntry streamItem, View rootView)355 static View addStreamItemText(Context context, StreamItemEntry streamItem, View rootView) { 356 TextView htmlView = (TextView) rootView.findViewById(R.id.stream_item_html); 357 TextView attributionView = (TextView) rootView.findViewById( 358 R.id.stream_item_attribution); 359 TextView commentsView = (TextView) rootView.findViewById(R.id.stream_item_comments); 360 ImageGetter imageGetter = new DefaultImageGetter(context.getPackageManager()); 361 362 // Stream item text 363 setDataOrHideIfNone(streamItem.getDecodedText(), htmlView); 364 // Attribution 365 setDataOrHideIfNone(ContactBadgeUtil.getSocialDate(streamItem, context), 366 attributionView); 367 // Comments 368 setDataOrHideIfNone(streamItem.getDecodedComments(), commentsView); 369 return rootView; 370 } 371 372 /** 373 * Sets the display name of this contact to the given {@link TextView}. If 374 * there is none, then set the view to gone. 375 */ setDisplayName(Context context, Contact contactData, TextView textView)376 public static void setDisplayName(Context context, Contact contactData, TextView textView) { 377 if (textView == null) { 378 return; 379 } 380 setDataOrHideIfNone(getDisplayName(context, contactData), textView); 381 } 382 383 /** 384 * Sets the company and job title of this contact to the given {@link TextView}. If 385 * there is none, then set the view to gone. 386 */ setCompanyName(Context context, Contact contactData, TextView textView)387 public static void setCompanyName(Context context, Contact contactData, TextView textView) { 388 if (textView == null) { 389 return; 390 } 391 setDataOrHideIfNone(getCompany(context, contactData), textView); 392 } 393 394 /** 395 * Sets the phonetic name of this contact to the given {@link TextView}. If 396 * there is none, then set the view to gone. 397 */ setPhoneticName(Context context, Contact contactData, TextView textView)398 public static void setPhoneticName(Context context, Contact contactData, TextView textView) { 399 if (textView == null) { 400 return; 401 } 402 setDataOrHideIfNone(getPhoneticName(context, contactData), textView); 403 } 404 405 /** 406 * Sets the attribution contact to the given {@link TextView}. If 407 * there is none, then set the view to gone. 408 */ setAttribution(Context context, Contact contactData, TextView textView)409 public static void setAttribution(Context context, Contact contactData, TextView textView) { 410 if (textView == null) { 411 return; 412 } 413 setDataOrHideIfNone(getAttribution(context, contactData), textView); 414 } 415 416 /** 417 * Helper function to display the given text in the {@link TextView} or 418 * hides the {@link TextView} if the text is empty or null. 419 */ setDataOrHideIfNone(CharSequence textToDisplay, TextView textView)420 private static void setDataOrHideIfNone(CharSequence textToDisplay, TextView textView) { 421 if (!TextUtils.isEmpty(textToDisplay)) { 422 textView.setText(textToDisplay); 423 textView.setVisibility(View.VISIBLE); 424 } else { 425 textView.setText(null); 426 textView.setVisibility(View.GONE); 427 } 428 } 429 430 private static Html.ImageGetter sImageGetter; 431 getImageGetter(Context context)432 public static Html.ImageGetter getImageGetter(Context context) { 433 if (sImageGetter == null) { 434 sImageGetter = new DefaultImageGetter(context.getPackageManager()); 435 } 436 return sImageGetter; 437 } 438 439 /** Fetcher for images from resources to be included in HTML text. */ 440 private static class DefaultImageGetter implements Html.ImageGetter { 441 /** The scheme used to load resources. */ 442 private static final String RES_SCHEME = "res"; 443 444 private final PackageManager mPackageManager; 445 DefaultImageGetter(PackageManager packageManager)446 public DefaultImageGetter(PackageManager packageManager) { 447 mPackageManager = packageManager; 448 } 449 450 @Override getDrawable(String source)451 public Drawable getDrawable(String source) { 452 // Returning null means that a default image will be used. 453 Uri uri; 454 try { 455 uri = Uri.parse(source); 456 } catch (Throwable e) { 457 Log.d(TAG, "Could not parse image source: " + source); 458 return null; 459 } 460 if (!RES_SCHEME.equals(uri.getScheme())) { 461 Log.d(TAG, "Image source does not correspond to a resource: " + source); 462 return null; 463 } 464 // The URI authority represents the package name. 465 String packageName = uri.getAuthority(); 466 467 Resources resources = getResourcesForResourceName(packageName); 468 if (resources == null) { 469 Log.d(TAG, "Could not parse image source: " + source); 470 return null; 471 } 472 473 List<String> pathSegments = uri.getPathSegments(); 474 if (pathSegments.size() != 1) { 475 Log.d(TAG, "Could not parse image source: " + source); 476 return null; 477 } 478 479 final String name = pathSegments.get(0); 480 final int resId = resources.getIdentifier(name, "drawable", packageName); 481 482 if (resId == 0) { 483 // Use the default image icon in this case. 484 Log.d(TAG, "Cannot resolve resource identifier: " + source); 485 return null; 486 } 487 488 try { 489 return getResourceDrawable(resources, resId); 490 } catch (NotFoundException e) { 491 Log.d(TAG, "Resource not found: " + source, e); 492 return null; 493 } 494 } 495 496 /** Returns the drawable associated with the given id. */ getResourceDrawable(Resources resources, int resId)497 private Drawable getResourceDrawable(Resources resources, int resId) 498 throws NotFoundException { 499 Drawable drawable = resources.getDrawable(resId); 500 drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()); 501 return drawable; 502 } 503 504 /** Returns the {@link Resources} of the package of the given resource name. */ getResourcesForResourceName(String packageName)505 private Resources getResourcesForResourceName(String packageName) { 506 try { 507 return mPackageManager.getResourcesForApplication(packageName); 508 } catch (NameNotFoundException e) { 509 Log.d(TAG, "Could not find package: " + packageName); 510 return null; 511 } 512 } 513 } 514 515 /** 516 * Sets an alpha value on the view. 517 */ setAlphaOnViewBackground(View view, float alpha)518 public static void setAlphaOnViewBackground(View view, float alpha) { 519 if (view != null) { 520 // Convert alpha layer to a black background HEX color with an alpha value for better 521 // performance (i.e. use setBackgroundColor() instead of setAlpha()) 522 view.setBackgroundColor((int) (MoreMath.clamp(alpha, 0.0f, 1.0f) * 255) << 24); 523 } 524 } 525 526 /** 527 * Returns the top coordinate of the first item in the {@link ListView}. If the first item 528 * in the {@link ListView} is not visible or there are no children in the list, then return 529 * Integer.MIN_VALUE. Note that the returned value will be <= 0 because the first item in the 530 * list cannot have a positive offset. 531 */ getFirstListItemOffset(ListView listView)532 public static int getFirstListItemOffset(ListView listView) { 533 if (listView == null || listView.getChildCount() == 0 || 534 listView.getFirstVisiblePosition() != 0) { 535 return Integer.MIN_VALUE; 536 } 537 return listView.getChildAt(0).getTop(); 538 } 539 540 /** 541 * Tries to scroll the first item in the list to the given offset (this can be a no-op if the 542 * list is already in the correct position). 543 * @param listView that should be scrolled 544 * @param offset which should be <= 0 545 */ requestToMoveToOffset(ListView listView, int offset)546 public static void requestToMoveToOffset(ListView listView, int offset) { 547 // We try to offset the list if the first item in the list is showing (which is presumed 548 // to have a larger height than the desired offset). If the first item in the list is not 549 // visible, then we simply do not scroll the list at all (since it can get complicated to 550 // compute how many items in the list will equal the given offset). Potentially 551 // some animation elsewhere will make the transition smoother for the user to compensate 552 // for this simplification. 553 if (listView == null || listView.getChildCount() == 0 || 554 listView.getFirstVisiblePosition() != 0 || offset > 0) { 555 return; 556 } 557 558 // As an optimization, check if the first item is already at the given offset. 559 if (listView.getChildAt(0).getTop() == offset) { 560 return; 561 } 562 563 listView.setSelectionFromTop(0, offset); 564 } 565 } 566