Lift loader to activity level.

Also fix a bug that leaves DocumentsUI in a weird state if it fails to
obtain root document.

Change-Id: Ibb67bfd0114f45f41c0000078ca56767b5a4542b
Tests: Manual tests and auto tests.
Bug: 35934082
diff --git a/src/com/android/documentsui/Model.java b/src/com/android/documentsui/Model.java
new file mode 100644
index 0000000..9cc6972
--- /dev/null
+++ b/src/com/android/documentsui/Model.java
@@ -0,0 +1,323 @@
+/*
+ * Copyright (C) 2017 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;
+
+import static com.android.documentsui.base.DocumentInfo.getCursorString;
+import static com.android.documentsui.base.Shared.DEBUG;
+import static com.android.documentsui.base.Shared.VERBOSE;
+
+import android.annotation.IntDef;
+import android.app.RecoverableSecurityException;
+import android.database.Cursor;
+import android.database.MergeCursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.DocumentsContract;
+import android.provider.DocumentsContract.Document;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.util.Log;
+
+import com.android.documentsui.DirectoryResult;
+import com.android.documentsui.base.DocumentFilters;
+import com.android.documentsui.base.DocumentInfo;
+import com.android.documentsui.base.EventListener;
+import com.android.documentsui.base.Features;
+import com.android.documentsui.roots.RootCursorWrapper;
+import com.android.documentsui.selection.Selection;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Predicate;
+
+/**
+ * The data model for the current loaded directory.
+ */
+@VisibleForTesting
+public class Model {
+
+    private static final String TAG = "Model";
+
+    public @Nullable String info;
+    public @Nullable String error;
+    public @Nullable DocumentInfo doc;
+
+    private final Features mFeatures;
+
+    /** Maps Model ID to cursor positions, for looking up items by Model ID. */
+    private final Map<String, Integer> mPositions = new HashMap<>();
+    private final Set<String> mFileNames = new HashSet<>();
+
+    private boolean mIsLoading;
+    private List<EventListener<Update>> mUpdateListeners = new ArrayList<>();
+    private @Nullable Cursor mCursor;
+    private int mCursorCount;
+    private String mIds[] = new String[0];
+
+    public Model(Features features) {
+        mFeatures = features;
+    }
+
+    public void addUpdateListener(EventListener<Update> listener) {
+        mUpdateListeners.add(listener);
+    }
+
+    public void removeUpdateListener(EventListener<Update> listener) {
+        mUpdateListeners.remove(listener);
+    }
+
+    private void notifyUpdateListeners() {
+        for (EventListener<Update> handler: mUpdateListeners) {
+            handler.accept(Update.UPDATE);
+        }
+    }
+
+    private void notifyUpdateListeners(Exception e) {
+        Update error = new Update(e, mFeatures.isRemoteActionsEnabled());
+        for (EventListener<Update> handler: mUpdateListeners) {
+            handler.accept(error);
+        }
+    }
+
+    public void reset() {
+        mCursor = null;
+        mCursorCount = 0;
+        mIds = new String[0];
+        mPositions.clear();
+        info = null;
+        error = null;
+        doc = null;
+        mIsLoading = false;
+        mFileNames.clear();
+        notifyUpdateListeners();
+    }
+
+    @VisibleForTesting
+    protected void update(DirectoryResult result) {
+        assert(result != null);
+
+        if (DEBUG) Log.i(TAG, "Updating model with new result set.");
+
+        if (result.exception != null) {
+            Log.e(TAG, "Error while loading directory contents", result.exception);
+            notifyUpdateListeners(result.exception);
+            return;
+        }
+
+        mCursor = result.cursor;
+        mCursorCount = mCursor.getCount();
+        doc = result.doc;
+
+        updateModelData();
+
+        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);
+        }
+
+        notifyUpdateListeners();
+    }
+
+    @VisibleForTesting
+    public int getItemCount() {
+        return mCursorCount;
+    }
+
+    /**
+     * Scan over the incoming cursor data, generate Model IDs for each row, and sort the IDs
+     * according to the current sort order.
+     */
+    private void updateModelData() {
+        mIds = new String[mCursorCount];
+        mFileNames.clear();
+        mCursor.moveToPosition(-1);
+        for (int pos = 0; pos < mCursorCount; ++pos) {
+            if (!mCursor.moveToNext()) {
+                Log.e(TAG, "Fail to move cursor to next pos: " + pos);
+                return;
+            }
+            // Generates a Model ID for a cursor entry that refers to a document. The Model ID is a
+            // unique string that can be used to identify the document referred to by the cursor.
+            // If the cursor is a merged cursor over multiple authorities, then prefix the ids
+            // with the authority to avoid collisions.
+            if (mCursor instanceof MergeCursor) {
+                mIds[pos] = getCursorString(mCursor, RootCursorWrapper.COLUMN_AUTHORITY)
+                        + "|" + getCursorString(mCursor, Document.COLUMN_DOCUMENT_ID);
+            } else {
+                mIds[pos] = getCursorString(mCursor, Document.COLUMN_DOCUMENT_ID);
+            }
+            mFileNames.add(getCursorString(mCursor, Document.COLUMN_DISPLAY_NAME));
+        }
+
+        // Populate the positions.
+        mPositions.clear();
+        for (int i = 0; i < mCursorCount; ++i) {
+            mPositions.put(mIds[i], i);
+        }
+    }
+
+    public boolean hasFileWithName(String name) {
+        return mFileNames.contains(name);
+    }
+
+    public @Nullable Cursor getItem(String modelId) {
+        Integer pos = mPositions.get(modelId);
+        if (pos == null) {
+            if (DEBUG) Log.d(TAG, "Unabled to find cursor position for modelId: " + modelId);
+            return null;
+        }
+
+        if (!mCursor.moveToPosition(pos)) {
+            if (DEBUG) Log.d(TAG,
+                    "Unabled to move cursor to position " + pos + " for modelId: " + modelId);
+            return null;
+        }
+
+        return mCursor;
+    }
+
+    public boolean isEmpty() {
+        return mCursorCount == 0;
+    }
+
+    public boolean isLoading() {
+        return mIsLoading;
+    }
+
+    public List<DocumentInfo> getDocuments(Selection selection) {
+        return loadDocuments(selection, DocumentFilters.ANY);
+    }
+
+    public @Nullable DocumentInfo getDocument(String modelId) {
+        final Cursor cursor = getItem(modelId);
+        return (cursor == null)
+                ? null
+                : DocumentInfo.fromDirectoryCursor(cursor);
+    }
+
+    public List<DocumentInfo> loadDocuments(Selection selection, Predicate<Cursor> filter) {
+        final int size = (selection != null) ? selection.size() : 0;
+
+        final List<DocumentInfo> docs =  new ArrayList<>(size);
+        DocumentInfo doc;
+        for (String modelId: selection) {
+            doc = loadDocument(modelId, filter);
+            if (doc != null) {
+                docs.add(doc);
+            }
+        }
+        return docs;
+    }
+
+    public boolean hasDocuments(Selection selection, Predicate<Cursor> filter) {
+        for (String modelId: selection) {
+            if (loadDocument(modelId, filter) != null) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * @return DocumentInfo, or null. If filter returns false, null will be returned.
+     */
+    private @Nullable DocumentInfo loadDocument(String modelId, Predicate<Cursor> filter) {
+        final Cursor cursor = getItem(modelId);
+
+        if (cursor == null) {
+            Log.w(TAG, "Unable to obtain document for modelId: " + modelId);
+            return null;
+        }
+
+        if (filter.test(cursor)) {
+            return DocumentInfo.fromDirectoryCursor(cursor);
+        }
+
+        if (VERBOSE) Log.v(TAG, "Filtered out document from results: " + modelId);
+        return null;
+    }
+
+    public Uri getItemUri(String modelId) {
+        final Cursor cursor = getItem(modelId);
+        return DocumentInfo.getUri(cursor);
+    }
+
+    /**
+     * @return An ordered array of model IDs representing the documents in the model. It is sorted
+     *         according to the current sort order, which was set by the last model update.
+     */
+    public String[] getModelIds() {
+        return mIds;
+    }
+
+    public static class Update {
+
+        public static final Update UPDATE = new Update();
+
+        @IntDef(value = {
+                TYPE_UPDATE,
+                TYPE_UPDATE_EXCEPTION
+        })
+        @Retention(RetentionPolicy.SOURCE)
+        public @interface UpdateType {}
+        public static final int TYPE_UPDATE = 0;
+        public static final int TYPE_UPDATE_EXCEPTION = 1;
+
+        private final @UpdateType int mUpdateType;
+        private final @Nullable Exception mException;
+        private final boolean mRemoteActionEnabled;
+
+        private Update() {
+            mUpdateType = TYPE_UPDATE;
+            mException = null;
+            mRemoteActionEnabled = false;
+        }
+
+        public Update(Exception exception, boolean remoteActionsEnabled) {
+            assert(exception != null);
+            mUpdateType = TYPE_UPDATE_EXCEPTION;
+            mException = exception;
+            mRemoteActionEnabled = remoteActionsEnabled;
+        }
+
+        public boolean isUpdate() {
+            return mUpdateType == TYPE_UPDATE;
+        }
+
+        public boolean hasException() {
+            return mUpdateType == TYPE_UPDATE_EXCEPTION;
+        }
+
+        public boolean hasRecoverableException() {
+            return mRemoteActionEnabled
+                    && hasException()
+                    && mException instanceof RecoverableSecurityException;
+        }
+
+        public @Nullable Exception getException() {
+            return mException;
+        }
+    }
+}