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 */
16package com.android.contacts.activities;
17
18import android.animation.Animator;
19import android.animation.AnimatorListenerAdapter;
20import android.animation.ObjectAnimator;
21import android.animation.PropertyValuesHolder;
22import android.app.Activity;
23import android.content.Context;
24import android.content.Intent;
25import android.content.res.Configuration;
26import android.graphics.Bitmap;
27import android.graphics.Rect;
28import android.net.Uri;
29import android.os.Bundle;
30import android.os.Parcelable;
31import android.view.View;
32import android.view.ViewGroup.MarginLayoutParams;
33import android.widget.FrameLayout.LayoutParams;
34import android.widget.ImageView;
35
36import com.android.contacts.common.ContactPhotoManager;
37import com.android.contacts.ContactSaveService;
38import com.android.contacts.R;
39import com.android.contacts.detail.PhotoSelectionHandler;
40import com.android.contacts.editor.PhotoActionPopup;
41import com.android.contacts.model.RawContactDeltaList;
42import com.android.contacts.util.ContactPhotoUtils;
43import com.android.contacts.util.SchedulingUtils;
44
45
46/**
47 * Popup activity for choosing a contact photo within the Contacts app.
48 */
49public class PhotoSelectionActivity extends Activity {
50
51    private static final String TAG = "PhotoSelectionActivity";
52
53    /** Number of ms for the animation to expand the photo. */
54    private static final int PHOTO_EXPAND_DURATION = 100;
55
56    /** Number of ms for the animation to contract the photo on activity exit. */
57    private static final int PHOTO_CONTRACT_DURATION = 50;
58
59    /** Number of ms for the animation to hide the backdrop on finish. */
60    private static final int BACKDROP_FADEOUT_DURATION = 100;
61
62    /** Key used to persist photo-filename (NOT full file-path). */
63    private static final String KEY_CURRENT_PHOTO_FILE = "currentphotofile";
64
65    /** Key used to persist whether a sub-activity is currently in progress. */
66    private static final String KEY_SUB_ACTIVITY_IN_PROGRESS = "subinprogress";
67
68    /** Intent extra to get the photo URI. */
69    public static final String PHOTO_URI = "photo_uri";
70
71    /** Intent extra to get the entity delta list. */
72    public static final String ENTITY_DELTA_LIST = "entity_delta_list";
73
74    /** Intent extra to indicate whether the contact is the user's profile. */
75    public static final String IS_PROFILE = "is_profile";
76
77    /** Intent extra to indicate whether the contact is from a directory (non-editable). */
78    public static final String IS_DIRECTORY_CONTACT = "is_directory_contact";
79
80    /**
81     * Intent extra to indicate whether the photo should be animated to show the full contents of
82     * the photo (on a larger portion of the screen) when clicked.  If unspecified or false, the
83     * photo will not move from its original location.
84     */
85    public static final String EXPAND_PHOTO = "expand_photo";
86
87    /** Source bounds of the image that was clicked on. */
88    private Rect mSourceBounds;
89
90    /**
91     * The photo URI. May be null, in which case the default avatar will be used.
92     */
93    private Uri mPhotoUri;
94
95    /** Entity delta list of the contact. */
96    private RawContactDeltaList mState;
97
98    /** Whether the contact is the user's profile. */
99    private boolean mIsProfile;
100
101    /** Whether the contact is from a directory. */
102    private boolean mIsDirectoryContact;
103
104    /** Whether to animate the photo to an expanded view covering more of the screen. */
105    private boolean mExpandPhoto;
106
107    /**
108     * Side length (in pixels) of the expanded photo if to be expanded. Photos are expected to
109     * be square.
110     */
111    private int mExpandedPhotoSize;
112
113    /** Height (in pixels) to leave underneath the expanded photo to show the list popup */
114    private int mHeightOffset;
115
116    /** The semi-transparent backdrop. */
117    private View mBackdrop;
118
119    /** The photo view. */
120    private ImageView mPhotoView;
121
122    /** The photo handler attached to this activity, if any. */
123    private PhotoHandler mPhotoHandler;
124
125    /** Animator to expand the photo out to full size. */
126    private ObjectAnimator mPhotoAnimator;
127
128    /** Listener for the animation. */
129    private AnimatorListenerAdapter mAnimationListener;
130
131    /** Whether a change in layout of the photo has occurred that has no animation yet. */
132    private boolean mAnimationPending;
133
134    /** Prior position of the image (for animating). */
135    Rect mOriginalPos = new Rect();
136
137    /** Layout params for the photo view before we started animating. */
138    private LayoutParams mPhotoStartParams;
139
140    /** Layout params for the photo view after we finished animating. */
141    private LayoutParams mPhotoEndParams;
142
143    /** Whether a sub-activity is currently in progress. */
144    private boolean mSubActivityInProgress;
145
146    private boolean mCloseActivityWhenCameBackFromSubActivity;
147
148    /**
149     * A photo result received by the activity, persisted across activity lifecycle.
150     */
151    private PendingPhotoResult mPendingPhotoResult;
152
153    /**
154     * The photo file being interacted with, if any.  Saved/restored between activity instances.
155     */
156    private String mCurrentPhotoFile;
157
158    @Override
159    protected void onCreate(Bundle savedInstanceState) {
160        super.onCreate(savedInstanceState);
161        setContentView(R.layout.photoselection_activity);
162        if (savedInstanceState != null) {
163            mCurrentPhotoFile = savedInstanceState.getString(KEY_CURRENT_PHOTO_FILE);
164            mSubActivityInProgress = savedInstanceState.getBoolean(KEY_SUB_ACTIVITY_IN_PROGRESS);
165        }
166
167        // Pull data out of the intent.
168        final Intent intent = getIntent();
169        mPhotoUri = intent.getParcelableExtra(PHOTO_URI);
170        mState = (RawContactDeltaList) intent.getParcelableExtra(ENTITY_DELTA_LIST);
171        mIsProfile = intent.getBooleanExtra(IS_PROFILE, false);
172        mIsDirectoryContact = intent.getBooleanExtra(IS_DIRECTORY_CONTACT, false);
173        mExpandPhoto = intent.getBooleanExtra(EXPAND_PHOTO, false);
174
175        // Pull out photo expansion properties from resources
176        mExpandedPhotoSize = getResources().getDimensionPixelSize(
177                R.dimen.detail_contact_photo_expanded_size);
178        mHeightOffset = getResources().getDimensionPixelOffset(
179                R.dimen.expanded_photo_height_offset);
180
181        mBackdrop = findViewById(R.id.backdrop);
182        mPhotoView = (ImageView) findViewById(R.id.photo);
183        mSourceBounds = intent.getSourceBounds();
184
185        // Fade in the background.
186        animateInBackground();
187
188        // Dismiss the dialog on clicking the backdrop.
189        mBackdrop.setOnClickListener(new View.OnClickListener() {
190            @Override
191            public void onClick(View v) {
192                finish();
193            }
194        });
195
196        // Wait until the layout pass to show the photo, so that the source bounds will match up.
197        SchedulingUtils.doAfterLayout(mBackdrop, new Runnable() {
198            @Override
199            public void run() {
200                displayPhoto();
201            }
202        });
203    }
204
205    /**
206     * Compute the adjusted expanded photo size to fit within the enclosing view with the same
207     * aspect ratio.
208     * @param enclosingView This is the view that the photo must fit within.
209     * @param heightOffset This is the amount of height to leave open for the photo action popup.
210     */
211    private int getAdjustedExpandedPhotoSize(View enclosingView, int heightOffset) {
212        // pull out the bounds of the backdrop
213        final Rect bounds = new Rect();
214        enclosingView.getDrawingRect(bounds);
215        final int boundsWidth = bounds.width();
216        final int boundsHeight = bounds.height() - heightOffset;
217
218        // ensure that the new expanded photo size can fit within the backdrop
219        final float alpha = Math.min((float) boundsHeight / (float) mExpandedPhotoSize,
220                (float) boundsWidth / (float) mExpandedPhotoSize);
221        if (alpha < 1.0f) {
222            // need to shrink width and height while maintaining aspect ratio
223            return (int) (alpha * mExpandedPhotoSize);
224        } else {
225            return mExpandedPhotoSize;
226        }
227    }
228
229    @Override
230    public void onConfigurationChanged(Configuration newConfig) {
231        super.onConfigurationChanged(newConfig);
232
233        // The current look may not seem right on the new configuration, so let's just close self.
234
235        if (!mSubActivityInProgress) {
236            finishImmediatelyWithNoAnimation();
237        } else {
238            // A sub-activity is in progress, so don't close it yet, but close it when we come back
239            // to this activity.
240            mCloseActivityWhenCameBackFromSubActivity = true;
241        }
242    }
243
244    @Override
245    public void finish() {
246        if (!mSubActivityInProgress) {
247            closePhotoAndFinish();
248        } else {
249            finishImmediatelyWithNoAnimation();
250        }
251    }
252
253    /**
254     * Builds a well-formed intent for invoking this activity.
255     * @param context The context.
256     * @param photoUri The URI of the current photo (may be null, in which case the default
257     *     avatar image will be displayed).
258     * @param photoBitmap The bitmap of the current photo (may be null, in which case the default
259     *     avatar image will be displayed).
260     * @param photoBytes The bytes for the current photo (may be null, in which case the default
261     *     avatar image will be displayed).
262     * @param photoBounds The pixel bounds of the current photo.
263     * @param delta The entity delta list for the contact.
264     * @param isProfile Whether the contact is the user's profile.
265     * @param isDirectoryContact Whether the contact comes from a directory (non-editable).
266     * @param expandPhotoOnClick Whether the photo should be expanded on click or not (generally,
267     *     this should be true for phones, and false for tablets).
268     * @return An intent that can be used to invoke the photo selection activity.
269     */
270    public static Intent buildIntent(Context context, Uri photoUri, Bitmap photoBitmap,
271            byte[] photoBytes, Rect photoBounds, RawContactDeltaList delta, boolean isProfile,
272            boolean isDirectoryContact, boolean expandPhotoOnClick) {
273        Intent intent = new Intent(context, PhotoSelectionActivity.class);
274        if (photoUri != null && photoBitmap != null && photoBytes != null) {
275            intent.putExtra(PHOTO_URI, photoUri);
276        }
277        intent.setSourceBounds(photoBounds);
278        intent.putExtra(ENTITY_DELTA_LIST, (Parcelable) delta);
279        intent.putExtra(IS_PROFILE, isProfile);
280        intent.putExtra(IS_DIRECTORY_CONTACT, isDirectoryContact);
281        intent.putExtra(EXPAND_PHOTO, expandPhotoOnClick);
282        return intent;
283    }
284
285    private void finishImmediatelyWithNoAnimation() {
286        super.finish();
287    }
288
289    @Override
290    protected void onDestroy() {
291        super.onDestroy();
292        if (mPhotoAnimator != null) {
293            mPhotoAnimator.cancel();
294            mPhotoAnimator = null;
295        }
296        if (mPhotoHandler != null) {
297            mPhotoHandler.destroy();
298            mPhotoHandler = null;
299        }
300    }
301
302    private void displayPhoto() {
303        // Animate the photo view into its end location.
304        final int[] pos = new int[2];
305        mBackdrop.getLocationOnScreen(pos);
306        LayoutParams layoutParams = new LayoutParams(mSourceBounds.width(),
307                mSourceBounds.height());
308        mOriginalPos.left = mSourceBounds.left - pos[0];
309        mOriginalPos.top = mSourceBounds.top - pos[1];
310        mOriginalPos.right = mOriginalPos.left + mSourceBounds.width();
311        mOriginalPos.bottom = mOriginalPos.top + mSourceBounds.height();
312        layoutParams.setMargins(mOriginalPos.left, mOriginalPos.top, mOriginalPos.right,
313                mOriginalPos.bottom);
314        mPhotoStartParams = layoutParams;
315        mPhotoView.setLayoutParams(layoutParams);
316        mPhotoView.requestLayout();
317
318        // Load the photo.
319        int photoWidth = getPhotoEndParams().width;
320        if (mPhotoUri != null) {
321            // If we have a URI, the bitmap should be cached directly.
322            ContactPhotoManager.getInstance(this).loadPhoto(mPhotoView, mPhotoUri, photoWidth,
323                    false);
324        } else {
325            // Fall back to avatar image.
326            mPhotoView.setImageResource(ContactPhotoManager.getDefaultAvatarResId(this, photoWidth,
327                    false));
328        }
329
330        mPhotoView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
331            @Override
332            public void onLayoutChange(View v, int left, int top, int right, int bottom,
333                    int oldLeft, int oldTop, int oldRight, int oldBottom) {
334                if (mAnimationPending) {
335                    mAnimationPending = false;
336                    PropertyValuesHolder pvhLeft =
337                            PropertyValuesHolder.ofInt("left", mOriginalPos.left, left);
338                    PropertyValuesHolder pvhTop =
339                            PropertyValuesHolder.ofInt("top", mOriginalPos.top, top);
340                    PropertyValuesHolder pvhRight =
341                            PropertyValuesHolder.ofInt("right", mOriginalPos.right, right);
342                    PropertyValuesHolder pvhBottom =
343                            PropertyValuesHolder.ofInt("bottom", mOriginalPos.bottom, bottom);
344                    ObjectAnimator anim = ObjectAnimator.ofPropertyValuesHolder(mPhotoView,
345                            pvhLeft, pvhTop, pvhRight, pvhBottom).setDuration(
346                            PHOTO_EXPAND_DURATION);
347                    if (mAnimationListener != null) {
348                        anim.addListener(mAnimationListener);
349                    }
350                    anim.start();
351                }
352            }
353        });
354        attachPhotoHandler();
355    }
356
357    /**
358     * This sets the photo's layout params at the end of the animation.
359     * <p>
360     * The scheme is to enlarge the photo to the desired size with the enlarged photo shifted
361     * to the top left of the screen as much as possible while keeping the underlying smaller
362     * photo occluded.
363     */
364    private LayoutParams getPhotoEndParams() {
365        if (mPhotoEndParams == null) {
366            mPhotoEndParams = new LayoutParams(mPhotoStartParams);
367            if (mExpandPhoto) {
368                final int adjustedPhotoSize = getAdjustedExpandedPhotoSize(mBackdrop,
369                        mHeightOffset);
370                int widthDelta = adjustedPhotoSize - mPhotoStartParams.width;
371                int heightDelta = adjustedPhotoSize - mPhotoStartParams.height;
372                if (widthDelta >= 1 || heightDelta >= 1) {
373                    // This is an actual expansion.
374                    mPhotoEndParams.width = adjustedPhotoSize;
375                    mPhotoEndParams.height = adjustedPhotoSize;
376                    mPhotoEndParams.topMargin =
377                            Math.max(mPhotoStartParams.topMargin - heightDelta, 0);
378                    mPhotoEndParams.leftMargin =
379                            Math.max(mPhotoStartParams.leftMargin - widthDelta, 0);
380                    mPhotoEndParams.bottomMargin = 0;
381                    mPhotoEndParams.rightMargin = 0;
382                }
383            }
384        }
385        return mPhotoEndParams;
386    }
387
388    private void animatePhotoOpen() {
389        mAnimationListener = new AnimatorListenerAdapter() {
390            private void capturePhotoPos() {
391                mPhotoView.requestLayout();
392                mOriginalPos.left = mPhotoView.getLeft();
393                mOriginalPos.top = mPhotoView.getTop();
394                mOriginalPos.right = mPhotoView.getRight();
395                mOriginalPos.bottom = mPhotoView.getBottom();
396            }
397
398            @Override
399            public void onAnimationEnd(Animator animation) {
400                capturePhotoPos();
401                if (mPhotoHandler != null) {
402                    mPhotoHandler.onClick(mPhotoView);
403                }
404            }
405
406            @Override
407            public void onAnimationCancel(Animator animation) {
408                capturePhotoPos();
409            }
410        };
411        animatePhoto(getPhotoEndParams());
412    }
413
414    private void closePhotoAndFinish() {
415        mAnimationListener = new AnimatorListenerAdapter() {
416            @Override
417            public void onAnimationEnd(Animator animation) {
418                // After the photo animates down, fade it away and finish.
419                ObjectAnimator anim = ObjectAnimator.ofFloat(
420                        mPhotoView, "alpha", 0f).setDuration(PHOTO_CONTRACT_DURATION);
421                anim.addListener(new AnimatorListenerAdapter() {
422                    @Override
423                    public void onAnimationEnd(Animator animation) {
424                        finishImmediatelyWithNoAnimation();
425                    }
426                });
427                anim.start();
428            }
429        };
430
431        animatePhoto(mPhotoStartParams);
432        animateAwayBackground();
433    }
434
435    private void animatePhoto(MarginLayoutParams to) {
436        // Cancel any existing animation.
437        if (mPhotoAnimator != null) {
438            mPhotoAnimator.cancel();
439        }
440
441        mPhotoView.setLayoutParams(to);
442        mAnimationPending = true;
443        mPhotoView.requestLayout();
444    }
445
446    private void animateInBackground() {
447        ObjectAnimator.ofFloat(mBackdrop, "alpha", 0, 0.5f).setDuration(
448                PHOTO_EXPAND_DURATION).start();
449    }
450
451    private void animateAwayBackground() {
452        ObjectAnimator.ofFloat(mBackdrop, "alpha", 0f).setDuration(
453                BACKDROP_FADEOUT_DURATION).start();
454    }
455
456    @Override
457    protected void onSaveInstanceState(Bundle outState) {
458        super.onSaveInstanceState(outState);
459        outState.putString(KEY_CURRENT_PHOTO_FILE, mCurrentPhotoFile);
460        outState.putBoolean(KEY_SUB_ACTIVITY_IN_PROGRESS, mSubActivityInProgress);
461    }
462
463    @Override
464    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
465        if (mPhotoHandler != null) {
466            mSubActivityInProgress = false;
467            if (mPhotoHandler.handlePhotoActivityResult(requestCode, resultCode, data)) {
468                // Clear out any pending photo result.
469                mPendingPhotoResult = null;
470            } else {
471                // User cancelled the sub-activity and returning to the photo selection activity.
472                if (mCloseActivityWhenCameBackFromSubActivity) {
473                    finishImmediatelyWithNoAnimation();
474                } else {
475                    // Re-display options.
476                    mPhotoHandler.onClick(mPhotoView);
477                }
478            }
479        } else {
480            // Create a pending photo result to be handled when the photo handler is created.
481            mPendingPhotoResult = new PendingPhotoResult(requestCode, resultCode, data);
482        }
483    }
484
485    private void attachPhotoHandler() {
486        // Always provide the same two choices (take a photo with the camera, select a photo
487        // from the gallery), but with slightly different wording.
488        // Note: don't worry about this being a read-only contact; this code will not be invoked.
489        int mode = (mPhotoUri == null) ? PhotoActionPopup.Modes.NO_PHOTO
490                : PhotoActionPopup.Modes.PHOTO_DISALLOW_PRIMARY;
491        // We don't want to provide a choice to remove the photo for two reasons:
492        //   1) the UX designs don't call for it
493        //   2) even if we wanted to, the implementation would be moderately hairy
494        mode &= ~PhotoActionPopup.Flags.REMOVE_PHOTO;
495
496        mPhotoHandler = new PhotoHandler(this, mPhotoView, mode, mState);
497
498        if (mPendingPhotoResult != null) {
499            mPhotoHandler.handlePhotoActivityResult(mPendingPhotoResult.mRequestCode,
500                    mPendingPhotoResult.mResultCode, mPendingPhotoResult.mData);
501            mPendingPhotoResult = null;
502        } else {
503            // Setting the photo in displayPhoto() resulted in a relayout
504            // request... to avoid jank, wait until this layout has happened.
505            SchedulingUtils.doAfterLayout(mBackdrop, new Runnable() {
506                @Override
507                public void run() {
508                    animatePhotoOpen();
509                }
510            });
511        }
512    }
513
514    private final class PhotoHandler extends PhotoSelectionHandler {
515        private final PhotoActionListener mListener;
516
517        private PhotoHandler(
518                Context context, View photoView, int photoMode, RawContactDeltaList state) {
519            super(context, photoView, photoMode, PhotoSelectionActivity.this.mIsDirectoryContact,
520                    state);
521            mListener = new PhotoListener();
522        }
523
524        @Override
525        public PhotoActionListener getListener() {
526            return mListener;
527        }
528
529        @Override
530        public void startPhotoActivity(Intent intent, int requestCode, String photoFile) {
531            mSubActivityInProgress = true;
532            mCurrentPhotoFile = photoFile;
533            PhotoSelectionActivity.this.startActivityForResult(intent, requestCode);
534        }
535
536        private final class PhotoListener extends PhotoActionListener {
537            @Override
538            public void onPhotoSelected(Bitmap bitmap) {
539                RawContactDeltaList delta = getDeltaForAttachingPhotoToContact();
540                long rawContactId = getWritableEntityId();
541                final String croppedPath = ContactPhotoUtils.pathForCroppedPhoto(
542                        PhotoSelectionActivity.this, mCurrentPhotoFile);
543                Intent intent = ContactSaveService.createSaveContactIntent(
544                        mContext, delta, "", 0, mIsProfile, null, null, rawContactId, croppedPath);
545                startService(intent);
546                finish();
547            }
548
549            @Override
550            public String getCurrentPhotoFile() {
551                return mCurrentPhotoFile;
552            }
553
554            @Override
555            public void onPhotoSelectionDismissed() {
556                if (!mSubActivityInProgress) {
557                    finish();
558                }
559            }
560        }
561    }
562
563    private static class PendingPhotoResult {
564        final private int mRequestCode;
565        final private int mResultCode;
566        final private Intent mData;
567        private PendingPhotoResult(int requestCode, int resultCode, Intent data) {
568            mRequestCode = requestCode;
569            mResultCode = resultCode;
570            mData = data;
571        }
572    }
573}
574