Merge change 25180 into eclair

* changes:
  Prevent unstateful edits, empty trimming, INSERT edge cases.
diff --git a/src/com/android/contacts/model/ContactsSource.java b/src/com/android/contacts/model/ContactsSource.java
index e623084..1e797d4 100644
--- a/src/com/android/contacts/model/ContactsSource.java
+++ b/src/com/android/contacts/model/ContactsSource.java
@@ -115,6 +115,11 @@
         return mInflatedLevel >= inflateLevel;
     }
 
+    /** @hide exposed for unit tests */
+    public void setInflatedLevel(int inflateLevel) {
+        mInflatedLevel = inflateLevel;
+    }
+
     /**
      * Ensure that the constraint rules behind this {@link ContactsSource} have
      * been inflated. Because this may involve parsing meta-data from
diff --git a/src/com/android/contacts/model/EntityDelta.java b/src/com/android/contacts/model/EntityDelta.java
index f221247..f51ea34 100644
--- a/src/com/android/contacts/model/EntityDelta.java
+++ b/src/com/android/contacts/model/EntityDelta.java
@@ -16,6 +16,8 @@
 
 package com.android.contacts.model;
 
+import com.android.contacts.model.ContactsSource.DataKind;
+import com.android.contacts.model.ContactsSource.EditField;
 import com.google.android.collect.Lists;
 import com.google.android.collect.Maps;
 import com.google.android.collect.Sets;
@@ -31,6 +33,8 @@
 import android.provider.BaseColumns;
 import android.provider.ContactsContract.Data;
 import android.provider.ContactsContract.RawContacts;
+import android.text.TextUtils;
+import android.util.Log;
 import android.view.View;
 
 import java.util.ArrayList;
@@ -294,6 +298,7 @@
             // Assert version is consistent while persisting changes
             final Long beforeId = mValues.getId();
             final Long beforeVersion = mValues.getAsLong(RawContacts.VERSION);
+            if (beforeId == null || beforeVersion == null) return;
 
             final ContentProviderOperation.Builder builder = ContentProviderOperation
                     .newAssertQuery(RawContacts.CONTENT_URI);
@@ -525,7 +530,7 @@
 
         public boolean isUpdate() {
             // When "after" has some changes, action is "update"
-            return beforeExists() && (mAfter.size() > 0);
+            return beforeExists() && (mAfter != null && mAfter.size() > 0);
         }
 
         public boolean isInsert() {
diff --git a/src/com/android/contacts/model/EntityModifier.java b/src/com/android/contacts/model/EntityModifier.java
index 61c0c75..b941b38 100644
--- a/src/com/android/contacts/model/EntityModifier.java
+++ b/src/com/android/contacts/model/EntityModifier.java
@@ -28,6 +28,8 @@
 import android.os.Bundle;
 import android.provider.ContactsContract.Data;
 import android.provider.ContactsContract.Intents;
+import android.provider.ContactsContract.RawContacts;
+import android.provider.ContactsContract.CommonDataKinds.BaseTypes;
 import android.provider.ContactsContract.CommonDataKinds.Email;
 import android.provider.ContactsContract.CommonDataKinds.Im;
 import android.provider.ContactsContract.CommonDataKinds.Phone;
@@ -339,12 +341,29 @@
     }
 
     /**
+     * Processing to trim any empty {@link ValuesDelta} and {@link EntityDelta}
+     * from the given {@link EntitySet}, assuming the given {@link Sources}
+     * dictates the structure for various fields. This method ignores rows not
+     * described by the {@link ContactsSource}.
+     */
+    public static void trimEmpty(EntitySet set, Sources sources) {
+        for (EntityDelta state : set) {
+            final String accountType = state.getValues().getAsString(RawContacts.ACCOUNT_TYPE);
+            final ContactsSource source = sources.getInflatedSource(accountType,
+                    ContactsSource.LEVEL_MIMETYPES);
+            trimEmpty(state, source);
+        }
+    }
+
+    /**
      * Processing to trim any empty {@link ValuesDelta} rows from the given
      * {@link EntityDelta}, assuming the given {@link ContactsSource} dictates
      * the structure for various fields. This method ignores rows not described
      * by the {@link ContactsSource}.
      */
