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