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