Allow adding/replacing a photo from contact card.

This involves a large refactoring of the photo handling code that
previously lived in ContactEditorFragment.  The bulk of that logic
has been extracted out into PhotoSelectionHandler and
PhotoSelectionActivity classes.

As part of this change, also removed the selection highlighting when
tapping on the current tab header in multi-tab views.

Bug 5294297
Bug 5379389

Change-Id: Ic929e4b4a730d91f768a34367bb76967228ded17
diff --git a/src/com/android/contacts/ContactLoader.java b/src/com/android/contacts/ContactLoader.java
index c711b6c..1cba29b 100644
--- a/src/com/android/contacts/ContactLoader.java
+++ b/src/com/android/contacts/ContactLoader.java
@@ -301,8 +301,10 @@
             return mRequestedUri;
         }
 
-        @VisibleForTesting
-        /*package*/ long getId() {
+        /**
+         * Returns the contact ID.
+         */
+        public long getId() {
             return mId;
         }
 
diff --git a/src/com/android/contacts/activities/AttachPhotoActivity.java b/src/com/android/contacts/activities/AttachPhotoActivity.java
index a697c29..8d4cb5d 100644
--- a/src/com/android/contacts/activities/AttachPhotoActivity.java
+++ b/src/com/android/contacts/activities/AttachPhotoActivity.java
@@ -156,7 +156,7 @@
                 Bitmap photo = extras.getParcelable("data");
                 if (photo != null) {
                     ByteArrayOutputStream stream = new ByteArrayOutputStream();
-                    photo.compress(Bitmap.CompressFormat.JPEG, 75, stream);
+                    photo.compress(Bitmap.CompressFormat.PNG, 100, stream);
 
                     final ContentValues imageValues = new ContentValues();
                     imageValues.put(Photo.PHOTO, stream.toByteArray());
diff --git a/src/com/android/contacts/activities/ContactDetailActivity.java b/src/com/android/contacts/activities/ContactDetailActivity.java
index b949176..b353a0b 100644
--- a/src/com/android/contacts/activities/ContactDetailActivity.java
+++ b/src/com/android/contacts/activities/ContactDetailActivity.java
@@ -74,7 +74,7 @@
     private Handler mHandler = new Handler();
 
     @Override
-    public void onCreate(Bundle savedState) {
+    protected void onCreate(Bundle savedState) {
         super.onCreate(savedState);
         if (PhoneCapabilityTester.isUsingTwoPanes(this)) {
             // This activity must not be shown. We have to select the contact in the
diff --git a/src/com/android/contacts/activities/PhotoSelectionActivity.java b/src/com/android/contacts/activities/PhotoSelectionActivity.java
new file mode 100644
index 0000000..73a85eb
--- /dev/null
+++ b/src/com/android/contacts/activities/PhotoSelectionActivity.java
@@ -0,0 +1,474 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.activities;
+
+import com.android.contacts.ContactSaveService;
+import com.android.contacts.R;
+import com.android.contacts.detail.PhotoSelectionHandler;
+import com.android.contacts.editor.PhotoActionPopup;
+import com.android.contacts.model.EntityDeltaList;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ObjectAnimator;
+import android.animation.PropertyValuesHolder;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.view.View;
+import android.view.ViewGroup.MarginLayoutParams;
+import android.view.ViewTreeObserver.OnGlobalLayoutListener;
+import android.widget.FrameLayout.LayoutParams;
+import android.widget.ImageView;
+
+import java.io.File;
+
+/**
+ * Popup activity for choosing a contact photo within the Contacts app.
+ */
+public class PhotoSelectionActivity extends Activity {
+
+    /** Number of ms for the animation to expand the photo. */
+    private static final int PHOTO_EXPAND_DURATION = 100;
+
+    /** Number of ms for the animation to contract the photo on activity exit. */
+    private static final int PHOTO_CONTRACT_DURATION = 50;
+
+    /** Number of ms for the animation to hide the backdrop on finish. */
+    private static final int BACKDROP_FADEOUT_DURATION = 100;
+
+    private static final String KEY_CURRENT_PHOTO_FILE = "currentphotofile";
+
+    private static final String KEY_SUB_ACTIVITY_IN_PROGRESS = "subinprogress";
+
+    /** Intent extra to get the photo bitmap. */
+    public static final String PHOTO_BITMAP = "photo_bitmap";
+
+    /** Intent extra to get the entity delta list. */
+    public static final String ENTITY_DELTA_LIST = "entity_delta_list";
+
+    /** Intent extra to indicate whether the contact is the user's profile. */
+    public static final String IS_PROFILE = "is_profile";
+
+    /** Intent extra to indicate whether the contact is from a directory (non-editable). */
+    public static final String IS_DIRECTORY_CONTACT = "is_directory_contact";
+
+    /**
+     * Intent extra to indicate whether the photo should be animated to show the full contents of
+     * the photo (on a larger portion of the screen) when clicked.  If unspecified or false, the
+     * photo will not move from its original location.
+     */
+    public static final String EXPAND_PHOTO = "expand_photo";
+
+    /** Source bounds of the image that was clicked on. */
+    private Rect mSourceBounds;
+
+    /** The photo bitmap. */
+    private Bitmap mPhotoBitmap;
+
+    /** Entity delta list of the contact. */
+    private EntityDeltaList mState;
+
+    /** Whether the contact is the user's profile. */
+    private boolean mIsProfile;
+
+    /** Whether the contact is from a directory. */
+    private boolean mIsDirectoryContact;
+
+    /** Whether to animate the photo to an expanded view covering more of the screen. */
+    private boolean mExpandPhoto;
+
+    /** The semi-transparent backdrop. */
+    private View mBackdrop;
+
+    /** The photo view. */
+    private ImageView mPhotoView;
+
+    /** The photo handler attached to this activity, if any. */
+    private PhotoHandler mPhotoHandler;
+
+    /** Animator to expand the photo out to full size. */
+    private ObjectAnimator mPhotoAnimator;
+
+    /** Listener for the animation. */
+    private AnimatorListenerAdapter mAnimationListener;
+
+    /** Whether a change in layout of the photo has occurred that has no animation yet. */
+    private boolean mAnimationPending;
+
+    /** Prior position of the image (for animating). */
+    Rect mOriginalPos = new Rect();
+
+    /** Layout params for the photo view before we started animating. */
+    private LayoutParams mPhotoStartParams;
+
+    /** Layout params for the photo view after we finished animating. */
+    private LayoutParams mPhotoEndParams;
+
+    /** Whether a sub-activity is currently in progress. */
+    private boolean mSubActivityInProgress;
+
+    /**
+     * A photo result received by the activity, persisted across activity lifecycle.
+     */
+    private PendingPhotoResult mPendingPhotoResult;
+
+    /**
+     * The photo file being interacted with, if any.  Saved/restored between activity instances.
+     */
+    private File mCurrentPhotoFile;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.photoselection_activity);
+        if (savedInstanceState != null) {
+            String fileName = savedInstanceState.getString(KEY_CURRENT_PHOTO_FILE);
+            if (fileName != null) {
+                mCurrentPhotoFile = new File(fileName);
+            }
+            mSubActivityInProgress = savedInstanceState.getBoolean(KEY_SUB_ACTIVITY_IN_PROGRESS);
+        }
+
+        // Pull data out of the intent.
+        final Intent intent = getIntent();
+        mPhotoBitmap = intent.getParcelableExtra(PHOTO_BITMAP);
+        mState = (EntityDeltaList) intent.getParcelableExtra(ENTITY_DELTA_LIST);
+        mIsProfile = intent.getBooleanExtra(IS_PROFILE, false);
+        mIsDirectoryContact = intent.getBooleanExtra(IS_DIRECTORY_CONTACT, false);
+        mExpandPhoto = intent.getBooleanExtra(EXPAND_PHOTO, false);
+
+        mBackdrop = findViewById(R.id.backdrop);
+        mPhotoView = (ImageView) findViewById(R.id.photo);
+        mSourceBounds = intent.getSourceBounds();
+
+        // Fade in the background.
+        animateInBackground();
+
+        // Dismiss the dialog on clicking the backdrop.
+        mBackdrop.setOnClickListener(new View.OnClickListener() {
+            @Override
+            public void onClick(View v) {
+                finish();
+            }
+        });
+
+        // Wait until the layout pass to show the photo, so that the source bounds will match up.
+        OnGlobalLayoutListener globalLayoutListener = new OnGlobalLayoutListener() {
+            @Override
+            public void onGlobalLayout() {
+                displayPhoto();
+                mBackdrop.getViewTreeObserver().removeGlobalOnLayoutListener(this);
+            }
+        };
+        mBackdrop.getViewTreeObserver().addOnGlobalLayoutListener(globalLayoutListener);
+    }
+
+    @Override
+    public void finish() {
+        if (!mSubActivityInProgress) {
+            closePhotoAndFinish();
+        } else {
+            activityFinish();
+        }
+    }
+
+    /**
+     * Builds a well-formed intent for invoking this activity.
+     * @param context The context.
+     * @param photoBitmap The bitmap of the current photo.
+     * @param photoBounds The pixel bounds of the current photo.
+     * @param delta The entity delta list for the contact.
+     * @param isProfile Whether the contact is the user's profile.
+     * @param isDirectoryContact Whether the contact comes from a directory (non-editable).
+     * @param expandPhotoOnClick Whether the photo should be expanded on click or not (generally,
+     *     this should be true for phones, and false for tablets).
+     * @return An intent that can be used to invoke the photo selection activity.
+     */
+    public static Intent buildIntent(Context context, Bitmap photoBitmap, Rect photoBounds,
+            EntityDeltaList delta, boolean isProfile, boolean isDirectoryContact,
+            boolean expandPhotoOnClick) {
+        Intent intent = new Intent(context, PhotoSelectionActivity.class);
+        intent.putExtra(PHOTO_BITMAP, photoBitmap);
+        intent.setSourceBounds(photoBounds);
+        intent.putExtra(ENTITY_DELTA_LIST, (Parcelable) delta);
+        intent.putExtra(IS_PROFILE, isProfile);
+        intent.putExtra(IS_DIRECTORY_CONTACT, isDirectoryContact);
+        intent.putExtra(EXPAND_PHOTO, expandPhotoOnClick);
+        return intent;
+    }
+
+    private void activityFinish() {
+        super.finish();
+    }
+
+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        if (mPhotoAnimator != null) {
+            mPhotoAnimator.cancel();
+            mPhotoAnimator = null;
+        }
+        if (mPhotoHandler != null) {
+            mPhotoHandler.destroy();
+            mPhotoHandler = null;
+        }
+    }
+
+    private void displayPhoto() {
+        if (mPhotoBitmap != null) {
+            final int[] pos = new int[2];
+            mBackdrop.getLocationOnScreen(pos);
+            LayoutParams layoutParams = new LayoutParams(mSourceBounds.width(),
+                    mSourceBounds.height());
+            mOriginalPos.left = mSourceBounds.left - pos[0];
+            mOriginalPos.top = mSourceBounds.top - pos[1];
+            mOriginalPos.right = mOriginalPos.left + mSourceBounds.width();
+            mOriginalPos.bottom = mOriginalPos.top + mSourceBounds.height();
+            layoutParams.setMargins(mOriginalPos.left, mOriginalPos.top, mOriginalPos.right,
+                    mOriginalPos.bottom);
+            mPhotoStartParams = layoutParams;
+            mPhotoView.setLayoutParams(layoutParams);
+            mPhotoView.requestLayout();
+
+            mPhotoView.setImageBitmap(mPhotoBitmap);
+            mPhotoView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
+                @Override
+                public void onLayoutChange(View v, int left, int top, int right, int bottom,
+                        int oldLeft, int oldTop, int oldRight, int oldBottom) {
+                    if (mAnimationPending) {
+                        mAnimationPending = false;
+                        PropertyValuesHolder pvhLeft =
+                                PropertyValuesHolder.ofInt("left", mOriginalPos.left, left);
+                        PropertyValuesHolder pvhTop =
+                                PropertyValuesHolder.ofInt("top", mOriginalPos.top, top);
+                        PropertyValuesHolder pvhRight =
+                                PropertyValuesHolder.ofInt("right", mOriginalPos.right, right);
+                        PropertyValuesHolder pvhBottom =
+                                PropertyValuesHolder.ofInt("bottom", mOriginalPos.bottom, bottom);
+                        ObjectAnimator anim = ObjectAnimator.ofPropertyValuesHolder(mPhotoView,
+                                pvhLeft, pvhTop, pvhRight, pvhBottom).setDuration(
+                                PHOTO_EXPAND_DURATION);
+                        if (mAnimationListener != null) {
+                            anim.addListener(mAnimationListener);
+                        }
+                        anim.start();
+                    }
+                }
+            });
+            attachPhotoHandler();
+        }
+    }
+
+    private LayoutParams getPhotoEndParams() {
+        if (mPhotoEndParams == null) {
+            mPhotoEndParams = new LayoutParams(mPhotoStartParams);
+            if (mExpandPhoto) {
+                Rect bounds = new Rect();
+                mBackdrop.getDrawingRect(bounds);
+                if (bounds.height() > bounds.width()) {
+                    //Take up full width.
+                    mPhotoEndParams.width = bounds.width();
+                    mPhotoEndParams.height = bounds.width();
+                } else {
+                    // Take up full height, leaving space for the popup.
+                    mPhotoEndParams.height = bounds.height() - 150;
+                    mPhotoEndParams.width = bounds.height() - 150;
+                }
+                mPhotoEndParams.topMargin = 0;
+                mPhotoEndParams.leftMargin = 0;
+                mPhotoEndParams.bottomMargin = mPhotoEndParams.height;
+                mPhotoEndParams.rightMargin = mPhotoEndParams.width;
+            }
+        }
+        return mPhotoEndParams;
+    }
+
+    private void animatePhotoOpen() {
+        mAnimationListener = new AnimatorListenerAdapter() {
+            private void capturePhotoPos() {
+                mPhotoView.requestLayout();
+                mOriginalPos.left = mPhotoView.getLeft();
+                mOriginalPos.top = mPhotoView.getTop();
+                mOriginalPos.right = mPhotoView.getRight();
+                mOriginalPos.bottom = mPhotoView.getBottom();
+            }
+
+            @Override
+            public void onAnimationEnd(Animator animation) {
+                capturePhotoPos();
+                if (mPhotoHandler != null) {
+                    mPhotoHandler.onClick(mPhotoView);
+                }
+            }
+
+            @Override
+            public void onAnimationCancel(Animator animation) {
+                capturePhotoPos();
+            }
+        };
+        animatePhoto(getPhotoEndParams());
+    }
+
+    private void closePhotoAndFinish() {
+        mAnimationListener = new AnimatorListenerAdapter() {
+            @Override
+            public void onAnimationEnd(Animator animation) {
+                // After the photo animates down, fade it away and finish.
+                ObjectAnimator anim = ObjectAnimator.ofFloat(
+                        mPhotoView, "alpha", 0f).setDuration(PHOTO_CONTRACT_DURATION);
+                anim.addListener(new AnimatorListenerAdapter() {
+                    @Override
+                    public void onAnimationEnd(Animator animation) {
+                        activityFinish();
+                    }
+                });
+                anim.start();
+            }
+        };
+
+        // TODO: This won't animate in the right way if the rotation has changed since the activity
+        // was first started.
+        animatePhoto(mPhotoStartParams);
+        animateAwayBackground();
+    }
+
+    private void animatePhoto(MarginLayoutParams to) {
+        // Cancel any existing animation.
+        if (mPhotoAnimator != null) {
+            mPhotoAnimator.cancel();
+        }
+
+        mPhotoView.setLayoutParams(to);
+        mAnimationPending = true;
+        mPhotoView.requestLayout();
+    }
+
+    private void animateInBackground() {
+        ObjectAnimator.ofFloat(mBackdrop, "alpha", 0, 0.5f).setDuration(
+                PHOTO_EXPAND_DURATION).start();
+    }
+
+    private void animateAwayBackground() {
+        ObjectAnimator.ofFloat(mBackdrop, "alpha", 0f).setDuration(
+                BACKDROP_FADEOUT_DURATION).start();
+    }
+
+    @Override
+    protected void onSaveInstanceState(Bundle outState) {
+        super.onSaveInstanceState(outState);
+        if (mCurrentPhotoFile != null) {
+            outState.putString(KEY_CURRENT_PHOTO_FILE, mCurrentPhotoFile.toString());
+        }
+        outState.putBoolean(KEY_SUB_ACTIVITY_IN_PROGRESS, mSubActivityInProgress);
+    }
+
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+        if (mPhotoHandler != null) {
+            mSubActivityInProgress = false;
+            if (mPhotoHandler.handlePhotoActivityResult(requestCode, resultCode, data)) {
+                // Clear out any pending photo result.
+                mPendingPhotoResult = null;
+            } else {
+                // User returning to the photo selection activity.  Re-display options.
+                mPhotoHandler.onClick(mPhotoView);
+            }
+        } else {
+            // Create a pending photo result to be handled when the photo handler is created.
+            mPendingPhotoResult = new PendingPhotoResult(requestCode, resultCode, data);
+        }
+    }
+
+    private void attachPhotoHandler() {
+        mPhotoHandler = new PhotoHandler(this, mPhotoView,
+                PhotoActionPopup.MODE_NO_PHOTO, mState);
+        if (mPendingPhotoResult != null) {
+            mPhotoHandler.handlePhotoActivityResult(mPendingPhotoResult.mRequestCode,
+                    mPendingPhotoResult.mResultCode, mPendingPhotoResult.mData);
+            mPendingPhotoResult = null;
+        } else {
+            animatePhotoOpen();
+        }
+    }
+
+    private final class PhotoHandler extends PhotoSelectionHandler {
+        private PhotoHandler(Context context, View photoView, int photoMode,
+                EntityDeltaList state) {
+            super(context, photoView, photoMode, mIsDirectoryContact, state);
+            setListener(new PhotoListener(context, mIsProfile));
+        }
+
+        private final class PhotoListener extends PhotoActionListener {
+            private final Context mContext;
+            private final boolean mIsProfile;
+            private PhotoListener(Context context, boolean isProfile) {
+                mContext = context;
+                mIsProfile = isProfile;
+            }
+
+            @Override
+            public void startTakePhotoActivity(Intent intent, int requestCode, File photoFile) {
+                mSubActivityInProgress = true;
+                mCurrentPhotoFile = photoFile;
+                startActivityForResult(intent, requestCode);
+            }
+
+            @Override
+            public void startPickFromGalleryActivity(Intent intent, int requestCode) {
+                mSubActivityInProgress = true;
+                startActivityForResult(intent, requestCode);
+            }
+
+            @Override
+            public void onPhotoSelected(Bitmap bitmap) {
+                EntityDeltaList delta = getDeltaForAttachingPhotoToContact(bitmap);
+                Intent intent = ContactSaveService.createSaveContactIntent(mContext, delta,
+                        "", 0, mIsProfile, PhotoSelectionActivity.class,
+                        ContactEditorActivity.ACTION_SAVE_COMPLETED);
+                startService(intent);
+                finish();
+            }
+
+            @Override
+            public File getCurrentPhotoFile() {
+                return mCurrentPhotoFile;
+            }
+
+            @Override
+            public void onPhotoSelectionDismissed() {
+                if (!mSubActivityInProgress) {
+                    finish();
+                }
+            }
+        }
+    }
+
+    private static class PendingPhotoResult {
+        private int mRequestCode;
+        private int mResultCode;
+        private Intent mData;
+        private PendingPhotoResult(int requestCode, int resultCode, Intent data) {
+            mRequestCode = requestCode;
+            mResultCode = resultCode;
+            mData = data;
+        }
+    }
+}
diff --git a/src/com/android/contacts/detail/CarouselTab.java b/src/com/android/contacts/detail/CarouselTab.java
index 677f0ad..cdcf6b2 100644
--- a/src/com/android/contacts/detail/CarouselTab.java
+++ b/src/com/android/contacts/detail/CarouselTab.java
@@ -73,8 +73,9 @@
 
     @Override
     public void disableTouchInterceptor() {
-        // This shouldn't be called because there is no need to disable the touch interceptor if
-        // there is no content within the tab that needs to be clicked.
+        if (mTouchInterceptLayer != null) {
+            mTouchInterceptLayer.setVisibility(View.GONE);
+        }
     }
 
     @Override
