Support for high-resolution contact photos.

Rather than getting a bitmap directly from the gallery app, with
this change we create a temporary file and request that the photo
be stored there.  This avoids running into bundle size limits when
passing large, uncompressed bitmaps back from the gallery.

After reading the photo out into the Contacts app, we use the
openAssetFile API to stream the large photo data into the
Contacts Provider.  Note that we do this rather than having Gallery
write directly to the provider because we have no guarantee that
the Gallery (or substitute) app has WRITE_CONTACTS permission.

In the Contact Editor, the image is not permanently stored until
the contact is saved.  This avoids needing special logic to handle
the case where the contact is newly-created.

Fix bug 5907233 en passant... the vestiges of some partially-
expunged code were causing the ContentEditorFragment to not
apply a selected photo.

Bug: 5786849
Bug: 5907233
Change-Id: Ic0cabaa50c08d6a9a0b730698c92f4092192438a
diff --git a/src/com/android/contacts/ContactSaveService.java b/src/com/android/contacts/ContactSaveService.java
index be84cc4..5dd1942 100644
--- a/src/com/android/contacts/ContactSaveService.java
+++ b/src/com/android/contacts/ContactSaveService.java
@@ -35,11 +35,13 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.OperationApplicationException;
+import android.content.res.AssetFileDescriptor;
 import android.database.Cursor;
 import android.net.Uri;
 import android.os.Handler;
 import android.os.Looper;
 import android.os.Parcelable;
+import android.os.Bundle;
 import android.os.RemoteException;
 import android.provider.ContactsContract;
 import android.provider.ContactsContract.AggregationExceptions;
@@ -53,10 +55,16 @@
 import android.util.Log;
 import android.widget.Toast;
 
+import java.lang.Long;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.Iterator;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
 
 /**
  * A service responsible for saving changes to the content provider.
@@ -80,6 +88,7 @@
     public static final String EXTRA_SAVE_MODE = "saveMode";
     public static final String EXTRA_SAVE_IS_PROFILE = "saveIsProfile";
     public static final String EXTRA_SAVE_SUCCEEDED = "saveSucceeded";
+    public static final String EXTRA_UPDATED_PHOTOS = "updatedPhotos";
 
     public static final String ACTION_CREATE_GROUP = "createGroup";
     public static final String ACTION_RENAME_GROUP = "renameGroup";
@@ -269,15 +278,38 @@
     /**
      * Creates an intent that can be sent to this service to create a new raw contact
      * using data presented as a set of ContentValues.
+     * This variant is more convenient to use when there is only one photo that can
+     * possibly be updated, as in the Contact Details screen.
+     * @param rawContactId identifies a writable raw-contact whose photo is to be updated.
+     * @param updatedPhotoPath denotes a temporary file containing the contact's new photo.
      */
     public static Intent createSaveContactIntent(Context context, EntityDeltaList state,
             String saveModeExtraKey, int saveMode, boolean isProfile, Class<?> callbackActivity,
-            String callbackAction) {
+            String callbackAction, long rawContactId, String updatedPhotoPath) {
+        Bundle bundle = new Bundle();
+        bundle.putString(String.valueOf(rawContactId), updatedPhotoPath);
+        return createSaveContactIntent(context, state, saveModeExtraKey, saveMode, isProfile,
+                callbackActivity, callbackAction, bundle);
+    }
+
+    /**
+     * Creates an intent that can be sent to this service to create a new raw contact
+     * using data presented as a set of ContentValues.
+     * This variant is used when multiple contacts' photos may be updated, as in the
+     * Contact Editor.
+     * @param updatedPhotos maps each raw-contact's ID to the file-path of the new photo.
+     */
+    public static Intent createSaveContactIntent(Context context, EntityDeltaList state,
+            String saveModeExtraKey, int saveMode, boolean isProfile, Class<?> callbackActivity,
+            String callbackAction, Bundle updatedPhotos) {
         Intent serviceIntent = new Intent(
                 context, ContactSaveService.class);
         serviceIntent.setAction(ContactSaveService.ACTION_SAVE_CONTACT);
         serviceIntent.putExtra(EXTRA_CONTACT_STATE, (Parcelable) state);
         serviceIntent.putExtra(EXTRA_SAVE_IS_PROFILE, isProfile);
+        if (updatedPhotos != null) {
+            serviceIntent.putExtra(EXTRA_UPDATED_PHOTOS, (Parcelable) updatedPhotos);
+        }
 
         // Callback intent will be invoked by the service once the contact is
         // saved.  The service will put the URI of the new contact as "data" on
@@ -293,6 +325,7 @@
         EntityDeltaList state = intent.getParcelableExtra(EXTRA_CONTACT_STATE);
         Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
         boolean isProfile = intent.getBooleanExtra(EXTRA_SAVE_IS_PROFILE, false);
+        Bundle updatedPhotos = intent.getParcelableExtra(EXTRA_UPDATED_PHOTOS);
 
         // Trim any empty fields, and RawContacts, before persisting
         final AccountTypeManager accountTypes = AccountTypeManager.getInstance(this);
@@ -301,6 +334,7 @@
         Uri lookupUri = null;
 
         final ContentResolver resolver = getContentResolver();
+        boolean succeeded = false;
 
         // Attempt to persist changes
         int tries = 0;
@@ -346,10 +380,9 @@
                     lookupUri = RawContacts.getContactLookupUri(resolver, rawContactUri);
                 }
                 Log.v(TAG, "Saved contact. New URI: " + lookupUri);
