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