-    public static void trimEmpty(ContactsSource source, EntityDelta state) {
+    public static void trimEmpty(EntityDelta state, ContactsSource source) {
+        boolean hasValues = false;
+
         // Walk through entries for each well-known kind
         for (DataKind kind : source.getSortedDataKinds()) {
             final String mimeType = kind.mimeType;
@@ -352,15 +371,28 @@
             if (entries == null) continue;
 
             for (ValuesDelta entry : entries) {
-                // Test and remove this row if empty
+                // Skip any values that haven't been touched
                 final boolean touched = entry.isInsert() || entry.isUpdate();
-                if (touched && EntityModifier.isEmpty(entry, kind)) {
+                if (!touched) {
+                    hasValues = true;
+                    continue;
+                }
+
+                // Test and remove this row if empty
+                if (EntityModifier.isEmpty(entry, kind)) {
                     // TODO: remove this verbose logging
                     Log.w(TAG, "Trimming: " + entry.toString());
                     entry.markDeleted();
+                } else {
+                    hasValues = true;
                 }
             }
         }
+
+        if (!hasValues) {
+            // Trim overall entity if no children exist
+            state.markDeleted();
+        }
     }
 
     /**
@@ -454,7 +486,10 @@
      */
     public static void parseExtras(EntityDelta state, DataKind kind, Bundle extras,
             String typeExtra, String valueExtra, String valueColumn) {
-        final String value = extras.getString(valueExtra);
+        final CharSequence value = extras.getCharSequence(valueExtra);
+
+        // Bail early if source doesn't handle this type
+        if (kind == null) return;
 
         // Bail when can't insert type, or value missing
         final boolean canInsert = EntityModifier.canInsert(state, kind);
@@ -462,16 +497,17 @@
         if (!validValue || !canInsert) return;
 
         // Find exact type, or otherwise best type
-        final int typeValue = extras.getInt(typeExtra, Integer.MIN_VALUE);
+        final int typeValue = extras.getInt(typeExtra, BaseTypes.TYPE_CUSTOM);
         final EditType editType = EntityModifier.getBestValidType(state, kind, true, typeValue);
 
         // Create data row and fill with value
         final ValuesDelta child = EntityModifier.insertChild(state, kind, editType);
-        child.put(valueColumn, value);
+        child.put(valueColumn, value.toString());
 
         if (editType != null && editType.customColumn != null) {
             // Write down label when custom type picked
-            child.put(editType.customColumn, extras.getString(typeExtra));
+            final CharSequence customType = extras.getCharSequence(typeExtra);
+            child.put(editType.customColumn, customType.toString());
         }
     }
 }
diff --git a/src/com/android/contacts/model/Sources.java b/src/com/android/contacts/model/Sources.java
index c0f49c6..71607c7 100644
--- a/src/com/android/contacts/model/Sources.java
+++ b/src/com/android/contacts/model/Sources.java
@@ -66,14 +66,20 @@
     }
 
     /**
-     * Internal constructor that only performs initial parsing. Obtain a
-     * {@link android.provider.ContactsContract.RawContacts#ACCOUNT_TYPE}.
+     * Internal constructor that only performs initial parsing.
      */
     private Sources(Context context) {
         mContext = context;
         loadAccounts();
     }
 