diff --git a/src/com/android/contacts/detail/ContactDetailDisplayUtils.java b/src/com/android/contacts/detail/ContactDetailDisplayUtils.java
index b81cebf..588e6ff 100644
--- a/src/com/android/contacts/detail/ContactDetailDisplayUtils.java
+++ b/src/com/android/contacts/detail/ContactDetailDisplayUtils.java
@@ -20,6 +20,8 @@
 import com.android.contacts.ContactLoader.Result;
 import com.android.contacts.ContactPhotoManager;
 import com.android.contacts.R;
+import com.android.contacts.activities.PhotoSelectionActivity;
+import com.android.contacts.model.EntityDeltaList;
 import com.android.contacts.preference.ContactsPreferences;
 import com.android.contacts.util.ContactBadgeUtil;
 import com.android.contacts.util.HtmlUtils;
@@ -27,19 +29,23 @@
 import com.android.contacts.util.StreamItemPhotoEntry;
 import com.google.common.annotations.VisibleForTesting;
 
+import android.app.Activity;
 import android.content.ContentUris;
 import android.content.ContentValues;
 import android.content.Context;
 import android.content.Entity;
 import android.content.Entity.NamedContentValues;
+import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.content.pm.PackageManager.NameNotFoundException;
 import android.content.res.Resources;
 import android.content.res.Resources.NotFoundException;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