-                // Mark the intent to indicate that the save was successful (even if the lookup URI
-                // is now null).  For local contacts or the local profile, it's possible that the
-                // save triggered removal of the contact, so no lookup URI would exist..
-                callbackIntent.putExtra(EXTRA_SAVE_SUCCEEDED, true);
+
+                // We can change this back to false later, if we fail to save the contact photo.
+                succeeded = true;
                 break;
 
             } catch (RemoteException e) {
@@ -395,11 +428,69 @@
             }
         }
 
+        // Now save any updated photos.  We do this at the end to ensure that
+        // the ContactProvider already knows about newly-created contacts.
+        if (updatedPhotos != null) {
+            for (String key : updatedPhotos.keySet()) {
+                String photoFilePath = updatedPhotos.getString(key);
+                long rawContactId = Long.parseLong(key);
+                File photoFile = new File(photoFilePath);
+                if (!saveUpdatedPhoto(rawContactId, photoFile)) succeeded = false;
+            }
+        }
+
+        if (succeeded) {
+            // Mark the intent to indicate that the save was successful (even if the lookup URI
+            // is now null).  For local contacts or the local profile, it's possible that the
+            // save triggered removal of the contact, so no lookup URI would exist..
+            callbackIntent.putExtra(EXTRA_SAVE_SUCCEEDED, true);
+        }
         callbackIntent.setData(lookupUri);
 
         deliverCallback(callbackIntent);
     }
 
+    /**
+     * Save updated photo for the specified raw-contact.
+     * @return true for success, false for failure
+     */
+    private boolean saveUpdatedPhoto(long rawContactId, File photoFile) {
+        Uri outputUri = Uri.withAppendedPath(
+                ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId),
+                RawContacts.DisplayPhoto.CONTENT_DIRECTORY);
+
+        FileOutputStream outputStream = null;
+        FileInputStream inputStream = null;
+        byte[] buffer = new byte[16 * 1024];
+        int length;
+        int totalLength = 0;
+        try {
+            AssetFileDescriptor fd = getContentResolver().openAssetFileDescriptor(outputUri, "rw");
+            outputStream = fd.createOutputStream();
+            inputStream = new FileInputStream(photoFile);
+            while ((length = inputStream.read(buffer)) > 0) {
+                outputStream.write(buffer, 0, length);
+                totalLength += length;
+            }
+            return true; // yay!
+        } catch(IOException e) {
+            Log.e(TAG, "Failed to write photo: " + photoFile.toString() + " because: " + e);
+        } finally {
+            Log.v(TAG, "Wrote " + totalLength + " bytes for photo " + photoFile.toString());
+            try {
+                inputStream.close();
+            } catch(IOException e) {
+                Log.e(TAG, "Failed to close photo input stream");
+            }
+            try {
+                outputStream.close();
+            } catch(IOException e) {
+                Log.e(TAG, "Failed to close photo output stream");
+            }
+        }
+        return false; // failed
+    }
+
     private long getRawContactId(EntityDeltaList state,
             final ArrayList<ContentProviderOperation> diff,
             final ContentProviderResult[] results) {
diff --git a/src/com/android/contacts/activities/PhotoSelectionActivity.java b/src/com/android/contacts/activities/PhotoSelectionActivity.java
index 9ac0fa8..02843e9 100644
--- a/src/com/android/contacts/activities/PhotoSelectionActivity.java
+++ b/src/com/android/contacts/activities/PhotoSelectionActivity.java
@@ -459,17 +459,21 @@
             }
 
             @Override
