1 /*
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 
17 package com.android.documentsui.dirlist;
18 
19 import static com.android.documentsui.Shared.DEBUG;
20 import static com.android.documentsui.State.SORT_ORDER_DISPLAY_NAME;
21 import static com.android.documentsui.State.SORT_ORDER_LAST_MODIFIED;
22 import static com.android.documentsui.State.SORT_ORDER_SIZE;
23 import static com.android.documentsui.model.DocumentInfo.getCursorLong;
24 import static com.android.documentsui.model.DocumentInfo.getCursorString;
25 
26 import android.database.Cursor;
27 import android.database.MergeCursor;
28 import android.os.Bundle;
29 import android.provider.DocumentsContract;
30 import android.provider.DocumentsContract.Document;
31 import android.support.annotation.Nullable;
32 import android.support.annotation.VisibleForTesting;
33 import android.util.Log;
34 
35 import com.android.documentsui.DirectoryResult;
36 import com.android.documentsui.RootCursorWrapper;
37 import com.android.documentsui.Shared;
38 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
39 import com.android.documentsui.model.DocumentInfo;
40 
41 import java.util.ArrayList;
42 import java.util.Collections;
43 import java.util.HashMap;
44 import java.util.List;
45 import java.util.Map;
46 
47 /**
48  * The data model for the current loaded directory.
49  */
50 @VisibleForTesting
51 public class Model {
52     private static final String TAG = "Model";
53 
54     private boolean mIsLoading;
55     private List<UpdateListener> mUpdateListeners = new ArrayList<>();
56     @Nullable private Cursor mCursor;
57     private int mCursorCount;
58     /** Maps Model ID to cursor positions, for looking up items by Model ID. */
59     private Map<String, Integer> mPositions = new HashMap<>();
60     /**
61      * A sorted array of model IDs for the files currently in the Model.  Sort order is determined
62      * by {@link #mSortOrder}
63      */
64     private String mIds[] = new String[0];
65     private int mSortOrder = SORT_ORDER_DISPLAY_NAME;
66 
67     @Nullable String info;
68     @Nullable String error;
69     @Nullable DocumentInfo doc;
70 
notifyUpdateListeners()71     private void notifyUpdateListeners() {
72         for (UpdateListener listener: mUpdateListeners) {
73             listener.onModelUpdate(this);
74         }
75     }
76 
notifyUpdateListeners(Exception e)77     private void notifyUpdateListeners(Exception e) {
78         for (UpdateListener listener: mUpdateListeners) {
79             listener.onModelUpdateFailed(e);
80         }
81     }
82 
update(DirectoryResult result)83     void update(DirectoryResult result) {
84         if (DEBUG) Log.i(TAG, "Updating model with new result set.");
85 
86         if (result == null) {
87             mCursor = null;
88             mCursorCount = 0;
89             mIds = new String[0];
90             mPositions.clear();
91             info = null;
92             error = null;
93             doc = null;
94             mIsLoading = false;
95             notifyUpdateListeners();
96             return;
97         }
98 
99         if (result.exception != null) {
100             Log.e(TAG, "Error while loading directory contents", result.exception);
101             notifyUpdateListeners(result.exception);
102             return;
103         }
104 
105         mCursor = result.cursor;
106         mCursorCount = mCursor.getCount();
107         mSortOrder = result.sortOrder;
108         doc = result.doc;
109 
110         updateModelData();
111 
112         final Bundle extras = mCursor.getExtras();
113         if (extras != null) {
114             info = extras.getString(DocumentsContract.EXTRA_INFO);
115             error = extras.getString(DocumentsContract.EXTRA_ERROR);
116             mIsLoading = extras.getBoolean(DocumentsContract.EXTRA_LOADING, false);
117         }
118 
119         notifyUpdateListeners();
120     }
121 
122     @VisibleForTesting
getItemCount()123     int getItemCount() {
124         return mCursorCount;
125     }
126 
127     /**
128      * Scan over the incoming cursor data, generate Model IDs for each row, and sort the IDs
129      * according to the current sort order.
130      */
updateModelData()131     private void updateModelData() {
132         int[] positions = new int[mCursorCount];
133         mIds = new String[mCursorCount];
134         boolean[] isDirs = new boolean[mCursorCount];
135         String[] displayNames = null;
136         long[] longValues = null;
137 
138         switch (mSortOrder) {
139             case SORT_ORDER_DISPLAY_NAME:
140                 displayNames = new String[mCursorCount];
141                 break;
142             case SORT_ORDER_LAST_MODIFIED:
143             case SORT_ORDER_SIZE:
144                 longValues = new long[mCursorCount];
145                 break;
146         }
147 
148         String mimeType;
149 
150         mCursor.moveToPosition(-1);
151         for (int pos = 0; pos < mCursorCount; ++pos) {
152             if (!mCursor.moveToNext()) {
153                 Log.e(TAG, "Fail to move cursor to next pos: " + pos);
154                 return;
155             }
156             positions[pos] = pos;
157 
158             // Generates a Model ID for a cursor entry that refers to a document. The Model ID is a
159             // unique string that can be used to identify the document referred to by the cursor.
160             // If the cursor is a merged cursor over multiple authorities, then prefix the ids
161             // with the authority to avoid collisions.
162             if (mCursor instanceof MergeCursor) {
163                 mIds[pos] = getCursorString(mCursor, RootCursorWrapper.COLUMN_AUTHORITY) + "|" +
164                         getCursorString(mCursor, Document.COLUMN_DOCUMENT_ID);
165             } else {
166                 mIds[pos] = getCursorString(mCursor, Document.COLUMN_DOCUMENT_ID);
167             }
168 
169             mimeType = getCursorString(mCursor, Document.COLUMN_MIME_TYPE);
170             isDirs[pos] = Document.MIME_TYPE_DIR.equals(mimeType);
171 
172             switch(mSortOrder) {
173                 case SORT_ORDER_DISPLAY_NAME:
174                     final String displayName = getCursorString(
175                             mCursor, Document.COLUMN_DISPLAY_NAME);
176                     displayNames[pos] = displayName;
177                     break;
178                 case SORT_ORDER_LAST_MODIFIED:
179                     longValues[pos] = getLastModified(mCursor);
180                     break;
181                 case SORT_ORDER_SIZE:
182                     longValues[pos] = getCursorLong(mCursor, Document.COLUMN_SIZE);
183                     break;
184             }
185         }
186 
187         switch (mSortOrder) {
188             case SORT_ORDER_DISPLAY_NAME:
189                 binarySort(displayNames, isDirs, positions, mIds);
190                 break;
191             case SORT_ORDER_LAST_MODIFIED:
192             case SORT_ORDER_SIZE:
193                 binarySort(longValues, isDirs, positions, mIds);
194                 break;
195         }
196 
197         // Populate the positions.
198         mPositions.clear();
199         for (int i = 0; i < mCursorCount; ++i) {
200             mPositions.put(mIds[i], positions[i]);
201         }
202     }
203 
204     /**
205      * Sorts model data. Takes three columns of index-corresponded data. The first column is the
206      * sort key. Rows are sorted in ascending alphabetical order on the sort key.
207      * Directories are always shown first. This code is based on TimSort.binarySort().
208      *
209      * @param sortKey Data is sorted in ascending alphabetical order.
210      * @param isDirs Array saying whether an item is a directory or not.
211      * @param positions Cursor positions to be sorted.
212      * @param ids Model IDs to be sorted.
213      */
binarySort(String[] sortKey, boolean[] isDirs, int[] positions, String[] ids)214     private static void binarySort(String[] sortKey, boolean[] isDirs, int[] positions, String[] ids) {
215         final int count = positions.length;
216         for (int start = 1; start < count; start++) {
217             final int pivotPosition = positions[start];
218             final String pivotValue = sortKey[start];
219             final boolean pivotIsDir = isDirs[start];
220             final String pivotId = ids[start];
221 
222             int left = 0;
223             int right = start;
224 
225             while (left < right) {
226                 int mid = (left + right) >>> 1;
227 
228                 // Directories always go in front.
229                 int compare = 0;
230                 final boolean rhsIsDir = isDirs[mid];
231                 if (pivotIsDir && !rhsIsDir) {
232                     compare = -1;
233                 } else if (!pivotIsDir && rhsIsDir) {
234                     compare = 1;
235                 } else {
236                     final String lhs = pivotValue;
237                     final String rhs = sortKey[mid];
238                     compare = Shared.compareToIgnoreCaseNullable(lhs, rhs);
239                 }
240 
241                 if (compare < 0) {
242                     right = mid;
243                 } else {
244                     left = mid + 1;
245                 }
246             }
247 
248             int n = start - left;
249             switch (n) {
250                 case 2:
251                     positions[left + 2] = positions[left + 1];
252                     sortKey[left + 2] = sortKey[left + 1];
253                     isDirs[left + 2] = isDirs[left + 1];
254                     ids[left + 2] = ids[left + 1];
255                 case 1:
256                     positions[left + 1] = positions[left];
257                     sortKey[left + 1] = sortKey[left];
258                     isDirs[left + 1] = isDirs[left];
259                     ids[left + 1] = ids[left];
260                     break;
261                 default:
262                     System.arraycopy(positions, left, positions, left + 1, n);
263                     System.arraycopy(sortKey, left, sortKey, left + 1, n);
264                     System.arraycopy(isDirs, left, isDirs, left + 1, n);
265                     System.arraycopy(ids, left, ids, left + 1, n);
266             }
267 
268             positions[left] = pivotPosition;
269             sortKey[left] = pivotValue;
270             isDirs[left] = pivotIsDir;
271             ids[left] = pivotId;
272         }
273     }
274 
275     /**
276      * Sorts model data. Takes four columns of index-corresponded data. The first column is the sort
277      * key, and the second is an array of mime types. The rows are first bucketed by mime type
278      * (directories vs documents) and then each bucket is sorted independently in descending
279      * numerical order on the sort key. This code is based on TimSort.binarySort().
280      *
281      * @param sortKey Data is sorted in descending numerical order.
282      * @param isDirs Array saying whether an item is a directory or not.
283      * @param positions Cursor positions to be sorted.
284      * @param ids Model IDs to be sorted.
285      */
binarySort( long[] sortKey, boolean[] isDirs, int[] positions, String[] ids)286     private static void binarySort(
287             long[] sortKey, boolean[] isDirs, int[] positions, String[] ids) {
288         final int count = positions.length;
289         for (int start = 1; start < count; start++) {
290             final int pivotPosition = positions[start];
291             final long pivotValue = sortKey[start];
292             final boolean pivotIsDir = isDirs[start];
293             final String pivotId = ids[start];
294 
295             int left = 0;
296             int right = start;
297 
298             while (left < right) {
299                 int mid = ((left + right) >>> 1);
300 
301                 // Directories always go in front.
302                 int compare = 0;
303                 final boolean rhsIsDir = isDirs[mid];
304                 if (pivotIsDir && !rhsIsDir) {
305                     compare = -1;
306                 } else if (!pivotIsDir && rhsIsDir) {
307                     compare = 1;
308                 } else {
309                     final long lhs = pivotValue;
310                     final long rhs = sortKey[mid];
311                     // Sort in descending numerical order. This matches legacy behaviour, which
312                     // yields largest or most recent items on top.
313                     compare = -Long.compare(lhs, rhs);
314                 }
315 
316                 // If numerical comparison yields a tie, use document ID as a tie breaker.  This
317                 // will yield stable results even if incoming items are continually shuffling and
318                 // have identical numerical sort keys.  One common example of this scenario is seen
319                 // when sorting a set of active downloads by mod time.
320                 if (compare == 0) {
321                     compare = pivotId.compareTo(ids[mid]);
322                 }
323 
324                 if (compare < 0) {
325                     right = mid;
326                 } else {
327                     left = mid + 1;
328                 }
329             }
330 
331             int n = start - left;
332             switch (n) {
333                 case 2:
334                     positions[left + 2] = positions[left + 1];
335                     sortKey[left + 2] = sortKey[left + 1];
336                     isDirs[left + 2] = isDirs[left + 1];
337                     ids[left + 2] = ids[left + 1];
338                 case 1:
339                     positions[left + 1] = positions[left];
340                     sortKey[left + 1] = sortKey[left];
341                     isDirs[left + 1] = isDirs[left];
342                     ids[left + 1] = ids[left];
343                     break;
344                 default:
345                     System.arraycopy(positions, left, positions, left + 1, n);
346                     System.arraycopy(sortKey, left, sortKey, left + 1, n);
347                     System.arraycopy(isDirs, left, isDirs, left + 1, n);
348                     System.arraycopy(ids, left, ids, left + 1, n);
349             }
350 
351             positions[left] = pivotPosition;
352             sortKey[left] = pivotValue;
353             isDirs[left] = pivotIsDir;
354             ids[left] = pivotId;
355         }
356     }
357 
358     /**
359      * @return Timestamp for the given document. Some docs (e.g. active downloads) have a null
360      * timestamp - these will be replaced with MAX_LONG so that such files get sorted to the top
361      * when sorting by date.
362      */
getLastModified(Cursor cursor)363     long getLastModified(Cursor cursor) {
364         long l = getCursorLong(mCursor, Document.COLUMN_LAST_MODIFIED);
365         return (l == -1) ? Long.MAX_VALUE : l;
366     }
367 
getItem(String modelId)368     public @Nullable Cursor getItem(String modelId) {
369         Integer pos = mPositions.get(modelId);
370         if (pos == null) {
371             if (DEBUG) Log.d(TAG, "Unabled to find cursor position for modelId: " + modelId);
372             return null;
373         }
374 
375         if (!mCursor.moveToPosition(pos)) {
376             if (DEBUG) Log.d(TAG,
377                     "Unabled to move cursor to position " + pos + " for modelId: " + modelId);
378             return null;
379         }
380 
381         return mCursor;
382     }
383 
isEmpty()384     boolean isEmpty() {
385         return mCursorCount == 0;
386     }
387 
isLoading()388     boolean isLoading() {
389         return mIsLoading;
390     }
391 
getDocuments(Selection items)392     List<DocumentInfo> getDocuments(Selection items) {
393         final int size = (items != null) ? items.size() : 0;
394 
395         final List<DocumentInfo> docs =  new ArrayList<>(size);
396         for (String modelId: items.getAll()) {
397             final Cursor cursor = getItem(modelId);
398             if (cursor == null) {
399                 Log.w(TAG,
400                         "Skipping document. Unabled to obtain cursor for modelId: " + modelId);
401                 continue;
402             }
403             docs.add(DocumentInfo.fromDirectoryCursor(cursor));
404         }
405         return docs;
406     }
407 
addUpdateListener(UpdateListener listener)408     void addUpdateListener(UpdateListener listener) {
409         mUpdateListeners.add(listener);
410     }
411 
removeUpdateListener(UpdateListener listener)412     void removeUpdateListener(UpdateListener listener) {
413         mUpdateListeners.remove(listener);
414     }
415 
416     static interface UpdateListener {
417         /**
418          * Called when a successful update has occurred.
419          */
onModelUpdate(Model model)420         void onModelUpdate(Model model);
421 
422         /**
423          * Called when an update has been attempted but failed.
424          */
onModelUpdateFailed(Exception e)425         void onModelUpdateFailed(Exception e);
426     }
427 
428     /**
429      * @return An ordered array of model IDs representing the documents in the model. It is sorted
430      *         according to the current sort order, which was set by the last model update.
431      */
getModelIds()432     public String[] getModelIds() {
433         return mIds;
434     }
435 }
436