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