• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2019 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.scan;
18 
19 import static android.media.MediaMetadataRetriever.METADATA_KEY_ALBUM;
20 import static android.media.MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST;
21 import static android.media.MediaMetadataRetriever.METADATA_KEY_ARTIST;
22 import static android.media.MediaMetadataRetriever.METADATA_KEY_AUTHOR;
23 import static android.media.MediaMetadataRetriever.METADATA_KEY_BITRATE;
24 import static android.media.MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE;
25 import static android.media.MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER;
26 import static android.media.MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE;
27 import static android.media.MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD;
28 import static android.media.MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER;
29 import static android.media.MediaMetadataRetriever.METADATA_KEY_COMPILATION;
30 import static android.media.MediaMetadataRetriever.METADATA_KEY_COMPOSER;
31 import static android.media.MediaMetadataRetriever.METADATA_KEY_DATE;
32 import static android.media.MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER;
33 import static android.media.MediaMetadataRetriever.METADATA_KEY_DURATION;
34 import static android.media.MediaMetadataRetriever.METADATA_KEY_GENRE;
35 import static android.media.MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT;
36 import static android.media.MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH;
37 import static android.media.MediaMetadataRetriever.METADATA_KEY_MIMETYPE;
38 import static android.media.MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS;
39 import static android.media.MediaMetadataRetriever.METADATA_KEY_TITLE;
40 import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_CODEC_MIME_TYPE;
41 import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT;
42 import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION;
43 import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH;
44 import static android.media.MediaMetadataRetriever.METADATA_KEY_WRITER;
45 import static android.media.MediaMetadataRetriever.METADATA_KEY_YEAR;
46 import static android.provider.MediaStore.AUTHORITY;
47 import static android.provider.MediaStore.UNKNOWN_STRING;
48 import static android.text.format.DateUtils.HOUR_IN_MILLIS;
49 import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
50 
51 import static com.android.providers.media.util.Metrics.translateReason;
52 
53 import static java.util.Objects.requireNonNull;
54 
55 import android.content.ContentProviderClient;
56 import android.content.ContentProviderOperation;
57 import android.content.ContentProviderResult;
58 import android.content.ContentResolver;
59 import android.content.ContentUris;
60 import android.content.Context;
61 import android.content.OperationApplicationException;
62 import android.database.Cursor;
63 import android.database.sqlite.SQLiteBlobTooBigException;
64 import android.database.sqlite.SQLiteDatabase;
65 import android.drm.DrmManagerClient;
66 import android.drm.DrmSupportInfo;
67 import android.graphics.BitmapFactory;
68 import android.media.ExifInterface;
69 import android.media.MediaMetadataRetriever;
70 import android.mtp.MtpConstants;
71 import android.net.Uri;
72 import android.os.Build;
73 import android.os.Bundle;
74 import android.os.CancellationSignal;
75 import android.os.Environment;
76 import android.os.OperationCanceledException;
77 import android.os.RemoteException;
78 import android.os.SystemClock;
79 import android.os.Trace;
80 import android.provider.MediaStore;
81 import android.provider.MediaStore.Audio.AudioColumns;
82 import android.provider.MediaStore.Audio.PlaylistsColumns;
83 import android.provider.MediaStore.Files.FileColumns;
84 import android.provider.MediaStore.Images.ImageColumns;
85 import android.provider.MediaStore.MediaColumns;
86 import android.provider.MediaStore.Video.VideoColumns;
87 import android.text.TextUtils;
88 import android.util.ArrayMap;
89 import android.util.ArraySet;
90 import android.util.Log;
91 import android.util.Pair;
92 
93 import androidx.annotation.GuardedBy;
94 import androidx.annotation.NonNull;
95 import androidx.annotation.Nullable;
96 import androidx.annotation.VisibleForTesting;
97 
98 import com.android.providers.media.MediaVolume;
99 import com.android.providers.media.util.DatabaseUtils;
100 import com.android.providers.media.util.ExifUtils;
101 import com.android.providers.media.util.FileUtils;
102 import com.android.providers.media.util.IsoInterface;
103 import com.android.providers.media.util.LongArray;
104 import com.android.providers.media.util.Metrics;
105 import com.android.providers.media.util.MimeUtils;
106 import com.android.providers.media.util.SpecialFormatDetector;
107 import com.android.providers.media.util.XmpInterface;
108 
109 import java.io.File;
110 import java.io.FileInputStream;
111 import java.io.FileNotFoundException;
112 import java.io.IOException;
113 import java.nio.file.FileVisitResult;
114 import java.nio.file.FileVisitor;
115 import java.nio.file.Files;
116 import java.nio.file.Path;
117 import java.nio.file.attribute.BasicFileAttributes;
118 import java.text.ParseException;
119 import java.text.SimpleDateFormat;
120 import java.util.ArrayList;
121 import java.util.Arrays;
122 import java.util.Iterator;
123 import java.util.List;
124 import java.util.Map;
125 import java.util.Objects;
126 import java.util.Optional;
127 import java.util.Set;
128 import java.util.TimeZone;
129 import java.util.concurrent.locks.Lock;
130 import java.util.concurrent.locks.ReentrantLock;
131 import java.util.regex.Matcher;
132 import java.util.regex.Pattern;
133 
134 /**
135  * Modern implementation of media scanner.
136  * <p>
137  * This is a bug-compatible reimplementation of the legacy media scanner, but
138  * written purely in managed code for better testability and long-term
139  * maintainability.
140  * <p>
141  * Initial tests shows it performing roughly on-par with the legacy scanner.
142  * <p>
143  * In general, we start by populating metadata based on file attributes, and
144  * then overwrite with any valid metadata found using
145  * {@link MediaMetadataRetriever}, {@link ExifInterface}, and
146  * {@link XmpInterface}, each with increasing levels of trust.
147  */
148 public class ModernMediaScanner implements MediaScanner {
149     private static final String TAG = "ModernMediaScanner";
150     private static final boolean LOGW = Log.isLoggable(TAG, Log.WARN);
151     private static final boolean LOGD = Log.isLoggable(TAG, Log.DEBUG);
152     private static final boolean LOGV = Log.isLoggable(TAG, Log.VERBOSE);
153 
154     // TODO: refactor to use UPSERT once we have SQLite 3.24.0
155 
156     // TODO: deprecate playlist editing
157     // TODO: deprecate PARENT column, since callers can't see directories
158 
159     @GuardedBy("S_DATE_FORMAT")
160     private static final SimpleDateFormat S_DATE_FORMAT;
161     @GuardedBy("S_DATE_FORMAT_WITH_MILLIS")
162     private static final SimpleDateFormat S_DATE_FORMAT_WITH_MILLIS;
163 
164     static {
165         S_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd'T'HHmmss");
166         S_DATE_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC"));
167 
168         S_DATE_FORMAT_WITH_MILLIS = new SimpleDateFormat("yyyyMMdd'T'HHmmss.SSS");
169         S_DATE_FORMAT_WITH_MILLIS.setTimeZone(TimeZone.getTimeZone("UTC"));
170     }
171 
172     private static final int BATCH_SIZE = 32;
173     private static final int MAX_XMP_SIZE_BYTES = 1024 * 1024;
174     // |excludeDirs * 2| < 1000 which is the max SQL expression size
175     // Because we add |excludeDir| and |excludeDir/| in the SQL expression to match dir and subdirs
176     // See SQLITE_MAX_EXPR_DEPTH in sqlite3.c
177     @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
178     static final int MAX_EXCLUDE_DIRS = 450;
179 
180     private static final Pattern PATTERN_YEAR = Pattern.compile("([1-9][0-9][0-9][0-9])");
181 
182     private static final Pattern PATTERN_ALBUM_ART = Pattern.compile(
183             "(?i)(?:(?:^folder|(?:^AlbumArt(?:(?:_\\{.*\\}_)?(?:small|large))?))(?:\\.jpg$)|(?:\\._.*))");
184 
185     @NonNull
186     private final Context mContext;
187     private final DrmManagerClient mDrmClient;
188     @GuardedBy("mPendingCleanDirectories")
189     private final Set<String> mPendingCleanDirectories = new ArraySet<>();
190 
191     /**
192      * List of active scans.
193      */
194     @GuardedBy("mActiveScans")
195 
196     private final List<Scan> mActiveScans = new ArrayList<>();
197 
198     /**
199      * Holder that contains a reference count of the number of threads
200      * interested in a specific directory, along with a lock to ensure that
201      * parallel scans don't overlap and confuse each other.
202      */
203     private static class DirectoryLock {
204         public int count;
205         public final Lock lock = new ReentrantLock();
206     }
207 
208     /**
209      * Map from directory to locks designed to ensure that parallel scans don't
210      * overlap and confuse each other.
211      */
212     @GuardedBy("mDirectoryLocks")
213     private final Map<Path, DirectoryLock> mDirectoryLocks = new ArrayMap<>();
214 
215     /**
216      * Set of MIME types that should be considered to be DRM, meaning we need to
217      * consult {@link DrmManagerClient} to obtain the actual MIME type.
218      */
219     private final Set<String> mDrmMimeTypes = new ArraySet<>();
220 
ModernMediaScanner(@onNull Context context)221     public ModernMediaScanner(@NonNull Context context) {
222         mContext = requireNonNull(context);
223         mDrmClient = new DrmManagerClient(context);
224 
225         // Dynamically collect the set of MIME types that should be considered
226         // to be DRM, as this can vary between devices
227         for (DrmSupportInfo info : mDrmClient.getAvailableDrmSupportInfo()) {
228             Iterator<String> mimeTypes = info.getMimeTypeIterator();
229             while (mimeTypes.hasNext()) {
230                 mDrmMimeTypes.add(mimeTypes.next());
231             }
232         }
233     }
234 
235     @Override
236     @NonNull
getContext()237     public Context getContext() {
238         return mContext;
239     }
240 
241     @Override
scanDirectory(@onNull File file, @ScanReason int reason)242     public void scanDirectory(@NonNull File file, @ScanReason int reason) {
243         requireNonNull(file);
244         try {
245             file = file.getCanonicalFile();
246         } catch (IOException e) {
247             Log.e(TAG, "Couldn't canonicalize directory to scan" + file, e);
248             return;
249         }
250 
251         try (Scan scan = new Scan(file, reason)) {
252             scan.run();
253         } catch (FileNotFoundException e) {
254             Log.e(TAG, "Couldn't find directory to scan", e);
255         } catch (OperationCanceledException ignored) {
256             // No-op.
257         }
258     }
259 
260     @Override
261     @Nullable
scanFile(@onNull File file, @ScanReason int reason)262     public Uri scanFile(@NonNull File file, @ScanReason int reason) {
263         requireNonNull(file);
264         try {
265             file = file.getCanonicalFile();
266         } catch (IOException e) {
267             Log.e(TAG, "Couldn't canonicalize file to scan" + file, e);
268             return null;
269         }
270 
271         try (Scan scan = new Scan(file, reason)) {
272             scan.run();
273             return scan.getFirstResult();
274         } catch (FileNotFoundException e) {
275             Log.e(TAG, "Couldn't find file to scan", e) ;
276             return null;
277         } catch (OperationCanceledException ignored) {
278             // No-op.
279             return null;
280         }
281     }
282 
283     @Override
onDetachVolume(@onNull MediaVolume volume)284     public void onDetachVolume(@NonNull MediaVolume volume) {
285         synchronized (mActiveScans) {
286             for (Scan scan : mActiveScans) {
287                 if (volume.equals(scan.mVolume)) {
288                     scan.mSignal.cancel();
289                 }
290             }
291         }
292     }
293 
294     @Override
onIdleScanStopped()295     public void onIdleScanStopped() {
296         synchronized (mActiveScans) {
297             for (Scan scan : mActiveScans) {
298                 if (scan.mReason == REASON_IDLE) {
299                     scan.mSignal.cancel();
300                 }
301             }
302         }
303     }
304 
305     @Override
onDirectoryDirty(@onNull File dir)306     public void onDirectoryDirty(@NonNull File dir) {
307         requireNonNull(dir);
308         try {
309             dir = dir.getCanonicalFile();
310         } catch (IOException e) {
311             Log.e(TAG, "Couldn't canonicalize directory" + dir, e);
312             return;
313         }
314 
315         synchronized (mPendingCleanDirectories) {
316             mPendingCleanDirectories.remove(dir.getPath());
317             FileUtils.setDirectoryDirty(dir, /* isDirty */ true);
318         }
319     }
320 
addActiveScan(Scan scan)321     private void addActiveScan(Scan scan) {
322         synchronized (mActiveScans) {
323             mActiveScans.add(scan);
324         }
325     }
326 
removeActiveScan(Scan scan)327     private void removeActiveScan(Scan scan) {
328         synchronized (mActiveScans) {
329             mActiveScans.remove(scan);
330         }
331     }
332 
333     /**
334      * Individual scan request for a specific file or directory. When run it
335      * will traverse all included media files under the requested location,
336      * reconciling them against {@link MediaStore}.
337      */
338     private class Scan implements Runnable, FileVisitor<Path>, AutoCloseable {
339         private final ContentProviderClient mClient;
340         private final ContentResolver mResolver;
341 
342         private final File mRoot;
343         private final int mReason;
344         private final MediaVolume mVolume;
345         private final String mVolumeName;
346         private final Uri mFilesUri;
347         private final CancellationSignal mSignal;
348         private final List<String> mExcludeDirs;
349 
350         private final long mStartGeneration;
351         private final boolean mSingleFile;
352         private final Set<Path> mAcquiredDirectoryLocks = new ArraySet<>();
353         private final ArrayList<ContentProviderOperation> mPending = new ArrayList<>();
354         private final LongArray mScannedIds = new LongArray();
355         private final LongArray mUnknownIds = new LongArray();
356 
357         private long mFirstId = -1;
358 
359         private int mFileCount;
360         private int mInsertCount;
361         private int mUpdateCount;
362         private int mDeleteCount;
363 
364         /**
365          * Tracks hidden directory and hidden subdirectories in a directory tree. A positive count
366          * indicates that one or more of the current file's parents is a hidden directory.
367          */
368         private int mHiddenDirCount;
369         /**
370          * Indicates if the nomedia directory tree is dirty. When a nomedia directory is dirty, we
371          * mark the top level nomedia as dirty. Hence if one of the sub directory in the nomedia
372          * directory is dirty, we consider the whole top level nomedia directory tree as dirty.
373          */
374         private boolean mIsDirectoryTreeDirty;
375 
Scan(File root, int reason)376         Scan(File root, int reason) throws FileNotFoundException {
377             Trace.beginSection("Scanner.ctor");
378 
379             mClient = mContext.getContentResolver()
380                     .acquireContentProviderClient(MediaStore.AUTHORITY);
381             mResolver = ContentResolver.wrap(mClient.getLocalContentProvider());
382 
383             mRoot = root;
384             mReason = reason;
385 
386             if (FileUtils.contains(Environment.getStorageDirectory(), root)) {
387                 mVolume = MediaVolume.fromStorageVolume(FileUtils.getStorageVolume(mContext, root));
388             } else {
389                 mVolume = MediaVolume.fromInternal();
390             }
391             mVolumeName = mVolume.getName();
392             mFilesUri = MediaStore.Files.getContentUri(mVolumeName);
393             mSignal = new CancellationSignal();
394 
395             mStartGeneration = MediaStore.getGeneration(mResolver, mVolumeName);
396             mSingleFile = mRoot.isFile();
397             mExcludeDirs = new ArrayList<>();
398 
399             Trace.endSection();
400         }
401 
402         @Override
run()403         public void run() {
404             addActiveScan(this);
405             try {
406                 runInternal();
407             } finally {
408                 removeActiveScan(this);
409             }
410         }
411 
runInternal()412         private void runInternal() {
413             final long startTime = SystemClock.elapsedRealtime();
414 
415             // First, scan everything that should be visible under requested
416             // location, tracking scanned IDs along the way
417             walkFileTree();
418 
419             // Second, reconcile all items known in the database against all the
420             // items we scanned above
421             if (mSingleFile && mScannedIds.size() == 1) {
422                 // We can safely skip this step if the scan targeted a single
423                 // file which we scanned above
424             } else {
425                 reconcileAndClean();
426             }
427 
428             // Third, resolve any playlists that we scanned
429             resolvePlaylists();
430 
431             if (!mSingleFile) {
432                 final long durationMillis = SystemClock.elapsedRealtime() - startTime;
433                 Metrics.logScan(mVolumeName, mReason, mFileCount, durationMillis,
434                         mInsertCount, mUpdateCount, mDeleteCount);
435             }
436         }
437 
walkFileTree()438         private void walkFileTree() {
439             mSignal.throwIfCanceled();
440             final Pair<Boolean, Boolean> isDirScannableAndHidden =
441                     shouldScanPathAndIsPathHidden(mSingleFile ? mRoot.getParentFile() : mRoot);
442             if (isDirScannableAndHidden.first) {
443                 // This directory is scannable.
444                 Trace.beginSection("Scanner.walkFileTree");
445 
446                 if (isDirScannableAndHidden.second) {
447                     // This directory is hidden
448                     mHiddenDirCount++;
449                 }
450                 if (mSingleFile) {
451                     acquireDirectoryLock(mRoot.getParentFile().toPath());
452                 }
453                 try {
454                     Files.walkFileTree(mRoot.toPath(), this);
455                     applyPending();
456                 } catch (IOException e) {
457                     // This should never happen, so yell loudly
458                     throw new IllegalStateException(e);
459                 } finally {
460                     if (mSingleFile) {
461                         releaseDirectoryLock(mRoot.getParentFile().toPath());
462                     }
463                     Trace.endSection();
464                 }
465             }
466         }
467 
buildExcludeDirClause(int count)468         private String buildExcludeDirClause(int count) {
469             if (count == 0) {
470                 return "";
471             }
472             String notLikeClause = FileColumns.DATA + " NOT LIKE ? ESCAPE '\\'";
473             String andClause = " AND ";
474             StringBuilder sb = new StringBuilder();
475             sb.append("(");
476             for (int i = 0; i < count; i++) {
477                 // Append twice because we want to match the path itself and the expanded path
478                 // using the SQL % LIKE operator. For instance, to exclude /sdcard/foo and all
479                 // subdirs, we need the following:
480                 // "NOT LIKE '/sdcard/foo/%' AND "NOT LIKE '/sdcard/foo'"
481                 // The first clause matches *just* subdirs, and the second clause matches the dir
482                 // itself
483                 sb.append(notLikeClause);
484                 sb.append(andClause);
485                 sb.append(notLikeClause);
486                 if (i != count - 1) {
487                     sb.append(andClause);
488                 }
489             }
490             sb.append(")");
491             return sb.toString();
492         }
493 
addEscapedAndExpandedPath(String path, List<String> paths)494         private void addEscapedAndExpandedPath(String path, List<String> paths) {
495             String escapedPath = DatabaseUtils.escapeForLike(path);
496             paths.add(escapedPath + "/%");
497             paths.add(escapedPath);
498         }
499 
buildSqlSelectionArgs()500         private String[] buildSqlSelectionArgs() {
501             List<String> escapedPaths = new ArrayList<>();
502 
503             addEscapedAndExpandedPath(mRoot.getAbsolutePath(), escapedPaths);
504             for (String dir : mExcludeDirs) {
505                 addEscapedAndExpandedPath(dir, escapedPaths);
506             }
507 
508             return escapedPaths.toArray(new String[0]);
509         }
510 
reconcileAndClean()511         private void reconcileAndClean() {
512             final long[] scannedIds = mScannedIds.toArray();
513             Arrays.sort(scannedIds);
514 
515             // The query phase is split from the delete phase so that our query
516             // remains stable if we need to paginate across multiple windows.
517             mSignal.throwIfCanceled();
518             Trace.beginSection("Scanner.reconcile");
519 
520             // Ignore abstract playlists which don't have files on disk
521             final String formatClause = "ifnull(" + FileColumns.FORMAT + ","
522                     + MtpConstants.FORMAT_UNDEFINED + ") != "
523                     + MtpConstants.FORMAT_ABSTRACT_AV_PLAYLIST;
524             final String dataClause = "(" + FileColumns.DATA + " LIKE ? ESCAPE '\\' OR "
525                     + FileColumns.DATA + " LIKE ? ESCAPE '\\')";
526             final String excludeDirClause = buildExcludeDirClause(mExcludeDirs.size());
527             final String generationClause = FileColumns.GENERATION_ADDED + " <= "
528                     + mStartGeneration;
529             final String sqlSelection = formatClause + " AND " + dataClause + " AND "
530                     + generationClause
531                     + (excludeDirClause.isEmpty() ? "" : " AND " + excludeDirClause);
532             final Bundle queryArgs = new Bundle();
533             queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SELECTION, sqlSelection);
534             queryArgs.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS,
535                     buildSqlSelectionArgs());
536             queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER,
537                     FileColumns._ID + " DESC");
538             queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
539             queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
540             queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_FAVORITE, MediaStore.MATCH_INCLUDE);
541 
542             int[] countPerMediaType;
543             try {
544                 countPerMediaType = addUnknownIdsAndGetMediaTypeCount(queryArgs, scannedIds);
545             } catch (SQLiteBlobTooBigException e) {
546                 // Catching SQLiteBlobTooBigException to avoid MP process crash. There can be two
547                 // scenarios where SQLiteBlobTooBigException is thrown.
548                 // First, where data read by cursor is more than 2MB size. In this case,
549                 // next fill window request might try to read data which may not exist anymore due
550                 // to a recent update after the last query.
551                 // Second, when columns being read have total size of more than 2MB.
552                 // We intend to solve for first scenario by querying MP again. If the initial
553                 // failure was because of second scenario, a runtime exception will be thrown.
554                 Log.e(TAG, "Encountered exception: ", e);
555                 mUnknownIds.clear();
556                 countPerMediaType = addUnknownIdsAndGetMediaTypeCount(queryArgs, scannedIds);
557             } finally {
558                 Trace.endSection();
559             }
560 
561             // Third, clean all the unknown database entries found above
562             mSignal.throwIfCanceled();
563             Trace.beginSection("Scanner.clean");
564             try {
565                 for (int i = 0; i < mUnknownIds.size(); i++) {
566                     final long id = mUnknownIds.get(i);
567                     if (LOGV) Log.v(TAG, "Cleaning " + id);
568                     final Uri uri = MediaStore.Files.getContentUri(mVolumeName, id).buildUpon()
569                             .appendQueryParameter(MediaStore.PARAM_DELETE_DATA, "false")
570                             .build();
571                     addPending(ContentProviderOperation.newDelete(uri).build());
572                     maybeApplyPending();
573                 }
574                 applyPending();
575             } finally {
576                 if (mUnknownIds.size() > 0) {
577                     String scanReason = "scan triggered by reason: " + translateReason(mReason);
578                     Metrics.logDeletionPersistent(mVolumeName, scanReason, countPerMediaType);
579                 }
580                 Trace.endSection();
581             }
582         }
583 
addUnknownIdsAndGetMediaTypeCount(Bundle queryArgs, long[] scannedIds)584         private int[] addUnknownIdsAndGetMediaTypeCount(Bundle queryArgs, long[] scannedIds) {
585             int[] countPerMediaType = new int[FileColumns.MEDIA_TYPE_COUNT];
586             try (Cursor c = mResolver.query(mFilesUri,
587                     new String[]{FileColumns._ID, FileColumns.MEDIA_TYPE, FileColumns.DATE_EXPIRES,
588                             FileColumns.IS_PENDING}, queryArgs, mSignal)) {
589                 while (c.moveToNext()) {
590                     final long id = c.getLong(0);
591                     if (Arrays.binarySearch(scannedIds, id) < 0) {
592                         final long dateExpire = c.getLong(2);
593                         final boolean isPending = c.getInt(3) == 1;
594                         // Don't delete the pending item which is not expired.
595                         // If the scan is triggered between invoking
596                         // ContentResolver#insert() and ContentResolver#openFileDescriptor(),
597                         // it raises the FileNotFoundException b/166063754.
598                         if (isPending && dateExpire > System.currentTimeMillis() / 1000) {
599                             continue;
600                         }
601                         mUnknownIds.add(id);
602                         final int mediaType = c.getInt(1);
603                         // Avoid ArrayIndexOutOfBounds if more mediaTypes are added,
604                         // but mediaTypeSize is not updated
605                         if (mediaType < countPerMediaType.length) {
606                             countPerMediaType[mediaType]++;
607                         }
608                     }
609                 }
610             }
611 
612             return countPerMediaType;
613         }
614 
resolvePlaylists()615         private void resolvePlaylists() {
616             mSignal.throwIfCanceled();
617 
618             // Playlists aren't supported on internal storage, so bail early
619             if (MediaStore.VOLUME_INTERNAL.equals(mVolumeName)) return;
620 
621             final Uri playlistsUri = MediaStore.Audio.Playlists.getContentUri(mVolumeName);
622             final Bundle queryArgs = new Bundle();
623             queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SELECTION,
624                     FileColumns.GENERATION_MODIFIED + " > " + mStartGeneration);
625             try (Cursor c = mResolver.query(playlistsUri, new String[] { FileColumns._ID },
626                     queryArgs, mSignal)) {
627                 while (c.moveToNext()) {
628                     final long id = c.getLong(0);
629                     MediaStore.resolvePlaylistMembers(mResolver,
630                             ContentUris.withAppendedId(playlistsUri, id));
631                 }
632             } finally {
633                 Trace.endSection();
634             }
635         }
636 
637         /**
638          * Create and acquire a lock on the given directory, giving the calling
639          * thread exclusive access to ensure that parallel scans don't overlap
640          * and confuse each other.
641          */
acquireDirectoryLock(@onNull Path dir)642         private void acquireDirectoryLock(@NonNull Path dir) {
643             Trace.beginSection("Scanner.acquireDirectoryLock");
644             DirectoryLock lock;
645             synchronized (mDirectoryLocks) {
646                 lock = mDirectoryLocks.get(dir);
647                 if (lock == null) {
648                     lock = new DirectoryLock();
649                     mDirectoryLocks.put(dir, lock);
650                 }
651                 lock.count++;
652             }
653             lock.lock.lock();
654             mAcquiredDirectoryLocks.add(dir);
655             Trace.endSection();
656         }
657 
658         /**
659          * Release a currently held lock on the given directory, releasing any
660          * other waiting parallel scans to proceed, and cleaning up data
661          * structures if no other threads are waiting.
662          */
releaseDirectoryLock(@onNull Path dir)663         private void releaseDirectoryLock(@NonNull Path dir) {
664             Trace.beginSection("Scanner.releaseDirectoryLock");
665             DirectoryLock lock;
666             synchronized (mDirectoryLocks) {
667                 lock = mDirectoryLocks.get(dir);
668                 if (lock == null) {
669                     throw new IllegalStateException();
670                 }
671                 if (--lock.count == 0) {
672                     mDirectoryLocks.remove(dir);
673                 }
674             }
675             lock.lock.unlock();
676             mAcquiredDirectoryLocks.remove(dir);
677             Trace.endSection();
678         }
679 
680         @Override
close()681         public void close() {
682             // Release any locks we're still holding, typically when we
683             // encountered an exception; we snapshot the original list so we're
684             // not confused as it's mutated by release operations
685             for (Path dir : new ArraySet<>(mAcquiredDirectoryLocks)) {
686                 releaseDirectoryLock(dir);
687             }
688 
689             mClient.close();
690         }
691 
692         @Override
preVisitDirectory(Path dir, BasicFileAttributes attrs)693         public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
694                 throws IOException {
695             // Possibly bail before digging into each directory
696             mSignal.throwIfCanceled();
697 
698             if (!shouldScanDirectory(dir.toFile())) {
699                 return FileVisitResult.SKIP_SUBTREE;
700             }
701 
702             synchronized (mPendingCleanDirectories) {
703                 if (mIsDirectoryTreeDirty) {
704                     // Directory tree is dirty, continue scanning subtree.
705                 } else if (FileUtils.getTopLevelNoMedia(dir.toFile()) == null) {
706                   // No nomedia file found, continue scanning.
707                 } else if (FileUtils.isDirectoryDirty(FileUtils.getTopLevelNoMedia(dir.toFile()))) {
708                     // Track the directory dirty status for directory tree in mIsDirectoryDirty.
709                     // This removes additional dirty state check for subdirectories of nomedia
710                     // directory.
711                     mIsDirectoryTreeDirty = true;
712                     mPendingCleanDirectories.add(dir.toFile().getPath());
713                 } else {
714                     Log.d(TAG, "Skipping preVisitDirectory " + dir.toFile());
715                     if (mExcludeDirs.size() <= MAX_EXCLUDE_DIRS) {
716                         mExcludeDirs.add(dir.toFile().getPath());
717                         return FileVisitResult.SKIP_SUBTREE;
718                     } else {
719                         Log.w(TAG, "ExcludeDir size exceeded, not skipping preVisitDirectory "
720                                 + dir.toFile());
721                     }
722                 }
723             }
724 
725             // Acquire lock on this directory to ensure parallel scans don't
726             // overlap and confuse each other
727             acquireDirectoryLock(dir);
728 
729             if (FileUtils.isDirectoryHidden(dir.toFile())) {
730                 mHiddenDirCount++;
731             }
732 
733             // Scan this directory as a normal file so that "parent" database
734             // entries are created
735             return visitFile(dir, attrs);
736         }
737 
738         @Override
visitFile(Path file, BasicFileAttributes attrs)739         public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
740                 throws IOException {
741             if (LOGV) Log.v(TAG, "Visiting " + file);
742             mFileCount++;
743 
744             // Skip files that have already been scanned, and which haven't
745             // changed since they were last scanned
746             final File realFile = file.toFile();
747             long existingId = -1;
748 
749             String actualMimeType;
750             if (attrs.isDirectory()) {
751                 actualMimeType = null;
752             } else {
753                 actualMimeType = MimeUtils.resolveMimeType(realFile);
754             }
755 
756             // Resolve the MIME type of DRM files before scanning them; if we
757             // have trouble then we'll continue scanning as a generic file
758             final boolean isDrm = mDrmMimeTypes.contains(actualMimeType);
759             if (isDrm) {
760                 actualMimeType = mDrmClient.getOriginalMimeType(realFile.getPath());
761             }
762 
763             int actualMediaType = mediaTypeFromMimeType(
764                     realFile, actualMimeType, FileColumns.MEDIA_TYPE_NONE);
765 
766             Trace.beginSection("Scanner.checkChanged");
767 
768             final Bundle queryArgs = new Bundle();
769             queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SELECTION,
770                     FileColumns.DATA + "=?");
771             queryArgs.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS,
772                     new String[] { realFile.getAbsolutePath() });
773             queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
774             queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
775             queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_FAVORITE, MediaStore.MATCH_INCLUDE);
776             final String[] projection = new String[] {FileColumns._ID, FileColumns.DATE_MODIFIED,
777                     FileColumns.SIZE, FileColumns.MIME_TYPE, FileColumns.MEDIA_TYPE,
778                     FileColumns.IS_PENDING, FileColumns._MODIFIER};
779 
780             final Matcher matcher = FileUtils.PATTERN_EXPIRES_FILE.matcher(realFile.getName());
781             // If IS_PENDING is set by FUSE, we should scan the file and update IS_PENDING to zero.
782             // Pending files from FUSE will not be rewritten to contain expiry timestamp.
783             boolean isPendingFromFuse = !matcher.matches();
784 
785             try (Cursor c = mResolver.query(mFilesUri, projection, queryArgs, mSignal)) {
786                 if (c.moveToFirst()) {
787                     existingId = c.getLong(0);
788                     final String mimeType = c.getString(3);
789                     final int mediaType = c.getInt(4);
790                     isPendingFromFuse &= c.getInt(5) != 0;
791 
792                     // Remember visiting this existing item, even if we skipped
793                     // due to it being unchanged; this is needed so we don't
794                     // delete the item during a later cleaning phase
795                     mScannedIds.add(existingId);
796 
797                     // We also technically found our first result
798                     if (mFirstId == -1) {
799                         mFirstId = existingId;
800                     }
801 
802                     if (attrs.isDirectory()) {
803                         if (LOGV) Log.v(TAG, "Skipping directory " + file);
804                         return FileVisitResult.CONTINUE;
805                     }
806 
807                     final boolean sameMetadata =
808                             hasSameMetadata(attrs, realFile, isPendingFromFuse, c);
809                     final boolean sameMediaType = actualMediaType == mediaType;
810                     if (sameMetadata && sameMediaType) {
811                         if (LOGV) Log.v(TAG, "Skipping unchanged " + file);
812                         return FileVisitResult.CONTINUE;
813                     }
814 
815                     // For this special case we may have changed mime type from the file's metadata.
816                     // This is safe because mime_type cannot be changed outside of scanning.
817                     if (sameMetadata
818                             && "video/mp4".equalsIgnoreCase(actualMimeType)
819                             && "audio/mp4".equalsIgnoreCase(mimeType)) {
820                         if (LOGV) Log.v(TAG, "Skipping unchanged video/audio " + file);
821                         return FileVisitResult.CONTINUE;
822                     }
823                 }
824 
825                 // Since we allow top-level mime type to be customised, we need to do this early
826                 // on, so the file is later scanned as the appropriate type (otherwise, this
827                 // audio filed would be scanned as video and it would be missing the correct
828                 // metadata).
829                 actualMimeType = updateM4aMimeType(realFile, actualMimeType);
830                 actualMediaType =
831                         mediaTypeFromMimeType(realFile, actualMimeType, actualMediaType);
832             } finally {
833                 Trace.endSection();
834             }
835 
836             final ContentProviderOperation.Builder op;
837             Trace.beginSection("Scanner.scanItem");
838             try {
839                 op = scanItem(existingId, realFile, attrs, actualMimeType, actualMediaType,
840                         mVolumeName);
841             } finally {
842                 Trace.endSection();
843             }
844             if (op != null) {
845                 op.withValue(FileColumns._MODIFIER, FileColumns._MODIFIER_MEDIA_SCAN);
846 
847                 // Force DRM files to be marked as DRM, since the lower level
848                 // stack may not set this correctly
849                 if (isDrm) {
850                     op.withValue(MediaColumns.IS_DRM, 1);
851                 }
852                 addPending(op.build());
853                 maybeApplyPending();
854             }
855             return FileVisitResult.CONTINUE;
856         }
857 
mediaTypeFromMimeType( File file, String mimeType, int defaultMediaType)858         private int mediaTypeFromMimeType(
859                 File file, String mimeType, int defaultMediaType) {
860             if (mimeType != null) {
861                 return resolveMediaTypeFromFilePath(
862                         file, mimeType, /*isHidden*/ mHiddenDirCount > 0);
863             }
864             return defaultMediaType;
865         }
866 
hasSameMetadata( BasicFileAttributes attrs, File realFile, boolean isPendingFromFuse, Cursor c)867         private boolean hasSameMetadata(
868                 BasicFileAttributes attrs, File realFile, boolean isPendingFromFuse, Cursor c) {
869             final long dateModified = c.getLong(1);
870             final boolean sameTime = (lastModifiedTime(realFile, attrs) == dateModified);
871 
872             final long size = c.getLong(2);
873             final boolean sameSize = (attrs.size() == size);
874 
875             final boolean isScanned =
876                     c.getInt(6) == FileColumns._MODIFIER_MEDIA_SCAN;
877 
878             return sameTime && sameSize && !isPendingFromFuse && isScanned;
879         }
880 
881         /**
882          * For this one very narrow case, we allow mime types to be customised when the top levels
883          * differ. This opens the given file, so avoid calling unless really necessary. This
884          * returns the defaultMimeType for non-m4a files or if opening the file throws an exception.
885          */
updateM4aMimeType(File file, String defaultMimeType)886         private String updateM4aMimeType(File file, String defaultMimeType) {
887             if ("video/mp4".equalsIgnoreCase(defaultMimeType)) {
888                 try (
889                     FileInputStream is = new FileInputStream(file);
890                     MediaMetadataRetriever mmr = new MediaMetadataRetriever()) {
891                     mmr.setDataSource(is.getFD());
892                     String refinedMimeType = mmr.extractMetadata(METADATA_KEY_MIMETYPE);
893                     if ("audio/mp4".equalsIgnoreCase(refinedMimeType)) {
894                         return refinedMimeType;
895                     }
896                 } catch (Exception e) {
897                     return defaultMimeType;
898                 }
899             }
900             return defaultMimeType;
901         }
902 
903         @Override
visitFileFailed(Path file, IOException exc)904         public FileVisitResult visitFileFailed(Path file, IOException exc)
905                 throws IOException {
906             Log.w(TAG, "Failed to visit " + file + ": " + exc);
907             return FileVisitResult.CONTINUE;
908         }
909 
910         @Override
postVisitDirectory(Path dir, IOException exc)911         public FileVisitResult postVisitDirectory(Path dir, IOException exc)
912                 throws IOException {
913             // We need to drain all pending changes related to this directory
914             // before releasing our lock below
915             applyPending();
916 
917             if (FileUtils.isDirectoryHidden(dir.toFile())) {
918                 mHiddenDirCount--;
919             }
920 
921             // Now that we're finished scanning this directory, release lock to
922             // allow other parallel scans to proceed
923             releaseDirectoryLock(dir);
924 
925             if (mIsDirectoryTreeDirty) {
926                 synchronized (mPendingCleanDirectories) {
927                     if (mPendingCleanDirectories.remove(dir.toFile().getPath())) {
928                         // If |dir| is still clean, then persist
929                         FileUtils.setDirectoryDirty(dir.toFile(), false /* isDirty */);
930                         mIsDirectoryTreeDirty = false;
931                     }
932                 }
933             }
934             return FileVisitResult.CONTINUE;
935         }
936 
addPending(@onNull ContentProviderOperation op)937         private void addPending(@NonNull ContentProviderOperation op) {
938             mPending.add(op);
939 
940             if (op.isInsert()) mInsertCount++;
941             if (op.isUpdate()) mUpdateCount++;
942             if (op.isDelete()) mDeleteCount++;
943         }
944 
maybeApplyPending()945         private void maybeApplyPending() {
946             if (mPending.size() > BATCH_SIZE) {
947                 applyPending();
948             }
949         }
950 
applyPending()951         private void applyPending() {
952             // Bail early when nothing pending
953             if (mPending.isEmpty()) return;
954 
955             Trace.beginSection("Scanner.applyPending");
956             try {
957                 ContentProviderResult[] results = mResolver.applyBatch(AUTHORITY, mPending);
958                 for (int index = 0; index < results.length; index++) {
959                     ContentProviderResult result = results[index];
960                     ContentProviderOperation operation = mPending.get(index);
961 
962                     if (result.exception != null) {
963                         Log.w(TAG, "Failed to apply " + operation, result.exception);
964                     }
965 
966                     Uri uri = result.uri;
967                     if (uri != null) {
968                         final long id = ContentUris.parseId(uri);
969                         if (mFirstId == -1) {
970                             mFirstId = id;
971                         }
972                         mScannedIds.add(id);
973                     }
974                 }
975             } catch (RemoteException | OperationApplicationException e) {
976                 Log.w(TAG, "Failed to apply", e);
977             } finally {
978                 mPending.clear();
979                 Trace.endSection();
980             }
981         }
982 
983         /**
984          * Return the first item encountered by this scan requested.
985          * <p>
986          * Internally resolves to the relevant media collection where this item
987          * exists based on {@link FileColumns#MEDIA_TYPE}.
988          */
getFirstResult()989         public @Nullable Uri getFirstResult() {
990             if (mFirstId == -1) return null;
991 
992             final Uri fileUri = MediaStore.Files.getContentUri(mVolumeName, mFirstId);
993             try (Cursor c = mResolver.query(fileUri,
994                     new String[] { FileColumns.MEDIA_TYPE }, null, null)) {
995                 if (c.moveToFirst()) {
996                     switch (c.getInt(0)) {
997                         case FileColumns.MEDIA_TYPE_AUDIO:
998                             return MediaStore.Audio.Media.getContentUri(mVolumeName, mFirstId);
999                         case FileColumns.MEDIA_TYPE_VIDEO:
1000                             return MediaStore.Video.Media.getContentUri(mVolumeName, mFirstId);
1001                         case FileColumns.MEDIA_TYPE_IMAGE:
1002                             return MediaStore.Images.Media.getContentUri(mVolumeName, mFirstId);
1003                         case FileColumns.MEDIA_TYPE_PLAYLIST:
1004                             return ContentUris.withAppendedId(
1005                                     MediaStore.Audio.Playlists.getContentUri(mVolumeName),
1006                                     mFirstId);
1007                     }
1008                 }
1009             }
1010 
1011             // Worst case, we can always use generic collection
1012             return fileUri;
1013         }
1014     }
1015 
1016     /**
1017      * Scan the requested file, returning a {@link ContentProviderOperation}
1018      * containing all indexed metadata, suitable for passing to a
1019      * {@link SQLiteDatabase#replace} operation.
1020      */
scanItem(long existingId, File file, BasicFileAttributes attrs, String mimeType, int mediaType, String volumeName)1021     private static @Nullable ContentProviderOperation.Builder scanItem(long existingId, File file,
1022             BasicFileAttributes attrs, String mimeType, int mediaType, String volumeName) {
1023         if (Objects.equals(file.getName(), ".nomedia")) {
1024             if (LOGD) Log.d(TAG, "Ignoring .nomedia file: " + file);
1025             return null;
1026         }
1027 
1028         if (attrs.isDirectory()) {
1029             return scanItemDirectory(existingId, file, attrs, mimeType, volumeName);
1030         }
1031 
1032         switch (mediaType) {
1033             case FileColumns.MEDIA_TYPE_AUDIO:
1034                 return scanItemAudio(existingId, file, attrs, mimeType, mediaType, volumeName);
1035             case FileColumns.MEDIA_TYPE_VIDEO:
1036                 return scanItemVideo(existingId, file, attrs, mimeType, mediaType, volumeName);
1037             case FileColumns.MEDIA_TYPE_IMAGE:
1038                 return scanItemImage(existingId, file, attrs, mimeType, mediaType, volumeName);
1039             case FileColumns.MEDIA_TYPE_PLAYLIST:
1040                 return scanItemPlaylist(existingId, file, attrs, mimeType, mediaType, volumeName);
1041             case FileColumns.MEDIA_TYPE_SUBTITLE:
1042                 return scanItemSubtitle(existingId, file, attrs, mimeType, mediaType, volumeName);
1043             case FileColumns.MEDIA_TYPE_DOCUMENT:
1044                 return scanItemDocument(existingId, file, attrs, mimeType, mediaType, volumeName);
1045             default:
1046                 return scanItemFile(existingId, file, attrs, mimeType, mediaType, volumeName);
1047         }
1048     }
1049 
1050     /**
1051      * Populate the given {@link ContentProviderOperation} with the generic
1052      * {@link MediaColumns} values that can be determined directly from the file
1053      * or its attributes.
1054      * <p>
1055      * This is typically the first set of values defined so that we correctly
1056      * clear any values that had been set by a previous scan and which are no
1057      * longer present in the media item.
1058      */
withGenericValues(ContentProviderOperation.Builder op, File file, BasicFileAttributes attrs, String mimeType, Integer mediaType)1059     private static void withGenericValues(ContentProviderOperation.Builder op,
1060             File file, BasicFileAttributes attrs, String mimeType, Integer mediaType) {
1061         withOptionalMimeTypeAndMediaType(op, Optional.ofNullable(mimeType),
1062                 Optional.ofNullable(mediaType));
1063 
1064         op.withValue(MediaColumns.DATA, file.getAbsolutePath());
1065         op.withValue(MediaColumns.SIZE, attrs.size());
1066         op.withValue(MediaColumns.DATE_MODIFIED, lastModifiedTime(file, attrs));
1067         op.withValue(MediaColumns.DATE_TAKEN, null);
1068         op.withValue(MediaColumns.IS_DRM, 0);
1069         op.withValue(MediaColumns.WIDTH, null);
1070         op.withValue(MediaColumns.HEIGHT, null);
1071         op.withValue(MediaColumns.RESOLUTION, null);
1072         op.withValue(MediaColumns.DOCUMENT_ID, null);
1073         op.withValue(MediaColumns.INSTANCE_ID, null);
1074         op.withValue(MediaColumns.ORIGINAL_DOCUMENT_ID, null);
1075         op.withValue(MediaColumns.ORIENTATION, null);
1076 
1077         op.withValue(MediaColumns.CD_TRACK_NUMBER, null);
1078         op.withValue(MediaColumns.ALBUM, null);
1079         op.withValue(MediaColumns.ARTIST, null);
1080         op.withValue(MediaColumns.AUTHOR, null);
1081         op.withValue(MediaColumns.COMPOSER, null);
1082         op.withValue(MediaColumns.GENRE, null);
1083         op.withValue(MediaColumns.TITLE, FileUtils.extractFileName(file.getName()));
1084         op.withValue(MediaColumns.YEAR, null);
1085         op.withValue(MediaColumns.DURATION, null);
1086         op.withValue(MediaColumns.NUM_TRACKS, null);
1087         op.withValue(MediaColumns.WRITER, null);
1088         op.withValue(MediaColumns.ALBUM_ARTIST, null);
1089         op.withValue(MediaColumns.DISC_NUMBER, null);
1090         op.withValue(MediaColumns.COMPILATION, null);
1091         op.withValue(MediaColumns.BITRATE, null);
1092         op.withValue(MediaColumns.CAPTURE_FRAMERATE, null);
1093     }
1094 
1095     /**
1096      * Populate the given {@link ContentProviderOperation} with the generic
1097      * {@link MediaColumns} values using the given
1098      * {@link MediaMetadataRetriever}.
1099      */
withRetrieverValues(ContentProviderOperation.Builder op, MediaMetadataRetriever mmr, String mimeType)1100     private static void withRetrieverValues(ContentProviderOperation.Builder op,
1101             MediaMetadataRetriever mmr, String mimeType) {
1102         withOptionalMimeTypeAndMediaType(op,
1103                 parseOptionalMimeType(mimeType, mmr.extractMetadata(METADATA_KEY_MIMETYPE)),
1104                 /*optionalMediaType*/ Optional.empty());
1105 
1106         withOptionalValue(op, MediaColumns.DATE_TAKEN,
1107                 parseOptionalDate(mmr.extractMetadata(METADATA_KEY_DATE)));
1108         withOptionalValue(op, MediaColumns.CD_TRACK_NUMBER,
1109                 parseOptional(mmr.extractMetadata(METADATA_KEY_CD_TRACK_NUMBER)));
1110         withOptionalValue(op, MediaColumns.ALBUM,
1111                 parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUM)));
1112         withOptionalValue(op, MediaColumns.ARTIST, firstPresent(
1113                 parseOptional(mmr.extractMetadata(METADATA_KEY_ARTIST)),
1114                 parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUMARTIST))));
1115         withOptionalValue(op, MediaColumns.AUTHOR,
1116                 parseOptional(mmr.extractMetadata(METADATA_KEY_AUTHOR)));
1117         withOptionalValue(op, MediaColumns.COMPOSER,
1118                 parseOptional(mmr.extractMetadata(METADATA_KEY_COMPOSER)));
1119         withOptionalValue(op, MediaColumns.GENRE,
1120                 parseOptional(mmr.extractMetadata(METADATA_KEY_GENRE)));
1121         withOptionalValue(op, MediaColumns.TITLE,
1122                 parseOptional(mmr.extractMetadata(METADATA_KEY_TITLE)));
1123         withOptionalValue(op, MediaColumns.YEAR,
1124                 parseOptionalYear(mmr.extractMetadata(METADATA_KEY_YEAR)));
1125         withOptionalValue(op, MediaColumns.DURATION,
1126                 parseOptional(mmr.extractMetadata(METADATA_KEY_DURATION)));
1127         withOptionalValue(op, MediaColumns.NUM_TRACKS,
1128                 parseOptional(mmr.extractMetadata(METADATA_KEY_NUM_TRACKS)));
1129         withOptionalValue(op, MediaColumns.WRITER,
1130                 parseOptional(mmr.extractMetadata(METADATA_KEY_WRITER)));
1131         withOptionalValue(op, MediaColumns.ALBUM_ARTIST,
1132                 parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUMARTIST)));
1133         withOptionalValue(op, MediaColumns.DISC_NUMBER,
1134                 parseOptional(mmr.extractMetadata(METADATA_KEY_DISC_NUMBER)));
1135         withOptionalValue(op, MediaColumns.COMPILATION,
1136                 parseOptional(mmr.extractMetadata(METADATA_KEY_COMPILATION)));
1137         withOptionalValue(op, MediaColumns.BITRATE,
1138                 parseOptional(mmr.extractMetadata(METADATA_KEY_BITRATE)));
1139         withOptionalValue(op, MediaColumns.CAPTURE_FRAMERATE,
1140                 parseOptional(mmr.extractMetadata(METADATA_KEY_CAPTURE_FRAMERATE)));
1141     }
1142 
1143     /**
1144      * Populate the given {@link ContentProviderOperation} with the generic
1145      * {@link MediaColumns} values using the given XMP metadata.
1146      */
withXmpValues(ContentProviderOperation.Builder op, XmpInterface xmp, String mimeType)1147     private static void withXmpValues(ContentProviderOperation.Builder op,
1148             XmpInterface xmp, String mimeType) {
1149         withOptionalMimeTypeAndMediaType(op,
1150                 parseOptionalMimeType(mimeType, xmp.getFormat()),
1151                 /*optionalMediaType*/ Optional.empty());
1152 
1153         op.withValue(MediaColumns.DOCUMENT_ID, xmp.getDocumentId());
1154         op.withValue(MediaColumns.INSTANCE_ID, xmp.getInstanceId());
1155         op.withValue(MediaColumns.ORIGINAL_DOCUMENT_ID, xmp.getOriginalDocumentId());
1156         op.withValue(MediaColumns.XMP, maybeTruncateXmp(xmp));
1157     }
1158 
maybeTruncateXmp(XmpInterface xmp)1159     private static byte[] maybeTruncateXmp(XmpInterface xmp) {
1160         byte[] redacted = xmp.getRedactedXmp();
1161         if (redacted.length > MAX_XMP_SIZE_BYTES) {
1162             return new byte[0];
1163         }
1164 
1165         return redacted;
1166     }
1167 
1168     /**
1169      * Overwrite a value in the given {@link ContentProviderOperation}, but only
1170      * when the given {@link Optional} value is present.
1171      */
withOptionalValue(@onNull ContentProviderOperation.Builder op, @NonNull String key, @NonNull Optional<?> value)1172     private static void withOptionalValue(@NonNull ContentProviderOperation.Builder op,
1173             @NonNull String key, @NonNull Optional<?> value) {
1174         if (value.isPresent()) {
1175             op.withValue(key, value.get());
1176         }
1177     }
1178 
1179     /**
1180      * Overwrite the {@link MediaColumns#MIME_TYPE} and
1181      * {@link FileColumns#MEDIA_TYPE} values in the given
1182      * {@link ContentProviderOperation}, but only when the given
1183      * {@link Optional} optionalMimeType is present.
1184      * If {@link Optional} optionalMediaType is not present, {@link FileColumns#MEDIA_TYPE} is
1185      * resolved from given {@code optionalMimeType} when {@code optionalMimeType} is present.
1186      *
1187      * @param optionalMimeType An optional MIME type to apply to this operation.
1188      * @param optionalMediaType An optional Media type to apply to this operation.
1189      */
withOptionalMimeTypeAndMediaType( @onNull ContentProviderOperation.Builder op, @NonNull Optional<String> optionalMimeType, @NonNull Optional<Integer> optionalMediaType)1190     private static void withOptionalMimeTypeAndMediaType(
1191             @NonNull ContentProviderOperation.Builder op,
1192             @NonNull Optional<String> optionalMimeType,
1193             @NonNull Optional<Integer> optionalMediaType) {
1194         if (optionalMimeType.isPresent()) {
1195             final String mimeType = optionalMimeType.get();
1196             op.withValue(MediaColumns.MIME_TYPE, mimeType);
1197             if (optionalMediaType.isPresent()) {
1198                 op.withValue(FileColumns.MEDIA_TYPE, optionalMediaType.get());
1199             } else {
1200                 op.withValue(FileColumns.MEDIA_TYPE, MimeUtils.resolveMediaType(mimeType));
1201             }
1202         }
1203     }
1204 
withResolutionValues( @onNull ContentProviderOperation.Builder op, @NonNull ExifInterface exif, @NonNull File file)1205     private static void withResolutionValues(
1206             @NonNull ContentProviderOperation.Builder op,
1207             @NonNull ExifInterface exif, @NonNull File file) {
1208         final Optional<?> width = parseOptionalOrZero(
1209                 exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH));
1210         final Optional<?> height = parseOptionalOrZero(
1211                 exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH));
1212         final Optional<String> resolution = parseOptionalResolution(width, height);
1213         if (resolution.isPresent()) {
1214             withOptionalValue(op, MediaColumns.WIDTH, width);
1215             withOptionalValue(op, MediaColumns.HEIGHT, height);
1216             op.withValue(MediaColumns.RESOLUTION, resolution.get());
1217         } else {
1218             withBitmapResolutionValues(op, file);
1219         }
1220     }
1221 
withBitmapResolutionValues( @onNull ContentProviderOperation.Builder op, @NonNull File file)1222     private static void withBitmapResolutionValues(
1223             @NonNull ContentProviderOperation.Builder op,
1224             @NonNull File file) {
1225         final BitmapFactory.Options bitmapOptions = new BitmapFactory.Options();
1226         bitmapOptions.inSampleSize = 1;
1227         bitmapOptions.inJustDecodeBounds = true;
1228         bitmapOptions.outWidth = 0;
1229         bitmapOptions.outHeight = 0;
1230         BitmapFactory.decodeFile(file.getAbsolutePath(), bitmapOptions);
1231 
1232         final Optional<?> width = parseOptionalOrZero(bitmapOptions.outWidth);
1233         final Optional<?> height = parseOptionalOrZero(bitmapOptions.outHeight);
1234         withOptionalValue(op, MediaColumns.WIDTH, width);
1235         withOptionalValue(op, MediaColumns.HEIGHT, height);
1236         withOptionalValue(op, MediaColumns.RESOLUTION, parseOptionalResolution(width, height));
1237     }
1238 
scanItemDirectory(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName)1239     private static @NonNull ContentProviderOperation.Builder scanItemDirectory(long existingId,
1240             File file, BasicFileAttributes attrs, String mimeType, String volumeName) {
1241         final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
1242         // Directory doesn't have any MIME type or Media Type.
1243         withGenericValues(op, file, attrs, mimeType, /*mediaType*/ null);
1244 
1245         try {
1246             op.withValue(FileColumns.FORMAT, MtpConstants.FORMAT_ASSOCIATION);
1247         } catch (Exception e) {
1248             logTroubleScanning(file, e);
1249         }
1250         return op;
1251     }
1252 
scanItemAudio(long existingId, File file, BasicFileAttributes attrs, String mimeType, int mediaType, String volumeName)1253     private static @NonNull ContentProviderOperation.Builder scanItemAudio(long existingId,
1254             File file, BasicFileAttributes attrs, String mimeType, int mediaType,
1255             String volumeName) {
1256         final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
1257         withGenericValues(op, file, attrs, mimeType, mediaType);
1258 
1259         op.withValue(MediaColumns.ARTIST, UNKNOWN_STRING);
1260         op.withValue(MediaColumns.ALBUM, file.getParentFile().getName());
1261         op.withValue(AudioColumns.TRACK, null);
1262 
1263         FileUtils.computeAudioTypeValuesFromData(file.getAbsolutePath(), op::withValue);
1264 
1265         try (FileInputStream is = new FileInputStream(file)) {
1266             try (MediaMetadataRetriever mmr = new MediaMetadataRetriever()) {
1267                 mmr.setDataSource(is.getFD());
1268 
1269                 withRetrieverValues(op, mmr, mimeType);
1270 
1271                 withOptionalValue(op, AudioColumns.TRACK,
1272                         parseOptionalTrack(mmr));
1273             }
1274 
1275             // Also hunt around for XMP metadata
1276             final IsoInterface iso = IsoInterface.fromFileDescriptor(is.getFD());
1277             final XmpInterface xmp = XmpInterface.fromContainer(iso);
1278             withXmpValues(op, xmp, mimeType);
1279 
1280         } catch (Exception e) {
1281             logTroubleScanning(file, e);
1282         }
1283         return op;
1284     }
1285 
scanItemPlaylist(long existingId, File file, BasicFileAttributes attrs, String mimeType, int mediaType, String volumeName)1286     private static @NonNull ContentProviderOperation.Builder scanItemPlaylist(long existingId,
1287             File file, BasicFileAttributes attrs, String mimeType, int mediaType,
1288             String volumeName) {
1289         final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
1290         withGenericValues(op, file, attrs, mimeType, mediaType);
1291 
1292         try {
1293             op.withValue(PlaylistsColumns.NAME, FileUtils.extractFileName(file.getName()));
1294         } catch (Exception e) {
1295             logTroubleScanning(file, e);
1296         }
1297         return op;
1298     }
1299 
scanItemSubtitle(long existingId, File file, BasicFileAttributes attrs, String mimeType, int mediaType, String volumeName)1300     private static @NonNull ContentProviderOperation.Builder scanItemSubtitle(long existingId,
1301             File file, BasicFileAttributes attrs, String mimeType, int mediaType,
1302             String volumeName) {
1303         final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
1304         withGenericValues(op, file, attrs, mimeType, mediaType);
1305 
1306         return op;
1307     }
1308 
scanItemDocument(long existingId, File file, BasicFileAttributes attrs, String mimeType, int mediaType, String volumeName)1309     private static @NonNull ContentProviderOperation.Builder scanItemDocument(long existingId,
1310             File file, BasicFileAttributes attrs, String mimeType, int mediaType,
1311             String volumeName) {
1312         final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
1313         withGenericValues(op, file, attrs, mimeType, mediaType);
1314 
1315         return op;
1316     }
1317 
scanItemVideo(long existingId, File file, BasicFileAttributes attrs, String mimeType, int mediaType, String volumeName)1318     private static @NonNull ContentProviderOperation.Builder scanItemVideo(long existingId,
1319             File file, BasicFileAttributes attrs, String mimeType, int mediaType,
1320             String volumeName) {
1321         final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
1322         withGenericValues(op, file, attrs, mimeType, mediaType);
1323 
1324         op.withValue(MediaColumns.ARTIST, UNKNOWN_STRING);
1325         op.withValue(MediaColumns.ALBUM, file.getParentFile().getName());
1326         op.withValue(VideoColumns.COLOR_STANDARD, null);
1327         op.withValue(VideoColumns.COLOR_TRANSFER, null);
1328         op.withValue(VideoColumns.COLOR_RANGE, null);
1329         op.withValue(FileColumns._VIDEO_CODEC_TYPE, null);
1330 
1331         try (FileInputStream is = new FileInputStream(file)) {
1332             try (MediaMetadataRetriever mmr = new MediaMetadataRetriever()) {
1333                 mmr.setDataSource(is.getFD());
1334 
1335                 withRetrieverValues(op, mmr, mimeType);
1336 
1337                 withOptionalValue(op, MediaColumns.WIDTH,
1338                         parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_WIDTH)));
1339                 withOptionalValue(op, MediaColumns.HEIGHT,
1340                         parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_HEIGHT)));
1341                 withOptionalValue(op, MediaColumns.RESOLUTION,
1342                         parseOptionalVideoResolution(mmr));
1343                 withOptionalValue(op, MediaColumns.ORIENTATION,
1344                         parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_ROTATION)));
1345 
1346                 withOptionalValue(op, VideoColumns.COLOR_STANDARD,
1347                         parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_STANDARD)));
1348                 withOptionalValue(op, VideoColumns.COLOR_TRANSFER,
1349                         parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_TRANSFER)));
1350                 withOptionalValue(op, VideoColumns.COLOR_RANGE,
1351                         parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_RANGE)));
1352                 withOptionalValue(op, FileColumns._VIDEO_CODEC_TYPE,
1353                         parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_CODEC_MIME_TYPE)));
1354             }
1355 
1356             // Also hunt around for XMP metadata
1357             final IsoInterface iso = IsoInterface.fromFileDescriptor(is.getFD());
1358             final XmpInterface xmp = XmpInterface.fromContainer(iso);
1359             withXmpValues(op, xmp, mimeType);
1360 
1361         } catch (Exception e) {
1362             logTroubleScanning(file, e);
1363         }
1364         return op;
1365     }
1366 
scanItemImage(long existingId, File file, BasicFileAttributes attrs, String mimeType, int mediaType, String volumeName)1367     private static @NonNull ContentProviderOperation.Builder scanItemImage(long existingId,
1368             File file, BasicFileAttributes attrs, String mimeType, int mediaType,
1369             String volumeName) {
1370         final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
1371         withGenericValues(op, file, attrs, mimeType, mediaType);
1372 
1373         op.withValue(ImageColumns.DESCRIPTION, null);
1374 
1375         try (FileInputStream is = new FileInputStream(file)) {
1376             final ExifInterface exif = new ExifInterface(is);
1377 
1378             withResolutionValues(op, exif, file);
1379 
1380             withOptionalValue(op, MediaColumns.DATE_TAKEN,
1381                     parseOptionalDateTaken(exif, lastModifiedTime(file, attrs) * 1000));
1382             withOptionalValue(op, MediaColumns.ORIENTATION,
1383                     parseOptionalOrientation(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION,
1384                             ExifInterface.ORIENTATION_UNDEFINED)));
1385 
1386             withOptionalValue(op, ImageColumns.DESCRIPTION,
1387                     parseOptional(exif.getAttribute(ExifInterface.TAG_IMAGE_DESCRIPTION)));
1388             withOptionalValue(op, ImageColumns.EXPOSURE_TIME,
1389                     parseOptional(exif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME)));
1390             withOptionalValue(op, ImageColumns.F_NUMBER,
1391                     parseOptional(exif.getAttribute(ExifInterface.TAG_F_NUMBER)));
1392             withOptionalValue(op, ImageColumns.ISO,
1393                     parseOptional(exif.getAttribute(ExifInterface.TAG_ISO_SPEED_RATINGS)));
1394             withOptionalValue(op, ImageColumns.SCENE_CAPTURE_TYPE,
1395                     parseOptional(exif.getAttribute(ExifInterface.TAG_SCENE_CAPTURE_TYPE)));
1396 
1397             // Also hunt around for XMP metadata
1398             final XmpInterface xmp = XmpInterface.fromContainer(exif);
1399             withXmpValues(op, xmp, mimeType);
1400 
1401             op.withValue(FileColumns._SPECIAL_FORMAT, SpecialFormatDetector.detect(exif, file));
1402         } catch (Exception e) {
1403             logTroubleScanning(file, e);
1404         }
1405         return op;
1406     }
1407 
scanItemFile(long existingId, File file, BasicFileAttributes attrs, String mimeType, int mediaType, String volumeName)1408     private static @NonNull ContentProviderOperation.Builder scanItemFile(long existingId,
1409             File file, BasicFileAttributes attrs, String mimeType, int mediaType,
1410             String volumeName) {
1411         final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
1412         withGenericValues(op, file, attrs, mimeType, mediaType);
1413 
1414         return op;
1415     }
1416 
newUpsert( @onNull String volumeName, long existingId)1417     private static @NonNull ContentProviderOperation.Builder newUpsert(
1418             @NonNull String volumeName, long existingId) {
1419         final Uri uri = MediaStore.Files.getContentUri(volumeName);
1420         if (existingId == -1) {
1421             return ContentProviderOperation.newInsert(uri)
1422                     .withExceptionAllowed(true);
1423         } else {
1424             return ContentProviderOperation.newUpdate(ContentUris.withAppendedId(uri, existingId))
1425                     .withExpectedCount(1)
1426                     .withExceptionAllowed(true);
1427         }
1428     }
1429 
1430     /**
1431      * Pick the first present {@link Optional} value from the given list.
1432      */
1433     @SafeVarargs
firstPresent(@onNull Optional<T>.... options)1434     private static @NonNull <T> Optional<T> firstPresent(@NonNull Optional<T>... options) {
1435         for (Optional<T> option : options) {
1436             if (option.isPresent()) {
1437                 return option;
1438             }
1439         }
1440         return Optional.empty();
1441     }
1442 
1443     @VisibleForTesting
parseOptional(@ullable T value)1444     static @NonNull <T> Optional<T> parseOptional(@Nullable T value) {
1445         if (value == null) {
1446             return Optional.empty();
1447         } else if (value instanceof String && ((String) value).length() == 0) {
1448             return Optional.empty();
1449         } else if (value instanceof String && ((String) value).equals("-1")) {
1450             return Optional.empty();
1451         } else if (value instanceof String && ((String) value).trim().length() == 0) {
1452             return Optional.empty();
1453         } else if (value instanceof Number && ((Number) value).intValue() == -1) {
1454             return Optional.empty();
1455         } else {
1456             return Optional.of(value);
1457         }
1458     }
1459 
1460     @VisibleForTesting
parseOptionalOrZero(@ullable T value)1461     static @NonNull <T> Optional<T> parseOptionalOrZero(@Nullable T value) {
1462         if (value instanceof String && isZero((String) value)) {
1463             return Optional.empty();
1464         } else if (value instanceof Number && ((Number) value).intValue() == 0) {
1465             return Optional.empty();
1466         } else {
1467             return parseOptional(value);
1468         }
1469     }
1470 
1471     @VisibleForTesting
parseOptionalNumerator(@ullable String value)1472     static @NonNull Optional<Integer> parseOptionalNumerator(@Nullable String value) {
1473         final Optional<String> parsedValue = parseOptional(value);
1474         if (parsedValue.isPresent()) {
1475             value = parsedValue.get();
1476             final int fractionIndex = value.indexOf('/');
1477             if (fractionIndex != -1) {
1478                 value = value.substring(0, fractionIndex);
1479             }
1480             try {
1481                 return Optional.of(Integer.parseInt(value));
1482             } catch (NumberFormatException ignored) {
1483                 return Optional.empty();
1484             }
1485         } else {
1486             return Optional.empty();
1487         }
1488     }
1489 
1490     /**
1491      * Try our best to calculate {@link MediaColumns#DATE_TAKEN} in reference to
1492      * the epoch, making our best guess from unrelated fields when offset
1493      * information isn't directly available.
1494      */
1495     @VisibleForTesting
parseOptionalDateTaken(@onNull ExifInterface exif, long lastModifiedTime)1496     static @NonNull Optional<Long> parseOptionalDateTaken(@NonNull ExifInterface exif,
1497             long lastModifiedTime) {
1498         final long originalTime = ExifUtils.getDateTimeOriginal(exif);
1499         if (exif.hasAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL)) {
1500             // We have known offset information, return it directly!
1501             return Optional.of(originalTime);
1502         } else {
1503             // Otherwise we need to guess the offset from unrelated fields
1504             final long smallestZone = 15 * MINUTE_IN_MILLIS;
1505             final long gpsTime = ExifUtils.getGpsDateTime(exif);
1506             if (gpsTime > 0) {
1507                 final long offset = gpsTime - originalTime;
1508                 if (Math.abs(offset) < 24 * HOUR_IN_MILLIS) {
1509                     final long rounded = Math.round((float) offset / smallestZone) * smallestZone;
1510                     return Optional.of(originalTime + rounded);
1511                 }
1512             }
1513             if (lastModifiedTime > 0) {
1514                 final long offset = lastModifiedTime - originalTime;
1515                 if (Math.abs(offset) < 24 * HOUR_IN_MILLIS) {
1516                     final long rounded = Math.round((float) offset / smallestZone) * smallestZone;
1517                     return Optional.of(originalTime + rounded);
1518                 }
1519             }
1520             return Optional.empty();
1521         }
1522     }
1523 
1524     @VisibleForTesting
parseOptionalOrientation(int orientation)1525     static @NonNull Optional<Integer> parseOptionalOrientation(int orientation) {
1526         switch (orientation) {
1527             case ExifInterface.ORIENTATION_FLIP_HORIZONTAL:
1528             case ExifInterface.ORIENTATION_NORMAL: return Optional.of(0);
1529             case ExifInterface.ORIENTATION_TRANSPOSE:
1530             case ExifInterface.ORIENTATION_ROTATE_90: return Optional.of(90);
1531             case ExifInterface.ORIENTATION_FLIP_VERTICAL:
1532             case ExifInterface.ORIENTATION_ROTATE_180: return Optional.of(180);
1533             case ExifInterface.ORIENTATION_TRANSVERSE:
1534             case ExifInterface.ORIENTATION_ROTATE_270: return Optional.of(270);
1535             default: return Optional.empty();
1536         }
1537     }
1538 
1539     @VisibleForTesting
parseOptionalVideoResolution( @onNull MediaMetadataRetriever mmr)1540     static @NonNull Optional<String> parseOptionalVideoResolution(
1541             @NonNull MediaMetadataRetriever mmr) {
1542         final Optional<?> width = parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_WIDTH));
1543         final Optional<?> height = parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_HEIGHT));
1544         return parseOptionalResolution(width, height);
1545     }
1546 
1547     @VisibleForTesting
parseOptionalImageResolution( @onNull MediaMetadataRetriever mmr)1548     static @NonNull Optional<String> parseOptionalImageResolution(
1549             @NonNull MediaMetadataRetriever mmr) {
1550         final Optional<?> width = parseOptional(mmr.extractMetadata(METADATA_KEY_IMAGE_WIDTH));
1551         final Optional<?> height = parseOptional(mmr.extractMetadata(METADATA_KEY_IMAGE_HEIGHT));
1552         return parseOptionalResolution(width, height);
1553     }
1554 
1555     @VisibleForTesting
parseOptionalResolution( @onNull ExifInterface exif)1556     static @NonNull Optional<String> parseOptionalResolution(
1557             @NonNull ExifInterface exif) {
1558         final Optional<?> width = parseOptionalOrZero(
1559                 exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH));
1560         final Optional<?> height = parseOptionalOrZero(
1561                 exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH));
1562         return parseOptionalResolution(width, height);
1563     }
1564 
parseOptionalResolution( @onNull Optional<?> width, @NonNull Optional<?> height)1565     private static @NonNull Optional<String> parseOptionalResolution(
1566             @NonNull Optional<?> width, @NonNull Optional<?> height) {
1567         if (width.isPresent() && height.isPresent()) {
1568             return Optional.of(width.get() + "\u00d7" + height.get());
1569         }
1570         return Optional.empty();
1571     }
1572 
1573     @VisibleForTesting
parseOptionalDate(@ullable String date)1574     static @NonNull Optional<Long> parseOptionalDate(@Nullable String date) {
1575         if (TextUtils.isEmpty(date)) return Optional.empty();
1576         try {
1577             synchronized (S_DATE_FORMAT_WITH_MILLIS) {
1578                 return parseDateWithFormat(date, S_DATE_FORMAT_WITH_MILLIS);
1579             }
1580         } catch (ParseException e) {
1581             // Log and try without millis as well
1582             Log.d(TAG, String.format(
1583                     "Parsing date with millis failed for [%s]. We will retry without millis",
1584                     date));
1585         }
1586         try {
1587             synchronized (S_DATE_FORMAT) {
1588                 return parseDateWithFormat(date, S_DATE_FORMAT);
1589             }
1590         } catch (ParseException e) {
1591             Log.d(TAG, String.format("Parsing date without millis failed for [%s]", date));
1592             return Optional.empty();
1593         }
1594     }
1595 
parseDateWithFormat( @ullable String date, SimpleDateFormat dateFormat)1596     private static Optional<Long> parseDateWithFormat(
1597             @Nullable String date, SimpleDateFormat dateFormat) throws ParseException {
1598         final long value = dateFormat.parse(date).getTime();
1599         return (value > 0) ? Optional.of(value) : Optional.empty();
1600     }
1601 
1602     @VisibleForTesting
parseOptionalYear(@ullable String value)1603     static @NonNull Optional<Integer> parseOptionalYear(@Nullable String value) {
1604         final Optional<String> parsedValue = parseOptional(value);
1605         if (parsedValue.isPresent()) {
1606             final Matcher m = PATTERN_YEAR.matcher(parsedValue.get());
1607             if (m.find()) {
1608                 return Optional.of(Integer.parseInt(m.group(1)));
1609             } else {
1610                 return Optional.empty();
1611             }
1612         } else {
1613             return Optional.empty();
1614         }
1615     }
1616 
1617     @VisibleForTesting
parseOptionalTrack( @onNull MediaMetadataRetriever mmr)1618     static @NonNull Optional<Integer> parseOptionalTrack(
1619             @NonNull MediaMetadataRetriever mmr) {
1620         final Optional<Integer> disc = parseOptionalNumerator(
1621                 mmr.extractMetadata(METADATA_KEY_DISC_NUMBER));
1622         final Optional<Integer> track = parseOptionalNumerator(
1623                 mmr.extractMetadata(METADATA_KEY_CD_TRACK_NUMBER));
1624         if (disc.isPresent() && track.isPresent()) {
1625             return Optional.of((disc.get() * 1000) + track.get());
1626         } else {
1627             return track;
1628         }
1629     }
1630 
1631     /**
1632      * Maybe replace the MIME type from extension with the MIME type from the
1633      * refined metadata, but only when the top-level MIME type agrees.
1634      */
1635     @VisibleForTesting
parseOptionalMimeType(@onNull String fileMimeType, @Nullable String refinedMimeType)1636     static @NonNull Optional<String> parseOptionalMimeType(@NonNull String fileMimeType,
1637             @Nullable String refinedMimeType) {
1638         // Ignore when missing
1639         if (TextUtils.isEmpty(refinedMimeType)) return Optional.empty();
1640 
1641         // Ignore when invalid
1642         final int refinedSplit = refinedMimeType.indexOf('/');
1643         if (refinedSplit == -1) return Optional.empty();
1644 
1645         if (fileMimeType.regionMatches(true, 0, refinedMimeType, 0, refinedSplit + 1)) {
1646             return Optional.of(refinedMimeType);
1647         } else {
1648             return Optional.empty();
1649         }
1650     }
1651 
1652     /**
1653      * Return last modified time of given file. This value is typically read
1654      * from the given {@link BasicFileAttributes}, except in the case of
1655      * read-only partitions, where {@link Build#TIME} is used instead.
1656      */
lastModifiedTime(@onNull File file, @NonNull BasicFileAttributes attrs)1657     public static long lastModifiedTime(@NonNull File file,
1658             @NonNull BasicFileAttributes attrs) {
1659         if (FileUtils.contains(Environment.getStorageDirectory(), file)) {
1660             return attrs.lastModifiedTime().toMillis() / 1000;
1661         } else {
1662             return Build.TIME / 1000;
1663         }
1664     }
1665 
1666     /**
1667      * Test if any parents of given path should be scanned and test if any parents of given
1668      * path should be considered hidden.
1669      */
shouldScanPathAndIsPathHidden(@onNull File dir)1670     static Pair<Boolean, Boolean> shouldScanPathAndIsPathHidden(@NonNull File dir) {
1671         Trace.beginSection("Scanner.shouldScanPathAndIsPathHidden");
1672         try {
1673             boolean isPathHidden = false;
1674             while (dir != null) {
1675                 if (!shouldScanDirectory(dir)) {
1676                     // When the path is not scannable, we don't care if it's hidden or not.
1677                     return Pair.create(false, false);
1678                 }
1679                 isPathHidden = isPathHidden || FileUtils.isDirectoryHidden(dir);
1680                 dir = dir.getParentFile();
1681             }
1682             return Pair.create(true, isPathHidden);
1683         } finally {
1684             Trace.endSection();
1685         }
1686     }
1687 
1688     @VisibleForTesting
shouldScanDirectory(@onNull File dir)1689     static boolean shouldScanDirectory(@NonNull File dir) {
1690         final File nomedia = new File(dir, ".nomedia");
1691 
1692         // Handle well-known paths that should always be visible or invisible,
1693         // regardless of .nomedia presence
1694         if (FileUtils.shouldBeVisible(dir.getAbsolutePath())) {
1695             // Well known paths can never be a hidden directory. Delete any non-standard nomedia
1696             // presence in well known path.
1697             nomedia.delete();
1698             return true;
1699         }
1700 
1701         if (FileUtils.shouldBeInvisible(dir.getAbsolutePath())) {
1702             // Create the .nomedia file in paths that are not scannable. This is useful when user
1703             // ejects the SD card and brings it to an older device and its media scanner can
1704             // now correctly identify these paths as not scannable.
1705             try {
1706                 nomedia.createNewFile();
1707             } catch (IOException ignored) {
1708             }
1709             return false;
1710         }
1711         return true;
1712     }
1713 
1714     /**
1715      * @return {@link FileColumns#MEDIA_TYPE}, resolved based on the file path and given
1716      * {@code mimeType}.
1717      */
resolveMediaTypeFromFilePath(@onNull File file, @NonNull String mimeType, boolean isHidden)1718     private static int resolveMediaTypeFromFilePath(@NonNull File file, @NonNull String mimeType,
1719             boolean isHidden) {
1720         int mediaType = MimeUtils.resolveMediaType(mimeType);
1721 
1722         if (isHidden || FileUtils.isFileHidden(file)) {
1723             mediaType = FileColumns.MEDIA_TYPE_NONE;
1724         }
1725         if (mediaType == FileColumns.MEDIA_TYPE_IMAGE && isFileAlbumArt(file)) {
1726             mediaType = FileColumns.MEDIA_TYPE_NONE;
1727         }
1728         return mediaType;
1729     }
1730 
1731     @VisibleForTesting
isFileAlbumArt(@onNull File file)1732     static boolean isFileAlbumArt(@NonNull File file) {
1733         return PATTERN_ALBUM_ART.matcher(file.getName()).matches();
1734     }
1735 
isZero(@onNull String value)1736     static boolean isZero(@NonNull String value) {
1737         if (value.length() == 0) {
1738             return false;
1739         }
1740         for (int i = 0; i < value.length(); i++) {
1741             if (value.charAt(i) != '0') {
1742                 return false;
1743             }
1744         }
1745         return true;
1746     }
1747 
logTroubleScanning(@onNull File file, @NonNull Exception e)1748     static void logTroubleScanning(@NonNull File file, @NonNull Exception e) {
1749         if (LOGW) Log.w(TAG, "Trouble scanning " + file + ": " + e);
1750     }
1751 }
1752