1 /*
2  * Copyright (C) 2013 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.providers.media;
18 
19 import static android.content.ContentResolver.EXTRA_SIZE;
20 
21 import android.annotation.Nullable;
22 import android.content.ContentResolver;
23 import android.content.ContentUris;
24 import android.content.Context;
25 import android.content.Intent;
26 import android.content.MimeTypeFilter;
27 import android.content.res.AssetFileDescriptor;
28 import android.database.Cursor;
29 import android.database.MatrixCursor;
30 import android.database.MatrixCursor.RowBuilder;
31 import android.graphics.BitmapFactory;
32 import android.graphics.Point;
33 import android.media.ExifInterface;
34 import android.media.MediaMetadata;
35 import android.net.Uri;
36 import android.os.Binder;
37 import android.os.Bundle;
38 import android.os.CancellationSignal;
39 import android.os.IBinder;
40 import android.os.ParcelFileDescriptor;
41 import android.os.UserHandle;
42 import android.os.UserManager;
43 import android.provider.BaseColumns;
44 import android.provider.DocumentsContract;
45 import android.provider.DocumentsContract.Document;
46 import android.provider.DocumentsContract.Root;
47 import android.provider.DocumentsProvider;
48 import android.provider.MediaStore;
49 import android.provider.MediaStore.Audio;
50 import android.provider.MediaStore.Audio.AlbumColumns;
51 import android.provider.MediaStore.Audio.Albums;
52 import android.provider.MediaStore.Audio.ArtistColumns;
53 import android.provider.MediaStore.Audio.Artists;
54 import android.provider.MediaStore.Audio.AudioColumns;
55 import android.provider.MediaStore.Files.FileColumns;
56 import android.provider.MediaStore.Images;
57 import android.provider.MediaStore.Images.ImageColumns;
58 import android.provider.MediaStore.Video;
59 import android.provider.MediaStore.Video.VideoColumns;
60 import android.provider.MetadataReader;
61 import android.text.TextUtils;
62 import android.text.format.DateFormat;
63 import android.text.format.DateUtils;
64 import android.util.Log;
65 import android.util.Pair;
66 
67 import com.android.internal.os.BackgroundThread;
68 
69 import libcore.io.IoUtils;
70 
71 import java.io.FileNotFoundException;
72 import java.io.IOException;
73 import java.io.InputStream;
74 import java.util.ArrayList;
75 import java.util.Collection;
76 import java.util.HashMap;
77 import java.util.List;
78 import java.util.Locale;
79 import java.util.Map;
80 
81 /**
82  * Presents a {@link DocumentsContract} view of {@link MediaProvider} external
83  * contents.
84  */
85 public class MediaDocumentsProvider extends DocumentsProvider {
86     private static final String TAG = "MediaDocumentsProvider";
87 
88     private static final String AUTHORITY = "com.android.providers.media.documents";
89 
90     private static final String SUPPORTED_QUERY_ARGS = joinNewline(
91             DocumentsContract.QUERY_ARG_DISPLAY_NAME,
92             DocumentsContract.QUERY_ARG_FILE_SIZE_OVER,
93             DocumentsContract.QUERY_ARG_LAST_MODIFIED_AFTER,
94             DocumentsContract.QUERY_ARG_MIME_TYPES);
95 
96     private static final String[] DEFAULT_ROOT_PROJECTION = new String[] {
97             Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON,
98             Root.COLUMN_TITLE, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_MIME_TYPES,
99             Root.COLUMN_QUERY_ARGS
100     };
101 
102     private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] {
103             Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME,
104             Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE,
105     };
106 
107     private static final String IMAGE_MIME_TYPES = joinNewline("image/*");
108 
109     private static final String VIDEO_MIME_TYPES = joinNewline("video/*");
110 
111     private static final String AUDIO_MIME_TYPES = joinNewline(
112             "audio/*", "application/ogg", "application/x-flac");
113 
114     private static final String TYPE_IMAGES_ROOT = "images_root";
115     private static final String TYPE_IMAGES_BUCKET = "images_bucket";
116     private static final String TYPE_IMAGE = "image";
117 
118     private static final String TYPE_VIDEOS_ROOT = "videos_root";
119     private static final String TYPE_VIDEOS_BUCKET = "videos_bucket";
120     private static final String TYPE_VIDEO = "video";
121 
122     private static final String TYPE_AUDIO_ROOT = "audio_root";
123     private static final String TYPE_AUDIO = "audio";
124     private static final String TYPE_ARTIST = "artist";
125     private static final String TYPE_ALBUM = "album";
126 
127     private static boolean sReturnedImagesEmpty = false;
128     private static boolean sReturnedVideosEmpty = false;
129     private static boolean sReturnedAudioEmpty = false;
130 
joinNewline(String... args)131     private static String joinNewline(String... args) {
132         return TextUtils.join("\n", args);
133     }
134 
135     public static final String METADATA_KEY_AUDIO = "android.media.metadata.audio";
136     public static final String METADATA_KEY_VIDEO = "android.media.metadata.video";
137     // Video lat/long are just that. Lat/long. Unlike EXIF where the values are
138     // in fact some funky string encoding. So we add our own contstant to convey coords.
139     public static final String METADATA_VIDEO_LATITUDE = "android.media.metadata.video:latitude";
140     public static final String METADATA_VIDEO_LONGITUTE = "android.media.metadata.video:longitude";
141 
142     /*
143      * A mapping between media colums and metadata tag names. These keys of the
144      * map form the projection for queries against the media store database.
145      */
146     private static final Map<String, String> IMAGE_COLUMN_MAP = new HashMap<>();
147     private static final Map<String, String> VIDEO_COLUMN_MAP = new HashMap<>();
148     private static final Map<String, String> AUDIO_COLUMN_MAP = new HashMap<>();
149 
150     static {
151         /**
152          * Note that for images (jpegs at least) we'll first try an alternate
153          * means of extracting metadata, one that provides more data. But if
154          * that fails, or if the image type is not JPEG, we fall back to these columns.
155          */
IMAGE_COLUMN_MAP.put(ImageColumns.WIDTH, ExifInterface.TAG_IMAGE_WIDTH)156         IMAGE_COLUMN_MAP.put(ImageColumns.WIDTH, ExifInterface.TAG_IMAGE_WIDTH);
IMAGE_COLUMN_MAP.put(ImageColumns.HEIGHT, ExifInterface.TAG_IMAGE_LENGTH)157         IMAGE_COLUMN_MAP.put(ImageColumns.HEIGHT, ExifInterface.TAG_IMAGE_LENGTH);
IMAGE_COLUMN_MAP.put(ImageColumns.DATE_TAKEN, ExifInterface.TAG_DATETIME)158         IMAGE_COLUMN_MAP.put(ImageColumns.DATE_TAKEN, ExifInterface.TAG_DATETIME);
IMAGE_COLUMN_MAP.put(ImageColumns.LATITUDE, ExifInterface.TAG_GPS_LATITUDE)159         IMAGE_COLUMN_MAP.put(ImageColumns.LATITUDE, ExifInterface.TAG_GPS_LATITUDE);
IMAGE_COLUMN_MAP.put(ImageColumns.LONGITUDE, ExifInterface.TAG_GPS_LONGITUDE)160         IMAGE_COLUMN_MAP.put(ImageColumns.LONGITUDE, ExifInterface.TAG_GPS_LONGITUDE);
161 
VIDEO_COLUMN_MAP.put(VideoColumns.DURATION, MediaMetadata.METADATA_KEY_DURATION)162         VIDEO_COLUMN_MAP.put(VideoColumns.DURATION, MediaMetadata.METADATA_KEY_DURATION);
VIDEO_COLUMN_MAP.put(VideoColumns.HEIGHT, ExifInterface.TAG_IMAGE_LENGTH)163         VIDEO_COLUMN_MAP.put(VideoColumns.HEIGHT, ExifInterface.TAG_IMAGE_LENGTH);
VIDEO_COLUMN_MAP.put(VideoColumns.WIDTH, ExifInterface.TAG_IMAGE_WIDTH)164         VIDEO_COLUMN_MAP.put(VideoColumns.WIDTH, ExifInterface.TAG_IMAGE_WIDTH);
VIDEO_COLUMN_MAP.put(VideoColumns.LATITUDE, METADATA_VIDEO_LATITUDE)165         VIDEO_COLUMN_MAP.put(VideoColumns.LATITUDE, METADATA_VIDEO_LATITUDE);
VIDEO_COLUMN_MAP.put(VideoColumns.LONGITUDE, METADATA_VIDEO_LONGITUTE)166         VIDEO_COLUMN_MAP.put(VideoColumns.LONGITUDE, METADATA_VIDEO_LONGITUTE);
VIDEO_COLUMN_MAP.put(VideoColumns.DATE_TAKEN, MediaMetadata.METADATA_KEY_DATE)167         VIDEO_COLUMN_MAP.put(VideoColumns.DATE_TAKEN, MediaMetadata.METADATA_KEY_DATE);
168 
AUDIO_COLUMN_MAP.put(AudioColumns.ARTIST, MediaMetadata.METADATA_KEY_ARTIST)169         AUDIO_COLUMN_MAP.put(AudioColumns.ARTIST, MediaMetadata.METADATA_KEY_ARTIST);
AUDIO_COLUMN_MAP.put(AudioColumns.COMPOSER, MediaMetadata.METADATA_KEY_COMPOSER)170         AUDIO_COLUMN_MAP.put(AudioColumns.COMPOSER, MediaMetadata.METADATA_KEY_COMPOSER);
AUDIO_COLUMN_MAP.put(AudioColumns.ALBUM, MediaMetadata.METADATA_KEY_ALBUM)171         AUDIO_COLUMN_MAP.put(AudioColumns.ALBUM, MediaMetadata.METADATA_KEY_ALBUM);
AUDIO_COLUMN_MAP.put(AudioColumns.YEAR, MediaMetadata.METADATA_KEY_YEAR)172         AUDIO_COLUMN_MAP.put(AudioColumns.YEAR, MediaMetadata.METADATA_KEY_YEAR);
AUDIO_COLUMN_MAP.put(AudioColumns.DURATION, MediaMetadata.METADATA_KEY_DURATION)173         AUDIO_COLUMN_MAP.put(AudioColumns.DURATION, MediaMetadata.METADATA_KEY_DURATION);
174     }
175 
copyNotificationUri(MatrixCursor result, Cursor cursor)176     private void copyNotificationUri(MatrixCursor result, Cursor cursor) {
177         result.setNotificationUri(getContext().getContentResolver(), cursor.getNotificationUri());
178     }
179 
180     @Override
onCreate()181     public boolean onCreate() {
182         notifyRootsChanged(getContext());
183         return true;
184     }
185 
enforceShellRestrictions()186     private void enforceShellRestrictions() {
187         if (UserHandle.getCallingAppId() == android.os.Process.SHELL_UID
188                 && getContext().getSystemService(UserManager.class)
189                         .hasUserRestriction(UserManager.DISALLOW_USB_FILE_TRANSFER)) {
190             throw new SecurityException(
191                     "Shell user cannot access files for user " + UserHandle.myUserId());
192         }
193     }
194 
195     @Override
enforceReadPermissionInner(Uri uri, String callingPkg, IBinder callerToken)196     protected int enforceReadPermissionInner(Uri uri, String callingPkg, IBinder callerToken)
197             throws SecurityException {
198         enforceShellRestrictions();
199         return super.enforceReadPermissionInner(uri, callingPkg, callerToken);
200     }
201 
202     @Override
enforceWritePermissionInner(Uri uri, String callingPkg, IBinder callerToken)203     protected int enforceWritePermissionInner(Uri uri, String callingPkg, IBinder callerToken)
204             throws SecurityException {
205         enforceShellRestrictions();
206         return super.enforceWritePermissionInner(uri, callingPkg, callerToken);
207     }
208 
notifyRootsChanged(Context context)209     private static void notifyRootsChanged(Context context) {
210         context.getContentResolver()
211                 .notifyChange(DocumentsContract.buildRootsUri(AUTHORITY), null, false);
212     }
213 
214     /**
215      * When inserting the first item of each type, we need to trigger a roots
216      * refresh to clear a previously reported {@link Root#FLAG_EMPTY}.
217      */
onMediaStoreInsert(Context context, String volumeName, int type, long id)218     static void onMediaStoreInsert(Context context, String volumeName, int type, long id) {
219         BackgroundThread.getExecutor().execute(() -> {
220             if (!"external".equals(volumeName)) return;
221 
222             if (type == FileColumns.MEDIA_TYPE_IMAGE && sReturnedImagesEmpty) {
223                 sReturnedImagesEmpty = false;
224                 notifyRootsChanged(context);
225             } else if (type == FileColumns.MEDIA_TYPE_VIDEO && sReturnedVideosEmpty) {
226                 sReturnedVideosEmpty = false;
227                 notifyRootsChanged(context);
228             } else if (type == FileColumns.MEDIA_TYPE_AUDIO && sReturnedAudioEmpty) {
229                 sReturnedAudioEmpty = false;
230                 notifyRootsChanged(context);
231             }
232         });
233     }
234 
235     /**
236      * When deleting an item, we need to revoke any outstanding Uri grants.
237      */
onMediaStoreDelete(Context context, String volumeName, int type, long id)238     static void onMediaStoreDelete(Context context, String volumeName, int type, long id) {
239         BackgroundThread.getExecutor().execute(() -> {
240             if (!"external".equals(volumeName)) return;
241 
242             if (type == FileColumns.MEDIA_TYPE_IMAGE) {
243                 final Uri uri = DocumentsContract.buildDocumentUri(
244                         AUTHORITY, getDocIdForIdent(TYPE_IMAGE, id));
245                 context.revokeUriPermission(uri, ~0);
246                 notifyRootsChanged(context);
247             } else if (type == FileColumns.MEDIA_TYPE_VIDEO) {
248                 final Uri uri = DocumentsContract.buildDocumentUri(
249                         AUTHORITY, getDocIdForIdent(TYPE_VIDEO, id));
250                 context.revokeUriPermission(uri, ~0);
251                 notifyRootsChanged(context);
252             } else if (type == FileColumns.MEDIA_TYPE_AUDIO) {
253                 final Uri uri = DocumentsContract.buildDocumentUri(
254                         AUTHORITY, getDocIdForIdent(TYPE_AUDIO, id));
255                 context.revokeUriPermission(uri, ~0);
256                 notifyRootsChanged(context);
257             }
258         });
259     }
260 
revokeAllUriGrants(Context context)261     static void revokeAllUriGrants(Context context) {
262         context.revokeUriPermission(DocumentsContract.buildBaseDocumentUri(AUTHORITY),
263                 Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
264     }
265 
266     private static class Ident {
267         public String type;
268         public long id;
269     }
270 
getIdentForDocId(String docId)271     private static Ident getIdentForDocId(String docId) {
272         final Ident ident = new Ident();
273         final int split = docId.indexOf(':');
274         if (split == -1) {
275             ident.type = docId;
276             ident.id = -1;
277         } else {
278             ident.type = docId.substring(0, split);
279             ident.id = Long.parseLong(docId.substring(split + 1));
280         }
281         return ident;
282     }
283 
getDocIdForIdent(String type, long id)284     private static String getDocIdForIdent(String type, long id) {
285         return type + ":" + id;
286     }
287 
resolveRootProjection(String[] projection)288     private static String[] resolveRootProjection(String[] projection) {
289         return projection != null ? projection : DEFAULT_ROOT_PROJECTION;
290     }
291 
resolveDocumentProjection(String[] projection)292     private static String[] resolveDocumentProjection(String[] projection) {
293         return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION;
294     }
295 
buildSearchSelection(String displayName, String[] mimeTypes, long lastModifiedAfter, long fileSizeOver, String columnDisplayName, String columnMimeType, String columnLastModified, String columnFileSize)296     private static Pair<String, String[]> buildSearchSelection(String displayName,
297             String[] mimeTypes, long lastModifiedAfter, long fileSizeOver, String columnDisplayName,
298             String columnMimeType, String columnLastModified, String columnFileSize) {
299         StringBuilder selection = new StringBuilder();
300         final ArrayList<String> selectionArgs = new ArrayList<>();
301 
302         if (!displayName.isEmpty()) {
303             selection.append(columnDisplayName + " LIKE ?");
304             selectionArgs.add("%" + displayName + "%");
305         }
306 
307         if (lastModifiedAfter != -1) {
308             if (selection.length() > 0) {
309                 selection.append(" AND ");
310             }
311 
312             // The units of DATE_MODIFIED are seconds since 1970.
313             // The units of lastModified are milliseconds since 1970.
314             selection.append(columnLastModified + " > " + lastModifiedAfter / 1000);
315         }
316 
317         if (fileSizeOver != -1) {
318             if (selection.length() > 0) {
319                 selection.append(" AND ");
320             }
321 
322             selection.append(columnFileSize + " > " + fileSizeOver);
323         }
324 
325         if (mimeTypes != null && mimeTypes.length > 0) {
326             for (int i = 0; i < mimeTypes.length; i++) {
327                 final String type = mimeTypes[i];
328                 if (i == 0) {
329                     if (selection.length() > 0) {
330                         selection.append(" AND ");
331                     }
332                     selection.append(columnMimeType + " IN ( ?");
333                 } else {
334                     selection.append(", ?");
335                 }
336                 selectionArgs.add(type);
337             }
338             selection.append(" )");
339         }
340 
341         return new Pair<>(selection.toString(), selectionArgs.toArray(new String[0]));
342     }
343 
344     /**
345      * Check whether filter mime type and get the matched mime types.
346      * If we don't need to filter mime type, the matchedMimeTypes will be empty.
347      *
348      * @param mimeTypes the mime types to test
349      * @param filter the filter. It is "image/*" or "video/*" or "audio/*".
350      * @param matchedMimeTypes the matched mime types will add into this.
351      * @return true, should do mime type filter. false, no need.
352      */
shouldFilterMimeType(String[] mimeTypes, String filter, List<String> matchedMimeTypes)353     private static boolean shouldFilterMimeType(String[] mimeTypes, String filter,
354             List<String> matchedMimeTypes) {
355         matchedMimeTypes.clear();
356         boolean shouldQueryMimeType = true;
357         if (mimeTypes != null) {
358             for (int i = 0; i < mimeTypes.length; i++) {
359                 // If the mime type is "*/*" or "image/*" or "video/*" or "audio/*",
360                 // we don't need to filter mime type.
361                 if (TextUtils.equals(mimeTypes[i], "*/*") ||
362                         TextUtils.equals(mimeTypes[i], filter)) {
363                     matchedMimeTypes.clear();
364                     shouldQueryMimeType = false;
365                     break;
366                 }
367                 if (MimeTypeFilter.matches(mimeTypes[i], filter)) {
368                     matchedMimeTypes.add(mimeTypes[i]);
369                 }
370             }
371         } else {
372             shouldQueryMimeType = false;
373         }
374 
375         return shouldQueryMimeType;
376     }
377 
getUriForDocumentId(String docId)378     private Uri getUriForDocumentId(String docId) {
379         final Ident ident = getIdentForDocId(docId);
380         if (TYPE_IMAGE.equals(ident.type) && ident.id != -1) {
381             return ContentUris.withAppendedId(
382                     Images.Media.EXTERNAL_CONTENT_URI, ident.id);
383         } else if (TYPE_VIDEO.equals(ident.type) && ident.id != -1) {
384             return ContentUris.withAppendedId(
385                     Video.Media.EXTERNAL_CONTENT_URI, ident.id);
386         } else if (TYPE_AUDIO.equals(ident.type) && ident.id != -1) {
387             return ContentUris.withAppendedId(
388                     Audio.Media.EXTERNAL_CONTENT_URI, ident.id);
389         } else {
390             throw new UnsupportedOperationException("Unsupported document " + docId);
391         }
392     }
393 
394     @Override
deleteDocument(String docId)395     public void deleteDocument(String docId) throws FileNotFoundException {
396         final Uri target = getUriForDocumentId(docId);
397 
398         // Delegate to real provider
399         final long token = Binder.clearCallingIdentity();
400         try {
401             getContext().getContentResolver().delete(target, null, null);
402         } finally {
403             Binder.restoreCallingIdentity(token);
404         }
405     }
406 
407     @Override
getDocumentMetadata(String docId)408     public @Nullable Bundle getDocumentMetadata(String docId) throws FileNotFoundException {
409 
410         String mimeType = getDocumentType(docId);
411 
412         if (MetadataReader.isSupportedMimeType(mimeType)) {
413             return getDocumentMetadataFromStream(docId, mimeType);
414         } else {
415             return getDocumentMetadataFromIndex(docId);
416         }
417     }
418 
getDocumentMetadataFromStream(String docId, String mimeType)419     private @Nullable Bundle getDocumentMetadataFromStream(String docId, String mimeType) {
420         assert MetadataReader.isSupportedMimeType(mimeType);
421         InputStream stream = null;
422         try {
423             stream = new ParcelFileDescriptor.AutoCloseInputStream(
424                     openDocument(docId, "r", null));
425             Bundle metadata = new Bundle();
426             MetadataReader.getMetadata(metadata, stream, mimeType, null);
427             return metadata;
428         } catch (IOException io) {
429             return null;
430         } finally {
431             IoUtils.closeQuietly(stream);
432         }
433     }
434 
getDocumentMetadataFromIndex(String docId)435     public @Nullable Bundle getDocumentMetadataFromIndex(String docId)
436             throws FileNotFoundException {
437 
438         final Ident ident = getIdentForDocId(docId);
439 
440         Map<String, String> columnMap = null;
441         String tagType;
442         Uri query;
443 
444         switch (ident.type) {
445             case TYPE_IMAGE:
446                 columnMap = IMAGE_COLUMN_MAP;
447                 tagType = DocumentsContract.METADATA_EXIF;
448                 query = Images.Media.EXTERNAL_CONTENT_URI;
449                 break;
450             case TYPE_VIDEO:
451                 columnMap = VIDEO_COLUMN_MAP;
452                 tagType = METADATA_KEY_VIDEO;
453                 query = Video.Media.EXTERNAL_CONTENT_URI;
454                 break;
455             case TYPE_AUDIO:
456                 columnMap = AUDIO_COLUMN_MAP;
457                 tagType = METADATA_KEY_AUDIO;
458                 query = Audio.Media.EXTERNAL_CONTENT_URI;
459                 break;
460             default:
461                 // Unsupported file type.
462                 throw new FileNotFoundException(
463                     "Metadata request for unsupported file type: " + ident.type);
464         }
465 
466         final long token = Binder.clearCallingIdentity();
467         Cursor cursor = null;
468         Bundle result = null;
469 
470         final ContentResolver resolver = getContext().getContentResolver();
471         Collection<String> columns = columnMap.keySet();
472         String[] projection = columns.toArray(new String[columns.size()]);
473         try {
474             cursor = resolver.query(
475                     query,
476                     projection,
477                     BaseColumns._ID + "=?",
478                     new String[]{Long.toString(ident.id)},
479                     null);
480 
481             if (!cursor.moveToFirst()) {
482                 throw new FileNotFoundException("Can't find document id: " + docId);
483             }
484 
485             final Bundle metadata = extractMetadataFromCursor(cursor, columnMap);
486             result = new Bundle();
487             result.putBundle(tagType, metadata);
488             result.putStringArray(
489                     DocumentsContract.METADATA_TYPES,
490                     new String[]{tagType});
491         } finally {
492             IoUtils.closeQuietly(cursor);
493             Binder.restoreCallingIdentity(token);
494         }
495         return result;
496     }
497 
extractMetadataFromCursor(Cursor cursor, Map<String, String> columns)498     private static Bundle extractMetadataFromCursor(Cursor cursor, Map<String, String> columns) {
499 
500         assert (cursor.getCount() == 1);
501 
502         final Bundle metadata = new Bundle();
503         for (String col : columns.keySet()) {
504 
505             int index = cursor.getColumnIndex(col);
506             String bundleTag = columns.get(col);
507 
508             // Special case to be able to pull longs out of a cursor, as long is not a supported
509             // field of getType.
510             if (ExifInterface.TAG_DATETIME.equals(bundleTag)) {
511                 // formate string to be consistent with how EXIF interface formats the date.
512                 long date = cursor.getLong(index);
513                 String format = DateFormat.getBestDateTimePattern(Locale.getDefault(),
514                     "MMM dd, yyyy, hh:mm");
515                 metadata.putString(bundleTag, DateFormat.format(format, date).toString());
516                 continue;
517             }
518 
519             switch (cursor.getType(index)) {
520                 case Cursor.FIELD_TYPE_INTEGER:
521                     metadata.putInt(bundleTag, cursor.getInt(index));
522                     break;
523                 case Cursor.FIELD_TYPE_FLOAT:
524                     //Errors on the side of greater precision since interface doesnt support doubles
525                     metadata.putFloat(bundleTag, cursor.getFloat(index));
526                     break;
527                 case Cursor.FIELD_TYPE_STRING:
528                     metadata.putString(bundleTag, cursor.getString(index));
529                     break;
530                 case Cursor.FIELD_TYPE_BLOB:
531                     Log.d(TAG, "Unsupported type, blob, for col: " + bundleTag);
532                     break;
533                 case Cursor.FIELD_TYPE_NULL:
534                     Log.d(TAG, "Unsupported type, null, for col: " + bundleTag);
535                     break;
536                 default:
537                     throw new RuntimeException("Data type not supported");
538             }
539         }
540 
541         return metadata;
542     }
543 
544     @Override
queryRoots(String[] projection)545     public Cursor queryRoots(String[] projection) throws FileNotFoundException {
546         final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection));
547         includeImagesRoot(result);
548         includeVideosRoot(result);
549         includeAudioRoot(result);
550         return result;
551     }
552 
553     @Override
queryDocument(String docId, String[] projection)554     public Cursor queryDocument(String docId, String[] projection) throws FileNotFoundException {
555         final ContentResolver resolver = getContext().getContentResolver();
556         final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection));
557         final Ident ident = getIdentForDocId(docId);
558         final String[] queryArgs = new String[] { Long.toString(ident.id) } ;
559 
560         final long token = Binder.clearCallingIdentity();
561         Cursor cursor = null;
562         try {
563             if (TYPE_IMAGES_ROOT.equals(ident.type)) {
564                 // single root
565                 includeImagesRootDocument(result);
566             } else if (TYPE_IMAGES_BUCKET.equals(ident.type)) {
567                 // single bucket
568                 cursor = resolver.query(Images.Media.EXTERNAL_CONTENT_URI,
569                         ImagesBucketQuery.PROJECTION, ImageColumns.BUCKET_ID + "=?",
570                         queryArgs, ImagesBucketQuery.SORT_ORDER);
571                 copyNotificationUri(result, cursor);
572                 if (cursor.moveToFirst()) {
573                     includeImagesBucket(result, cursor);
574                 }
575             } else if (TYPE_IMAGE.equals(ident.type)) {
576                 // single image
577                 cursor = resolver.query(Images.Media.EXTERNAL_CONTENT_URI,
578                         ImageQuery.PROJECTION, BaseColumns._ID + "=?", queryArgs,
579                         null);
580                 copyNotificationUri(result, cursor);
581                 if (cursor.moveToFirst()) {
582                     includeImage(result, cursor);
583                 }
584             } else if (TYPE_VIDEOS_ROOT.equals(ident.type)) {
585                 // single root
586                 includeVideosRootDocument(result);
587             } else if (TYPE_VIDEOS_BUCKET.equals(ident.type)) {
588                 // single bucket
589                 cursor = resolver.query(Video.Media.EXTERNAL_CONTENT_URI,
590                         VideosBucketQuery.PROJECTION, VideoColumns.BUCKET_ID + "=?",
591                         queryArgs, VideosBucketQuery.SORT_ORDER);
592                 copyNotificationUri(result, cursor);
593                 if (cursor.moveToFirst()) {
594                     includeVideosBucket(result, cursor);
595                 }
596             } else if (TYPE_VIDEO.equals(ident.type)) {
597                 // single video
598                 cursor = resolver.query(Video.Media.EXTERNAL_CONTENT_URI,
599                         VideoQuery.PROJECTION, BaseColumns._ID + "=?", queryArgs,
600                         null);
601                 copyNotificationUri(result, cursor);
602                 if (cursor.moveToFirst()) {
603                     includeVideo(result, cursor);
604                 }
605             } else if (TYPE_AUDIO_ROOT.equals(ident.type)) {
606                 // single root
607                 includeAudioRootDocument(result);
608             } else if (TYPE_ARTIST.equals(ident.type)) {
609                 // single artist
610                 cursor = resolver.query(Artists.EXTERNAL_CONTENT_URI,
611                         ArtistQuery.PROJECTION, BaseColumns._ID + "=?", queryArgs,
612                         null);
613                 copyNotificationUri(result, cursor);
614                 if (cursor.moveToFirst()) {
615                     includeArtist(result, cursor);
616                 }
617             } else if (TYPE_ALBUM.equals(ident.type)) {
618                 // single album
619                 cursor = resolver.query(Albums.EXTERNAL_CONTENT_URI,
620                         AlbumQuery.PROJECTION, BaseColumns._ID + "=?", queryArgs,
621                         null);
622                 copyNotificationUri(result, cursor);
623                 if (cursor.moveToFirst()) {
624                     includeAlbum(result, cursor);
625                 }
626             } else if (TYPE_AUDIO.equals(ident.type)) {
627                 // single song
628                 cursor = resolver.query(Audio.Media.EXTERNAL_CONTENT_URI,
629                         SongQuery.PROJECTION, BaseColumns._ID + "=?", queryArgs,
630                         null);
631                 copyNotificationUri(result, cursor);
632                 if (cursor.moveToFirst()) {
633                     includeAudio(result, cursor);
634                 }
635             } else {
636                 throw new UnsupportedOperationException("Unsupported document " + docId);
637             }
638         } finally {
639             IoUtils.closeQuietly(cursor);
640             Binder.restoreCallingIdentity(token);
641         }
642         return result;
643     }
644 
645     @Override
queryChildDocuments(String docId, String[] projection, String sortOrder)646     public Cursor queryChildDocuments(String docId, String[] projection, String sortOrder)
647             throws FileNotFoundException {
648         final ContentResolver resolver = getContext().getContentResolver();
649         final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection));
650         final Ident ident = getIdentForDocId(docId);
651         final String[] queryArgs = new String[] { Long.toString(ident.id) } ;
652 
653         final long token = Binder.clearCallingIdentity();
654         Cursor cursor = null;
655         try {
656             if (TYPE_IMAGES_ROOT.equals(ident.type)) {
657                 // include all unique buckets
658                 cursor = resolver.query(Images.Media.EXTERNAL_CONTENT_URI,
659                         ImagesBucketQuery.PROJECTION, null, null, ImagesBucketQuery.SORT_ORDER);
660                 // multiple orders
661                 copyNotificationUri(result, cursor);
662                 long lastId = Long.MIN_VALUE;
663                 while (cursor.moveToNext()) {
664                     final long id = cursor.getLong(ImagesBucketQuery.BUCKET_ID);
665                     if (lastId != id) {
666                         includeImagesBucket(result, cursor);
667                         lastId = id;
668                     }
669                 }
670             } else if (TYPE_IMAGES_BUCKET.equals(ident.type)) {
671                 // include images under bucket
672                 cursor = resolver.query(Images.Media.EXTERNAL_CONTENT_URI,
673                         ImageQuery.PROJECTION, ImageColumns.BUCKET_ID + "=?",
674                         queryArgs, null);
675                 copyNotificationUri(result, cursor);
676                 while (cursor.moveToNext()) {
677                     includeImage(result, cursor);
678                 }
679             } else if (TYPE_VIDEOS_ROOT.equals(ident.type)) {
680                 // include all unique buckets
681                 cursor = resolver.query(Video.Media.EXTERNAL_CONTENT_URI,
682                         VideosBucketQuery.PROJECTION, null, null, VideosBucketQuery.SORT_ORDER);
683                 copyNotificationUri(result, cursor);
684                 long lastId = Long.MIN_VALUE;
685                 while (cursor.moveToNext()) {
686                     final long id = cursor.getLong(VideosBucketQuery.BUCKET_ID);
687                     if (lastId != id) {
688                         includeVideosBucket(result, cursor);
689                         lastId = id;
690                     }
691                 }
692             } else if (TYPE_VIDEOS_BUCKET.equals(ident.type)) {
693                 // include videos under bucket
694                 cursor = resolver.query(Video.Media.EXTERNAL_CONTENT_URI,
695                         VideoQuery.PROJECTION, VideoColumns.BUCKET_ID + "=?",
696                         queryArgs, null);
697                 copyNotificationUri(result, cursor);
698                 while (cursor.moveToNext()) {
699                     includeVideo(result, cursor);
700                 }
701             } else if (TYPE_AUDIO_ROOT.equals(ident.type)) {
702                 // include all artists
703                 cursor = resolver.query(Audio.Artists.EXTERNAL_CONTENT_URI,
704                         ArtistQuery.PROJECTION, null, null, null);
705                 copyNotificationUri(result, cursor);
706                 while (cursor.moveToNext()) {
707                     includeArtist(result, cursor);
708                 }
709             } else if (TYPE_ARTIST.equals(ident.type)) {
710                 // include all albums under artist
711                 cursor = resolver.query(Artists.Albums.getContentUri("external", ident.id),
712                         AlbumQuery.PROJECTION, null, null, null);
713                 copyNotificationUri(result, cursor);
714                 while (cursor.moveToNext()) {
715                     includeAlbum(result, cursor);
716                 }
717             } else if (TYPE_ALBUM.equals(ident.type)) {
718                 // include all songs under album
719                 cursor = resolver.query(Audio.Media.EXTERNAL_CONTENT_URI,
720                         SongQuery.PROJECTION, AudioColumns.ALBUM_ID + "=?",
721                         queryArgs, null);
722                 copyNotificationUri(result, cursor);
723                 while (cursor.moveToNext()) {
724                     includeAudio(result, cursor);
725                 }
726             } else {
727                 throw new UnsupportedOperationException("Unsupported document " + docId);
728             }
729         } finally {
730             IoUtils.closeQuietly(cursor);
731             Binder.restoreCallingIdentity(token);
732         }
733         return result;
734     }
735 
736     @Override
queryRecentDocuments( String rootId, String[] projection, @Nullable Bundle queryArgs, @Nullable CancellationSignal signal)737     public Cursor queryRecentDocuments(
738             String rootId, String[] projection, @Nullable Bundle queryArgs,
739       @Nullable CancellationSignal signal)
740             throws FileNotFoundException {
741         final ContentResolver resolver = getContext().getContentResolver();
742         final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection));
743 
744         final long token = Binder.clearCallingIdentity();
745 
746         int limit = -1;
747         if (queryArgs != null) {
748             limit = queryArgs.getInt(ContentResolver.QUERY_ARG_LIMIT, -1);
749         }
750         if (limit < 0) {
751             // Use default value, and no QUERY_ARG* is honored.
752             limit = 64;
753         } else {
754             // We are honoring the QUERY_ARG_LIMIT.
755             Bundle extras = new Bundle();
756             result.setExtras(extras);
757             extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, new String[]{
758                 ContentResolver.QUERY_ARG_LIMIT
759             });
760         }
761 
762         Cursor cursor = null;
763         try {
764             if (TYPE_IMAGES_ROOT.equals(rootId)) {
765                 // include all unique buckets
766                 cursor = resolver.query(Images.Media.EXTERNAL_CONTENT_URI,
767                         ImageQuery.PROJECTION, null, null, ImageColumns.DATE_MODIFIED + " DESC");
768                 copyNotificationUri(result, cursor);
769                 while (cursor.moveToNext() && result.getCount() < limit) {
770                     includeImage(result, cursor);
771                 }
772             } else if (TYPE_VIDEOS_ROOT.equals(rootId)) {
773                 // include all unique buckets
774                 cursor = resolver.query(Video.Media.EXTERNAL_CONTENT_URI,
775                         VideoQuery.PROJECTION, null, null, VideoColumns.DATE_MODIFIED + " DESC");
776                 copyNotificationUri(result, cursor);
777                 while (cursor.moveToNext() && result.getCount() < limit) {
778                     includeVideo(result, cursor);
779                 }
780             } else {
781                 throw new UnsupportedOperationException("Unsupported root " + rootId);
782             }
783         } finally {
784             IoUtils.closeQuietly(cursor);
785             Binder.restoreCallingIdentity(token);
786         }
787         return result;
788     }
789 
790     @Override
querySearchDocuments(String rootId, String[] projection, Bundle queryArgs)791     public Cursor querySearchDocuments(String rootId, String[] projection, Bundle queryArgs)
792             throws FileNotFoundException {
793         final ContentResolver resolver = getContext().getContentResolver();
794         final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection));
795 
796         final long token = Binder.clearCallingIdentity();
797 
798         final String displayName = queryArgs.getString(DocumentsContract.QUERY_ARG_DISPLAY_NAME,
799                 "" /* defaultValue */);
800         final long lastModifiedAfter = queryArgs.getLong(
801                 DocumentsContract.QUERY_ARG_LAST_MODIFIED_AFTER, -1 /* defaultValue */);
802         final long fileSizeOver = queryArgs.getLong(DocumentsContract.QUERY_ARG_FILE_SIZE_OVER,
803                 -1 /* defaultValue */);
804         final String[] mimeTypes = queryArgs.getStringArray(DocumentsContract.QUERY_ARG_MIME_TYPES);
805         final ArrayList<String> matchedMimeTypes = new ArrayList<>();
806 
807         Cursor cursor = null;
808         try {
809             if (TYPE_IMAGES_ROOT.equals(rootId)) {
810                 final boolean shouldFilterMimeType = shouldFilterMimeType(mimeTypes, "image/*",
811                         matchedMimeTypes);
812 
813                 // If the queried mime types didn't match the root, we don't need to
814                 // query the provider. Ex: the queried mime type is "video/*", but the root
815                 // is images root.
816                 if (mimeTypes == null || !shouldFilterMimeType || matchedMimeTypes.size() > 0) {
817                     final Pair<String, String[]> selectionPair = buildSearchSelection(displayName,
818                             matchedMimeTypes.toArray(new String[0]), lastModifiedAfter,
819                             fileSizeOver, ImageColumns.DISPLAY_NAME, ImageColumns.MIME_TYPE,
820                             ImageColumns.DATE_MODIFIED, ImageColumns.SIZE);
821 
822                     cursor = resolver.query(Images.Media.EXTERNAL_CONTENT_URI,
823                             ImageQuery.PROJECTION,
824                             selectionPair.first, selectionPair.second,
825                             ImageColumns.DATE_MODIFIED + " DESC");
826 
827                     copyNotificationUri(result, cursor);
828                     while (cursor.moveToNext()) {
829                         includeImage(result, cursor);
830                     }
831                 }
832             } else if (TYPE_VIDEOS_ROOT.equals(rootId)) {
833                 final boolean shouldFilterMimeType = shouldFilterMimeType(mimeTypes, "video/*",
834                         matchedMimeTypes);
835 
836                 // If the queried mime types didn't match the root, we don't need to
837                 // query the provider.
838                 if (mimeTypes == null || !shouldFilterMimeType || matchedMimeTypes.size() > 0) {
839                     final Pair<String, String[]> selectionPair = buildSearchSelection(displayName,
840                             matchedMimeTypes.toArray(new String[0]), lastModifiedAfter,
841                             fileSizeOver, VideoColumns.DISPLAY_NAME, VideoColumns.MIME_TYPE,
842                             VideoColumns.DATE_MODIFIED, VideoColumns.SIZE);
843                     cursor = resolver.query(Video.Media.EXTERNAL_CONTENT_URI, VideoQuery.PROJECTION,
844                             selectionPair.first, selectionPair.second,
845                             VideoColumns.DATE_MODIFIED + " DESC");
846                     copyNotificationUri(result, cursor);
847                     while (cursor.moveToNext()) {
848                         includeVideo(result, cursor);
849                     }
850                 }
851             } else if (TYPE_AUDIO_ROOT.equals(rootId)) {
852                 final boolean shouldFilterMimeType = shouldFilterMimeType(mimeTypes, "audio/*",
853                         matchedMimeTypes);
854 
855                 // If the queried mime types didn't match the root, we don't need to
856                 // query the provider.
857                 if (mimeTypes == null || !shouldFilterMimeType || matchedMimeTypes.size() > 0) {
858                     final Pair<String, String[]> selectionPair = buildSearchSelection(displayName,
859                             matchedMimeTypes.toArray(new String[0]), lastModifiedAfter,
860                             fileSizeOver, AudioColumns.TITLE, AudioColumns.MIME_TYPE,
861                             AudioColumns.DATE_MODIFIED, AudioColumns.SIZE);
862 
863                     cursor = resolver.query(Audio.Media.EXTERNAL_CONTENT_URI, SongQuery.PROJECTION,
864                             selectionPair.first, selectionPair.second,
865                             AudioColumns.DATE_MODIFIED + " DESC");
866                     copyNotificationUri(result, cursor);
867                     while (cursor.moveToNext()) {
868                         includeAudio(result, cursor);
869                     }
870                 }
871             } else {
872                 throw new UnsupportedOperationException("Unsupported root " + rootId);
873             }
874         } finally {
875             IoUtils.closeQuietly(cursor);
876             Binder.restoreCallingIdentity(token);
877         }
878 
879         final String[] handledQueryArgs = DocumentsContract.getHandledQueryArguments(queryArgs);
880         if (handledQueryArgs.length > 0) {
881             final Bundle extras = new Bundle();
882             extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, handledQueryArgs);
883             result.setExtras(extras);
884         }
885 
886         return result;
887     }
888 
889     @Override
openDocument(String docId, String mode, CancellationSignal signal)890     public ParcelFileDescriptor openDocument(String docId, String mode, CancellationSignal signal)
891             throws FileNotFoundException {
892         final Uri target = getUriForDocumentId(docId);
893 
894         if (!"r".equals(mode)) {
895             throw new IllegalArgumentException("Media is read-only");
896         }
897 
898         // Delegate to real provider
899         final long token = Binder.clearCallingIdentity();
900         try {
901             return getContext().getContentResolver().openFileDescriptor(target, mode);
902         } finally {
903             Binder.restoreCallingIdentity(token);
904         }
905     }
906 
907     @Override
openDocumentThumbnail( String docId, Point sizeHint, CancellationSignal signal)908     public AssetFileDescriptor openDocumentThumbnail(
909             String docId, Point sizeHint, CancellationSignal signal) throws FileNotFoundException {
910         final Ident ident = getIdentForDocId(docId);
911 
912         final long token = Binder.clearCallingIdentity();
913         try {
914             if (TYPE_IMAGES_BUCKET.equals(ident.type)) {
915                 final long id = getImageForBucketCleared(ident.id);
916                 return openOrCreateImageThumbnailCleared(id, sizeHint, signal);
917             } else if (TYPE_IMAGE.equals(ident.type)) {
918                 return openOrCreateImageThumbnailCleared(ident.id, sizeHint, signal);
919             } else if (TYPE_VIDEOS_BUCKET.equals(ident.type)) {
920                 final long id = getVideoForBucketCleared(ident.id);
921                 return openOrCreateVideoThumbnailCleared(id, sizeHint, signal);
922             } else if (TYPE_VIDEO.equals(ident.type)) {
923                 return openOrCreateVideoThumbnailCleared(ident.id, sizeHint, signal);
924             } else {
925                 throw new UnsupportedOperationException("Unsupported document " + docId);
926             }
927         } finally {
928             Binder.restoreCallingIdentity(token);
929         }
930     }
931 
isEmpty(Uri uri)932     private boolean isEmpty(Uri uri) {
933         final ContentResolver resolver = getContext().getContentResolver();
934         final long token = Binder.clearCallingIdentity();
935         Cursor cursor = null;
936         try {
937             cursor = resolver.query(uri, new String[] {
938                     BaseColumns._ID }, null, null, null);
939             return (cursor == null) || (cursor.getCount() == 0);
940         } finally {
941             IoUtils.closeQuietly(cursor);
942             Binder.restoreCallingIdentity(token);
943         }
944     }
945 
includeImagesRoot(MatrixCursor result)946     private void includeImagesRoot(MatrixCursor result) {
947         int flags = Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_RECENTS | Root.FLAG_SUPPORTS_SEARCH;
948         if (isEmpty(Images.Media.EXTERNAL_CONTENT_URI)) {
949             flags |= Root.FLAG_EMPTY;
950             sReturnedImagesEmpty = true;
951         }
952 
953         final RowBuilder row = result.newRow();
954         row.add(Root.COLUMN_ROOT_ID, TYPE_IMAGES_ROOT);
955         row.add(Root.COLUMN_FLAGS, flags);
956         row.add(Root.COLUMN_TITLE, getContext().getString(R.string.root_images));
957         row.add(Root.COLUMN_DOCUMENT_ID, TYPE_IMAGES_ROOT);
958         row.add(Root.COLUMN_MIME_TYPES, IMAGE_MIME_TYPES);
959         row.add(Root.COLUMN_QUERY_ARGS, SUPPORTED_QUERY_ARGS);
960     }
961 
includeVideosRoot(MatrixCursor result)962     private void includeVideosRoot(MatrixCursor result) {
963         int flags = Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_RECENTS | Root.FLAG_SUPPORTS_SEARCH;
964         if (isEmpty(Video.Media.EXTERNAL_CONTENT_URI)) {
965             flags |= Root.FLAG_EMPTY;
966             sReturnedVideosEmpty = true;
967         }
968 
969         final RowBuilder row = result.newRow();
970         row.add(Root.COLUMN_ROOT_ID, TYPE_VIDEOS_ROOT);
971         row.add(Root.COLUMN_FLAGS, flags);
972         row.add(Root.COLUMN_TITLE, getContext().getString(R.string.root_videos));
973         row.add(Root.COLUMN_DOCUMENT_ID, TYPE_VIDEOS_ROOT);
974         row.add(Root.COLUMN_MIME_TYPES, VIDEO_MIME_TYPES);
975         row.add(Root.COLUMN_QUERY_ARGS, SUPPORTED_QUERY_ARGS);
976     }
977 
includeAudioRoot(MatrixCursor result)978     private void includeAudioRoot(MatrixCursor result) {
979         int flags = Root.FLAG_LOCAL_ONLY | Root.FLAG_SUPPORTS_SEARCH;
980         if (isEmpty(Audio.Media.EXTERNAL_CONTENT_URI)) {
981             flags |= Root.FLAG_EMPTY;
982             sReturnedAudioEmpty = true;
983         }
984 
985         final RowBuilder row = result.newRow();
986         row.add(Root.COLUMN_ROOT_ID, TYPE_AUDIO_ROOT);
987         row.add(Root.COLUMN_FLAGS, flags);
988         row.add(Root.COLUMN_TITLE, getContext().getString(R.string.root_audio));
989         row.add(Root.COLUMN_DOCUMENT_ID, TYPE_AUDIO_ROOT);
990         row.add(Root.COLUMN_MIME_TYPES, AUDIO_MIME_TYPES);
991         row.add(Root.COLUMN_QUERY_ARGS, SUPPORTED_QUERY_ARGS);
992     }
993 
includeImagesRootDocument(MatrixCursor result)994     private void includeImagesRootDocument(MatrixCursor result) {
995         final RowBuilder row = result.newRow();
996         row.add(Document.COLUMN_DOCUMENT_ID, TYPE_IMAGES_ROOT);
997         row.add(Document.COLUMN_DISPLAY_NAME, getContext().getString(R.string.root_images));
998         row.add(Document.COLUMN_FLAGS,
999                 Document.FLAG_DIR_PREFERS_GRID | Document.FLAG_DIR_PREFERS_LAST_MODIFIED);
1000         row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
1001     }
1002 
includeVideosRootDocument(MatrixCursor result)1003     private void includeVideosRootDocument(MatrixCursor result) {
1004         final RowBuilder row = result.newRow();
1005         row.add(Document.COLUMN_DOCUMENT_ID, TYPE_VIDEOS_ROOT);
1006         row.add(Document.COLUMN_DISPLAY_NAME, getContext().getString(R.string.root_videos));
1007         row.add(Document.COLUMN_FLAGS,
1008                 Document.FLAG_DIR_PREFERS_GRID | Document.FLAG_DIR_PREFERS_LAST_MODIFIED);
1009         row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
1010     }
1011 
includeAudioRootDocument(MatrixCursor result)1012     private void includeAudioRootDocument(MatrixCursor result) {
1013         final RowBuilder row = result.newRow();
1014         row.add(Document.COLUMN_DOCUMENT_ID, TYPE_AUDIO_ROOT);
1015         row.add(Document.COLUMN_DISPLAY_NAME, getContext().getString(R.string.root_audio));
1016         row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
1017     }
1018 
1019     private interface ImagesBucketQuery {
1020         final String[] PROJECTION = new String[] {
1021                 ImageColumns.BUCKET_ID,
1022                 ImageColumns.BUCKET_DISPLAY_NAME,
1023                 ImageColumns.DATE_MODIFIED };
1024         final String SORT_ORDER = ImageColumns.BUCKET_ID + ", " + ImageColumns.DATE_MODIFIED
1025                 + " DESC";
1026 
1027         final int BUCKET_ID = 0;
1028         final int BUCKET_DISPLAY_NAME = 1;
1029         final int DATE_MODIFIED = 2;
1030     }
1031 
includeImagesBucket(MatrixCursor result, Cursor cursor)1032     private void includeImagesBucket(MatrixCursor result, Cursor cursor) {
1033         final long id = cursor.getLong(ImagesBucketQuery.BUCKET_ID);
1034         final String docId = getDocIdForIdent(TYPE_IMAGES_BUCKET, id);
1035 
1036         final RowBuilder row = result.newRow();
1037         row.add(Document.COLUMN_DOCUMENT_ID, docId);
1038         row.add(Document.COLUMN_DISPLAY_NAME,
1039             cleanUpMediaBucketName(cursor.getString(ImagesBucketQuery.BUCKET_DISPLAY_NAME)));
1040         row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
1041         row.add(Document.COLUMN_LAST_MODIFIED,
1042                 cursor.getLong(ImagesBucketQuery.DATE_MODIFIED) * DateUtils.SECOND_IN_MILLIS);
1043         row.add(Document.COLUMN_FLAGS, Document.FLAG_DIR_PREFERS_GRID
1044                 | Document.FLAG_SUPPORTS_THUMBNAIL | Document.FLAG_DIR_PREFERS_LAST_MODIFIED);
1045     }
1046 
1047     private interface ImageQuery {
1048         final String[] PROJECTION = new String[] {
1049                 ImageColumns._ID,
1050                 ImageColumns.DISPLAY_NAME,
1051                 ImageColumns.MIME_TYPE,
1052                 ImageColumns.SIZE,
1053                 ImageColumns.DATE_MODIFIED };
1054 
1055         final int _ID = 0;
1056         final int DISPLAY_NAME = 1;
1057         final int MIME_TYPE = 2;
1058         final int SIZE = 3;
1059         final int DATE_MODIFIED = 4;
1060     }
1061 
includeImage(MatrixCursor result, Cursor cursor)1062     private void includeImage(MatrixCursor result, Cursor cursor) {
1063         final long id = cursor.getLong(ImageQuery._ID);
1064         final String docId = getDocIdForIdent(TYPE_IMAGE, id);
1065 
1066         final RowBuilder row = result.newRow();
1067         row.add(Document.COLUMN_DOCUMENT_ID, docId);
1068         row.add(Document.COLUMN_DISPLAY_NAME, cursor.getString(ImageQuery.DISPLAY_NAME));
1069         row.add(Document.COLUMN_SIZE, cursor.getLong(ImageQuery.SIZE));
1070         row.add(Document.COLUMN_MIME_TYPE, cursor.getString(ImageQuery.MIME_TYPE));
1071         row.add(Document.COLUMN_LAST_MODIFIED,
1072                 cursor.getLong(ImageQuery.DATE_MODIFIED) * DateUtils.SECOND_IN_MILLIS);
1073         row.add(Document.COLUMN_FLAGS,
1074                 Document.FLAG_SUPPORTS_THUMBNAIL
1075                     | Document.FLAG_SUPPORTS_DELETE
1076                     | Document.FLAG_SUPPORTS_METADATA);
1077     }
1078 
1079     private interface VideosBucketQuery {
1080         final String[] PROJECTION = new String[] {
1081                 VideoColumns.BUCKET_ID,
1082                 VideoColumns.BUCKET_DISPLAY_NAME,
1083                 VideoColumns.DATE_MODIFIED };
1084         final String SORT_ORDER = VideoColumns.BUCKET_ID + ", " + VideoColumns.DATE_MODIFIED
1085                 + " DESC";
1086 
1087         final int BUCKET_ID = 0;
1088         final int BUCKET_DISPLAY_NAME = 1;
1089         final int DATE_MODIFIED = 2;
1090     }
1091 
includeVideosBucket(MatrixCursor result, Cursor cursor)1092     private void includeVideosBucket(MatrixCursor result, Cursor cursor) {
1093         final long id = cursor.getLong(VideosBucketQuery.BUCKET_ID);
1094         final String docId = getDocIdForIdent(TYPE_VIDEOS_BUCKET, id);
1095 
1096         final RowBuilder row = result.newRow();
1097         row.add(Document.COLUMN_DOCUMENT_ID, docId);
1098         row.add(Document.COLUMN_DISPLAY_NAME,
1099             cleanUpMediaBucketName(cursor.getString(VideosBucketQuery.BUCKET_DISPLAY_NAME)));
1100         row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
1101         row.add(Document.COLUMN_LAST_MODIFIED,
1102                 cursor.getLong(VideosBucketQuery.DATE_MODIFIED) * DateUtils.SECOND_IN_MILLIS);
1103         row.add(Document.COLUMN_FLAGS, Document.FLAG_DIR_PREFERS_GRID
1104                 | Document.FLAG_SUPPORTS_THUMBNAIL | Document.FLAG_DIR_PREFERS_LAST_MODIFIED);
1105     }
1106 
1107     private interface VideoQuery {
1108         final String[] PROJECTION = new String[] {
1109                 VideoColumns._ID,
1110                 VideoColumns.DISPLAY_NAME,
1111                 VideoColumns.MIME_TYPE,
1112                 VideoColumns.SIZE,
1113                 VideoColumns.DATE_MODIFIED };
1114 
1115         final int _ID = 0;
1116         final int DISPLAY_NAME = 1;
1117         final int MIME_TYPE = 2;
1118         final int SIZE = 3;
1119         final int DATE_MODIFIED = 4;
1120     }
1121 
includeVideo(MatrixCursor result, Cursor cursor)1122     private void includeVideo(MatrixCursor result, Cursor cursor) {
1123         final long id = cursor.getLong(VideoQuery._ID);
1124         final String docId = getDocIdForIdent(TYPE_VIDEO, id);
1125 
1126         final RowBuilder row = result.newRow();
1127         row.add(Document.COLUMN_DOCUMENT_ID, docId);
1128         row.add(Document.COLUMN_DISPLAY_NAME, cursor.getString(VideoQuery.DISPLAY_NAME));
1129         row.add(Document.COLUMN_SIZE, cursor.getLong(VideoQuery.SIZE));
1130         row.add(Document.COLUMN_MIME_TYPE, cursor.getString(VideoQuery.MIME_TYPE));
1131         row.add(Document.COLUMN_LAST_MODIFIED,
1132                 cursor.getLong(VideoQuery.DATE_MODIFIED) * DateUtils.SECOND_IN_MILLIS);
1133         row.add(Document.COLUMN_FLAGS,
1134                 Document.FLAG_SUPPORTS_THUMBNAIL
1135                     | Document.FLAG_SUPPORTS_DELETE
1136                     | Document.FLAG_SUPPORTS_METADATA);
1137     }
1138 
1139     private interface ArtistQuery {
1140         final String[] PROJECTION = new String[] {
1141                 BaseColumns._ID,
1142                 ArtistColumns.ARTIST };
1143 
1144         final int _ID = 0;
1145         final int ARTIST = 1;
1146     }
1147 
includeArtist(MatrixCursor result, Cursor cursor)1148     private void includeArtist(MatrixCursor result, Cursor cursor) {
1149         final long id = cursor.getLong(ArtistQuery._ID);
1150         final String docId = getDocIdForIdent(TYPE_ARTIST, id);
1151 
1152         final RowBuilder row = result.newRow();
1153         row.add(Document.COLUMN_DOCUMENT_ID, docId);
1154         row.add(Document.COLUMN_DISPLAY_NAME,
1155                 cleanUpMediaDisplayName(cursor.getString(ArtistQuery.ARTIST)));
1156         row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
1157     }
1158 
1159     private interface AlbumQuery {
1160         final String[] PROJECTION = new String[] {
1161                 AlbumColumns.ALBUM_ID,
1162                 AlbumColumns.ALBUM };
1163 
1164         final int ALBUM_ID = 0;
1165         final int ALBUM = 1;
1166     }
1167 
includeAlbum(MatrixCursor result, Cursor cursor)1168     private void includeAlbum(MatrixCursor result, Cursor cursor) {
1169         final long id = cursor.getLong(AlbumQuery.ALBUM_ID);
1170         final String docId = getDocIdForIdent(TYPE_ALBUM, id);
1171 
1172         final RowBuilder row = result.newRow();
1173         row.add(Document.COLUMN_DOCUMENT_ID, docId);
1174         row.add(Document.COLUMN_DISPLAY_NAME,
1175                 cleanUpMediaDisplayName(cursor.getString(AlbumQuery.ALBUM)));
1176         row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
1177     }
1178 
1179     private interface SongQuery {
1180         final String[] PROJECTION = new String[] {
1181                 AudioColumns._ID,
1182                 AudioColumns.DISPLAY_NAME,
1183                 AudioColumns.MIME_TYPE,
1184                 AudioColumns.SIZE,
1185                 AudioColumns.DATE_MODIFIED };
1186 
1187         final int _ID = 0;
1188         final int DISPLAY_NAME = 1;
1189         final int MIME_TYPE = 2;
1190         final int SIZE = 3;
1191         final int DATE_MODIFIED = 4;
1192     }
1193 
includeAudio(MatrixCursor result, Cursor cursor)1194     private void includeAudio(MatrixCursor result, Cursor cursor) {
1195         final long id = cursor.getLong(SongQuery._ID);
1196         final String docId = getDocIdForIdent(TYPE_AUDIO, id);
1197 
1198         final RowBuilder row = result.newRow();
1199         row.add(Document.COLUMN_DOCUMENT_ID, docId);
1200         row.add(Document.COLUMN_DISPLAY_NAME, cursor.getString(SongQuery.DISPLAY_NAME));
1201         row.add(Document.COLUMN_SIZE, cursor.getLong(SongQuery.SIZE));
1202         row.add(Document.COLUMN_MIME_TYPE, cursor.getString(SongQuery.MIME_TYPE));
1203         row.add(Document.COLUMN_LAST_MODIFIED,
1204                 cursor.getLong(SongQuery.DATE_MODIFIED) * DateUtils.SECOND_IN_MILLIS);
1205         row.add(Document.COLUMN_FLAGS, Document.FLAG_SUPPORTS_DELETE
1206                 | Document.FLAG_SUPPORTS_METADATA);
1207     }
1208 
1209     private interface ImagesBucketThumbnailQuery {
1210         final String[] PROJECTION = new String[] {
1211                 ImageColumns._ID,
1212                 ImageColumns.BUCKET_ID,
1213                 ImageColumns.DATE_MODIFIED };
1214 
1215         final int _ID = 0;
1216         final int BUCKET_ID = 1;
1217         final int DATE_MODIFIED = 2;
1218     }
1219 
getImageForBucketCleared(long bucketId)1220     private long getImageForBucketCleared(long bucketId) throws FileNotFoundException {
1221         final ContentResolver resolver = getContext().getContentResolver();
1222         Cursor cursor = null;
1223         try {
1224             cursor = resolver.query(Images.Media.EXTERNAL_CONTENT_URI,
1225                     ImagesBucketThumbnailQuery.PROJECTION, ImageColumns.BUCKET_ID + "=" + bucketId,
1226                     null, ImageColumns.DATE_MODIFIED + " DESC");
1227             if (cursor.moveToFirst()) {
1228                 return cursor.getLong(ImagesBucketThumbnailQuery._ID);
1229             }
1230         } finally {
1231             IoUtils.closeQuietly(cursor);
1232         }
1233         throw new FileNotFoundException("No video found for bucket");
1234     }
1235 
openImageThumbnailCleared(long id, Point size, CancellationSignal signal)1236     private AssetFileDescriptor openImageThumbnailCleared(long id, Point size,
1237             CancellationSignal signal) throws FileNotFoundException {
1238         final ContentResolver resolver = getContext().getContentResolver();
1239 
1240         final Uri uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id);
1241         final Bundle opts = new Bundle();
1242         opts.putParcelable(EXTRA_SIZE, size);
1243         return resolver.openTypedAssetFile(uri, "image/*", opts, signal);
1244     }
1245 
openOrCreateImageThumbnailCleared(long id, Point size, CancellationSignal signal)1246     private AssetFileDescriptor openOrCreateImageThumbnailCleared(long id, Point size,
1247             CancellationSignal signal) throws FileNotFoundException {
1248         final ContentResolver resolver = getContext().getContentResolver();
1249 
1250         AssetFileDescriptor afd = openImageThumbnailCleared(id, size, signal);
1251 
1252         if (afd == null) {
1253             // No thumbnail yet, so generate. This is messy, since we drop the
1254             // Bitmap on the floor, but its the least-complicated way.
1255             final BitmapFactory.Options opts = new BitmapFactory.Options();
1256             opts.inJustDecodeBounds = true;
1257             Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, opts);
1258 
1259             afd = openImageThumbnailCleared(id, size, signal);
1260         }
1261 
1262         if (afd != null) {
1263             return afd;
1264         }
1265 
1266         // Phoey, fallback to full image
1267         final Uri fullUri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id);
1268         final ParcelFileDescriptor pfd = resolver.openFileDescriptor(fullUri, "r", signal);
1269 
1270         final int orientation = queryOrientationForImage(id, signal);
1271         final Bundle extras;
1272         if (orientation != 0) {
1273             extras = new Bundle(1);
1274             extras.putInt(DocumentsContract.EXTRA_ORIENTATION, orientation);
1275         } else {
1276             extras = null;
1277         }
1278 
1279         return new AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH, extras);
1280     }
1281 
1282     private interface VideosBucketThumbnailQuery {
1283         final String[] PROJECTION = new String[] {
1284                 VideoColumns._ID,
1285                 VideoColumns.BUCKET_ID,
1286                 VideoColumns.DATE_MODIFIED };
1287 
1288         final int _ID = 0;
1289         final int BUCKET_ID = 1;
1290         final int DATE_MODIFIED = 2;
1291     }
1292 
getVideoForBucketCleared(long bucketId)1293     private long getVideoForBucketCleared(long bucketId)
1294             throws FileNotFoundException {
1295         final ContentResolver resolver = getContext().getContentResolver();
1296         Cursor cursor = null;
1297         try {
1298             cursor = resolver.query(Video.Media.EXTERNAL_CONTENT_URI,
1299                     VideosBucketThumbnailQuery.PROJECTION, VideoColumns.BUCKET_ID + "=" + bucketId,
1300                     null, VideoColumns.DATE_MODIFIED + " DESC");
1301             if (cursor.moveToFirst()) {
1302                 return cursor.getLong(VideosBucketThumbnailQuery._ID);
1303             }
1304         } finally {
1305             IoUtils.closeQuietly(cursor);
1306         }
1307         throw new FileNotFoundException("No video found for bucket");
1308     }
1309 
openVideoThumbnailCleared(long id, Point size, CancellationSignal signal)1310     private AssetFileDescriptor openVideoThumbnailCleared(long id, Point size,
1311             CancellationSignal signal) throws FileNotFoundException {
1312         final ContentResolver resolver = getContext().getContentResolver();
1313 
1314         final Uri uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id);
1315         final Bundle opts = new Bundle();
1316         opts.putParcelable(EXTRA_SIZE, size);
1317         return resolver.openTypedAssetFile(uri, "image/*", opts, signal);
1318     }
1319 
openOrCreateVideoThumbnailCleared(long id, Point size, CancellationSignal signal)1320     private AssetFileDescriptor openOrCreateVideoThumbnailCleared(long id, Point size,
1321             CancellationSignal signal) throws FileNotFoundException {
1322         final ContentResolver resolver = getContext().getContentResolver();
1323 
1324         AssetFileDescriptor afd = openVideoThumbnailCleared(id, size, signal);
1325         if (afd == null) {
1326             // No thumbnail yet, so generate. This is messy, since we drop the
1327             // Bitmap on the floor, but its the least-complicated way.
1328             final BitmapFactory.Options opts = new BitmapFactory.Options();
1329             opts.inJustDecodeBounds = true;
1330             Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, opts);
1331 
1332             afd = openVideoThumbnailCleared(id, size, signal);
1333         }
1334         return afd;
1335     }
1336 
1337     private interface ImageOrientationQuery {
1338         final String[] PROJECTION = new String[] {
1339                 ImageColumns.ORIENTATION };
1340 
1341         final int ORIENTATION = 0;
1342     }
1343 
queryOrientationForImage(long id, CancellationSignal signal)1344     private int queryOrientationForImage(long id, CancellationSignal signal) {
1345         final ContentResolver resolver = getContext().getContentResolver();
1346 
1347         Cursor cursor = null;
1348         try {
1349             cursor = resolver.query(Images.Media.EXTERNAL_CONTENT_URI,
1350                     ImageOrientationQuery.PROJECTION, ImageColumns._ID + "=" + id, null, null,
1351                     signal);
1352             if (cursor.moveToFirst()) {
1353                 return cursor.getInt(ImageOrientationQuery.ORIENTATION);
1354             } else {
1355                 Log.w(TAG, "Missing orientation data for " + id);
1356                 return 0;
1357             }
1358         } finally {
1359             IoUtils.closeQuietly(cursor);
1360         }
1361     }
1362 
cleanUpMediaDisplayName(String displayName)1363     private String cleanUpMediaDisplayName(String displayName) {
1364         if (!MediaStore.UNKNOWN_STRING.equals(displayName)) {
1365             return displayName;
1366         }
1367         return getContext().getResources().getString(com.android.internal.R.string.unknownName);
1368     }
1369 
cleanUpMediaBucketName(String bucketDisplayName)1370     private String cleanUpMediaBucketName(String bucketDisplayName) {
1371         if (!TextUtils.isEmpty(bucketDisplayName)) {
1372             return bucketDisplayName;
1373         }
1374         return getContext().getResources().getString(com.android.internal.R.string.unknownName);
1375     }
1376 }
1377