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