-            public void startPickFromGalleryActivity(Intent intent, int requestCode) {
+            public void startPickFromGalleryActivity(Intent intent, int requestCode,
+                    File photoFile) {
                 mSubActivityInProgress = true;
+                mCurrentPhotoFile = photoFile;
                 startActivityForResult(intent, requestCode);
             }
 
             @Override
             public void onPhotoSelected(Bitmap bitmap) {
-                EntityDeltaList delta = getDeltaForAttachingPhotoToContact(bitmap);
+                EntityDeltaList delta = getDeltaForAttachingPhotoToContact();
+                long rawContactId = getWritableEntityId();
+                String filePath = mCurrentPhotoFile.getAbsolutePath();
                 Intent intent = ContactSaveService.createSaveContactIntent(mContext, delta,
                         "", 0, mIsProfile, PhotoSelectionActivity.class,
-                        ContactEditorActivity.ACTION_SAVE_COMPLETED);
+                        ContactEditorActivity.ACTION_SAVE_COMPLETED, rawContactId, filePath);
                 startService(intent);
                 finish();
             }
diff --git a/src/com/android/contacts/detail/ContactDetailFragment.java b/src/com/android/contacts/detail/ContactDetailFragment.java
index beee359..7c79a4c 100644
--- a/src/com/android/contacts/detail/ContactDetailFragment.java
+++ b/src/com/android/contacts/detail/ContactDetailFragment.java
@@ -2000,7 +2000,7 @@
             // should update the ui
             final Intent intent = ContactSaveService.createSaveContactIntent(getActivity(),
                     contactDeltaList, "", 0, false, getActivity().getClass(),
-                    Intent.ACTION_VIEW);
+                    Intent.ACTION_VIEW, null);
             getActivity().startService(intent);
         }
     }
diff --git a/src/com/android/contacts/detail/PhotoSelectionHandler.java b/src/com/android/contacts/detail/PhotoSelectionHandler.java
index 72397c0..1423c65 100644
--- a/src/com/android/contacts/detail/PhotoSelectionHandler.java
+++ b/src/com/android/contacts/detail/PhotoSelectionHandler.java
@@ -32,6 +32,7 @@
 import android.content.Intent;
 import android.database.Cursor;
 import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
 import android.media.MediaScannerConnection;
 import android.net.Uri;
 import android.os.Environment;
