Do a comparative sync between local and server voicemails.

Currently a sync will completely wipe local voicemails and replace them
with the server voicemails. Instead, we will do a comparative sync to be
less disruptive to the user, and properly notify when a new voicemail is
available on the server. The changes are as follows:

* Rename DirtyVoicemailQuery to VoicemailsQueryHelper to handle all
  queries related to the voicemail provider.
* Implement the comparative sync logic in OmtpVvmSyncService
* Fixed bug when receiving mailbox update sync message in
  WrappedMessageData (tried to parse a field that did not exist)

Bug: 20764449
Change-Id: Iaae2a9fe7184e827d72baf1d5d5a72a36b3124a6
diff --git a/src/com/android/phone/vvm/omtp/OmtpVvmSyncService.java b/src/com/android/phone/vvm/omtp/OmtpVvmSyncService.java
index fc444ba..c9a1c32 100644
--- a/src/com/android/phone/vvm/omtp/OmtpVvmSyncService.java
+++ b/src/com/android/phone/vvm/omtp/OmtpVvmSyncService.java
@@ -26,24 +26,20 @@
 import android.content.AbstractThreadedSyncAdapter;
 import android.content.ContentProviderClient;
 import android.content.ContentResolver;
-import android.content.ContentUris;
-import android.content.ContentValues;
 import android.content.Context;
 import android.content.Intent;
 import android.content.SyncResult;
-import android.database.Cursor;
-import android.net.Uri;
 import android.os.Bundle;
 import android.os.IBinder;
 import android.provider.VoicemailContract;
-import android.provider.VoicemailContract.Voicemails;
 import android.telecom.Voicemail;
 
 import com.android.phone.vvm.omtp.imap.ImapHelper;
-import com.android.phone.vvm.omtp.sync.DirtyVoicemailQuery;
+import com.android.phone.vvm.omtp.sync.VoicemailsQueryHelper;
 
-import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * A service to run the VvmSyncAdapter.
@@ -73,106 +69,81 @@
          * Sync triggers should pass this extra to clear the database and freshly populate from the
          * server.
          */
-        public static final String SYNC_EXTRAS_CLEAR_AND_RELOAD = "extra_clear_and_reload";
+        public static final String SYNC_EXTRAS_DOWNLOAD = "extra_download";
 
         private Context mContext;