+    /** @hide exposed for unit tests */
+    public Sources(ContactsSource... sources) {
+        for (ContactsSource source : sources) {
+            mSources.put(source.accountType, source);
+        }
+    }
+
     /**
      * Blocking call to load all {@link AuthenticatorDescription} known by the
      * {@link AccountManager} on the system.
diff --git a/src/com/android/contacts/ui/EditContactActivity.java b/src/com/android/contacts/ui/EditContactActivity.java
index baf3fb0..2354a7f 100644
--- a/src/com/android/contacts/ui/EditContactActivity.java
+++ b/src/com/android/contacts/ui/EditContactActivity.java
@@ -62,7 +62,6 @@
 import android.provider.ContactsContract.Contacts.Data;
 import android.util.Log;
 import android.view.ContextThemeWrapper;
-import android.view.KeyEvent;
 import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuInflater;
@@ -90,19 +89,9 @@
     /** The launch code when picking a photo and the raw data is returned */
     private static final int PHOTO_PICKED_WITH_DATA = 3021;
 
-    private static final int TOKEN_ENTITY = 41;
-
     private static final String KEY_EDIT_STATE = "state";
     private static final String KEY_SELECTED_RAW_CONTACT = "selected";
 
-//    private static final String KEY_SELECTED_TAB_ID = "tabId";
-//    private static final String KEY_CONTACT_ID = "contactId";
-
-//    private int mSelectedTab = -1;
-
-//    private long mSelectedRawContactId = -1;
-//    private long mContactId = -1;
-
     private String mQuerySelection;
 
     private ScrollingTabWidget mTabWidget;
@@ -188,6 +177,20 @@
 
             target.mQuerySelection = selection;
             target.mState = EntitySet.fromQuery(resolver, selection, null, null);
+
+            // Handle any incoming values that should be inserted
+            final Bundle extras = intent.getExtras();
+            final boolean hasExtras = extras.size() > 0;
+            final boolean hasState = target.mState.size() > 0;
+            if (hasExtras && hasState) {
+                // Find source defining the first RawContact found
+                final EntityDelta state = target.mState.get(0);
+                final String accountType = state.getValues().getAsString(RawContacts.ACCOUNT_TYPE);
+                final ContactsSource source = sources.getInflatedSource(accountType,
+                        ContactsSource.LEVEL_CONSTRAINTS);
+                EntityModifier.parseExtras(context, source, state, extras);
+            }
+
             return null;
         }
 
@@ -203,11 +206,11 @@
 
     @Override
     protected void onSaveInstanceState(Bundle outState) {
-        // Store entities with modifications
-        outState.putParcelable(KEY_EDIT_STATE, mState);
-        outState.putLong(KEY_SELECTED_RAW_CONTACT, getSelectedRawContactId());
-//        outState.putLong(KEY_SELECTED_TAB_ID, mSelectedRawContactId);
-//        outState.putLong(KEY_CONTACT_ID, mContactId);
+        if (hasValidState()) {
+            // Store entities with modifications
+            outState.putParcelable(KEY_EDIT_STATE, mState);
+            outState.putLong(KEY_SELECTED_RAW_CONTACT, getSelectedRawContactId());
+        }
 
         super.onSaveInstanceState(outState);
     }
@@ -217,16 +220,13 @@
         // Read modifications from instance
         mState = savedInstanceState.<EntitySet> getParcelable(KEY_EDIT_STATE);
 
-//        mSelectedRawContactId = savedInstanceState.getLong(KEY_SELECTED_TAB_ID);
-//        mContactId = savedInstanceState.getLong(KEY_CONTACT_ID);
-
-        Log.d(TAG, "onrestoreinstancestate");
-
         bindTabs();
         bindHeader();
 
-        final long selectedId = savedInstanceState.getLong(KEY_SELECTED_RAW_CONTACT);
-        setSelectedRawContactId(selectedId);
+        if (hasValidState()) {
+            final long selectedId = savedInstanceState.getLong(KEY_SELECTED_RAW_CONTACT);
+            setSelectedRawContactId(selectedId);
+        }
 
         // Restore selected tab and any focus
         super.onRestoreInstanceState(savedInstanceState);
