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