@@ -131,7 +132,8 @@
         if (resultCode == Activity.RESULT_OK) {
             switch (requestCode) {
                 case REQUEST_CODE_PHOTO_PICKED_WITH_DATA: {
-                    Bitmap bitmap = data.getParcelableExtra("data");
+                    Bitmap bitmap = BitmapFactory.decodeFile(
+                            mListener.getCurrentPhotoFile().getAbsolutePath());
                     mListener.onPhotoSelected(bitmap);
                     return true;
                 }
@@ -172,6 +174,16 @@
     }
 
     /**
+     * Return the raw-contact id of the first entity in the contact data that belongs to a
+     * contact-writable account, or -1 if no such entity exists.
+     */
+    protected long getWritableEntityId() {
+        int index = getWritableEntityIndex();
+        if (index == -1) return -1;
+        return mState.get(index).getValues().getId();
+    }
+
+    /**
      * 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.
@@ -180,28 +192,14 @@
      *     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) {
+    public EntityDeltaList getDeltaForAttachingPhotoToContact() {
         // 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);
 
@@ -223,8 +221,8 @@
                     null);
 
             // Launch gallery to crop the photo
-            final Intent intent = getCropImageIntent(Uri.fromFile(f));
-            mListener.startPickFromGalleryActivity(intent, REQUEST_CODE_PHOTO_PICKED_WITH_DATA);
+            final Intent intent = getCropImageIntent(f);
+            mListener.startPickFromGalleryActivity(intent, REQUEST_CODE_PHOTO_PICKED_WITH_DATA, f);
         } catch (Exception e) {
             Log.e(TAG, "Cannot crop image", e);
             Toast.makeText(mContext, R.string.photoPickerNotFoundText, Toast.LENGTH_LONG).show();
@@ -237,6 +235,11 @@
         return dateFormat.format(date) + ".jpg";
     }
 
+    private File getPhotoFile() {
+        PHOTO_DIR.mkdirs();
+        return new File(PHOTO_DIR, getPhotoFileName());
+    }
+
     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,
@@ -252,7 +255,8 @@
     /**
      * Constructs an intent for picking a photo from Gallery, cropping it and returning the bitmap.
      */
-    private Intent getPhotoPickIntent() {
+    private Intent getPhotoPickIntent(File photoFile) {
+        Uri photoUri = Uri.fromFile(photoFile);
         Intent intent = new Intent(Intent.ACTION_GET_CONTENT, null);
         intent.setType("image/*");
         intent.putExtra("crop", "true");
@@ -260,14 +264,15 @@
         intent.putExtra("aspectY", 1);
         intent.putExtra("outputX", mPhotoPickSize);
         intent.putExtra("outputY", mPhotoPickSize);
-        intent.putExtra("return-data", true);
+        intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri);
         return intent;
     }
 
     /**
      * Constructs an intent for image cropping.
      */
-    private Intent getCropImageIntent(Uri photoUri) {
+    private Intent getCropImageIntent(File photoFile) {
+        Uri photoUri = Uri.fromFile(photoFile);
         Intent intent = new Intent("com.android.camera.action.CROP");
         intent.setDataAndType(photoUri, "image/*");
         intent.putExtra("crop", "true");
@@ -275,7 +280,7 @@
         intent.putExtra("aspectY", 1);
         intent.putExtra("outputX", mPhotoPickSize);
         intent.putExtra("outputY", mPhotoPickSize);
-        intent.putExtra("return-data", true);
+        intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri);
         return intent;
     }
 
@@ -303,10 +308,9 @@
         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);