+import android.graphics.Rect;
 import android.graphics.drawable.Drawable;
 import android.net.Uri;
+import android.os.Parcelable;
 import android.provider.ContactsContract;
 import android.provider.ContactsContract.CommonDataKinds.Organization;
 import android.provider.ContactsContract.Data;
@@ -51,6 +57,7 @@
 import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.View;
+import android.view.View.OnClickListener;
 import android.view.ViewGroup;
 import android.view.animation.AccelerateInterpolator;
 import android.view.animation.AlphaAnimation;
@@ -192,11 +199,20 @@
     /**
      * Sets the contact photo to display in the given {@link ImageView}. If bitmap is null, the
      * default placeholder image is shown.
+     * @param context The context.
+     * @param contactData The contact loader result.
+     * @param photoView The photo view that will host the image and act as the basis for the
+     *     photo selector.
+     * @param expandPhotoOnClick Whether the photo should be expanded to fill more of the screen
+     *     when clicked.
+     * @return The onclick listener for the photo.  When clicked, a photo selection activity will
+     *     be launched.
      */
-    public static void setPhoto(Context context, Result contactData, ImageView photoView) {
+    public static OnClickListener setPhoto(Context context, Result contactData,
+            ImageView photoView, boolean expandPhotoOnClick) {
         if (contactData.isLoadingPhoto()) {
             photoView.setImageBitmap(null);
-            return;
+            return null;
         }
         byte[] photo = contactData.getPhotoBinaryData();
         Bitmap bitmap = photo != null ? BitmapFactory.decodeByteArray(photo, 0, photo.length)
@@ -209,6 +225,52 @@
             photoView.startAnimation(animation);
         }
         photoView.setImageBitmap(bitmap);
+
+        // Set up the photo to display a full-screen photo selection activity when clicked.
+        OnClickListener clickListener = new PhotoClickListener(context, contactData, bitmap,
+                expandPhotoOnClick);
+        photoView.setOnClickListener(clickListener);
+        return clickListener;
+    }
+
+    private static final class PhotoClickListener implements OnClickListener {
+
+        private final Context mContext;
+        private final Result mContactData;
+        private final Bitmap mPhotoBitmap;
+        private final boolean mExpandPhotoOnClick;
+        public PhotoClickListener(Context context, Result contactData, Bitmap photoBitmap,
+                boolean expandPhotoOnClick) {
+            mContext = context;
+            mContactData = contactData;
+            mPhotoBitmap = photoBitmap;
+            mExpandPhotoOnClick = expandPhotoOnClick;
+        }
+
+        @Override
+        public void onClick(View v) {
+            // Assemble the intent.
+            EntityDeltaList delta = EntityDeltaList.fromIterator(
+                    mContactData.getEntities().iterator());
+
+            // Find location and bounds of target view, adjusting based on the
+            // assumed local density.
+            final float appScale =
+                    mContext.getResources().getCompatibilityInfo().applicationScale;
+            final int[] pos = new int[2];
+            v.getLocationOnScreen(pos);
+
+            final Rect rect = new Rect();
+            rect.left = (int) (pos[0] * appScale + 0.5f);
+            rect.top = (int) (pos[1] * appScale + 0.5f);
+            rect.right = (int) ((pos[0] + v.getWidth()) * appScale + 0.5f);
+            rect.bottom = (int) ((pos[1] + v.getHeight()) * appScale + 0.5f);
+
+            Intent photoSelectionIntent = PhotoSelectionActivity.buildIntent(mContext,
+                    mPhotoBitmap, rect, delta, mContactData.isUserProfile(),
+                    mContactData.isDirectoryEntry(), mExpandPhotoOnClick);
+            mContext.startActivity(photoSelectionIntent);
+        }
     }
 
     /**
diff --git a/src/com/android/contacts/detail/ContactDetailFragment.java b/src/com/android/contacts/detail/ContactDetailFragment.java
index e74f481..577062e 100644
--- a/src/com/android/contacts/detail/ContactDetailFragment.java
+++ b/src/com/android/contacts/detail/ContactDetailFragment.java
@@ -40,8 +40,8 @@
 import com.android.contacts.util.Constants;
 import com.android.contacts.util.DataStatus;
 import com.android.contacts.util.DateUtils;
-import com.android.contacts.util.StructuredPostalUtils;
 import com.android.contacts.util.PhoneCapabilityTester;
+import com.android.contacts.util.StructuredPostalUtils;
 import com.android.contacts.widget.TransitionAnimationView;
 import com.android.internal.telephony.ITelephony;
 import com.google.common.annotations.VisibleForTesting;
@@ -147,7 +147,8 @@
     private Listener mListener;
 
     private ContactLoader.Result mContactData;
-    private ImageView mStaticPhotoView;
+    private ViewGroup mStaticPhotoContainer;
+    private View mPhotoTouchOverlay;
     private ListView mListView;
     private ViewAdapter mAdapter;
     private Uri mPrimaryPhoneUri = null;
@@ -162,7 +163,8 @@
 
     private final QuickFix[] mPotentialQuickFixes = new QuickFix[] {
             new MakeLocalCopyQuickFix(),
-            new AddToMyContactsQuickFix() };
+            new AddToMyContactsQuickFix()
+    };
 
     /**
      * Device capability: Set during buildEntries and used in the long-press context menu
@@ -280,7 +282,8 @@
 
         mInflater = inflater;
 
-        mStaticPhotoView = (ImageView) mView.findViewById(R.id.photo);
+        mStaticPhotoContainer = (ViewGroup) mView.findViewById(R.id.static_photo_container);
+        mPhotoTouchOverlay = mView.findViewById(R.id.photo_touch_intercept_overlay);
 
         mListView = (ListView) mView.findViewById(android.R.id.list);
         mListView.setScrollBarStyle(ListView.SCROLLBARS_OUTSIDE_OVERLAY);
@@ -442,16 +445,22 @@
         mContactHasSocialUpdates = !mContactData.getStreamItems().isEmpty();
 
         // Setup the photo if applicable
-        if (mStaticPhotoView != null) {
-            // The presence of a static photo view is not sufficient to determine whether or not
-            // we should show the photo. Check the mShowStaticPhoto flag which can be set by an
+        if (mStaticPhotoContainer != null) {
+            // The presence of a static photo container is not sufficient to determine whether or
+            // not we should show the photo. Check the mShowStaticPhoto flag which can be set by an
             // outside class depending on screen size, layout, and whether the contact has social
             // updates or not.
             if (mShowStaticPhoto) {
-                mStaticPhotoView.setVisibility(View.VISIBLE);
-                ContactDetailDisplayUtils.setPhoto(mContext, mContactData, mStaticPhotoView);
+                mStaticPhotoContainer.setVisibility(View.VISIBLE);
+                ImageView photoView = (ImageView) mStaticPhotoContainer.findViewById(R.id.photo);
+                OnClickListener listener = ContactDetailDisplayUtils.setPhoto(mContext,
+                        mContactData, photoView, !PhoneCapabilityTester.isUsingTwoPanes(mContext));
+                if (mPhotoTouchOverlay != null) {
+                    mPhotoTouchOverlay.setVisibility(View.VISIBLE);
+                    mPhotoTouchOverlay.setOnClickListener(listener);
+                }
             } else {
-                mStaticPhotoView.setVisibility(View.GONE);
+                mStaticPhotoContainer.setVisibility(View.GONE);
             }
         }
 
@@ -1371,10 +1380,11 @@
     /**
      * Cache of the children views for a view that displays a header view entry.
      */
