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