+                File f = getPhotoFile();
+                final Intent intent = getTakePhotoIntent(f);
+                startTakePhotoActivity(intent, REQUEST_CODE_CAMERA_WITH_DATA, f);
             } catch (ActivityNotFoundException e) {
                 Toast.makeText(mContext, R.string.photoPickerNotFoundText,
                         Toast.LENGTH_LONG).show();
@@ -317,8 +321,9 @@
         public void onPickFromGalleryChosen() {
             try {
                 // Launch picker to choose photo for selected contact
-                final Intent intent = getPhotoPickIntent();
-                startPickFromGalleryActivity(intent, REQUEST_CODE_PHOTO_PICKED_WITH_DATA);
+                File f = getPhotoFile();
+                final Intent intent = getPhotoPickIntent(f);
+                startPickFromGalleryActivity(intent, REQUEST_CODE_PHOTO_PICKED_WITH_DATA, f);
             } catch (ActivityNotFoundException e) {
                 Toast.makeText(mContext, R.string.photoPickerNotFoundText,
                         Toast.LENGTH_LONG).show();
@@ -340,9 +345,12 @@
          * 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
+         * @param photoFile The temporary file that the cropped image is written to before being
+         *     stored by the content-provider.
          *     {@link PhotoSelectionHandler#handlePhotoActivityResult(int, int, Intent)}.
          */
-        public abstract void startPickFromGalleryActivity(Intent intent, int requestCode);
+        public abstract void startPickFromGalleryActivity(Intent intent, int requestCode,
+                File photoFile);
 
         /**
          * Called when the user has completed selection of a photo.
diff --git a/src/com/android/contacts/editor/ContactEditorFragment.java b/src/com/android/contacts/editor/ContactEditorFragment.java
index 97b1c47..2ede872 100644
--- a/src/com/android/contacts/editor/ContactEditorFragment.java
+++ b/src/com/android/contacts/editor/ContactEditorFragment.java
@@ -188,9 +188,7 @@
     private static final int REQUEST_CODE_JOIN = 0;
     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();
@@ -198,6 +196,7 @@
     private Cursor mGroupMetaData;
 
     private File mCurrentPhotoFile;
+    private final Bundle mUpdatedPhotos = new Bundle();
 
     private Context mContext;
     private String mAction;
@@ -975,8 +974,9 @@
 
         // Save contact
         Intent intent = ContactSaveService.createSaveContactIntent(getActivity(), mState,
-                SAVE_MODE_EXTRA_KEY, saveMode, isEditingUserProfile(),
-                getActivity().getClass(), ContactEditorActivity.ACTION_SAVE_COMPLETED);
+                SAVE_MODE_EXTRA_KEY, saveMode, isEditingUserProfile(), getActivity().getClass(),
+                ContactEditorActivity.ACTION_SAVE_COMPLETED, mUpdatedPhotos);
+
         getActivity().startService(intent);
         return true;
     }
@@ -1564,13 +1564,21 @@
     /**
      * Sets the photo stored in mPhoto and writes it to the RawContact with the given id
      */
-    private void setPhoto(long rawContact, Bitmap photo) {
+    private void setPhoto(long rawContact, Bitmap photo, File photoFile) {
         BaseRawContactEditorView requestingEditor = getRawContactEditorView(rawContact);
+
+        if (photo == null || photo.getHeight() < 0 || photo.getWidth() < 0) {
+            // This is unexpected.
+            Log.w(TAG, "Invalid bitmap passed to setPhoto()");
+        }
+
         if (requestingEditor != null) {
             requestingEditor.setPhotoBitmap(photo);
         } else {
             Log.w(TAG, "The contact that requested the photo is no longer present.");
         }
+
+        mUpdatedPhotos.putString(String.valueOf(rawContact), photoFile.getAbsolutePath());
     }
 
     /**
@@ -1637,12 +1645,6 @@
             setData(data);
             final long setDataEndTime = SystemClock.elapsedRealtime();
 
-            // If we are coming back from the photo trimmer, this will be set.
-            if (mRawContactIdRequestingPhotoAfterLoad != -1) {
-                setPhoto(mRawContactIdRequestingPhotoAfterLoad, mPhoto);
-                mRawContactIdRequestingPhotoAfterLoad = -1;
-                mPhoto = null;
-            }
             Log.v(TAG, "Time needed for setting UI: " + (setDataEndTime-setDataStartTime));
         }
 
@@ -1757,16 +1759,17 @@
             }
 
             @Override
-            public void startPickFromGalleryActivity(Intent intent, int requestCode) {
+            public void startPickFromGalleryActivity(Intent intent, int requestCode,
+                    File photoFile) {
                 mRawContactIdRequestingPhoto = mEditor.getRawContactId();
                 mStatus = Status.SUB_ACTIVITY;
+                mCurrentPhotoFile = photoFile;
                 startActivityForResult(intent, requestCode);
             }
 
             @Override
             public void onPhotoSelected(Bitmap bitmap) {
-                setPhoto(mRawContactIdRequestingPhoto, bitmap);
-                mRawContactIdRequestingPhotoAfterLoad = mRawContactIdRequestingPhoto;
+                setPhoto(mRawContactIdRequestingPhoto, bitmap, mCurrentPhotoFile);
                 mRawContactIdRequestingPhoto = -1;
             }
 
diff --git a/src/com/android/contacts/editor/PhotoEditorView.java b/src/com/android/contacts/editor/PhotoEditorView.java
index 086b07f..db29544 100644
--- a/src/com/android/contacts/editor/PhotoEditorView.java
+++ b/src/com/android/contacts/editor/PhotoEditorView.java
@@ -132,25 +132,13 @@
             return;
         }
 
-        final int size = photo.getWidth() * photo.getHeight() * 4;
-        final ByteArrayOutputStream out = new ByteArrayOutputStream(size);
+        mPhotoImageView.setImageBitmap(photo);
+        mFrameView.setEnabled(isEnabled());
+        mHasSetPhoto = true;
+        mEntry.setFromTemplate(false);
 
-        try {
-            photo.compress(Bitmap.CompressFormat.PNG, 100, out);
-            out.flush();
-            out.close();
-
-            mEntry.put(Photo.PHOTO, out.toByteArray());
-            mPhotoImageView.setImageBitmap(photo);
-            mFrameView.setEnabled(isEnabled());
-            mHasSetPhoto = true;
-            mEntry.setFromTemplate(false);
-
-            // When the user chooses a new photo mark it as super primary
-            mEntry.put(Photo.IS_SUPER_PRIMARY, 1);
-        } catch (IOException e) {
-            Log.w(TAG, "Unable to serialize photo: " + e.toString());
-        }
+        // When the user chooses a new photo mark it as super primary
+        mEntry.put(Photo.IS_SUPER_PRIMARY, 1);
     }
 
     /**