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