-        private ContentResolver mContentResolver;
 
         public OmtpVvmSyncAdapter(Context context, boolean autoInitialize) {
             super(context, autoInitialize);
             mContext = context;
-            mContentResolver = context.getContentResolver();
         }
 
         @Override
         public void onPerformSync(Account account, Bundle extras, String authority,
                 ContentProviderClient provider, SyncResult syncResult) {
             ImapHelper imapHelper = new ImapHelper(mContext, account);
+            VoicemailsQueryHelper queryHelper = new VoicemailsQueryHelper(mContext);
 
             if (extras.getBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, false)) {
-                List<Voicemail> readVoicemails = new ArrayList<Voicemail>();
-                List<Voicemail> deletedVoicemails = new ArrayList<Voicemail>();
+                List<Voicemail> readVoicemails = queryHelper.getReadVoicemails();
+                List<Voicemail> deletedVoicemails = queryHelper.getDeletedVoicemails();
 
-                Cursor cursor = DirtyVoicemailQuery.getDirtyVoicemails(mContext);
-                if (cursor == null) {
-                    return;
-                }
-                try {
-                    while (cursor.moveToNext()) {
-                        final long id = cursor.getLong(DirtyVoicemailQuery._ID);
-                        final String sourceData = cursor.getString(DirtyVoicemailQuery.SOURCE_DATA);
-                        final boolean isRead = cursor.getInt(DirtyVoicemailQuery.IS_READ) == 1;
-                        final boolean deleted = cursor.getInt(DirtyVoicemailQuery.DELETED) == 1;
-                        Voicemail voicemail = Voicemail.createForUpdate(id, sourceData).build();
-                        if (deleted) {
-                            // Check deleted first because if the voicemail is deleted, there's no
-                            // need to mark as read.
-                            deletedVoicemails.add(voicemail);
-                        } else if (isRead) {
-                            readVoicemails.add(voicemail);
-                        }
-                    }
-                } finally {
-                    cursor.close();
-                }
-                if (imapHelper.markMessagesAsDeleted(deletedVoicemails)) {
+                if (deletedVoicemails != null &&
+                        imapHelper.markMessagesAsDeleted(deletedVoicemails)) {
                     // We want to delete selectively instead of all the voicemails for this provider
                     // in case the state changed since the IMAP query was completed.
-                    deleteFromDatabase(deletedVoicemails);
+                    queryHelper.deleteFromDatabase(deletedVoicemails);
                 }
 
-                if (imapHelper.markMessagesAsRead(readVoicemails)) {
-                    markReadInDatabase(readVoicemails);
+                if (readVoicemails != null && imapHelper.markMessagesAsRead(readVoicemails)) {
+                    queryHelper.markReadInDatabase(readVoicemails);
                 }
             }
 
-            if (extras.getBoolean(SYNC_EXTRAS_CLEAR_AND_RELOAD, false)) {
-                // Fetch voicemails first before deleting local copy, the fetching may take awhile.
-                List<Voicemail> voicemails = imapHelper.fetchAllVoicemails();
+            if (extras.getBoolean(SYNC_EXTRAS_DOWNLOAD, false)) {
+                List<Voicemail> serverVoicemails = imapHelper.fetchAllVoicemails();
+                List<Voicemail> localVoicemails = queryHelper.getAllVoicemails();
 
-                // Deleting current local messages ensure that we start with a fresh copy
-                // and also don't need to deal with comparing between local and server.
-                VoicemailContract.Voicemails.deleteAll(mContext);
+                if (localVoicemails == null || serverVoicemails == null) {
+                    // Null value means the query failed.
+                    return;
+                }
 
-                if (voicemails != null) {
-                    VoicemailContract.Voicemails.insert(mContext, voicemails);
+                Map<String, Voicemail> remoteMap = buildMap(serverVoicemails);
+
+                // Go through all the local voicemails and check if they are on the server.
+                // They may be read or deleted on the server but not locally. Perform the
+                // appropriate local operation if the status differs from the server. Remove
+                // the messages that exist both locally and on the server to know which server
+                // messages to insert locally.
+                for (int i = 0; i < localVoicemails.size(); i++) {
+                    Voicemail localVoicemail = localVoicemails.get(i);
+                    Voicemail remoteVoicemail = remoteMap.remove(localVoicemail.getSourceData());
+                    if (remoteVoicemail == null) {
+                        queryHelper.deleteFromDatabase(localVoicemail);
+                    } else {
+                        if (remoteVoicemail.isRead() != localVoicemail.isRead()) {
+                            queryHelper.markReadInDatabase(localVoicemail);
+                        }
+                    }
+                }
+
+                // The leftover messages are messages that exist on the server but not locally.
+                for (Voicemail remoteVoicemail : remoteMap.values()) {
+                    VoicemailContract.Voicemails.insert(mContext, remoteVoicemail);
                 }
             }
         }
 
         /**
-         * Deletes a list of voicemails from the voicemail content provider.
-         *
-         * @param voicemails The list of voicemails to delete
-         * @return The number of voicemails deleted
+         * Builds a map from provider data to message for the given collection of voicemails.
          */
-        public int deleteFromDatabase(List<Voicemail> voicemails) {
-            int count = voicemails.size();
-            for (int i = 0; i < count; i++) {
-                mContentResolver.delete(Voicemails.CONTENT_URI, Voicemails._ID + "=?",
-                        new String[] { Long.toString(voicemails.get(i).getId()) });
+        private Map<String, Voicemail> buildMap(List<Voicemail> messages) {
+            Map<String, Voicemail> map = new HashMap<String, Voicemail>();
+            for (Voicemail message : messages) {
+                map.put(message.getSourceData(), message);
             }
-            return count;
-        }
-
-        /**
-         * Sends an update command to the voicemail content provider for a list of voicemails.
-         * From the view of the provider, since the updater is the owner of the entry, a blank
-         * "update" means that the voicemail source is indicating that the server has up-to-date
-         * information on the voicemail. This flips the "dirty" bit to "0".
-         *
-         * @param voicemails The list of voicemails to update
-         * @return The number of voicemails updated
-         */
-        public int markReadInDatabase(List<Voicemail> voicemails) {
-            int count = voicemails.size();
-            for (int i = 0; i < count; i++) {
-                Uri uri = ContentUris.withAppendedId(
-                        VoicemailContract.Voicemails.buildSourceUri(mContext.getPackageName()),
-                        voicemails.get(i).getId());
-                mContentResolver.update(uri, new ContentValues(), null, null);
-            }
-            return count;
+            return map;
         }
     }
 }
diff --git a/src/com/android/phone/vvm/omtp/sms/OmtpMessageReceiver.java b/src/com/android/phone/vvm/omtp/sms/OmtpMessageReceiver.java
index 57979ff..1270658 100644
--- a/src/com/android/phone/vvm/omtp/sms/OmtpMessageReceiver.java
+++ b/src/com/android/phone/vvm/omtp/sms/OmtpMessageReceiver.java
@@ -34,8 +34,6 @@
 import com.android.phone.vvm.omtp.OmtpVvmSyncAccountManager;
 import com.android.phone.vvm.omtp.OmtpVvmSyncService.OmtpVvmSyncAdapter;
 
-import java.io.UnsupportedEncodingException;
-
 /**
  * Receive SMS messages and send for processing by the OMTP visual voicemail source.
  */
@@ -96,7 +94,7 @@
             case OmtpConstants.MAILBOX_UPDATE:
                 // Needs a total resync
                 Bundle bundle = new Bundle();
-                bundle.putBoolean(OmtpVvmSyncAdapter.SYNC_EXTRAS_CLEAR_AND_RELOAD, true);
+                bundle.putBoolean(OmtpVvmSyncAdapter.SYNC_EXTRAS_DOWNLOAD, true);
                 ContentResolver.requestSync(
                         new Account(mPhoneAccount.getId(), OmtpVvmSyncAccountManager.ACCOUNT_TYPE),
                         VoicemailContract.AUTHORITY, bundle);
@@ -105,7 +103,7 @@
                 // Not implemented in V1
                 break;
            default:
-               Log.e(TAG, "Unrecognized sync trigger event: "+message.getSyncTriggerEvent());
+               Log.e(TAG, "Unrecognized sync trigger event: " + message.getSyncTriggerEvent());
                break;
         }
     }
@@ -126,7 +124,7 @@
                     VoicemailContract.Status.NOTIFICATION_CHANNEL_STATE_OK);
 
             Bundle bundle = new Bundle();