-    private static class HeaderViewCache {
+    private static class HeaderViewCache implements ViewOverlay {
         public final TextView displayNameView;
         public final TextView companyView;
         public final ImageView photoView;
+        public final View photoOverlayView;
         public final CheckBox starredView;
         public final int layoutResourceId;
 
@@ -1382,9 +1392,30 @@
             displayNameView = (TextView) view.findViewById(R.id.name);
             companyView = (TextView) view.findViewById(R.id.company);
             photoView = (ImageView) view.findViewById(R.id.photo);
+            photoOverlayView = view.findViewById(R.id.photo_touch_intercept_overlay);
             starredView = (CheckBox) view.findViewById(R.id.star);
             layoutResourceId = layoutResourceInflated;
         }
+
+        @Override
+        public void setAlphaLayerValue(float alpha) {
+            // Nothing to do.
+        }
+
+        @Override
+        public void enableTouchInterceptor(OnClickListener clickListener) {
+            if (photoOverlayView != null) {
+                photoOverlayView.setVisibility(View.VISIBLE);
+                photoOverlayView.setOnClickListener(clickListener);
+            }
+        }
+
+        @Override
+        public void disableTouchInterceptor() {
+            if (photoOverlayView != null) {
+                photoOverlayView.setVisibility(View.GONE);
+            }
+        }
     }
 
     /**
@@ -1498,7 +1529,10 @@
 
             // Set the photo if it should be displayed
             if (viewCache.photoView != null) {
-                ContactDetailDisplayUtils.setPhoto(mContext, mContactData, viewCache.photoView);
+                OnClickListener listener = ContactDetailDisplayUtils.setPhoto(mContext,
+                        mContactData, viewCache.photoView,
+                        !PhoneCapabilityTester.isUsingTwoPanes(mContext));
+                viewCache.enableTouchInterceptor(listener);
             }
 
             // Set the starred state if it should be displayed
diff --git a/src/com/android/contacts/detail/ContactDetailTabCarousel.java b/src/com/android/contacts/detail/ContactDetailTabCarousel.java
index 045e900..e13d3c8 100644
--- a/src/com/android/contacts/detail/ContactDetailTabCarousel.java
+++ b/src/com/android/contacts/detail/ContactDetailTabCarousel.java
@@ -18,7 +18,9 @@
 
 import com.android.contacts.ContactLoader;
 import com.android.contacts.R;
+import com.android.contacts.util.PhoneCapabilityTester;
 
+import android.app.Activity;
 import android.content.Context;
 import android.content.res.Resources;
 import android.util.AttributeSet;
@@ -50,6 +52,8 @@
     private ImageView mPhotoView;
     private TextView mStatusView;
     private ImageView mStatusPhotoView;
+    private boolean mHasPhoto;
+    private OnClickListener mPhotoClickListener;
 
     private Listener mListener;
 
@@ -153,7 +157,11 @@
     private final OnClickListener mAboutTabTouchInterceptListener = new OnClickListener() {
         @Override
         public void onClick(View v) {
-            mListener.onTabSelected(TAB_INDEX_ABOUT);
+            if (mCurrentTab == TAB_INDEX_ABOUT && mPhotoClickListener != null) {
+                mPhotoClickListener.onClick(v);
+            } else {
+                mListener.onTabSelected(TAB_INDEX_ABOUT);
+            }
         }
     };
 
@@ -256,9 +264,11 @@
             case TAB_INDEX_ABOUT:
                 mAboutTab.showSelectedState();
                 mUpdatesTab.showDeselectedState();
+                mUpdatesTab.enableTouchInterceptor(mUpdatesTabTouchInterceptListener);
                 break;
             case TAB_INDEX_UPDATES:
                 mUpdatesTab.showSelectedState();
+                mUpdatesTab.disableTouchInterceptor();
                 mAboutTab.showDeselectedState();
                 break;
             default:
@@ -275,10 +285,12 @@
         if (contactData == null) {
             return;
         }
+        mHasPhoto = contactData.getPhotoUri() != null;
 
         // TODO: Move this into the {@link CarouselTab} class when the updates fragment code is more
         // finalized
-        ContactDetailDisplayUtils.setPhoto(mContext, contactData, mPhotoView);
+        mPhotoClickListener = ContactDetailDisplayUtils.setPhoto(mContext, contactData, mPhotoView,
+                !PhoneCapabilityTester.isUsingTwoPanes(mContext));
         ContactDetailDisplayUtils.setSocialSnippet(mContext, contactData, mStatusView,
                 mStatusPhotoView);
     }
diff --git a/src/com/android/contacts/detail/PhotoSelectionHandler.java b/src/com/android/contacts/detail/PhotoSelectionHandler.java
new file mode 100644
index 0000000..72397c0
--- /dev/null
+++ b/src/com/android/contacts/detail/PhotoSelectionHandler.java
@@ -0,0 +1,365 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.contacts.detail;
+
+import com.android.contacts.R;
+import com.android.contacts.editor.PhotoActionPopup;
+import com.android.contacts.model.AccountType;
+import com.android.contacts.model.AccountTypeManager;
+import com.android.contacts.model.EntityDelta;
+import com.android.contacts.model.EntityDelta.ValuesDelta;
+import com.android.contacts.model.EntityDeltaList;
+import com.android.contacts.model.EntityModifier;
+
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.media.MediaScannerConnection;
+import android.net.Uri;
+import android.os.Environment;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.DisplayPhoto;
+import android.provider.ContactsContract.RawContacts;
+import android.provider.MediaStore;
+import android.util.Log;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.ListPopupWindow;
+import android.widget.PopupWindow.OnDismissListener;
+import android.widget.Toast;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/**
+ * Handles displaying a photo selection popup for a given photo view and dealing with the results
+ * that come back.
+ */
+public class PhotoSelectionHandler implements OnClickListener {
+
+    private static final String TAG = PhotoSelectionHandler.class.getSimpleName();
+
+    private static final File PHOTO_DIR = new File(
+            Environment.getExternalStorageDirectory() + "/DCIM/Camera");
+
+    private static final String PHOTO_DATE_FORMAT = "'IMG'_yyyyMMdd_HHmmss";
+
+    private static final int REQUEST_CODE_CAMERA_WITH_DATA = 1001;
+    private static final int REQUEST_CODE_PHOTO_PICKED_WITH_DATA = 1002;
+
+    private final Context mContext;
+    private final View mPhotoView;
+    private final int mPhotoMode;
+    private final int mPhotoPickSize;
+    private final EntityDeltaList mState;
+    private final boolean mIsDirectoryContact;
+    private ListPopupWindow mPopup;
+    private AccountType mWritableAccount;
+    private PhotoActionListener mListener;
+
+    public PhotoSelectionHandler(Context context, View photoView, int photoMode,
+            boolean isDirectoryContact, EntityDeltaList state) {
+        mContext = context;
+        mPhotoView = photoView;
+        mPhotoMode = photoMode;
+        mIsDirectoryContact = isDirectoryContact;
+        mState = state;
+        mPhotoPickSize = getPhotoPickSize();
+    }
+
+    public void destroy() {
+        if (mPopup != null) {
+            mPopup.dismiss();
+        }
+    }
+
+    public PhotoActionListener getListener() {
+        return mListener;
+    }
+
+    public void setListener(PhotoActionListener listener) {
+        mListener = listener;
+    }
+
+    @Override
+    public void onClick(View v) {
+        if (mListener != null) {
+            if (getWritableEntityIndex() != -1) {
+                mPopup = PhotoActionPopup.createPopupMenu(
+                        mContext, mPhotoView, mListener, mPhotoMode);
+                mPopup.setOnDismissListener(new OnDismissListener() {
+                    @Override
+                    public void onDismiss() {
+                        mListener.onPhotoSelectionDismissed();
+                    }
+                });
+                mPopup.show();
+            }
+        }
+    }
+
+    /**
+     * Attempts to handle the given activity result.  Returns whether this handler was able to
+     * process the result successfully.
+     * @param requestCode The request code.
+     * @param resultCode The result code.
+     * @param data The intent that was returned.
+     * @return Whether the handler was able to process the result.
+     */
+    public boolean handlePhotoActivityResult(int requestCode, int resultCode, Intent data) {
+        if (resultCode == Activity.RESULT_OK) {
+            switch (requestCode) {
+                case REQUEST_CODE_PHOTO_PICKED_WITH_DATA: {
+                    Bitmap bitmap = data.getParcelableExtra("data");
+                    mListener.onPhotoSelected(bitmap);
+                    return true;
+                }
+                case REQUEST_CODE_CAMERA_WITH_DATA: {
+                    doCropPhoto(mListener.getCurrentPhotoFile());
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Return the index of the first entity in the contact data that belongs to a contact-writable
+     * account, or -1 if no such entity exists.
+     */
+    private int getWritableEntityIndex() {
+        // Directory entries are non-writable.
+        if (mIsDirectoryContact) {
+            return -1;
+        }
+
+        // Find the first writable entity.
+        int entityIndex = 0;
+        for (EntityDelta delta : mState) {
+            ContentValues entityValues = delta.getValues().getCompleteValues();
+            String type = entityValues.getAsString(RawContacts.ACCOUNT_TYPE);
+            String dataSet = entityValues.getAsString(RawContacts.DATA_SET);
+            AccountType accountType = AccountTypeManager.getInstance(mContext).getAccountType(
+                    type, dataSet);
+            if (accountType.areContactsWritable()) {
+                mWritableAccount = accountType;
+                return entityIndex;
+            }
+            entityIndex++;
+        }
+        return -1;
+    }
+
+    /**
+     * Utility method to retrieve the entity delta for attaching the given bitmap to the contact.
+     * This will attach the photo to the first contact-writable account that provided data to the
+     * contact.  It is the caller's responsibility to apply the delta.
+     * @param bitmap The photo to use.
+     * @return An entity delta list that can be applied to associate the bitmap with the contact,
+     *     or null if the photo could not be parsed or none of the accounts associated with the
+     *     contact are writable.
+     */
+    public EntityDeltaList getDeltaForAttachingPhotoToContact(Bitmap bitmap) {
+        // Find the first writable entity.
+        int writableEntityIndex = getWritableEntityIndex();
+        if (writableEntityIndex != -1) {
+            // Convert the photo to a byte array.
+            final int size = bitmap.getWidth() * bitmap.getHeight() * 4;
+            final ByteArrayOutputStream out = new ByteArrayOutputStream(size);
+
+            try {
+                bitmap.compress(Bitmap.CompressFormat.PNG, 100, out);
+                out.flush();
+                out.close();
+            } catch (IOException e) {
+                Log.w(TAG, "Unable to serialize photo: " + e.toString());
+                return null;
+            }
+
+            // Note - guaranteed to have contact data if we have a writable entity index.
+            EntityDelta delta = mState.get(writableEntityIndex);
+            ValuesDelta child = EntityModifier.ensureKindExists(
+                    delta, mWritableAccount, Photo.CONTENT_ITEM_TYPE);
+            child.put(Photo.PHOTO, out.toByteArray());
+            child.setFromTemplate(false);
+            child.put(Photo.IS_SUPER_PRIMARY, 1);
+
+            return mState;
+        }
+        return null;
+    }
+
+    /**
+     * Sends a newly acquired photo to Gallery for cropping
+     */
+    private void doCropPhoto(File f) {
+        try {
+            // Add the image to the media store
+            MediaScannerConnection.scanFile(
+                    mContext,
+                    new String[] { f.getAbsolutePath() },
+                    new String[] { null },
+                    null);
+
+            // Launch gallery to crop the photo
+            final Intent intent = getCropImageIntent(Uri.fromFile(f));
+            mListener.startPickFromGalleryActivity(intent, REQUEST_CODE_PHOTO_PICKED_WITH_DATA);
+        } catch (Exception e) {
+            Log.e(TAG, "Cannot crop image", e);
+            Toast.makeText(mContext, R.string.photoPickerNotFoundText, Toast.LENGTH_LONG).show();
+        }
+    }
+
+    private String getPhotoFileName() {
+        Date date = new Date(System.currentTimeMillis());
+        SimpleDateFormat dateFormat = new SimpleDateFormat(PHOTO_DATE_FORMAT);
+        return dateFormat.format(date) + ".jpg";
+    }
+
+    private int getPhotoPickSize() {
+        // Note that this URI is safe to call on the UI thread.
+        Cursor c = mContext.getContentResolver().query(DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI,
+                new String[]{DisplayPhoto.DISPLAY_MAX_DIM}, null, null, null);
+        try {
+            c.moveToFirst();
+            return c.getInt(0);
+        } finally {
+            c.close();
+        }
+    }
+
+    /**
+     * Constructs an intent for picking a photo from Gallery, cropping it and returning the bitmap.
+     */
+    private Intent getPhotoPickIntent() {
+        Intent intent = new Intent(Intent.ACTION_GET_CONTENT, null);
+        intent.setType("image/*");
+        intent.putExtra("crop", "true");
+        intent.putExtra("aspectX", 1);
+        intent.putExtra("aspectY", 1);
+        intent.putExtra("outputX", mPhotoPickSize);
+        intent.putExtra("outputY", mPhotoPickSize);
+        intent.putExtra("return-data", true);
+        return intent;
+    }
+
+    /**
+     * Constructs an intent for image cropping.
+     */
+    private Intent getCropImageIntent(Uri photoUri) {
+        Intent intent = new Intent("com.android.camera.action.CROP");
+        intent.setDataAndType(photoUri, "image/*");
+        intent.putExtra("crop", "true");
+        intent.putExtra("aspectX", 1);
+        intent.putExtra("aspectY", 1);
+        intent.putExtra("outputX", mPhotoPickSize);
+        intent.putExtra("outputY", mPhotoPickSize);
+        intent.putExtra("return-data", true);
+        return intent;
+    }
+
+    /**
+     * Constructs an intent for capturing a photo and storing it in a temporary file.
+     */
+    public static Intent getTakePhotoIntent(File f) {
+        Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE, null);
+        intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(f));
+        return intent;
+    }
+
+    public abstract class PhotoActionListener implements PhotoActionPopup.Listener {
+        @Override
+        public void onUseAsPrimaryChosen() {
+            // No default implementation.
+        }
+
+        @Override
+        public void onRemovePictureChosen() {
+            // No default implementation.
+        }
+
+        @Override
+        public void onTakePhotoChosen() {
+            try {
+                // Launch camera to take photo for selected contact
+                PHOTO_DIR.mkdirs();
+                File photoFile = new File(PHOTO_DIR, getPhotoFileName());
+                startTakePhotoActivity(getTakePhotoIntent(photoFile),
+                        REQUEST_CODE_CAMERA_WITH_DATA, photoFile);
+            } catch (ActivityNotFoundException e) {
+                Toast.makeText(mContext, R.string.photoPickerNotFoundText,
+                        Toast.LENGTH_LONG).show();
+            }
+        }
+
+        @Override
+        public void onPickFromGalleryChosen() {
+            try {
+                // Launch picker to choose photo for selected contact
+                final Intent intent = getPhotoPickIntent();
+                startPickFromGalleryActivity(intent, REQUEST_CODE_PHOTO_PICKED_WITH_DATA);
+            } catch (ActivityNotFoundException e) {
+                Toast.makeText(mContext, R.string.photoPickerNotFoundText,
+                        Toast.LENGTH_LONG).show();
+            }
+        }
+
+        /**
+         * Should initiate an activity to take a photo using the camera.
+         * @param intent The image capture intent.
+         * @param requestCode The request code to use, suitable for handling by
+         *     {@link PhotoSelectionHandler#handlePhotoActivityResult(int, int, Intent)}.
+         * @param photoFile The file path that will be used to store the photo.  This is generally
+         *     what should be returned by
+         *     {@link PhotoSelectionHandler.PhotoActionListener#getCurrentPhotoFile()}.
+         */
+        public abstract void startTakePhotoActivity(Intent intent, int requestCode, File photoFile);
+
+        /**
+         * Should initiate an activity pick a photo from the gallery.
+         * @param intent The image capture intent.
+         * @param requestCode The request code to use, suitable for handling by
+         *     {@link PhotoSelectionHandler#handlePhotoActivityResult(int, int, Intent)}.
+         */
+        public abstract void startPickFromGalleryActivity(Intent intent, int requestCode);
+
+        /**
+         * Called when the user has completed selection of a photo.
+         * @param bitmap The selected and cropped photo.
+         */
+        public abstract void onPhotoSelected(Bitmap bitmap);
+
+        /**
+         * Gets the current photo file that is being interacted with.  It is the activity or
+         * fragment's responsibility to maintain this in saved state, since this handler instance
+         * will not survive rotation.
+         */
+        public abstract File getCurrentPhotoFile();
+
+        /**
+         * Called when the photo selection dialog is dismissed.
+         */
+        public abstract void onPhotoSelectionDismissed();
+    }
+}
diff --git a/src/com/android/contacts/detail/TransformableImageView.java b/src/com/android/contacts/detail/TransformableImageView.java
new file mode 100644
index 0000000..6edc42b
--- /dev/null
+++ b/src/com/android/contacts/detail/TransformableImageView.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.contacts.detail;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.ImageView;
+
+/**
+ * Extension to ImageView that handles cropping during resize animations.
+ */
+public class TransformableImageView extends ImageView {
+
+    public TransformableImageView(Context context) {
+        super(context);
+    }
+
+    public TransformableImageView(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public TransformableImageView(Context context, AttributeSet attrs, int defStyle) {
+        super(context, attrs, defStyle);
+    }
+
+    @Override
+    protected void onDraw(Canvas canvas) {
+        int saveCount = canvas.getSaveCount();
+        canvas.save();
+        canvas.translate(mPaddingLeft, mPaddingTop);
+        Matrix drawMatrix = new Matrix();
+        int dwidth = getDrawable().getIntrinsicWidth();
+        int dheight = getDrawable().getIntrinsicHeight();
+
+        int vwidth = getWidth() - mPaddingLeft - mPaddingRight;
+        int vheight = getHeight() - mPaddingTop - mPaddingBottom;
+        float scale;
+        float dx = 0, dy = 0;
+
+        if (dwidth * vheight > vwidth * dheight) {
+            scale = (float) vheight / (float) dheight;
+            dx = (vwidth - dwidth * scale) * 0.5f;
+        } else {
+            scale = (float) vwidth / (float) dwidth;
+            dy = (vheight - dheight * scale) * 0.5f;
+        }
+
+        drawMatrix.setScale(scale, scale);
+        drawMatrix.postTranslate((int) (dx + 0.5f), (int) (dy + 0.5f));
+        canvas.concat(drawMatrix);
+        getDrawable().draw(canvas);
+        canvas.restoreToCount(saveCount);
+    }
+}
diff --git a/src/com/android/contacts/editor/ContactEditorFragment.java b/src/com/android/contacts/editor/ContactEditorFragment.java
index da5237f..5448005 100644
--- a/src/com/android/contacts/editor/ContactEditorFragment.java
+++ b/src/com/android/contacts/editor/ContactEditorFragment.java
@@ -23,6 +23,7 @@
 import com.android.contacts.activities.ContactEditorAccountsChangedActivity;
 import com.android.contacts.activities.ContactEditorActivity;
 import com.android.contacts.activities.JoinContactActivity;
+import com.android.contacts.detail.PhotoSelectionHandler;
 import com.android.contacts.editor.AggregationSuggestionEngine.Suggestion;
 import com.android.contacts.editor.Editor.EditorListener;
 import com.android.contacts.model.AccountType;
@@ -44,7 +45,6 @@
 import android.app.Fragment;
 import android.app.LoaderManager;
 import android.app.LoaderManager.LoaderCallbacks;
-import android.content.ActivityNotFoundException;
 import android.content.ContentUris;
 import android.content.ContentValues;
 import android.content.Context;
@@ -56,10 +56,8 @@
 import android.database.Cursor;
 import android.graphics.Bitmap;
 import android.graphics.Rect;
-import android.media.MediaScannerConnection;
 import android.net.Uri;
 import android.os.Bundle;
-import android.os.Environment;
 import android.os.SystemClock;
 import android.provider.ContactsContract.CommonDataKinds.Email;
 import android.provider.ContactsContract.CommonDataKinds.Event;
@@ -67,11 +65,9 @@
 import android.provider.ContactsContract.CommonDataKinds.Phone;
 import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
 import android.provider.ContactsContract.Contacts;
-import android.provider.ContactsContract.DisplayPhoto;
 import android.provider.ContactsContract.Groups;
 import android.provider.ContactsContract.Intents;
 import android.provider.ContactsContract.RawContacts;
-import android.provider.MediaStore;
 import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.Menu;
@@ -87,11 +83,9 @@
 import android.widget.Toast;
 
 import java.io.File;
-import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
-import java.util.Date;
 import java.util.List;
 
 public class ContactEditorFragment extends Fragment implements
@@ -192,26 +186,19 @@
     }
 
     private static final int REQUEST_CODE_JOIN = 0;
-    private static final int REQUEST_CODE_CAMERA_WITH_DATA = 1;
-    private static final int REQUEST_CODE_PHOTO_PICKED_WITH_DATA = 2;
-    private static final int REQUEST_CODE_ACCOUNTS_CHANGED = 3;
+    private static final int REQUEST_CODE_ACCOUNTS_CHANGED = 1;
 
     private Bitmap mPhoto = null;
     private long mRawContactIdRequestingPhoto = -1;
     private long mRawContactIdRequestingPhotoAfterLoad = -1;
+    private PhotoSelectionHandler mPhotoSelectionHandler;
 
     private final EntityDeltaComparator mComparator = new EntityDeltaComparator();
 
-    private static final File PHOTO_DIR = new File(
-            Environment.getExternalStorageDirectory() + "/DCIM/Camera");
-
     private Cursor mGroupMetaData;
 
     private File mCurrentPhotoFile;
 
-    // Height/width (in pixels) to request for the photo - queried from the provider.
-    private int mPhotoPickSize;
-
     private Context mContext;
     private String mAction;
     private Uri mLookupUri;
@@ -322,7 +309,6 @@
         super.onAttach(activity);
         mContext = activity;
         mEditorUtils = ContactEditorUtils.getInstance(mContext);
-        loadPhotoPickSize();
     }
 
     @Override
@@ -729,8 +715,9 @@
 
             editor.setState(entity, type, mViewIdGenerator, isEditingUserProfile());
 
-            editor.getPhotoEditor().setEditorListener(
-                    new PhotoEditorListener(editor, type.areContactsWritable()));
+            // Set up the photo handler.
+            bindPhotoHandler(editor, type, mState);
+
             if (editor instanceof RawContactEditorView) {
                 final RawContactEditorView rawContactEditor = (RawContactEditorView) editor;
                 EditorListener listener = new EditorListener() {
@@ -776,7 +763,32 @@
         // Activity can be null if we have been detached from the Activity
         final Activity activity = getActivity();
         if (activity != null) activity.invalidateOptionsMenu();
+    }
 
+    private void bindPhotoHandler(BaseRawContactEditorView editor, AccountType type,
+            EntityDeltaList state) {
+        final int mode;
+        if (type.areContactsWritable()) {
+            if (editor.hasSetPhoto()) {
+                if (hasMoreThanOnePhoto()) {
+                    mode = PhotoActionPopup.MODE_PHOTO_ALLOW_PRIMARY;
+                } else {
+                    mode = PhotoActionPopup.MODE_PHOTO_DISALLOW_PRIMARY;
+                }
+            } else {
+                mode = PhotoActionPopup.MODE_NO_PHOTO;
+            }
+        } else {
+            if (editor.hasSetPhoto() && hasMoreThanOnePhoto()) {
+                mode = PhotoActionPopup.MODE_READ_ONLY_ALLOW_PRIMARY;
+            } else {
+                // Read-only and either no photo or the only photo ==> no options
+                return;
+            }
+        }
+        mPhotoSelectionHandler = new PhotoHandler(mContext, editor, mode, state);
+        editor.getPhotoEditor().setEditorListener(
+                (PhotoHandler.PhotoEditorListener) mPhotoSelectionHandler.getListener());
     }
 
     private void bindGroupMetaData() {
@@ -926,32 +938,6 @@
         return save(SaveMode.JOIN);
     }
 
-    private void loadPhotoPickSize() {
-        Cursor c = mContext.getContentResolver().query(DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI,
-                new String[]{DisplayPhoto.DISPLAY_MAX_DIM}, null, null, null);
-        try {
-            c.moveToFirst();
-            mPhotoPickSize = c.getInt(0);
-        } finally {
-            c.close();
-        }
-    }
-
-    /**
-     * Constructs an intent for picking a photo from Gallery, cropping it and returning the bitmap.
-     */
-    public Intent getPhotoPickIntent() {
-        Intent intent = new Intent(Intent.ACTION_GET_CONTENT, null);
-        intent.setType("image/*");
-        intent.putExtra("crop", "true");
-        intent.putExtra("aspectX", 1);
-        intent.putExtra("aspectY", 1);
-        intent.putExtra("outputX", mPhotoPickSize);
-        intent.putExtra("outputY", mPhotoPickSize);
-        intent.putExtra("return-data", true);
-        return intent;
-    }
-
     /**
      * Check if our internal {@link #mState} is valid, usually checked before
      * performing user actions.
@@ -961,61 +947,6 @@
     }
 
     /**
-     * Create a file name for the icon photo using current time.
-     */
-    private String getPhotoFileName() {
-        Date date = new Date(System.currentTimeMillis());
-        SimpleDateFormat dateFormat = new SimpleDateFormat("'IMG'_yyyyMMdd_HHmmss");
-        return dateFormat.format(date) + ".jpg";
-    }
-
-    /**
-     * Constructs an intent for capturing a photo and storing it in a temporary file.
-     */
-    public static Intent getTakePickIntent(File f) {
-        Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE, null);
-        intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(f));
-        return intent;
-    }
-
-    /**
-     * Sends a newly acquired photo to Gallery for cropping
-     */
-    protected void doCropPhoto(File f) {
-        try {
-            // Add the image to the media store
-            MediaScannerConnection.scanFile(
-                    mContext,
-                    new String[] { f.getAbsolutePath() },
-                    new String[] { null },
-                    null);
-
-            // Launch gallery to crop the photo
-            final Intent intent = getCropImageIntent(Uri.fromFile(f));
-            mStatus = Status.SUB_ACTIVITY;
-            startActivityForResult(intent, REQUEST_CODE_PHOTO_PICKED_WITH_DATA);
-        } catch (Exception e) {
-            Log.e(TAG, "Cannot crop image", e);
-            Toast.makeText(mContext, R.string.photoPickerNotFoundText, Toast.LENGTH_LONG).show();
-        }
-    }
-
-    /**
-     * Constructs an intent for image cropping.
-     */
-    public Intent getCropImageIntent(Uri photoUri) {
-        Intent intent = new Intent("com.android.camera.action.CROP");
-        intent.setDataAndType(photoUri, "image/*");
-        intent.putExtra("crop", "true");
-        intent.putExtra("aspectX", 1);
-        intent.putExtra("aspectY", 1);
-        intent.putExtra("outputX", mPhotoPickSize);
-        intent.putExtra("outputY", mPhotoPickSize);
-        intent.putExtra("return-data", true);
-        return intent;
-    }
-
-    /**
      * Saves or creates the contact based on the mode, and if successful
      * finishes the activity.
      */
