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