Add unit tests for DirectoryFragment.Model.
Refactor DirectoryFragment.Model to be a static class.
Introduce some unit tests.
BUG=23754695
Change-Id: Iaa064292ab26b23ac7247e49c05ba91033d84a18
diff --git a/src/com/android/documentsui/DirectoryFragment.java b/src/com/android/documentsui/DirectoryFragment.java
index edf829d..93921dd 100644
--- a/src/com/android/documentsui/DirectoryFragment.java
+++ b/src/com/android/documentsui/DirectoryFragment.java
@@ -62,6 +62,7 @@
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Document;
import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
import android.support.design.widget.Snackbar;
import android.support.v7.widget.GridLayoutManager;
import android.support.v7.widget.LinearLayoutManager;
@@ -132,7 +133,7 @@
private static final String EXTRA_QUERY = "query";
private static final String EXTRA_IGNORE_STATE = "ignoreState";
- private final Model mModel = new Model();
+ private Model mModel;
private final Handler mHandler = new Handler(Looper.getMainLooper());
@@ -304,7 +305,10 @@
? MultiSelectManager.MODE_MULTIPLE
: MultiSelectManager.MODE_SINGLE);
selMgr.addCallback(new SelectionModeListener());
+
+ mModel = new Model(context, selMgr);
mModel.setSelectionManager(selMgr);
+ mModel.addUpdateListener(mAdapter);
mType = getArguments().getInt(EXTRA_TYPE);
mStateKey = buildStateKey(root, doc);
@@ -374,9 +378,7 @@
if (!isAdded()) return;
- // TODO: make the adapter listen to the model
mModel.update(result);
- mAdapter.update();
// Push latest state up to UI
// TODO: if mode change was racing with us, don't overwrite it
@@ -407,9 +409,7 @@
@Override
public void onLoaderReset(Loader<DirectoryResult> loader) {
- // TODO: make the adapter listen to the model.
mModel.update(null);
- mAdapter.update();
}
};
@@ -827,9 +827,9 @@
if (event == Snackbar.Callback.DISMISS_EVENT_ACTION) {
mModel.undoDeletion();
} else {
- mModel.finalizeDeletion();
+ // TODO: Use a listener rather than pushing the view.
+ mModel.finalizeDeletion(DirectoryFragment.this.getView());
}
- ;
}
})
.show();
@@ -953,7 +953,8 @@
}
}
- private final class DocumentsAdapter extends RecyclerView.Adapter<DocumentHolder> {
+ private final class DocumentsAdapter extends RecyclerView.Adapter<DocumentHolder>
+ implements Model.UpdateListener {
private final Context mContext;
private final LayoutInflater mInflater;
@@ -965,19 +966,19 @@
mInflater = LayoutInflater.from(context);
}
- public void update() {
+ public void onModelUpdate(Model model) {
mFooters.clear();
- if (mModel.info != null) {
- mFooters.add(new MessageFooter(2, R.drawable.ic_dialog_info, mModel.info));
+ if (model.info != null) {
+ mFooters.add(new MessageFooter(2, R.drawable.ic_dialog_info, model.info));
}
- if (mModel.error != null) {
- mFooters.add(new MessageFooter(3, R.drawable.ic_dialog_alert, mModel.error));
+ if (model.error != null) {
+ mFooters.add(new MessageFooter(3, R.drawable.ic_dialog_alert, model.error));
}
- if (mModel.isLoading()) {
+ if (model.isLoading()) {
mFooters.add(new LoadingFooter());
}
- if (mModel.isEmpty()) {
+ if (model.isEmpty()) {
mEmptyView.setVisibility(View.VISIBLE);
} else {
mEmptyView.setVisibility(View.GONE);
@@ -986,6 +987,12 @@
notifyDataSetChanged();
}
+ public void onModelUpdateFailed(Exception e) {
+ String error = getString(R.string.query_error);
+ mFooters.add(new MessageFooter(3, R.drawable.ic_dialog_alert, error));
+ notifyDataSetChanged();
+ }
+
@Override
public DocumentHolder onCreateViewHolder(ViewGroup parent, int viewType) {
final State state = getDisplayState(DirectoryFragment.this);
@@ -1736,14 +1743,22 @@
/**
* The data model for the current loaded directory.
*/
- private final class Model implements DocumentContext {
+ @VisibleForTesting
+ public static final class Model implements DocumentContext {
private MultiSelectManager mSelectionManager;
+ private Context mContext;
private int mCursorCount;
private boolean mIsLoading;
+ private SparseBooleanArray mMarkedForDeletion = new SparseBooleanArray();
+ private UpdateListener mUpdateListener;
@Nullable private Cursor mCursor;
@Nullable private String info;
@Nullable private String error;
- private SparseBooleanArray mMarkedForDeletion = new SparseBooleanArray();
+
+ Model(Context context, MultiSelectManager selectionManager) {
+ mContext = context;
+ mSelectionManager = selectionManager;
+ }
/**
* Sets the selection manager used by the model.
@@ -1794,12 +1809,13 @@
info = null;
error = null;
mIsLoading = false;
+ if (mUpdateListener != null) mUpdateListener.onModelUpdate(this);
return;
}
if (result.exception != null) {
Log.e(TAG, "Error while loading directory contents", result.exception);
- error = getString(R.string.query_error);
+ if (mUpdateListener != null) mUpdateListener.onModelUpdateFailed(result.exception);
return;
}
@@ -1812,13 +1828,15 @@
error = extras.getString(DocumentsContract.EXTRA_ERROR);
mIsLoading = extras.getBoolean(DocumentsContract.EXTRA_LOADING, false);
}
+
+ if (mUpdateListener != null) mUpdateListener.onModelUpdate(this);
}
- private int getItemCount() {
+ int getItemCount() {
return mCursorCount - mMarkedForDeletion.size();
}
- private Cursor getItem(int position) {
+ Cursor getItem(int position) {
// 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.
@@ -1859,7 +1877,7 @@
return getDocuments(sel);
}
- private List<DocumentInfo> getDocuments(Selection items) {
+ List<DocumentInfo> getDocuments(Selection items) {
final int size = (items != null) ? items.size() : 0;
final List<DocumentInfo> docs = new ArrayList<>(size);
@@ -1880,7 +1898,7 @@
return mCursor;
}
- private List<DocumentInfo> getDocumentsMarkedForDeletion() {
+ List<DocumentInfo> getDocumentsMarkedForDeletion() {
final int size = mMarkedForDeletion.size();
List<DocumentInfo> docs = new ArrayList<>(size);
@@ -1901,7 +1919,7 @@
*
* @param selected A selection representing the files to delete.
*/
- public void markForDeletion(Selection selected) {
+ void markForDeletion(Selection selected) {
// Only one deletion operation at a time.
checkState(mMarkedForDeletion.size() == 0);
// There should never be more to delete than what exists.
@@ -1912,7 +1930,7 @@
int position = selected.get(i);
if (DEBUG) Log.d(TAG, "Marked position " + position + " for deletion");
mMarkedForDeletion.append(position, true);
- mAdapter.notifyItemRemoved(position);
+ if (mUpdateListener != null) mUpdateListener.notifyItemRemoved(position);
}
}
@@ -1920,14 +1938,14 @@
* Cancels an ongoing deletion operation. All files currently marked for deletion will be
* unmarked, and restored in the UI. See {@link #markForDeletion(Selection)}.
*/
- public void undoDeletion() {
+ void undoDeletion() {
// 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);
- mAdapter.notifyItemInserted(position);
+ if (mUpdateListener != null) mUpdateListener.notifyItemInserted(position);
}
// Then, clear the deletion list.
@@ -1937,11 +1955,26 @@
/**
* 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.
*/
- public void finalizeDeletion() {
- final Context context = getActivity();
- final ContentResolver resolver = context.getContentResolver();
- new DeleteFilesTask(resolver).execute();
+ void finalizeDeletion(final View view) {
+ final ContentResolver resolver = mContext.getContentResolver();
+ DeleteFilesTask task = new DeleteFilesTask(
+ resolver,
+ new Runnable() {
+ @Override
+ public void run() {
+ Snackbar.make(
+ view,
+ R.string.toast_failed_delete,
+ Snackbar.LENGTH_LONG)
+ .show();
+
+ }
+ });
+ task.execute();
}
/**
@@ -1950,9 +1983,16 @@
*/
private class DeleteFilesTask extends AsyncTask<Void, Void, List<DocumentInfo>> {
private ContentResolver mResolver;
+ private Runnable mErrorCallback;
- public DeleteFilesTask(ContentResolver resolver) {
+ /**
+ * @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, Runnable errorCallback) {
mResolver = resolver;
+ mErrorCallback = errorCallback;
}
@Override
@@ -1985,10 +2025,8 @@
}
if (hadTrouble) {
- // TODO show which files failed?
- Snackbar.make(DirectoryFragment.this.getView(),
- R.string.toast_failed_delete,
- Snackbar.LENGTH_LONG).show();
+ // TODO show which files failed? b/23720103
+ mErrorCallback.run();
if (DEBUG) Log.d(TAG, "Deletion task completed. Some deletions failed.");
} else {
if (DEBUG) Log.d(TAG, "Deletion task completed successfully.");
@@ -1996,5 +2034,32 @@
mMarkedForDeletion.clear();
}
}
+
+ void addUpdateListener(UpdateListener listener) {
+ checkState(mUpdateListener == null);
+ mUpdateListener = listener;
+ }
+
+ interface 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);
+
+ /**
+ * Called when an item has been removed from the model.
+ */
+ void notifyItemRemoved(int position);
+
+ /**
+ * Called when an item has been added to the model.
+ */
+ void notifyItemInserted(int position);
+ }
}
}