@@ -1591,27 +1522,13 @@
             mStatus = Status.EDITING;
         }
 
-        switch (requestCode) {
-            case REQUEST_CODE_PHOTO_PICKED_WITH_DATA: {
-                // Ignore failed requests
-                if (resultCode != Activity.RESULT_OK) return;
-                // As we are coming back to this view, the editor will be reloaded automatically,
-                // which will cause the photo that is set here to disappear. To prevent this,
-                // we remember to set a flag which is interpreted after loading.
-                // This photo is set here already to reduce flickering.
-                mPhoto = data.getParcelableExtra("data");
-                setPhoto(mRawContactIdRequestingPhoto, mPhoto);
-                mRawContactIdRequestingPhotoAfterLoad = mRawContactIdRequestingPhoto;
-                mRawContactIdRequestingPhoto = -1;
+        // See if the photo selection handler handles this result.
+        if (mPhotoSelectionHandler != null && mPhotoSelectionHandler.handlePhotoActivityResult(
+                requestCode, resultCode, data)) {
+            return;
+        }
 
-                break;
-            }
-            case REQUEST_CODE_CAMERA_WITH_DATA: {
-                // Ignore failed requests
-                if (resultCode != Activity.RESULT_OK) return;
-                doCropPhoto(mCurrentPhotoFile);
-                break;
-            }
+        switch (requestCode) {
             case REQUEST_CODE_JOIN: {
                 // Ignore failed requests
                 if (resultCode != Activity.RESULT_OK) return;
@@ -1770,111 +1687,97 @@
         save(SaveMode.SPLIT);
     }
 
-    private final class PhotoEditorListener
-            implements EditorListener, PhotoActionPopup.Listener {
-        private final BaseRawContactEditorView mEditor;
-        private final boolean mAccountWritable;
-
-        private PhotoEditorListener(BaseRawContactEditorView editor, boolean accountWritable) {
-            mEditor = editor;
-            mAccountWritable = accountWritable;
+    /**
+     * Custom photo handler for the editor.  The inner listener that this creates also has a
+     * reference to the editor and acts as an {@link EditorListener}, and uses that editor to hold
+     * state information in several of the listener methods.
+     */
+    private final class PhotoHandler extends PhotoSelectionHandler {
+        public PhotoHandler(Context context, BaseRawContactEditorView editor, int photoMode,
+                EntityDeltaList state) {
+            super(context, editor.getPhotoEditor(), photoMode, false, state);
+            setListener(new PhotoEditorListener(editor));
         }
 
-        @Override
-        public void onRequest(int request) {
-            if (!hasValidState()) return;
+        private final class PhotoEditorListener extends PhotoSelectionHandler.PhotoActionListener
+                implements EditorListener {
+            private final BaseRawContactEditorView mEditor;
 
-            if (request == EditorListener.REQUEST_PICK_PHOTO) {
-                // Determine mode
-                final int mode;
-                if (mAccountWritable) {
-                    if (mEditor.hasSetPhoto()) {
-                        if (hasMoreThanOnePhoto()) {
-                            mode = PhotoActionPopup.MODE_PHOTO_ALLOW_PRIMARY;
-                        } else {
-                            mode = PhotoActionPopup.MODE_PHOTO_DISALLOW_PRIMARY;
-                        }
-                    } else {
-                        mode = PhotoActionPopup.MODE_NO_PHOTO;
-                    }
-                } else {
-                    if (mEditor.hasSetPhoto() && hasMoreThanOnePhoto()) {
-                        mode = PhotoActionPopup.MODE_READ_ONLY_ALLOW_PRIMARY;
-                    } else {
-                        // Read-only and either no photo or the only photo ==> no options
-                        return;
-                    }
-                }
-                PhotoActionPopup.createPopupMenu(mContext, mEditor.getPhotoEditor(), this, mode)
-                        .show();
+            private PhotoEditorListener(BaseRawContactEditorView editor) {
+                mEditor = editor;
             }
-        }
 
-        @Override
-        public void onDeleteRequested(Editor removedEditor) {
-            // The picture cannot be deleted, it can only be removed, which is handled by
-            // onRemovePictureChosen()
-        }
+            @Override
+            public void onRequest(int request) {
+                if (!hasValidState()) return;
 
-        /**
-         * User has chosen to set the selected photo as the (super) primary photo
-         */
-        @Override
-        public void onUseAsPrimaryChosen() {
-            // Set the IsSuperPrimary for each editor
-            int count = mContent.getChildCount();
-            for (int i = 0; i < count; i++) {
-                final View childView = mContent.getChildAt(i);
-                if (childView instanceof BaseRawContactEditorView) {
-                    final BaseRawContactEditorView editor = (BaseRawContactEditorView) childView;
-                    final PhotoEditorView photoEditor = editor.getPhotoEditor();
-                    photoEditor.setSuperPrimary(editor == mEditor);
+                if (request == EditorListener.REQUEST_PICK_PHOTO) {
+                    onClick(mEditor.getPhotoEditor());
                 }
             }
-        }
 
-        /**
-         * User has chosen to remove a picture
-         */
-        @Override
-        public void onRemovePictureChosen() {
-            mEditor.setPhotoBitmap(null);
-        }
-
-        /**
-         * Launches Camera to take a picture and store it in a file.
-         */
-        @Override
-        public void onTakePhotoChosen() {
-            mRawContactIdRequestingPhoto = mEditor.getRawContactId();
-            try {
-                // Launch camera to take photo for selected contact
-                PHOTO_DIR.mkdirs();
-                mCurrentPhotoFile = new File(PHOTO_DIR, getPhotoFileName());
-                final Intent intent = getTakePickIntent(mCurrentPhotoFile);
-
-                mStatus = Status.SUB_ACTIVITY;
-                startActivityForResult(intent, REQUEST_CODE_CAMERA_WITH_DATA);
-            } catch (ActivityNotFoundException e) {
-                Toast.makeText(mContext, R.string.photoPickerNotFoundText,
-                        Toast.LENGTH_LONG).show();
+            @Override
+            public void onDeleteRequested(Editor removedEditor) {
+                // The picture cannot be deleted, it can only be removed, which is handled by
+                // onRemovePictureChosen()
             }
-        }
 
-        /**
-         * Launches Gallery to pick a photo.
-         */
-        @Override
-        public void onPickFromGalleryChosen() {
-            mRawContactIdRequestingPhoto = mEditor.getRawContactId();
-            try {
-                // Launch picker to choose photo for selected contact
-                final Intent intent = getPhotoPickIntent();
+            /**
+             * User has chosen to set the selected photo as the (super) primary photo
+             */
+            @Override
+            public void onUseAsPrimaryChosen() {
+                // Set the IsSuperPrimary for each editor
+                int count = mContent.getChildCount();
+                for (int i = 0; i < count; i++) {
+                    final View childView = mContent.getChildAt(i);
+                    if (childView instanceof BaseRawContactEditorView) {
+                        final BaseRawContactEditorView editor =
+                                (BaseRawContactEditorView) childView;
+                        final PhotoEditorView photoEditor = editor.getPhotoEditor();
+                        photoEditor.setSuperPrimary(editor == mEditor);
+                    }
+                }
+            }
+
+            /**
+             * User has chosen to remove a picture
+             */
+            @Override
+            public void onRemovePictureChosen() {
+                mEditor.setPhotoBitmap(null);
+            }
+
+            @Override
+            public void startTakePhotoActivity(Intent intent, int requestCode, File photoFile) {
+                mRawContactIdRequestingPhoto = mEditor.getRawContactId();
                 mStatus = Status.SUB_ACTIVITY;
-                startActivityForResult(intent, REQUEST_CODE_PHOTO_PICKED_WITH_DATA);
-            } catch (ActivityNotFoundException e) {
-                Toast.makeText(mContext, R.string.photoPickerNotFoundText,
-                        Toast.LENGTH_LONG).show();
+                mCurrentPhotoFile = photoFile;
+                startActivityForResult(intent, requestCode);
+            }
+
+            @Override
+            public void startPickFromGalleryActivity(Intent intent, int requestCode) {
+                mRawContactIdRequestingPhoto = mEditor.getRawContactId();
+                mStatus = Status.SUB_ACTIVITY;
+                startActivityForResult(intent, requestCode);
+            }
+
+            @Override
+            public void onPhotoSelected(Bitmap bitmap) {
+                setPhoto(mRawContactIdRequestingPhoto, bitmap);
+                mRawContactIdRequestingPhotoAfterLoad = mRawContactIdRequestingPhoto;
+                mRawContactIdRequestingPhoto = -1;
+            }
+
+            @Override
+            public File getCurrentPhotoFile() {
+                return mCurrentPhotoFile;
+            }
+
+            @Override
+            public void onPhotoSelectionDismissed() {
+                // Nothing to do.
             }
         }
     }
diff --git a/src/com/android/contacts/editor/PhotoActionPopup.java b/src/com/android/contacts/editor/PhotoActionPopup.java
index cca6f9d..029212f 100644
--- a/src/com/android/contacts/editor/PhotoActionPopup.java
+++ b/src/com/android/contacts/editor/PhotoActionPopup.java
@@ -76,8 +76,6 @@
             @Override
             public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                 final ChoiceListItem choice = choices.get(position);
-                listPopupWindow.dismiss();
-
                 switch (choice.getId()) {
                     case ChoiceListItem.ID_USE_AS_PRIMARY:
                         listener.onUseAsPrimaryChosen();
@@ -92,6 +90,8 @@
                         listener.onPickFromGalleryChosen();
                         break;
                 }
+
+                listPopupWindow.dismiss();
             }
         };
 
diff --git a/src/com/android/contacts/model/EntityModifier.java b/src/com/android/contacts/model/EntityModifier.java
index 289ca54..ef5d304 100644
--- a/src/com/android/contacts/model/EntityModifier.java
+++ b/src/com/android/contacts/model/EntityModifier.java
@@ -103,19 +103,28 @@
     /**
      * Ensure that at least one of the given {@link DataKind} exists in the
      * given {@link EntityDelta} state, and try creating one if none exist.
+     * @return The child (either newly created or the first existing one), or null if the
+     *     account doesn't support this {@link DataKind}.
      */
-    public static void ensureKindExists(
+    public static ValuesDelta ensureKindExists(
             EntityDelta state, AccountType accountType, String mimeType) {
         final DataKind kind = accountType.getKindForMimetype(mimeType);
         final boolean hasChild = state.getMimeEntriesCount(mimeType, true) > 0;
 
-        if (!hasChild && kind != null) {
-            // Create child when none exists and valid kind
-            final ValuesDelta child = insertChild(state, kind);
-            if (kind.mimeType.equals(Photo.CONTENT_ITEM_TYPE)) {
-                child.setFromTemplate(true);
+        if (kind != null) {
+            if (hasChild) {
+                // Return the first entry.
+                return state.getMimeEntries(mimeType).get(0);
+            } else {
+                // Create child when none exists and valid kind
+                final ValuesDelta child = insertChild(state, kind);
+                if (kind.mimeType.equals(Photo.CONTENT_ITEM_TYPE)) {
+                    child.setFromTemplate(true);
+                }
+                return child;
             }
         }
+        return null;
     }
 
     /**