@@ -248,6 +248,14 @@
         mTabWidget.setCurrentTab(index);
     }
 
+    /**
+     * Check if our internal {@link #mState} is valid, usually checked before
+     * performing user actions.
+     */
+    protected boolean hasValidState() {
+        return mState != null && mState.size() > 0;
+    }
+
 
 
     /**
@@ -256,6 +264,8 @@
      * {@link RawContacts}.
      */
     protected void bindTabs() {
+        if (!hasValidState()) return;
+
         final Sources sources = Sources.getInstance(this);
         int selectedTab = 0;
 
@@ -287,6 +297,8 @@
      * primary {@link Data} change.
      */
     protected void bindHeader() {
+        if (!hasValidState()) return;
+
         // TODO: rebuild header widget based on internal entities
 
         // TODO: fill header bar with newly parsed data for speed
@@ -304,8 +316,8 @@
 
     /** {@inheritDoc} */
     public void onTabSelectionChanged(int tabIndex, boolean clicked) {
-        boolean validTab = mState != null && tabIndex >= 0 && tabIndex < mState.size();
-        if (!validTab) return;
+        if (!hasValidState()) return;
+        if (tabIndex < 0 || tabIndex >= mState.size()) return;
 
         // Find entity and source for selected tab
         final EntityDelta entity = mState.get(tabIndex);
@@ -321,11 +333,13 @@
 
     /** {@inheritDoc} */
     public void onDisplayNameLongClick(View view) {
+        if (!hasValidState()) return;
         this.createNameDialog().show();
     }
 
     /** {@inheritDoc} */
     public void onPhotoLongClick(View view) {
+        if (!hasValidState()) return;
         this.createPhotoDialog().show();
     }
 
@@ -347,12 +361,8 @@
 
     /** {@inheritDoc} */
     @Override
-    public boolean onKeyDown(int keyCode, KeyEvent event) {
-        switch (keyCode) {
-            case KeyEvent.KEYCODE_BACK:
-                return doSaveAction();
-        }
-        return super.onKeyDown(keyCode, event);
+    public void onBackPressed() {
+        doSaveAction();
     }
 
     /** {@inheritDoc} */
@@ -456,11 +466,18 @@
         /** {@inheritDoc} */
         @Override
         protected Integer doInBackground(EditContactActivity target, EntitySet... params) {
-            final ContentResolver resolver = target.getContentResolver();
+            final Context context = target;
+            final ContentResolver resolver = context.getContentResolver();
 
+            EntitySet state = params[0];
+
+            // Trim any empty fields, and RawContacts, before persisting
+            final Sources sources = Sources.getInstance(context);
+            EntityModifier.trimEmpty(state, sources);
+
+            // Attempt to persist changes
             int tries = 0;
             Integer result = RESULT_FAILURE;
-            EntitySet state = params[0];
             while (tries < PERSIST_TRIES) {
                 try {
                     // Build operations and try applying
@@ -515,8 +532,7 @@
      * finishes the activity.
      */
     private boolean doSaveAction() {
-        // Bail early if nothing to save
-        if (mState == null || mState.size() == 0) return true;
+        if (!hasValidState()) return false;
 
         // Pass back last-selected contact
         final int selectedTab = mTabWidget.getCurrentTab();
@@ -558,6 +574,7 @@
      * {@link EntityDelta} under the currently edited {@link Contacts}.
      */
     private boolean doAddAction() {
+        // Adding is okay when missing state
         new AddContactTask(this).execute();
         return true;
     }
@@ -567,6 +584,8 @@
      * user confirmation before continuing.
      */
     private boolean doDeleteAction() {
+        if (!hasValidState()) return false;
+
         this.createDeleteDialog().show();
         return true;
     }
@@ -575,6 +594,8 @@
      * Pick a specific photo to be added under the currently selected tab.
      */
     private boolean doPickPhotoAction() {
+        if (!hasValidState()) return false;
+
         try {
             // Launch picker to choose photo for selected contact
             final Intent intent = ContactsUtils.getPhotoPickIntent();
@@ -589,6 +610,8 @@
      * Clear any existing photo under the currently selected tab.
      */
     public boolean doRemovePhotoAction() {
+        if (!hasValidState()) return false;
+
         // Remove photo from selected contact
         mEditor.setPhotoBitmap(null);
         return true;
@@ -601,6 +624,8 @@
 
     /** {@inheritDoc} */
     public void onRequest(int request) {
+        if (!hasValidState()) return;
+
         switch (request) {
             case EditorListener.REQUEST_PICK_PHOTO: {
                 doPickPhotoAction();
@@ -704,7 +729,7 @@
             final DialogInterface.OnCancelListener cancelListener = new DialogInterface.OnCancelListener() {
                 public void onCancel(DialogInterface dialog) {
                     // If nothing remains, close activity
-                    if (target.mState == null || target.mState.size() == 0) {
+                    if (!target.hasValidState()) {
                         target.finish();
                     }
                 }
@@ -739,6 +764,8 @@
                 delta.markDeleted();
 
                 // TODO: trigger task to update tabs (doesnt need to be background)
+                bindTabs();
+                bindHeader();
             }
         });
         builder.setNegativeButton(android.R.string.cancel, null);
diff --git a/tests/src/com/android/contacts/EntityModifierTests.java b/tests/src/com/android/contacts/EntityModifierTests.java
index 9d0c1be..dd40634 100644
--- a/tests/src/com/android/contacts/EntityModifierTests.java
+++ b/tests/src/com/android/contacts/EntityModifierTests.java
@@ -23,6 +23,8 @@
 import com.android.contacts.model.ContactsSource;
 import com.android.contacts.model.EntityDelta;
 import com.android.contacts.model.EntityModifier;
+import com.android.contacts.model.EntitySet;
+import com.android.contacts.model.Sources;
 import com.android.contacts.model.ContactsSource.DataKind;
 import com.android.contacts.model.ContactsSource.EditField;
 import com.android.contacts.model.ContactsSource.EditType;
@@ -40,7 +42,6 @@
 import android.util.Log;
 
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.List;
 
 /**
@@ -54,6 +55,9 @@
     private static final long TEST_ID = 4;
     private static final String TEST_PHONE = "218-555-1212";
 
+    private static final String TEST_ACCOUNT_NAME = "unittest@example.com";
+    private static final String TEST_ACCOUNT_TYPE = "com.example.unittest";
+
     public EntityModifierTests() {
         super();
     }
@@ -69,6 +73,8 @@
      */
     protected ContactsSource getSource() {
         final ContactsSource list = new ContactsSource();
+        list.accountType = TEST_ACCOUNT_TYPE;
+        list.setInflatedLevel(ContactsSource.LEVEL_CONSTRAINTS);
 
         {
             // Phone allows maximum 2 home, 1 work, and unlimited other, with
@@ -94,11 +100,23 @@
     }
 
     /**
+     * Build {@link Sources} instance.
+     */
+    protected Sources getSources(ContactsSource... sources) {
+        return new Sources(sources);
+    }
+
+    /**
      * Build an {@link Entity} with the requested set of phone numbers.
      */
-    protected EntityDelta getEntity(ContentValues... entries) {
+    protected EntityDelta getEntity(Long existingId, ContentValues... entries) {
         final ContentValues contact = new ContentValues();
-        contact.put(RawContacts._ID, TEST_ID);
+        if (existingId != null) {
+            contact.put(RawContacts._ID, existingId);
+        }
+        contact.put(RawContacts.ACCOUNT_NAME, TEST_ACCOUNT_NAME);
+        contact.put(RawContacts.ACCOUNT_TYPE, TEST_ACCOUNT_TYPE);
+
         final Entity before = new Entity(contact);
         for (ContentValues values : entries) {
             before.addSubValue(Data.CONTENT_URI, values);
@@ -135,7 +153,7 @@
         List<EditType> validTypes;
 
         // Add first home, first work
-        final EntityDelta state = getEntity();
+        final EntityDelta state = getEntity(TEST_ID);
         EntityModifier.insertChild(state, kindPhone, typeHome);
         EntityModifier.insertChild(state, kindPhone, typeWork);
 
@@ -178,7 +196,7 @@
         final EditType typeOther = EntityModifier.getType(kindPhone, Phone.TYPE_OTHER);
 
         // Add first home, first work
-        final EntityDelta state = getEntity();
+        final EntityDelta state = getEntity(TEST_ID);
         EntityModifier.insertChild(state, kindPhone, typeHome);
         EntityModifier.insertChild(state, kindPhone, typeWork);
         assertTrue("Unable to insert", EntityModifier.canInsert(state, kindPhone));
@@ -210,7 +228,7 @@
         EditType suggested;
 
         // Default suggestion should be home
-        final EntityDelta state = getEntity();
+        final EntityDelta state = getEntity(TEST_ID);
         suggested = EntityModifier.getBestValidType(state, kindPhone, false, Integer.MIN_VALUE);
         assertEquals("Unexpected suggestion", typeHome, suggested);
 
@@ -252,7 +270,7 @@
         final EditType typeHome = EntityModifier.getType(kindPhone, Phone.TYPE_HOME);
 
         // Test row that has type values, but core fields are empty
-        final EntityDelta state = getEntity();
+        final EntityDelta state = getEntity(TEST_ID);
         final ValuesDelta values = EntityModifier.insertChild(state, kindPhone, typeHome);
 
         assertTrue("Expected empty", EntityModifier.isEmpty(values, kindPhone));
@@ -269,7 +287,7 @@
         final EditType typeHome = EntityModifier.getType(kindPhone, Phone.TYPE_HOME);
 
         // Test row that has type values, but core fields are empty
-        final EntityDelta state = getEntity();
+        final EntityDelta state = getEntity(TEST_ID);
         final ValuesDelta values = EntityModifier.insertChild(state, kindPhone, typeHome);
 
         // Build diff, expecting insert for data row and update enforcement
@@ -292,11 +310,16 @@
             assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
         }
 
-        // Trim empty rows and try again, expecting no changes
-        EntityModifier.trimEmpty(source, state);
+        // Trim empty rows and try again, expecting delete of overall contact
+        EntityModifier.trimEmpty(state, source);
         diff.clear();
         state.buildDiff(diff);
-        assertEquals("Unexpected operations", 0, diff.size());
+        assertEquals("Unexpected operations", 1, diff.size());
+        {
+            final ContentProviderOperation oper = diff.get(0);
+            assertEquals("Incorrect type", TYPE_DELETE, oper.getType());
+            assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+        }
     }
 
     public void testTrimEmptyUntouched() {
@@ -305,7 +328,7 @@
         final EditType typeHome = EntityModifier.getType(kindPhone, Phone.TYPE_HOME);
 
         // Build "before" that has empty row
-        final EntityDelta state = getEntity();
+        final EntityDelta state = getEntity(TEST_ID);
         final ContentValues before = new ContentValues();
         before.put(Data._ID, TEST_ID);
         before.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
@@ -317,7 +340,7 @@
         assertEquals("Unexpected operations", 0, diff.size());
 
         // Try trimming existing empty, which we shouldn't touch
-        EntityModifier.trimEmpty(source, state);
+        EntityModifier.trimEmpty(state, source);
         diff.clear();
         state.buildDiff(diff);
         assertEquals("Unexpected operations", 0, diff.size());
@@ -334,7 +357,7 @@
         before.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
         before.put(kindPhone.typeColumn, typeHome.rawValue);
         before.put(Phone.NUMBER, TEST_PHONE);
-        final EntityDelta state = getEntity(before);
+        final EntityDelta state = getEntity(TEST_ID, before);
 
         // Build diff, expecting no changes
         final ArrayList<ContentProviderOperation> diff = Lists.newArrayList();
@@ -364,7 +387,128 @@
         }
 
         // Now run trim, which should turn that update into delete
-        EntityModifier.trimEmpty(source, state);
+        EntityModifier.trimEmpty(state, source);
+        diff.clear();
+        state.buildDiff(diff);
+        assertEquals("Unexpected operations", 1, diff.size());
+        {
+            final ContentProviderOperation oper = diff.get(0);
+            assertEquals("Incorrect type", TYPE_DELETE, oper.getType());
+            assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+        }
+    }
+
+    public void testTrimInsertEmpty() {
+        final ContactsSource source = getSource();
+        final Sources sources = getSources(source);
+        final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+        final EditType typeHome = EntityModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+        // Try creating a contact without any child entries
+        final EntityDelta state = getEntity(null);
+        final EntitySet set = EntitySet.fromSingle(state);
+
+        // Build diff, expecting single insert
+        final ArrayList<ContentProviderOperation> diff = Lists.newArrayList();
+        state.buildDiff(diff);
+        assertEquals("Unexpected operations", 1, diff.size());
+        {
+            final ContentProviderOperation oper = diff.get(0);
+            assertEquals("Incorrect type", TYPE_INSERT, oper.getType());
+            assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+        }
+
+        // Trim empty rows and try again, expecting no insert
+        EntityModifier.trimEmpty(set, sources);
+        diff.clear();
+        state.buildDiff(diff);
+        assertEquals("Unexpected operations", 0, diff.size());
+    }
+
+    public void testTrimInsertInsert() {
+        final ContactsSource source = getSource();
+        final Sources sources = getSources(source);
+        final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+        final EditType typeHome = EntityModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+        // Try creating a contact with single empty entry
+        final EntityDelta state = getEntity(null);
+        final ValuesDelta values = EntityModifier.insertChild(state, kindPhone, typeHome);
+        final EntitySet set = EntitySet.fromSingle(state);
+
+        // Build diff, expecting two insert operations
+        final ArrayList<ContentProviderOperation> diff = Lists.newArrayList();
+        state.buildDiff(diff);
+        assertEquals("Unexpected operations", 2, diff.size());
+        {
+            final ContentProviderOperation oper = diff.get(0);
+            assertEquals("Incorrect type", TYPE_INSERT, oper.getType());
+            assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+        }
+        {
+            final ContentProviderOperation oper = diff.get(1);
+            assertEquals("Incorrect type", TYPE_INSERT, oper.getType());
+            assertEquals("Incorrect target", Data.CONTENT_URI, oper.getUri());
+        }
+
+        // Trim empty rows and try again, expecting silence
+        EntityModifier.trimEmpty(set, sources);
+        diff.clear();
+        state.buildDiff(diff);
+        assertEquals("Unexpected operations", 0, diff.size());
+    }
+
+    public void testTrimUpdateRemain() {
+        final ContactsSource source = getSource();
+        final Sources sources = getSources(source);
+        final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+        final EditType typeHome = EntityModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+        // Build "before" with two phone numbers
+        final ContentValues first = new ContentValues();
+        first.put(Data._ID, TEST_ID);
+        first.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+        first.put(kindPhone.typeColumn, typeHome.rawValue);
+        first.put(Phone.NUMBER, TEST_PHONE);
+
+        final ContentValues second = new ContentValues();
+        second.put(Data._ID, TEST_ID);
+        second.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+        second.put(kindPhone.typeColumn, typeHome.rawValue);
+        second.put(Phone.NUMBER, TEST_PHONE);
+
+        final EntityDelta state = getEntity(TEST_ID, first, second);
+        final EntitySet set = EntitySet.fromSingle(state);
+
+        // Build diff, expecting no changes
+        final ArrayList<ContentProviderOperation> diff = Lists.newArrayList();
+        state.buildDiff(diff);
+        assertEquals("Unexpected operations", 0, diff.size());
+
+        // Now update row by changing number to empty string, expecting single update
+        final ValuesDelta child = state.getEntry(TEST_ID);
+        child.put(Phone.NUMBER, "");
+        diff.clear();
+        state.buildDiff(diff);
+        assertEquals("Unexpected operations", 3, diff.size());
+        {
+            final ContentProviderOperation oper = diff.get(0);
+            assertEquals("Expected aggregation mode change", TYPE_UPDATE, oper.getType());
+            assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+        }
+        {
+            final ContentProviderOperation oper = diff.get(1);
+            assertEquals("Incorrect type", TYPE_UPDATE, oper.getType());
+            assertEquals("Incorrect target", Data.CONTENT_URI, oper.getUri());
+        }
+        {
+            final ContentProviderOperation oper = diff.get(2);
+            assertEquals("Expected aggregation mode change", TYPE_UPDATE, oper.getType());
+            assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+        }
+
+        // Now run trim, which should turn that update into delete
+        EntityModifier.trimEmpty(set, sources);
         diff.clear();
         state.buildDiff(diff);
         assertEquals("Unexpected operations", 3, diff.size());
@@ -384,4 +528,59 @@
             assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
         }
     }
+
+    public void testTrimUpdateUpdate() {
+        final ContactsSource source = getSource();
+        final Sources sources = getSources(source);
+        final DataKind kindPhone = source.getKindForMimetype(Phone.CONTENT_ITEM_TYPE);
+        final EditType typeHome = EntityModifier.getType(kindPhone, Phone.TYPE_HOME);
+
+        // Build "before" with two phone numbers
+        final ContentValues first = new ContentValues();
+        first.put(Data._ID, TEST_ID);
+        first.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
+        first.put(kindPhone.typeColumn, typeHome.rawValue);
+        first.put(Phone.NUMBER, TEST_PHONE);
+
+        final EntityDelta state = getEntity(TEST_ID, first);
+        final EntitySet set = EntitySet.fromSingle(state);
+
+        // Build diff, expecting no changes
+        final ArrayList<ContentProviderOperation> diff = Lists.newArrayList();
+        state.buildDiff(diff);
+        assertEquals("Unexpected operations", 0, diff.size());
+
+        // Now update row by changing number to empty string, expecting single update
+        final ValuesDelta child = state.getEntry(TEST_ID);
+        child.put(Phone.NUMBER, "");
+        diff.clear();
+        state.buildDiff(diff);
+        assertEquals("Unexpected operations", 3, diff.size());
+        {
+            final ContentProviderOperation oper = diff.get(0);
+            assertEquals("Expected aggregation mode change", TYPE_UPDATE, oper.getType());
+            assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+        }
+        {
+            final ContentProviderOperation oper = diff.get(1);
+            assertEquals("Incorrect type", TYPE_UPDATE, oper.getType());
+            assertEquals("Incorrect target", Data.CONTENT_URI, oper.getUri());
+        }
+        {
+            final ContentProviderOperation oper = diff.get(2);
+            assertEquals("Expected aggregation mode change", TYPE_UPDATE, oper.getType());
+            assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+        }
+
+        // Now run trim, which should turn into deleting the whole contact
+        EntityModifier.trimEmpty(set, sources);
+        diff.clear();
+        state.buildDiff(diff);
+        assertEquals("Unexpected operations", 1, diff.size());
+        {
+            final ContentProviderOperation oper = diff.get(0);
+            assertEquals("Incorrect type", TYPE_DELETE, oper.getType());
+            assertEquals("Incorrect target", RawContacts.CONTENT_URI, oper.getUri());
+        }
+    }
 }