Switch DirectoryFragment code to use model IDs.

A Model ID is a unique, stable identifier referring to a document.
The basic idea is to move away from doing everything using adapter
positions, and instead use model IDs.

This is the first in a line of CLs toward that goal.  It does the
following:

- Introduce the concept of a Model ID, which is unique for each
  document.
- Add a method to retrieve items from the Model using ID rather than
  adapter position.
- Transition code in the DirectoryFragment to talk to the Model using
  Model IDs rather than positions.
- Break the Model class out into a separate file of its own.

BUG=26024369

Change-Id: Ia5171f089d6b8a83855423ec05cf14dbfc7b6ba8
diff --git a/src/com/android/documentsui/dirlist/Model.java b/src/com/android/documentsui/dirlist/Model.java
new file mode 100644
index 0000000..9d0bceb
--- /dev/null
+++ b/src/com/android/documentsui/dirlist/Model.java
@@ -0,0 +1,377 @@
+/*
+ * 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.documentsui.dirlist;
+
+import static com.android.documentsui.Shared.DEBUG;
+import static com.android.documentsui.model.DocumentInfo.getCursorString;
+import static com.android.internal.util.Preconditions.checkNotNull;
+import static com.android.internal.util.Preconditions.checkState;
+
+import android.content.ContentProviderClient;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Looper;
+import android.provider.DocumentsContract;
+import android.provider.DocumentsContract.Document;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.support.v7.widget.RecyclerView;
+import android.util.Log;
+import android.util.SparseBooleanArray;
+
+import com.android.documentsui.BaseActivity.DocumentContext;
+import com.android.documentsui.DirectoryResult;
+import com.android.documentsui.DocumentsApplication;
+import com.android.documentsui.RootCursorWrapper;
+import com.android.documentsui.dirlist.MultiSelectManager.Selection;
+import com.android.documentsui.model.DocumentInfo;
+import com.android.internal.annotations.GuardedBy;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+
+/**
+ * The data model for the current loaded directory.
+ */
+@VisibleForTesting
+public class Model implements DocumentContext {
+    private static final String TAG = "Model";
+    private RecyclerView.Adapter<?> mViewAdapter;
+    private Context mContext;
+    private int mCursorCount;
+    private boolean mIsLoading;
+    @GuardedBy("mPendingDelete")
+    private Boolean mPendingDelete = false;
+    @GuardedBy("mPendingDelete")
+    private SparseBooleanArray mMarkedForDeletion = new SparseBooleanArray();
+    private Model.UpdateListener mUpdateListener;
+    @Nullable private Cursor mCursor;
+    @Nullable String info;
+    @Nullable String error;
+    private HashMap<String, Integer> mPositions = new HashMap<>();
+
+    Model(Context context, RecyclerView.Adapter<?> viewAdapter) {
+        mContext = context;
+        mViewAdapter = viewAdapter;
+    }
+
+    void update(DirectoryResult result) {
+        if (DEBUG) Log.i(TAG, "Updating model with new result set.");
+
+        if (result == null) {
+            mCursor = null;
+            mCursorCount = 0;
+            info = null;
+            error = null;
+            mIsLoading = false;
+            mUpdateListener.onModelUpdate(this);
+            return;
+        }
+
+        if (result.exception != null) {
+            Log.e(TAG, "Error while loading directory contents", result.exception);
+            mUpdateListener.onModelUpdateFailed(result.exception);
+            return;
+        }
+
+        mCursor = result.cursor;
+        mCursorCount = mCursor.getCount();
+
+        updatePositions();
+
+        final Bundle extras = mCursor.getExtras();
+        if (extras != null) {
+            info = extras.getString(DocumentsContract.EXTRA_INFO);
+            error = extras.getString(DocumentsContract.EXTRA_ERROR);
+            mIsLoading = extras.getBoolean(DocumentsContract.EXTRA_LOADING, false);
+        }
+
+        mUpdateListener.onModelUpdate(this);
+    }
+
+    int getItemCount() {
+        synchronized(mPendingDelete) {
+            return mCursorCount - mMarkedForDeletion.size();
+        }
+    }
+
+    /**
+     * Update the ModelId-position map.
+     */
+    private void updatePositions() {
+        mPositions.clear();
+        mCursor.moveToPosition(-1);
+        for (int pos = 0; pos < mCursorCount; ++pos) {
+            mCursor.moveToNext();
+            // TODO(stable-id): factor the model ID construction code.
+            String modelId = getCursorString(mCursor, RootCursorWrapper.COLUMN_AUTHORITY) +
+                    "|" + getCursorString(mCursor, Document.COLUMN_DOCUMENT_ID);
+            mPositions.put(modelId, pos);
+        }
+    }
+
+    @Nullable Cursor getItem(String modelId) {
+        Integer pos = mPositions.get(modelId);
+        if (pos != null) {
+            mCursor.moveToPosition(pos);
+            return mCursor;
+        }
+        return null;
+    }
+
+    Cursor getItem(int position) {
+        synchronized(mPendingDelete) {
+            // Items marked for deletion are masked out of the UI.  To do this, for every marked
+            // item whose position is less than the requested item position, advance the requested
+            // position by 1.
+            final int originalPos = position;
+            final int size = mMarkedForDeletion.size();
+            for (int i = 0; i < size; ++i) {
+                // It'd be more concise, but less efficient, to iterate over positions while calling
+                // mMarkedForDeletion.get.  Instead, iterate over deleted entries.
+                if (mMarkedForDeletion.keyAt(i) <= position && mMarkedForDeletion.valueAt(i)) {
+                    ++position;
+                }
+            }
+
+            if (DEBUG && position != originalPos) {
+                Log.d(TAG, "Item position adjusted for deletion.  Original: " + originalPos
+                        + "  Adjusted: " + position);
+            }
+
+            if (position >= mCursorCount) {
+                throw new IndexOutOfBoundsException("Attempt to retrieve " + position + " of " +
+                        mCursorCount + " items");
+            }
+
+            mCursor.moveToPosition(position);
+            return mCursor;
+        }
+    }
+
+    boolean isEmpty() {
+        return mCursorCount == 0;
+    }
+
+    boolean isLoading() {
+        return mIsLoading;
+    }
+
+    List<DocumentInfo> getDocuments(Selection items) {
+        final int size = (items != null) ? items.size() : 0;
+
+        final List<DocumentInfo> docs =  new ArrayList<>(size);
+        for (int i = 0; i < size; i++) {
+            final Cursor cursor = getItem(items.get(i));
+            checkNotNull(cursor, "Cursor cannot be null.");
+            final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
+            docs.add(doc);
+        }
+        return docs;
+    }
+
+    @Override
+    public Cursor getCursor() {
+        if (Looper.myLooper() != Looper.getMainLooper()) {
+            throw new IllegalStateException("Can't call getCursor from non-main thread.");
+        }
+        return mCursor;
+    }
+
+    List<DocumentInfo> getDocumentsMarkedForDeletion() {
+        synchronized (mPendingDelete) {
+            final int size = mMarkedForDeletion.size();
+            List<DocumentInfo> docs =  new ArrayList<>(size);
+
+            for (int i = 0; i < size; ++i) {
+                final int position = mMarkedForDeletion.keyAt(i);
+                checkState(position < mCursorCount);
+                mCursor.moveToPosition(position);
+                final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(mCursor);
+                docs.add(doc);
+            }
+            return docs;
+        }
+    }
+
+    /**
+     * Marks the given files for deletion. This will remove them from the UI. Clients must then
+     * call either {@link #undoDeletion()} or {@link #finalizeDeletion()} to cancel or confirm
+     * the deletion, respectively. Only one deletion operation is allowed at a time.
+     *
+     * @param selected A selection representing the files to delete.
+     */
+    void markForDeletion(Selection selected) {
+        synchronized (mPendingDelete) {
+            mPendingDelete = true;
+            // Only one deletion operation at a time.
+            checkState(mMarkedForDeletion.size() == 0);
+            // There should never be more to delete than what exists.
+            checkState(mCursorCount >= selected.size());
+
+            int[] positions = selected.getAll();
+            Arrays.sort(positions);
+
+            // Walk backwards through the set, since we're removing positions.
+            // Otherwise, positions would change after the first modification.
+            for (int p = positions.length - 1; p >= 0; p--) {
+                mMarkedForDeletion.append(positions[p], true);
+                mViewAdapter.notifyItemRemoved(positions[p]);
+                if (DEBUG) Log.d(TAG, "Scheduled " + positions[p] + " for delete.");
+            }
+        }
+    }
+
+    /**
+     * Cancels an ongoing deletion operation. All files currently marked for deletion will be
+     * unmarked, and restored in the UI.  See {@link #markForDeletion(Selection)}.
+     */
+    void undoDeletion() {
+        synchronized (mPendingDelete) {
+            // Iterate over deleted items, temporarily marking them false in the deletion list, and
+            // re-adding them to the UI.
+            final int size = mMarkedForDeletion.size();
+            for (int i = 0; i < size; ++i) {
+                final int position = mMarkedForDeletion.keyAt(i);
+                mMarkedForDeletion.put(position, false);
+                mViewAdapter.notifyItemInserted(position);
+            }
+            resetDeleteData();
+        }
+    }
+
+    private void resetDeleteData() {
+        synchronized (mPendingDelete) {
+            mPendingDelete = false;
+            mMarkedForDeletion.clear();
+        }
+    }
+
+    /**
+     * Finalizes an ongoing deletion operation. All files currently marked for deletion will be
+     * deleted.  See {@link #markForDeletion(Selection)}.
+     *
+     * @param view The view which will be used to interact with the user (e.g. surfacing
+     * snackbars) for errors, info, etc.
+     */
+    void finalizeDeletion(DeletionListener listener) {
+        synchronized (mPendingDelete) {
+            if (mPendingDelete) {
+                // Necessary to avoid b/25072545. Even when that's resolved, this
+                // is a nice safe thing to day.
+                mPendingDelete = false;
+                final ContentResolver resolver = mContext.getContentResolver();
+                DeleteFilesTask task = new DeleteFilesTask(resolver, listener);
+                task.execute();
+            }
+        }
+    }
+
+    /**
+     * A Task which collects the DocumentInfo for documents that have been marked for deletion,
+     * and actually deletes them.
+     */
+    private class DeleteFilesTask extends AsyncTask<Void, Void, List<DocumentInfo>> {
+        private ContentResolver mResolver;
+        private DeletionListener mListener;
+
+        /**
+         * @param resolver A ContentResolver for performing the actual file deletions.
+         * @param errorCallback A Runnable that is executed in the event that one or more errors
+         *     occured while copying files.  Execution will occur on the UI thread.
+         */
+        public DeleteFilesTask(ContentResolver resolver, DeletionListener listener) {
+            mResolver = resolver;
+            mListener = listener;
+        }
+
+        @Override
+        protected List<DocumentInfo> doInBackground(Void... params) {
+            return getDocumentsMarkedForDeletion();
+        }
+
+        @Override
+        protected void onPostExecute(List<DocumentInfo> docs) {
+            boolean hadTrouble = false;
+            for (DocumentInfo doc : docs) {
+                if (!doc.isDeleteSupported()) {
+                    Log.w(TAG, doc + " could not be deleted.  Skipping...");
+                    hadTrouble = true;
+                    continue;
+                }
+
+                ContentProviderClient client = null;
+                try {
+                    if (DEBUG) Log.d(TAG, "Deleting: " + doc.displayName);
+                    client = DocumentsApplication.acquireUnstableProviderOrThrow(
+                        mResolver, doc.derivedUri.getAuthority());
+                    DocumentsContract.deleteDocument(client, doc.derivedUri);
+                } catch (Exception e) {
+                    Log.w(TAG, "Failed to delete " + doc);
+                    hadTrouble = true;
+                } finally {
+                    ContentProviderClient.releaseQuietly(client);
+                }
+            }
+
+            if (hadTrouble) {
+                // TODO show which files failed? b/23720103
+                mListener.onError();
+                if (DEBUG) Log.d(TAG, "Deletion task completed.  Some deletions failed.");
+            } else {
+                if (DEBUG) Log.d(TAG, "Deletion task completed successfully.");
+            }
+            resetDeleteData();
+
+            mListener.onCompletion();
+        }
+    }
+
+    static class DeletionListener {
+        /**
+         * Called when deletion has completed (regardless of whether an error occurred).
+         */
+        void onCompletion() {}
+
+        /**
+         * Called at the end of a deletion operation that produced one or more errors.
+         */
+        void onError() {}
+    }
+
+    void addUpdateListener(UpdateListener listener) {
+        checkState(mUpdateListener == null);
+        mUpdateListener = listener;
+    }
+
+    static class UpdateListener {
+        /**
+         * Called when a successful update has occurred.
+         */
+        void onModelUpdate(Model model) {}
+
+        /**
+         * Called when an update has been attempted but failed.
+         */
+        void onModelUpdateFailed(Exception e) {}
+    }
+}