-            bundle.putBoolean(OmtpVvmSyncAdapter.SYNC_EXTRAS_CLEAR_AND_RELOAD, true);
+            bundle.putBoolean(OmtpVvmSyncAdapter.SYNC_EXTRAS_DOWNLOAD, true);
             bundle.putBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD, true);
             ContentResolver.requestSync(account, VoicemailContract.AUTHORITY, bundle);
         }
diff --git a/src/com/android/phone/vvm/omtp/sms/WrappedMessageData.java b/src/com/android/phone/vvm/omtp/sms/WrappedMessageData.java
index 109dfb2..b4c86d4 100644
--- a/src/com/android/phone/vvm/omtp/sms/WrappedMessageData.java
+++ b/src/com/android/phone/vvm/omtp/sms/WrappedMessageData.java
@@ -66,6 +66,10 @@
      */
     String extractString(final String field) {
         String value = mFields.get(field);
+        if (value == null) {
+            return null;
+        }
+
         String[] possibleValues = OmtpConstants.possibleValuesMap.get(field);
         if (possibleValues == null) {
             return value;
diff --git a/src/com/android/phone/vvm/omtp/sync/DirtyVoicemailQuery.java b/src/com/android/phone/vvm/omtp/sync/DirtyVoicemailQuery.java
deleted file mode 100644
index 8c70372..0000000
--- a/src/com/android/phone/vvm/omtp/sync/DirtyVoicemailQuery.java
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright (C) 2015 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.phone.vvm.omtp.sync;
-
-import android.content.ContentResolver;
-import android.content.Context;
-import android.database.Cursor;
-import android.net.Uri;
-import android.provider.VoicemailContract;
-import android.provider.VoicemailContract.Voicemails;
-
-/**
- * Construct a query to get dirty voicemails.
- */
-public class DirtyVoicemailQuery {
-    final static String[] PROJECTION = new String[] {
-            Voicemails._ID,              // 0
-            Voicemails.SOURCE_DATA,      // 1
-            Voicemails.IS_READ,          // 2
-            Voicemails.DELETED,          // 3
-    };
-
-    public static final int _ID = 0;
-    public static final int SOURCE_DATA = 1;
-    public static final int IS_READ = 2;
-    public static final int DELETED = 3;
-
-    final static String SELECTION = Voicemails.DIRTY + "=1";
-
-    /**
-     * Get all the locally modified voicemails that have not been synced to the server.
-     *
-     * @param context The context from the package calling the method. This will be the source.
-     * @return A list of all locally modified voicemails.
-     */
-    public static Cursor getDirtyVoicemails(Context context) {
-        ContentResolver contentResolver = context.getContentResolver();
-        Uri sourceUri = VoicemailContract.Voicemails.buildSourceUri(context.getPackageName());
-        return contentResolver.query(sourceUri, PROJECTION, SELECTION, null, null);
-    }
-}
diff --git a/src/com/android/phone/vvm/omtp/sync/VoicemailsQueryHelper.java b/src/com/android/phone/vvm/omtp/sync/VoicemailsQueryHelper.java
new file mode 100644
index 0000000..151afed
--- /dev/null
+++ b/src/com/android/phone/vvm/omtp/sync/VoicemailsQueryHelper.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2015 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.phone.vvm.omtp.sync;
+
+import android.content.ContentResolver;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.VoicemailContract;
+import android.provider.VoicemailContract.Voicemails;
+import android.telecom.Voicemail;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Construct a queries to interact with the voicemails table.
+ */
+public class VoicemailsQueryHelper {
+    final static String[] PROJECTION = new String[] {
+            Voicemails._ID,              // 0
+            Voicemails.SOURCE_DATA,      // 1
+            Voicemails.IS_READ,          // 2
+            Voicemails.DELETED,          // 3
+    };
+
+    public static final int _ID = 0;
+    public static final int SOURCE_DATA = 1;
+    public static final int IS_READ = 2;
+    public static final int DELETED = 3;
+
+    final static String READ_SELECTION = Voicemails.DIRTY + "=1 AND " + Voicemails.DELETED + "!=1";
+    final static String DELETED_SELECTION = Voicemails.DELETED + "=1";
+
+    private Context mContext;
+    private ContentResolver mContentResolver;
+    private Uri mSourceUri;
+
+    public VoicemailsQueryHelper(Context context) {
+        mContext = context;
+        mContentResolver = context.getContentResolver();
+        mSourceUri = VoicemailContract.Voicemails.buildSourceUri(mContext.getPackageName());
+    }
+
+    /**
+     * Get all the local read voicemails that have not been synced to the server.
+     *
+     * @return A list of read voicemails.
+     */
+    public List<Voicemail> getReadVoicemails() {
+        return getLocalVoicemails(READ_SELECTION);
+    }
+
+    /**
+     * Get all the locally deleted voicemails that have not been synced to the server.
+     *
+     * @return A list of deleted voicemails.
+     */
+    public List<Voicemail> getDeletedVoicemails() {
+        return getLocalVoicemails(DELETED_SELECTION);
+    }
+
+    /**
+     * Get all voicemails locally stored.
+     *
+     * @return A list of all locally stored voicemails.
+     */
+    public List<Voicemail> getAllVoicemails() {
+        return getLocalVoicemails(null);
+    }
+
+    /**
+     * Utility method to make queries to the voicemail database.
+     *
+     * @param selection A filter declaring which rows to return. {@code null} returns all rows.
+     * @return A list of voicemails according to the selection statement.
+     */
+    private List<Voicemail> getLocalVoicemails(String selection) {
+        Cursor cursor = mContentResolver.query(mSourceUri, PROJECTION, selection, null, null);
+        if (cursor == null) {
+            return null;
+        }
+        try {
+            List<Voicemail> voicemails = new ArrayList<Voicemail>();
+            while (cursor.moveToNext()) {
+                final long id = cursor.getLong(VoicemailsQueryHelper._ID);
+                final String sourceData = cursor.getString(VoicemailsQueryHelper.SOURCE_DATA);
+                Voicemail voicemail = Voicemail.createForUpdate(id, sourceData).build();
+                voicemails.add(voicemail);
+            }
+            return voicemails;
+        } finally {
+            cursor.close();
+        }
+    }
+
+    /**
+     * Deletes a list of voicemails from the voicemail content provider.
+     *
+     * @param voicemails The list of voicemails to delete
+     * @return The number of voicemails deleted
+     */
+    public int deleteFromDatabase(List<Voicemail> voicemails) {
+        int count = voicemails.size();
+        if (count == 0) {
+            return 0;
+        }
+
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < count; i++) {
+            if (i > 0) {
+                sb.append(",");
+            }
+            sb.append(voicemails.get(i).getId());
+        }
+
+        String selectionStatement = String.format(Voicemails._ID + " IN (%s)", sb.toString());
+        return mContentResolver.delete(Voicemails.CONTENT_URI, selectionStatement, null);
+    }
+
+    /**
+     * Utility method to delete a single voicemail.
+     */
+    public void deleteFromDatabase(Voicemail voicemail) {
+        mContentResolver.delete(Voicemails.CONTENT_URI, Voicemails._ID + "=?",
+                new String[] { Long.toString(voicemail.getId()) });
+    }
+
+    /**
+     * Sends an update command to the voicemail content provider for a list of voicemails.
+     * From the view of the provider, since the updater is the owner of the entry, a blank
+     * "update" means that the voicemail source is indicating that the server has up-to-date
+     * information on the voicemail. This flips the "dirty" bit to "0".
+     *
+     * @param voicemails The list of voicemails to update
+     * @return The number of voicemails updated
+     */
+    public int markReadInDatabase(List<Voicemail> voicemails) {
+        int count = voicemails.size();
+        for (int i = 0; i < count; i++) {
+            markReadInDatabase(voicemails.get(i));
+        }
+        return count;
+    }
+
+    /**
+     * Utility method to mark single message as read.
+     */
+    public void markReadInDatabase(Voicemail voicemail) {
+        Uri uri = ContentUris.withAppendedId(mSourceUri, voicemail.getId());
+        ContentValues contentValues = new ContentValues();
+        contentValues.put(Voicemails.IS_READ, "1");
+        mContentResolver.update(uri, contentValues, null, null);
+    }
+}