blob: 9d0bceb616d5a35ff0239df636604b7df6b41e62 [file] [log] [blame]
Ben Kwa0497da82015-11-30 23:00:02 -08001/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.documentsui.dirlist;
18
19import static com.android.documentsui.Shared.DEBUG;
20import static com.android.documentsui.model.DocumentInfo.getCursorString;
21import static com.android.internal.util.Preconditions.checkNotNull;
22import static com.android.internal.util.Preconditions.checkState;
23
24import android.content.ContentProviderClient;
25import android.content.ContentResolver;
26import android.content.Context;
27import android.database.Cursor;
28import android.os.AsyncTask;
29import android.os.Bundle;
30import android.os.Looper;
31import android.provider.DocumentsContract;
32import android.provider.DocumentsContract.Document;
33import android.support.annotation.Nullable;
34import android.support.annotation.VisibleForTesting;
35import android.support.v7.widget.RecyclerView;
36import android.util.Log;
37import android.util.SparseBooleanArray;
38
39import com.android.documentsui.BaseActivity.DocumentContext;
40import com.android.documentsui.DirectoryResult;
41import com.android.documentsui.DocumentsApplication;
42import com.android.documentsui.RootCursorWrapper;
43import com.android.documentsui.dirlist.MultiSelectManager.Selection;
44import com.android.documentsui.model.DocumentInfo;
45import com.android.internal.annotations.GuardedBy;
46
47import java.util.ArrayList;
48import java.util.Arrays;
49import java.util.HashMap;
50import java.util.List;
51
52/**
53 * The data model for the current loaded directory.
54 */
55@VisibleForTesting
56public class Model implements DocumentContext {
57 private static final String TAG = "Model";
58 private RecyclerView.Adapter<?> mViewAdapter;
59 private Context mContext;
60 private int mCursorCount;
61 private boolean mIsLoading;
62 @GuardedBy("mPendingDelete")
63 private Boolean mPendingDelete = false;
64 @GuardedBy("mPendingDelete")
65 private SparseBooleanArray mMarkedForDeletion = new SparseBooleanArray();
66 private Model.UpdateListener mUpdateListener;
67 @Nullable private Cursor mCursor;
68 @Nullable String info;
69 @Nullable String error;
70 private HashMap<String, Integer> mPositions = new HashMap<>();
71
72 Model(Context context, RecyclerView.Adapter<?> viewAdapter) {
73 mContext = context;
74 mViewAdapter = viewAdapter;
75 }
76
77 void update(DirectoryResult result) {
78 if (DEBUG) Log.i(TAG, "Updating model with new result set.");
79
80 if (result == null) {
81 mCursor = null;
82 mCursorCount = 0;
83 info = null;
84 error = null;
85 mIsLoading = false;
86 mUpdateListener.onModelUpdate(this);
87 return;
88 }
89
90 if (result.exception != null) {
91 Log.e(TAG, "Error while loading directory contents", result.exception);
92 mUpdateListener.onModelUpdateFailed(result.exception);
93 return;
94 }
95
96 mCursor = result.cursor;
97 mCursorCount = mCursor.getCount();
98
99 updatePositions();
100
101 final Bundle extras = mCursor.getExtras();
102 if (extras != null) {
103 info = extras.getString(DocumentsContract.EXTRA_INFO);
104 error = extras.getString(DocumentsContract.EXTRA_ERROR);
105 mIsLoading = extras.getBoolean(DocumentsContract.EXTRA_LOADING, false);
106 }
107
108 mUpdateListener.onModelUpdate(this);
109 }
110
111 int getItemCount() {
112 synchronized(mPendingDelete) {
113 return mCursorCount - mMarkedForDeletion.size();
114 }
115 }
116
117 /**
118 * Update the ModelId-position map.
119 */
120 private void updatePositions() {
121 mPositions.clear();
122 mCursor.moveToPosition(-1);
123 for (int pos = 0; pos < mCursorCount; ++pos) {
124 mCursor.moveToNext();
125 // TODO(stable-id): factor the model ID construction code.
126 String modelId = getCursorString(mCursor, RootCursorWrapper.COLUMN_AUTHORITY) +
127 "|" + getCursorString(mCursor, Document.COLUMN_DOCUMENT_ID);
128 mPositions.put(modelId, pos);
129 }
130 }
131
132 @Nullable Cursor getItem(String modelId) {
133 Integer pos = mPositions.get(modelId);
134 if (pos != null) {
135 mCursor.moveToPosition(pos);
136 return mCursor;
137 }
138 return null;
139 }
140
141 Cursor getItem(int position) {
142 synchronized(mPendingDelete) {
143 // Items marked for deletion are masked out of the UI. To do this, for every marked
144 // item whose position is less than the requested item position, advance the requested
145 // position by 1.
146 final int originalPos = position;
147 final int size = mMarkedForDeletion.size();
148 for (int i = 0; i < size; ++i) {
149 // It'd be more concise, but less efficient, to iterate over positions while calling
150 // mMarkedForDeletion.get. Instead, iterate over deleted entries.
151 if (mMarkedForDeletion.keyAt(i) <= position && mMarkedForDeletion.valueAt(i)) {
152 ++position;
153 }
154 }
155
156 if (DEBUG && position != originalPos) {
157 Log.d(TAG, "Item position adjusted for deletion. Original: " + originalPos
158 + " Adjusted: " + position);
159 }
160
161 if (position >= mCursorCount) {
162 throw new IndexOutOfBoundsException("Attempt to retrieve " + position + " of " +
163 mCursorCount + " items");
164 }
165
166 mCursor.moveToPosition(position);
167 return mCursor;
168 }
169 }
170
171 boolean isEmpty() {
172 return mCursorCount == 0;
173 }
174
175 boolean isLoading() {
176 return mIsLoading;
177 }
178
179 List<DocumentInfo> getDocuments(Selection items) {
180 final int size = (items != null) ? items.size() : 0;
181
182 final List<DocumentInfo> docs = new ArrayList<>(size);
183 for (int i = 0; i < size; i++) {
184 final Cursor cursor = getItem(items.get(i));
185 checkNotNull(cursor, "Cursor cannot be null.");
186 final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
187 docs.add(doc);
188 }
189 return docs;
190 }
191
192 @Override
193 public Cursor getCursor() {
194 if (Looper.myLooper() != Looper.getMainLooper()) {
195 throw new IllegalStateException("Can't call getCursor from non-main thread.");
196 }
197 return mCursor;
198 }
199
200 List<DocumentInfo> getDocumentsMarkedForDeletion() {
201 synchronized (mPendingDelete) {
202 final int size = mMarkedForDeletion.size();
203 List<DocumentInfo> docs = new ArrayList<>(size);
204
205 for (int i = 0; i < size; ++i) {
206 final int position = mMarkedForDeletion.keyAt(i);
207 checkState(position < mCursorCount);
208 mCursor.moveToPosition(position);
209 final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(mCursor);
210 docs.add(doc);
211 }
212 return docs;
213 }
214 }
215
216 /**
217 * Marks the given files for deletion. This will remove them from the UI. Clients must then
218 * call either {@link #undoDeletion()} or {@link #finalizeDeletion()} to cancel or confirm
219 * the deletion, respectively. Only one deletion operation is allowed at a time.
220 *
221 * @param selected A selection representing the files to delete.
222 */
223 void markForDeletion(Selection selected) {
224 synchronized (mPendingDelete) {
225 mPendingDelete = true;
226 // Only one deletion operation at a time.
227 checkState(mMarkedForDeletion.size() == 0);
228 // There should never be more to delete than what exists.
229 checkState(mCursorCount >= selected.size());
230
231 int[] positions = selected.getAll();
232 Arrays.sort(positions);
233
234 // Walk backwards through the set, since we're removing positions.
235 // Otherwise, positions would change after the first modification.
236 for (int p = positions.length - 1; p >= 0; p--) {
237 mMarkedForDeletion.append(positions[p], true);
238 mViewAdapter.notifyItemRemoved(positions[p]);
239 if (DEBUG) Log.d(TAG, "Scheduled " + positions[p] + " for delete.");
240 }
241 }
242 }
243
244 /**
245 * Cancels an ongoing deletion operation. All files currently marked for deletion will be
246 * unmarked, and restored in the UI. See {@link #markForDeletion(Selection)}.
247 */
248 void undoDeletion() {
249 synchronized (mPendingDelete) {
250 // Iterate over deleted items, temporarily marking them false in the deletion list, and
251 // re-adding them to the UI.
252 final int size = mMarkedForDeletion.size();
253 for (int i = 0; i < size; ++i) {
254 final int position = mMarkedForDeletion.keyAt(i);
255 mMarkedForDeletion.put(position, false);
256 mViewAdapter.notifyItemInserted(position);
257 }
258 resetDeleteData();
259 }
260 }
261
262 private void resetDeleteData() {
263 synchronized (mPendingDelete) {
264 mPendingDelete = false;
265 mMarkedForDeletion.clear();
266 }
267 }
268
269 /**
270 * Finalizes an ongoing deletion operation. All files currently marked for deletion will be
271 * deleted. See {@link #markForDeletion(Selection)}.
272 *
273 * @param view The view which will be used to interact with the user (e.g. surfacing
274 * snackbars) for errors, info, etc.
275 */
276 void finalizeDeletion(DeletionListener listener) {
277 synchronized (mPendingDelete) {
278 if (mPendingDelete) {
279 // Necessary to avoid b/25072545. Even when that's resolved, this
280 // is a nice safe thing to day.
281 mPendingDelete = false;
282 final ContentResolver resolver = mContext.getContentResolver();
283 DeleteFilesTask task = new DeleteFilesTask(resolver, listener);
284 task.execute();
285 }
286 }
287 }
288
289 /**
290 * A Task which collects the DocumentInfo for documents that have been marked for deletion,
291 * and actually deletes them.
292 */
293 private class DeleteFilesTask extends AsyncTask<Void, Void, List<DocumentInfo>> {
294 private ContentResolver mResolver;
295 private DeletionListener mListener;
296
297 /**
298 * @param resolver A ContentResolver for performing the actual file deletions.
299 * @param errorCallback A Runnable that is executed in the event that one or more errors
300 * occured while copying files. Execution will occur on the UI thread.
301 */
302 public DeleteFilesTask(ContentResolver resolver, DeletionListener listener) {
303 mResolver = resolver;
304 mListener = listener;
305 }
306
307 @Override
308 protected List<DocumentInfo> doInBackground(Void... params) {
309 return getDocumentsMarkedForDeletion();
310 }
311
312 @Override
313 protected void onPostExecute(List<DocumentInfo> docs) {
314 boolean hadTrouble = false;
315 for (DocumentInfo doc : docs) {
316 if (!doc.isDeleteSupported()) {
317 Log.w(TAG, doc + " could not be deleted. Skipping...");
318 hadTrouble = true;
319 continue;
320 }
321
322 ContentProviderClient client = null;
323 try {
324 if (DEBUG) Log.d(TAG, "Deleting: " + doc.displayName);
325 client = DocumentsApplication.acquireUnstableProviderOrThrow(
326 mResolver, doc.derivedUri.getAuthority());
327 DocumentsContract.deleteDocument(client, doc.derivedUri);
328 } catch (Exception e) {
329 Log.w(TAG, "Failed to delete " + doc);
330 hadTrouble = true;
331 } finally {
332 ContentProviderClient.releaseQuietly(client);
333 }
334 }
335
336 if (hadTrouble) {
337 // TODO show which files failed? b/23720103
338 mListener.onError();
339 if (DEBUG) Log.d(TAG, "Deletion task completed. Some deletions failed.");
340 } else {
341 if (DEBUG) Log.d(TAG, "Deletion task completed successfully.");
342 }
343 resetDeleteData();
344
345 mListener.onCompletion();
346 }
347 }
348
349 static class DeletionListener {
350 /**
351 * Called when deletion has completed (regardless of whether an error occurred).
352 */
353 void onCompletion() {}
354
355 /**
356 * Called at the end of a deletion operation that produced one or more errors.
357 */
358 void onError() {}
359 }
360
361 void addUpdateListener(UpdateListener listener) {
362 checkState(mUpdateListener == null);
363 mUpdateListener = listener;
364 }
365
366 static class UpdateListener {
367 /**
368 * Called when a successful update has occurred.
369 */
370 void onModelUpdate(Model model) {}
371
372 /**
373 * Called when an update has been attempted but failed.
374 */
375 void onModelUpdateFailed(Exception e) {}
376 }
377}