1 /*
2  * Copyright (C) 2016 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.sorting;
18 
19 import static com.android.documentsui.base.SharedMinimal.DEBUG;
20 
21 import android.content.ContentResolver;
22 import android.database.Cursor;
23 import android.os.Bundle;
24 import android.os.Parcel;
25 import android.os.Parcelable;
26 import android.provider.DocumentsContract.Document;
27 import android.util.Log;
28 import android.util.SparseArray;
29 import android.view.View;
30 
31 import androidx.annotation.IntDef;
32 import androidx.annotation.Nullable;
33 import androidx.annotation.VisibleForTesting;
34 
35 import com.android.documentsui.R;
36 import com.android.documentsui.base.Lookup;
37 import com.android.documentsui.sorting.SortDimension.SortDirection;
38 
39 import java.lang.annotation.Retention;
40 import java.lang.annotation.RetentionPolicy;
41 import java.util.ArrayList;
42 import java.util.Collection;
43 import java.util.List;
44 import java.util.function.Consumer;
45 
46 /**
47  * Sort model that contains all columns and their sorting state.
48  */
49 public class SortModel implements Parcelable {
50     public static final int SORT_DIMENSION_ID_UNKNOWN = 0;
51     public static final int SORT_DIMENSION_ID_TITLE = android.R.id.title;
52     public static final int SORT_DIMENSION_ID_SUMMARY = android.R.id.summary;
53     public static final int SORT_DIMENSION_ID_SIZE = R.id.size;
54     public static final int SORT_DIMENSION_ID_FILE_TYPE = R.id.file_type;
55     public static final int SORT_DIMENSION_ID_DATE = R.id.date;
56 
57     @IntDef(flag = true, value = {
58             UPDATE_TYPE_NONE,
59             UPDATE_TYPE_UNSPECIFIED,
60             UPDATE_TYPE_VISIBILITY,
61             UPDATE_TYPE_SORTING
62     })
63     @Retention(RetentionPolicy.SOURCE)
64     public @interface UpdateType {}
65     /**
66      * Default value for update type. Nothing is updated.
67      */
68     public static final int UPDATE_TYPE_NONE = 0;
69     /**
70      * Indicates the visibility of at least one dimension has changed.
71      */
72     public static final int UPDATE_TYPE_VISIBILITY = 1;
73     /**
74      * Indicates the sorting order has changed, either because the sorted dimension has changed or
75      * the sort direction has changed.
76      */
77     public static final int UPDATE_TYPE_SORTING = 1 << 1;
78     /**
79      * Anything can be changed if the type is unspecified.
80      */
81     public static final int UPDATE_TYPE_UNSPECIFIED = -1;
82 
83     private static final String TAG = "SortModel";
84 
85     private final SparseArray<SortDimension> mDimensions;
86 
87     private transient final List<UpdateListener> mListeners;
88     private transient Consumer<SortDimension> mMetricRecorder;
89 
90     private int mDefaultDimensionId = SORT_DIMENSION_ID_UNKNOWN;
91     private boolean mIsUserSpecified = false;
92     private @Nullable SortDimension mSortedDimension;
93 
94     @VisibleForTesting
SortModel(Collection<SortDimension> columns)95     SortModel(Collection<SortDimension> columns) {
96         mDimensions = new SparseArray<>(columns.size());
97 
98         for (SortDimension column : columns) {
99             if (column.getId() == SORT_DIMENSION_ID_UNKNOWN) {
100                 throw new IllegalArgumentException(
101                         "SortDimension id can't be " + SORT_DIMENSION_ID_UNKNOWN + ".");
102             }
103             if (mDimensions.get(column.getId()) != null) {
104                 throw new IllegalStateException(
105                         "SortDimension id must be unique. Duplicate id: " + column.getId());
106             }
107             mDimensions.put(column.getId(), column);
108         }
109 
110         mListeners = new ArrayList<>();
111     }
112 
getSize()113     public int getSize() {
114         return mDimensions.size();
115     }
116 
getDimensionAt(int index)117     public SortDimension getDimensionAt(int index) {
118         return mDimensions.valueAt(index);
119     }
120 
getDimensionById(int id)121     public @Nullable SortDimension getDimensionById(int id) {
122         return mDimensions.get(id);
123     }
124 
125     /**
126      * Gets the sorted dimension id.
127      * @return the sorted dimension id or {@link #SORT_DIMENSION_ID_UNKNOWN} if there is no sorted
128      * dimension.
129      */
getSortedDimensionId()130     public int getSortedDimensionId() {
131         return mSortedDimension != null ? mSortedDimension.getId() : SORT_DIMENSION_ID_UNKNOWN;
132     }
133 
getCurrentSortDirection()134     public @SortDirection int getCurrentSortDirection() {
135         return mSortedDimension != null
136                 ? mSortedDimension.getSortDirection()
137                 : SortDimension.SORT_DIRECTION_NONE;
138     }
139 
140     /**
141      * Sort by the default direction of the given dimension if user has never specified any sort
142      * direction before.
143      * @param dimensionId the id of the dimension
144      */
setDefaultDimension(int dimensionId)145     public void setDefaultDimension(int dimensionId) {
146         final boolean mayNeedSorting = (mDefaultDimensionId != dimensionId);
147 
148         mDefaultDimensionId = dimensionId;
149 
150         if (mayNeedSorting) {
151             sortOnDefault();
152         }
153     }
154 
setMetricRecorder(Consumer<SortDimension> metricRecorder)155     void setMetricRecorder(Consumer<SortDimension> metricRecorder) {
156         mMetricRecorder = metricRecorder;
157     }
158 
159     /**
160      * Sort by given dimension and direction. Should only be used when user explicitly asks to sort
161      * docs.
162      * @param dimensionId the id of the dimension
163      * @param direction the direction to sort docs in
164      */
sortByUser(int dimensionId, @SortDirection int direction)165     public void sortByUser(int dimensionId, @SortDirection int direction) {
166         SortDimension dimension = mDimensions.get(dimensionId);
167         if (dimension == null) {
168             throw new IllegalArgumentException("Unknown column id: " + dimensionId);
169         }
170 
171         sortByDimension(dimension, direction);
172 
173         if (mMetricRecorder != null) {
174             mMetricRecorder.accept(dimension);
175         }
176 
177         mIsUserSpecified = true;
178     }
179 
sortByDimension( SortDimension newSortedDimension, @SortDirection int direction)180     private void sortByDimension(
181             SortDimension newSortedDimension, @SortDirection int direction) {
182         if (newSortedDimension == mSortedDimension
183                 && mSortedDimension.mSortDirection == direction) {
184             // Sort direction not changed, no need to proceed.
185             return;
186         }
187 
188         if ((newSortedDimension.getSortCapability() & direction) == 0) {
189             throw new IllegalStateException(
190                     "Dimension with id: " + newSortedDimension.getId()
191                     + " can't be sorted in direction:" + direction);
192         }
193 
194         switch (direction) {
195             case SortDimension.SORT_DIRECTION_ASCENDING:
196             case SortDimension.SORT_DIRECTION_DESCENDING:
197                 newSortedDimension.mSortDirection = direction;
198                 break;
199             default:
200                 throw new IllegalArgumentException("Unknown sort direction: " + direction);
201         }
202 
203         if (mSortedDimension != null && mSortedDimension != newSortedDimension) {
204             mSortedDimension.mSortDirection = SortDimension.SORT_DIRECTION_NONE;
205         }
206 
207         mSortedDimension = newSortedDimension;
208 
209         notifyListeners(UPDATE_TYPE_SORTING);
210     }
211 
setDimensionVisibility(int columnId, int visibility)212     public void setDimensionVisibility(int columnId, int visibility) {
213         assert(mDimensions.get(columnId) != null);
214 
215         mDimensions.get(columnId).mVisibility = visibility;
216 
217         notifyListeners(UPDATE_TYPE_VISIBILITY);
218     }
219 
sortCursor(Cursor cursor, Lookup<String, String> fileTypesMap)220     public Cursor sortCursor(Cursor cursor, Lookup<String, String> fileTypesMap) {
221         if (mSortedDimension != null) {
222             return new SortingCursorWrapper(cursor, mSortedDimension, fileTypesMap);
223         } else {
224             return cursor;
225         }
226     }
227 
addQuerySortArgs(Bundle queryArgs)228     public void addQuerySortArgs(Bundle queryArgs) {
229         // should only be called when R.bool.feature_content_paging is true
230 
231         final int id = getSortedDimensionId();
232         if (id == SORT_DIMENSION_ID_UNKNOWN) {
233             return;
234         } else if (id == SortModel.SORT_DIMENSION_ID_TITLE) {
235             queryArgs.putStringArray(
236                     ContentResolver.QUERY_ARG_SORT_COLUMNS,
237                     new String[]{Document.COLUMN_DISPLAY_NAME});
238         } else if (id == SortModel.SORT_DIMENSION_ID_DATE) {
239             queryArgs.putStringArray(
240                     ContentResolver.QUERY_ARG_SORT_COLUMNS,
241                     new String[]{Document.COLUMN_LAST_MODIFIED});
242         } else if (id == SortModel.SORT_DIMENSION_ID_SIZE) {
243             queryArgs.putStringArray(
244                     ContentResolver.QUERY_ARG_SORT_COLUMNS,
245                     new String[]{Document.COLUMN_SIZE});
246         } else if (id == SortModel.SORT_DIMENSION_ID_FILE_TYPE) {
247             // Unfortunately sorting by mime type is pretty much guaranteed different from
248             // sorting by user-friendly type, so there is no point to guide the provider to sort
249             // in a particular order.
250             return;
251         } else {
252             throw new IllegalStateException(
253                     "Unexpected sort dimension id: " + id);
254         }
255 
256         final SortDimension dimension = getDimensionById(id);
257         switch (dimension.getSortDirection()) {
258             case SortDimension.SORT_DIRECTION_ASCENDING:
259                 queryArgs.putInt(
260                         ContentResolver.QUERY_ARG_SORT_DIRECTION,
261                         ContentResolver.QUERY_SORT_DIRECTION_ASCENDING);
262                 break;
263             case SortDimension.SORT_DIRECTION_DESCENDING:
264                 queryArgs.putInt(
265                         ContentResolver.QUERY_ARG_SORT_DIRECTION,
266                         ContentResolver.QUERY_SORT_DIRECTION_DESCENDING);
267                 break;
268             default:
269                 throw new IllegalStateException(
270                         "Unexpected sort direction: " + dimension.getSortDirection());
271         }
272     }
273 
getDocumentSortQuery()274     public @Nullable String getDocumentSortQuery() {
275         // This method should only be called when R.bool.feature_content_paging exists.
276         // Once that feature is enabled by default (and reference removed), this method
277         // should also be removed.
278         // The following log message exists simply to make reference to
279         // R.bool.feature_content_paging so that compiler will fail when value
280         // is remove from config.xml.
281         int readTheCommentAbove = R.bool.feature_content_paging;
282 
283         final int id = getSortedDimensionId();
284         final String columnName;
285         if (id == SORT_DIMENSION_ID_UNKNOWN) {
286             return null;
287         } else if (id == SortModel.SORT_DIMENSION_ID_TITLE) {
288             columnName = Document.COLUMN_DISPLAY_NAME;
289         } else if (id == SortModel.SORT_DIMENSION_ID_DATE) {
290             columnName = Document.COLUMN_LAST_MODIFIED;
291         } else if (id == SortModel.SORT_DIMENSION_ID_SIZE) {
292             columnName = Document.COLUMN_SIZE;
293         } else if (id == SortModel.SORT_DIMENSION_ID_FILE_TYPE) {
294             // Unfortunately sorting by mime type is pretty much guaranteed different from
295             // sorting by user-friendly type, so there is no point to guide the provider to sort
296             // in a particular order.
297             return null;
298         } else {
299             throw new IllegalStateException(
300                     "Unexpected sort dimension id: " + id);
301         }
302 
303         final SortDimension dimension = getDimensionById(id);
304         final String direction;
305         switch (dimension.getSortDirection()) {
306             case SortDimension.SORT_DIRECTION_ASCENDING:
307                 direction = " ASC";
308                 break;
309             case SortDimension.SORT_DIRECTION_DESCENDING:
310                 direction = " DESC";
311                 break;
312             default:
313                 throw new IllegalStateException(
314                         "Unexpected sort direction: " + dimension.getSortDirection());
315         }
316 
317         return columnName + direction;
318     }
319 
notifyListeners(@pdateType int updateType)320     private void notifyListeners(@UpdateType int updateType) {
321         for (int i = mListeners.size() - 1; i >= 0; --i) {
322             mListeners.get(i).onModelUpdate(this, updateType);
323         }
324     }
325 
addListener(UpdateListener listener)326     public void addListener(UpdateListener listener) {
327         mListeners.add(listener);
328     }
329 
removeListener(UpdateListener listener)330     public void removeListener(UpdateListener listener) {
331         mListeners.remove(listener);
332     }
333 
334     /**
335      * Sort by default dimension and direction if there is no history of user specifying a sort
336      * order.
337      */
sortOnDefault()338     private void sortOnDefault() {
339         if (!mIsUserSpecified) {
340             SortDimension dimension = mDimensions.get(mDefaultDimensionId);
341             if (dimension == null) {
342                 if (DEBUG) {
343                     Log.d(TAG, "No default sort dimension.");
344                 }
345                 return;
346             }
347 
348             sortByDimension(dimension, dimension.getDefaultSortDirection());
349         }
350     }
351 
352     @Override
equals(Object o)353     public boolean equals(Object o) {
354         if (o == null || !(o instanceof SortModel)) {
355             return false;
356         }
357 
358         if (this == o) {
359             return true;
360         }
361 
362         SortModel other = (SortModel) o;
363         if (mDimensions.size() != other.mDimensions.size()) {
364             return false;
365         }
366         for (int i = 0; i < mDimensions.size(); ++i) {
367             final SortDimension dimension = mDimensions.valueAt(i);
368             final int id = dimension.getId();
369             if (!dimension.equals(other.getDimensionById(id))) {
370                 return false;
371             }
372         }
373 
374         return mDefaultDimensionId == other.mDefaultDimensionId
375                 && (mSortedDimension == other.mSortedDimension
376                     || mSortedDimension.equals(other.mSortedDimension));
377     }
378 
379     @Override
toString()380     public String toString() {
381         return new StringBuilder()
382                 .append("SortModel{")
383                 .append("dimensions=").append(mDimensions)
384                 .append(", defaultDimensionId=").append(mDefaultDimensionId)
385                 .append(", sortedDimension=").append(mSortedDimension)
386                 .append("}")
387                 .toString();
388     }
389 
390     @Override
describeContents()391     public int describeContents() {
392         return 0;
393     }
394 
395     @Override
writeToParcel(Parcel out, int flag)396     public void writeToParcel(Parcel out, int flag) {
397         out.writeInt(mDimensions.size());
398         for (int i = 0; i < mDimensions.size(); ++i) {
399             out.writeParcelable(mDimensions.valueAt(i), flag);
400         }
401 
402         out.writeInt(mDefaultDimensionId);
403         out.writeInt(getSortedDimensionId());
404     }
405 
406     public static final Parcelable.Creator<SortModel> CREATOR =
407             new Parcelable.Creator<SortModel>() {
408 
409         @Override
410         public SortModel createFromParcel(Parcel in) {
411             final int size = in.readInt();
412             Collection<SortDimension> columns = new ArrayList<>(size);
413             for (int i = 0; i < size; ++i) {
414                 columns.add(in.readParcelable(getClass().getClassLoader()));
415             }
416             SortModel model = new SortModel(columns);
417 
418             model.mDefaultDimensionId = in.readInt();
419             model.mSortedDimension = model.getDimensionById(in.readInt());
420 
421             return model;
422         }
423 
424         @Override
425         public SortModel[] newArray(int size) {
426             return new SortModel[size];
427         }
428     };
429 
430     /**
431      * Creates a model for all other roots.
432      *
433      * TODO: move definition of columns into xml, and inflate model from it.
434      */
createModel()435     public static SortModel createModel() {
436         List<SortDimension> dimensions = new ArrayList<>(4);
437         SortDimension.Builder builder = new SortDimension.Builder();
438 
439         // Name column
440         dimensions.add(builder
441                 .withId(SORT_DIMENSION_ID_TITLE)
442                 .withLabelId(R.string.sort_dimension_name)
443                 .withDataType(SortDimension.DATA_TYPE_STRING)
444                 .withSortCapability(SortDimension.SORT_CAPABILITY_BOTH_DIRECTION)
445                 .withDefaultSortDirection(SortDimension.SORT_DIRECTION_ASCENDING)
446                 .withVisibility(View.VISIBLE)
447                 .build()
448         );
449 
450         // Summary column
451         // Summary is only visible in Downloads and Recents root.
452         dimensions.add(builder
453                 .withId(SORT_DIMENSION_ID_SUMMARY)
454                 .withLabelId(R.string.sort_dimension_summary)
455                 .withDataType(SortDimension.DATA_TYPE_STRING)
456                 .withSortCapability(SortDimension.SORT_CAPABILITY_NONE)
457                 .withVisibility(View.INVISIBLE)
458                 .build()
459         );
460 
461         // Size column
462         dimensions.add(builder
463                 .withId(SORT_DIMENSION_ID_SIZE)
464                 .withLabelId(R.string.sort_dimension_size)
465                 .withDataType(SortDimension.DATA_TYPE_NUMBER)
466                 .withSortCapability(SortDimension.SORT_CAPABILITY_BOTH_DIRECTION)
467                 .withDefaultSortDirection(SortDimension.SORT_DIRECTION_ASCENDING)
468                 .withVisibility(View.VISIBLE)
469                 .build()
470         );
471 
472         // Type column
473         dimensions.add(builder
474             .withId(SORT_DIMENSION_ID_FILE_TYPE)
475             .withLabelId(R.string.sort_dimension_file_type)
476             .withDataType(SortDimension.DATA_TYPE_STRING)
477             .withSortCapability(SortDimension.SORT_CAPABILITY_BOTH_DIRECTION)
478             .withDefaultSortDirection(SortDimension.SORT_DIRECTION_ASCENDING)
479             .withVisibility(View.VISIBLE)
480             .build());
481 
482         // Date column
483         dimensions.add(builder
484                 .withId(SORT_DIMENSION_ID_DATE)
485                 .withLabelId(R.string.sort_dimension_date)
486                 .withDataType(SortDimension.DATA_TYPE_NUMBER)
487                 .withSortCapability(SortDimension.SORT_CAPABILITY_BOTH_DIRECTION)
488                 .withDefaultSortDirection(SortDimension.SORT_DIRECTION_DESCENDING)
489                 .withVisibility(View.VISIBLE)
490                 .build()
491         );
492 
493         return new SortModel(dimensions);
494     }
495 
496     public interface UpdateListener {
onModelUpdate(SortModel newModel, @UpdateType int updateType)497         void onModelUpdate(SortModel newModel, @UpdateType int updateType);
498     }
499 }
500