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.StreamItems;
30import android.text.Html;
31import android.text.Html.ImageGetter;
32import android.text.TextUtils;
33import android.util.Log;
34import android.view.LayoutInflater;
35import android.view.MenuItem;
36import android.view.View;
37import android.view.ViewGroup;
38import android.widget.ImageView;
39import android.widget.ListView;
40import android.widget.TextView;
41
42import com.android.contacts.ContactPhotoManager;
43import com.android.contacts.R;
44import com.android.contacts.model.Contact;
45import com.android.contacts.model.RawContact;
46import com.android.contacts.model.dataitem.DataItem;
47import com.android.contacts.model.dataitem.OrganizationDataItem;
48import com.android.contacts.preference.ContactsPreferences;
49import com.android.contacts.util.ContactBadgeUtil;
50import com.android.contacts.util.HtmlUtils;
51import com.android.contacts.util.MoreMath;
52import com.android.contacts.util.StreamItemEntry;
53import com.android.contacts.util.StreamItemPhotoEntry;
54import com.google.common.annotations.VisibleForTesting;
55import com.google.common.collect.Iterables;
56
57import 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 */
64public 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
74        public StreamPhotoTag(StreamItemEntry streamItem, StreamItemPhotoEntry streamItemPhoto) {
75            this.streamItem = streamItem;
76            this.streamItemPhoto = streamItemPhoto;
77        }
78
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
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     */
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     */
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            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     */
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     */
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     */
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     */
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. */
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. */
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
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     */
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     */
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     */
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     */
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     */
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
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
446        public DefaultImageGetter(PackageManager packageManager) {
447            mPackageManager = packageManager;
448        }
449
450        @Override
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. */
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. */
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     */
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     */
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     */
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