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