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