• 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_CD_TRACK_NUMBER;
23 import static android.media.MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE;
24 import static android.media.MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD;
25 import static android.media.MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER;
26 import static android.media.MediaMetadataRetriever.METADATA_KEY_COMPILATION;
27 import static android.media.MediaMetadataRetriever.METADATA_KEY_COMPOSER;
28 import static android.media.MediaMetadataRetriever.METADATA_KEY_DATE;
29 import static android.media.MediaMetadataRetriever.METADATA_KEY_DURATION;
30 import static android.media.MediaMetadataRetriever.METADATA_KEY_GENRE;
31 import static android.media.MediaMetadataRetriever.METADATA_KEY_IS_DRM;
32 import static android.media.MediaMetadataRetriever.METADATA_KEY_TITLE;
33 import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT;
34 import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH;
35 import static android.media.MediaMetadataRetriever.METADATA_KEY_YEAR;
36 import static android.os.Trace.TRACE_TAG_DATABASE;
37 import static android.provider.MediaStore.AUTHORITY;
38 import static android.provider.MediaStore.UNKNOWN_STRING;
39 import static android.text.format.DateUtils.HOUR_IN_MILLIS;
40 import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
41 
42 import android.annotation.CurrentTimeMillisLong;
43 import android.annotation.CurrentTimeSecondsLong;
44 import android.annotation.NonNull;
45 import android.annotation.Nullable;
46 import android.content.ContentProviderClient;
47 import android.content.ContentProviderOperation;
48 import android.content.ContentProviderResult;
49 import android.content.ContentResolver;
50 import android.content.ContentUris;
51 import android.content.Context;
52 import android.content.OperationApplicationException;
53 import android.database.Cursor;
54 import android.database.sqlite.SQLiteDatabase;
55 import android.media.ExifInterface;
56 import android.media.MediaFile;
57 import android.media.MediaMetadataRetriever;
58 import android.mtp.MtpConstants;
59 import android.net.Uri;
60 import android.os.Build;
61 import android.os.CancellationSignal;
62 import android.os.Environment;
63 import android.os.FileUtils;
64 import android.os.OperationCanceledException;
65 import android.os.RemoteException;
66 import android.os.Trace;
67 import android.provider.MediaStore;
68 import android.provider.MediaStore.Audio.AudioColumns;
69 import android.provider.MediaStore.Audio.PlaylistsColumns;
70 import android.provider.MediaStore.Files.FileColumns;
71 import android.provider.MediaStore.Images.ImageColumns;
72 import android.provider.MediaStore.MediaColumns;
73 import android.provider.MediaStore.Video.VideoColumns;
74 import android.text.TextUtils;
75 import android.util.ArrayMap;
76 import android.util.Log;
77 import android.util.LongArray;
78 
79 import com.android.internal.annotations.GuardedBy;
80 import com.android.internal.annotations.VisibleForTesting;
81 import com.android.providers.media.util.IsoInterface;
82 import com.android.providers.media.util.XmpInterface;
83 
84 import libcore.net.MimeUtils;
85 
86 import java.io.File;
87 import java.io.FileInputStream;
88 import java.io.IOException;
89 import java.nio.file.FileVisitResult;
90 import java.nio.file.FileVisitor;
91 import java.nio.file.Files;
92 import java.nio.file.Path;
93 import java.nio.file.attribute.BasicFileAttributes;
94 import java.text.ParseException;
95 import java.text.SimpleDateFormat;
96 import java.util.ArrayList;
97 import java.util.Arrays;
98 import java.util.List;
99 import java.util.Locale;
100 import java.util.Optional;
101 import java.util.TimeZone;
102 import java.util.regex.Pattern;
103 
104 /**
105  * Modern implementation of media scanner.
106  * <p>
107  * This is a bug-compatible reimplementation of the legacy media scanner, but
108  * written purely in managed code for better testability and long-term
109  * maintainability.
110  * <p>
111  * Initial tests shows it performing roughly on-par with the legacy scanner.
112  * <p>
113  * In general, we start by populating metadata based on file attributes, and
114  * then overwrite with any valid metadata found using
115  * {@link MediaMetadataRetriever}, {@link ExifInterface}, and
116  * {@link XmpInterface}, each with increasing levels of trust.
117  */
118 public class ModernMediaScanner implements MediaScanner {
119     private static final String TAG = "ModernMediaScanner";
120     private static final boolean LOGW = Log.isLoggable(TAG, Log.WARN);
121     private static final boolean LOGD = Log.isLoggable(TAG, Log.DEBUG);
122     private static final boolean LOGV = Log.isLoggable(TAG, Log.VERBOSE);
123 
124     // TODO: add DRM support
125 
126     // TODO: refactor to use UPSERT once we have SQLite 3.24.0
127 
128     // TODO: deprecate playlist editing
129     // TODO: deprecate PARENT column, since callers can't see directories
130 
131     private static final SimpleDateFormat sDateFormat;
132 
133     static {
134         sDateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss");
135         sDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
136     }
137 
138     private static final int BATCH_SIZE = 32;
139 
140     private static final Pattern PATTERN_VISIBLE = Pattern.compile(
141             "(?i)^/storage/[^/]+(?:/[0-9]+)?(?:/Android/sandbox/([^/]+))?$");
142     private static final Pattern PATTERN_INVISIBLE = Pattern.compile(
143             "(?i)^/storage/[^/]+(?:/[0-9]+)?(?:/Android/sandbox/([^/]+))?/Android/(?:data|obb)$");
144 
145     private final Context mContext;
146 
147     /**
148      * Map from volume name to signals that can be used to cancel any active
149      * scan operations on those volumes.
150      */
151     @GuardedBy("mSignals")
152     private final ArrayMap<String, CancellationSignal> mSignals = new ArrayMap<>();
153 
ModernMediaScanner(Context context)154     public ModernMediaScanner(Context context) {
155         mContext = context;
156     }
157 
158     @Override
getContext()159     public Context getContext() {
160         return mContext;
161     }
162 
163     @Override
scanDirectory(File file)164     public void scanDirectory(File file) {
165         try (Scan scan = new Scan(file)) {
166             scan.run();
167         } catch (OperationCanceledException ignored) {
168         }
169     }
170 
171     @Override
scanFile(File file)172     public Uri scanFile(File file) {
173         try (Scan scan = new Scan(file)) {
174             scan.run();
175             return scan.mFirstResult;
176         } catch (OperationCanceledException ignored) {
177             return null;
178         }
179     }
180 
181     @Override
onDetachVolume(String volumeName)182     public void onDetachVolume(String volumeName) {
183         synchronized (mSignals) {
184             final CancellationSignal signal = mSignals.remove(volumeName);
185             if (signal != null) {
186                 signal.cancel();
187             }
188         }
189     }
190 
getOrCreateSignal(String volumeName)191     private CancellationSignal getOrCreateSignal(String volumeName) {
192         synchronized (mSignals) {
193             CancellationSignal signal = mSignals.get(volumeName);
194             if (signal == null) {
195                 signal = new CancellationSignal();
196                 mSignals.put(volumeName, signal);
197             }
198             return signal;
199         }
200     }
201 
202     /**
203      * Individual scan request for a specific file or directory. When run it
204      * will traverse all included media files under the requested location,
205      * reconciling them against {@link MediaStore}.
206      */
207     private class Scan implements Runnable, FileVisitor<Path>, AutoCloseable {
208         private final ContentProviderClient mClient;
209         private final ContentResolver mResolver;
210 
211         private final File mRoot;
212         private final String mVolumeName;
213         private final Uri mFilesUri;
214         private final CancellationSignal mSignal;
215 
216         private final boolean mSingleFile;
217         private final ArrayList<ContentProviderOperation> mPending = new ArrayList<>();
218         private LongArray mScannedIds = new LongArray();
219         private LongArray mUnknownIds = new LongArray();
220         private LongArray mPlaylistIds = new LongArray();
221 
222         private Uri mFirstResult;
223 
Scan(File root)224         public Scan(File root) {
225             Trace.traceBegin(TRACE_TAG_DATABASE, "ctor");
226 
227             mClient = mContext.getContentResolver()
228                     .acquireContentProviderClient(MediaStore.AUTHORITY);
229             mResolver = ContentResolver.wrap(mClient.getLocalContentProvider());
230 
231             mRoot = root;
232             mVolumeName = MediaStore.getVolumeName(root);
233             mFilesUri = MediaStore.setIncludePending(MediaStore.Files.getContentUri(mVolumeName));
234             mSignal = getOrCreateSignal(mVolumeName);
235 
236             mSingleFile = mRoot.isFile();
237 
238             Trace.traceEnd(TRACE_TAG_DATABASE);
239         }
240 
241         @Override
run()242         public void run() {
243             // First, scan everything that should be visible under requested
244             // location, tracking scanned IDs along the way
245             walkFileTree();
246 
247             // Second, reconcile all items known in the database against all the
248             // items we scanned above
249             if (mSingleFile && mScannedIds.size() == 1) {
250                 // We can safely skip this step if the scan targeted a single
251                 // file which we scanned above
252             } else {
253                 reconcileAndClean();
254             }
255 
256             // Third, resolve any playlists that we scanned
257             if (mPlaylistIds.size() > 0) {
258                 resolvePlaylists();
259             }
260         }
261 
walkFileTree()262         private void walkFileTree() {
263             mSignal.throwIfCanceled();
264             if (!isDirectoryHiddenRecursive(mSingleFile ? mRoot.getParentFile() : mRoot)) {
265                 Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "walkFileTree");
266                 try {
267                     Files.walkFileTree(mRoot.toPath(), this);
268                 } catch (IOException e) {
269                     // This should never happen, so yell loudly
270                     throw new IllegalStateException(e);
271                 } finally {
272                     Trace.traceEnd(Trace.TRACE_TAG_DATABASE);
273                 }
274                 applyPending();
275             }
276         }
277 
reconcileAndClean()278         private void reconcileAndClean() {
279             final long[] scannedIds = mScannedIds.toArray();
280             Arrays.sort(scannedIds);
281 
282             // The query phase is split from the delete phase so that our query
283             // remains stable if we need to paginate across multiple windows.
284             mSignal.throwIfCanceled();
285             Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "reconcile");
286             try (Cursor c = mResolver.query(mFilesUri,
287                     new String[]{FileColumns._ID},
288                     FileColumns.FORMAT + "!=? AND " + FileColumns.DATA + " LIKE ? ESCAPE '\\'",
289                     new String[]{
290                             // Ignore abstract playlists which don't have files on disk
291                             String.valueOf(MtpConstants.FORMAT_ABSTRACT_AV_PLAYLIST),
292                             escapeForLike(mRoot.getAbsolutePath()) + '%'
293                     },
294                     FileColumns._ID + " DESC", mSignal)) {
295                 while (c.moveToNext()) {
296                     final long id = c.getLong(0);
297                     if (Arrays.binarySearch(scannedIds, id) < 0) {
298                         mUnknownIds.add(id);
299                     }
300                 }
301             } finally {
302                 Trace.traceEnd(Trace.TRACE_TAG_DATABASE);
303             }
304 
305             // Third, clean all the unknown database entries found above
306             mSignal.throwIfCanceled();
307             Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "clean");
308             try {
309                 for (int i = 0; i < mUnknownIds.size(); i++) {
310                     final long id = mUnknownIds.get(i);
311                     if (LOGV) Log.v(TAG, "Cleaning " + id);
312                     final Uri uri = MediaStore.Files.getContentUri(mVolumeName, id).buildUpon()
313                             .appendQueryParameter(MediaStore.PARAM_DELETE_DATA, "false")
314                             .build();
315                     mPending.add(ContentProviderOperation.newDelete(uri).build());
316                     maybeApplyPending();
317                 }
318                 applyPending();
319             } finally {
320                 Trace.traceEnd(Trace.TRACE_TAG_DATABASE);
321             }
322         }
323 
resolvePlaylists()324         private void resolvePlaylists() {
325             mSignal.throwIfCanceled();
326             for (int i = 0; i < mPlaylistIds.size(); i++) {
327                 final Uri uri = MediaStore.Files.getContentUri(mVolumeName, mPlaylistIds.get(i));
328                 try {
329                     mPending.addAll(
330                             PlaylistResolver.resolvePlaylist(mResolver, uri));
331                     maybeApplyPending();
332                 } catch (IOException e) {
333                     if (LOGW) Log.w(TAG, "Ignoring troubled playlist: " + uri, e);
334                 }
335                 applyPending();
336             }
337         }
338 
339         @Override
close()340         public void close() {
341             // Sanity check that we drained any pending operations
342             if (!mPending.isEmpty()) {
343                 throw new IllegalStateException();
344             }
345 
346             mClient.close();
347         }
348 
349         @Override
preVisitDirectory(Path dir, BasicFileAttributes attrs)350         public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
351                 throws IOException {
352             // Possibly bail before digging into each directory
353             mSignal.throwIfCanceled();
354 
355             if (isDirectoryHidden(dir.toFile())) {
356                 return FileVisitResult.SKIP_SUBTREE;
357             }
358 
359             // Scan this directory as a normal file so that "parent" database
360             // entries are created
361             return visitFile(dir, attrs);
362         }
363 
364         @Override
visitFile(Path file, BasicFileAttributes attrs)365         public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
366                 throws IOException {
367             if (LOGV) Log.v(TAG, "Visiting " + file);
368 
369             // Skip files that have already been scanned, and which haven't
370             // changed since they were last scanned
371             final File realFile = file.toFile();
372             long existingId = -1;
373             Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "checkChanged");
374             try (Cursor c = mResolver.query(mFilesUri,
375                     new String[] { FileColumns._ID, FileColumns.DATE_MODIFIED, FileColumns.SIZE },
376                     FileColumns.DATA + "=?", new String[] { realFile.getAbsolutePath() }, null)) {
377                 if (c.moveToFirst()) {
378                     existingId = c.getLong(0);
379                     final long dateModified = c.getLong(1);
380                     final long size = c.getLong(2);
381 
382                     // Remember visiting this existing item, even if we skipped
383                     // due to it being unchanged; this is needed so we don't
384                     // delete the item during a later cleaning phase
385                     mScannedIds.add(existingId);
386 
387                     // We also technically found our first result
388                     if (mFirstResult == null) {
389                         mFirstResult = MediaStore.Files.getContentUri(mVolumeName, existingId);
390                     }
391 
392                     final boolean sameTime = (lastModifiedTime(realFile, attrs) == dateModified);
393                     final boolean sameSize = (attrs.size() == size);
394                     if (attrs.isDirectory() || (sameTime && sameSize)) {
395                         if (LOGV) Log.v(TAG, "Skipping unchanged " + file);
396                         return FileVisitResult.CONTINUE;
397                     }
398                 }
399             } finally {
400                 Trace.traceEnd(Trace.TRACE_TAG_DATABASE);
401             }
402 
403             final ContentProviderOperation op;
404             Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "scanItem");
405             try {
406                 op = scanItem(existingId, file.toFile(), attrs, mVolumeName);
407             } finally {
408                 Trace.traceEnd(Trace.TRACE_TAG_DATABASE);
409             }
410             if (op != null) {
411                 mPending.add(op);
412                 maybeApplyPending();
413             }
414             return FileVisitResult.CONTINUE;
415         }
416 
417         @Override
visitFileFailed(Path file, IOException exc)418         public FileVisitResult visitFileFailed(Path file, IOException exc)
419                 throws IOException {
420             Log.w(TAG, "Failed to visit " + file + ": " + exc);
421             return FileVisitResult.CONTINUE;
422         }
423 
424         @Override
postVisitDirectory(Path dir, IOException exc)425         public FileVisitResult postVisitDirectory(Path dir, IOException exc)
426                 throws IOException {
427             return FileVisitResult.CONTINUE;
428         }
429 
maybeApplyPending()430         private void maybeApplyPending() {
431             if (mPending.size() > BATCH_SIZE) {
432                 applyPending();
433             }
434         }
435 
applyPending()436         private void applyPending() {
437             Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "applyPending");
438             try {
439                 ContentProviderResult[] results = mResolver.applyBatch(AUTHORITY, mPending);
440                 for (int index = 0; index < results.length; index++) {
441                     ContentProviderResult result = results[index];
442                     ContentProviderOperation operation = mPending.get(index);
443 
444                     Uri uri = result.uri;
445                     if (uri != null) {
446                         if (mFirstResult == null) {
447                             mFirstResult = uri;
448                         }
449                         final long id = ContentUris.parseId(uri);
450                         mScannedIds.add(id);
451                     }
452 
453                     // Some operations don't return a URI, so check the original if necessary
454                     Uri uriToCheck = uri == null ? operation.getUri() : uri;
455                     if (uriToCheck != null) {
456                         if (isPlaylist(uriToCheck)) {
457                             // If this was a playlist, remember it so we can resolve
458                             // its contents once all other media has been scanned
459                             mPlaylistIds.add(ContentUris.parseId(uriToCheck));
460                         }
461                     }
462                 }
463             } catch (RemoteException | OperationApplicationException e) {
464                 Log.w(TAG, "Failed to apply: " + e);
465             } finally {
466                 mPending.clear();
467                 Trace.traceEnd(Trace.TRACE_TAG_DATABASE);
468             }
469         }
470     }
471 
472     /**
473      * Scan the requested file, returning a {@link ContentProviderOperation}
474      * containing all indexed metadata, suitable for passing to a
475      * {@link SQLiteDatabase#replace} operation.
476      */
scanItem(long existingId, File file, BasicFileAttributes attrs, String volumeName)477     private static @Nullable ContentProviderOperation scanItem(long existingId, File file,
478             BasicFileAttributes attrs, String volumeName) {
479         final String name = file.getName();
480         if (name.startsWith(".")) {
481             if (LOGD) Log.d(TAG, "Ignoring hidden file: " + file);
482             return null;
483         }
484 
485         try {
486             final String mimeType;
487             if (attrs.isDirectory()) {
488                 mimeType = null;
489             } else {
490                 mimeType = MediaFile.getMimeTypeForFile(file.getPath());
491             }
492 
493             if (attrs.isDirectory()) {
494                 return scanItemDirectory(existingId, file, attrs, mimeType, volumeName);
495             } else if (MediaFile.isPlayListMimeType(mimeType)) {
496                 return scanItemPlaylist(existingId, file, attrs, mimeType, volumeName);
497             } else if (MediaFile.isAudioMimeType(mimeType)) {
498                 return scanItemAudio(existingId, file, attrs, mimeType, volumeName);
499             } else if (MediaFile.isVideoMimeType(mimeType)) {
500                 return scanItemVideo(existingId, file, attrs, mimeType, volumeName);
501             } else if (MediaFile.isImageMimeType(mimeType)) {
502                 return scanItemImage(existingId, file, attrs, mimeType, volumeName);
503             } else {
504                 return scanItemFile(existingId, file, attrs, mimeType, volumeName);
505             }
506         } catch (IOException e) {
507             if (LOGW) Log.w(TAG, "Ignoring troubled file: " + file, e);
508             return null;
509         }
510     }
511 
512     /**
513      * Populate the given {@link ContentProviderOperation} with the generic
514      * {@link MediaColumns} values that can be determined directly from the file
515      * or its attributes.
516      */
withGenericValues(ContentProviderOperation.Builder op, File file, BasicFileAttributes attrs, String mimeType)517     private static void withGenericValues(ContentProviderOperation.Builder op,
518             File file, BasicFileAttributes attrs, String mimeType) {
519         op.withValue(MediaColumns.DATA, file.getAbsolutePath());
520         op.withValue(MediaColumns.SIZE, attrs.size());
521         op.withValue(MediaColumns.TITLE, extractName(file));
522         op.withValue(MediaColumns.DATE_MODIFIED, lastModifiedTime(file, attrs));
523         op.withValue(MediaColumns.DATE_TAKEN, null);
524         op.withValue(MediaColumns.MIME_TYPE, mimeType);
525         op.withValue(MediaColumns.IS_DRM, 0);
526         op.withValue(MediaColumns.WIDTH, null);
527         op.withValue(MediaColumns.HEIGHT, null);
528         op.withValue(MediaColumns.DOCUMENT_ID, null);
529         op.withValue(MediaColumns.INSTANCE_ID, null);
530         op.withValue(MediaColumns.ORIGINAL_DOCUMENT_ID, null);
531         op.withValue(MediaColumns.DURATION, null);
532         op.withValue(MediaColumns.ORIENTATION, null);
533     }
534 
535     /**
536      * Populate the given {@link ContentProviderOperation} with the generic
537      * {@link MediaColumns} values using the given XMP metadata.
538      */
withXmpValues(ContentProviderOperation.Builder op, XmpInterface xmp, String mimeType)539     private static void withXmpValues(ContentProviderOperation.Builder op,
540             XmpInterface xmp, String mimeType) {
541         op.withValue(MediaColumns.MIME_TYPE,
542                 maybeOverrideMimeType(mimeType, xmp.getFormat()));
543         op.withValue(MediaColumns.DOCUMENT_ID, xmp.getDocumentId());
544         op.withValue(MediaColumns.INSTANCE_ID, xmp.getInstanceId());
545         op.withValue(MediaColumns.ORIGINAL_DOCUMENT_ID, xmp.getOriginalDocumentId());
546     }
547 
548     /**
549      * Overwrite a value in the given {@link ContentProviderOperation}, but only
550      * when the given {@link Optional} value is present.
551      */
withOptionalValue(ContentProviderOperation.Builder op, String key, Optional<?> value)552     private static void withOptionalValue(ContentProviderOperation.Builder op,
553             String key, Optional<?> value) {
554         if (value.isPresent()) {
555             op.withValue(key, value.get());
556         }
557     }
558 
scanItemDirectory(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName)559     private static @NonNull ContentProviderOperation scanItemDirectory(long existingId, File file,
560             BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException {
561         final ContentProviderOperation.Builder op = newUpsert(
562                 MediaStore.Files.getContentUri(volumeName), existingId);
563         try {
564             withGenericValues(op, file, attrs, mimeType);
565             op.withValue(FileColumns.MEDIA_TYPE, 0);
566             op.withValue(FileColumns.FORMAT, MtpConstants.FORMAT_ASSOCIATION);
567             op.withValue(FileColumns.MIME_TYPE, null);
568         } catch (Exception e) {
569             throw new IOException(e);
570         }
571         return op.build();
572     }
573 
574     private static ArrayMap<String, String> sAudioTypes = new ArrayMap<>();
575 
576     static {
sAudioTypes.put(Environment.DIRECTORY_RINGTONES, AudioColumns.IS_RINGTONE)577         sAudioTypes.put(Environment.DIRECTORY_RINGTONES, AudioColumns.IS_RINGTONE);
sAudioTypes.put(Environment.DIRECTORY_NOTIFICATIONS, AudioColumns.IS_NOTIFICATION)578         sAudioTypes.put(Environment.DIRECTORY_NOTIFICATIONS, AudioColumns.IS_NOTIFICATION);
sAudioTypes.put(Environment.DIRECTORY_ALARMS, AudioColumns.IS_ALARM)579         sAudioTypes.put(Environment.DIRECTORY_ALARMS, AudioColumns.IS_ALARM);
sAudioTypes.put(Environment.DIRECTORY_PODCASTS, AudioColumns.IS_PODCAST)580         sAudioTypes.put(Environment.DIRECTORY_PODCASTS, AudioColumns.IS_PODCAST);
sAudioTypes.put(Environment.DIRECTORY_AUDIOBOOKS, AudioColumns.IS_AUDIOBOOK)581         sAudioTypes.put(Environment.DIRECTORY_AUDIOBOOKS, AudioColumns.IS_AUDIOBOOK);
sAudioTypes.put(Environment.DIRECTORY_MUSIC, AudioColumns.IS_MUSIC)582         sAudioTypes.put(Environment.DIRECTORY_MUSIC, AudioColumns.IS_MUSIC);
583     }
584 
scanItemAudio(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName)585     private static @NonNull ContentProviderOperation scanItemAudio(long existingId, File file,
586             BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException {
587         final ContentProviderOperation.Builder op = newUpsert(
588                 MediaStore.Audio.Media.getContentUri(volumeName), existingId);
589 
590         withGenericValues(op, file, attrs, mimeType);
591         op.withValue(AudioColumns.ARTIST, UNKNOWN_STRING);
592         op.withValue(AudioColumns.ALBUM_ARTIST, null);
593         op.withValue(AudioColumns.COMPILATION, null);
594         op.withValue(AudioColumns.COMPOSER, null);
595         op.withValue(AudioColumns.ALBUM, file.getParentFile().getName());
596         op.withValue(AudioColumns.TRACK, null);
597         op.withValue(AudioColumns.YEAR, null);
598         op.withValue(AudioColumns.GENRE, null);
599 
600         final String lowPath = file.getAbsolutePath().toLowerCase(Locale.ROOT);
601         boolean anyMatch = false;
602         for (int i = 0; i < sAudioTypes.size(); i++) {
603             final boolean match = lowPath
604                     .contains('/' + sAudioTypes.keyAt(i).toLowerCase(Locale.ROOT) + '/');
605             op.withValue(sAudioTypes.valueAt(i), match ? 1 : 0);
606             anyMatch |= match;
607         }
608         if (!anyMatch) {
609             op.withValue(AudioColumns.IS_MUSIC, 1);
610         }
611 
612         try (FileInputStream is = new FileInputStream(file)) {
613             try (MediaMetadataRetriever mmr = new MediaMetadataRetriever()) {
614                 mmr.setDataSource(is.getFD());
615 
616                 withOptionalValue(op, MediaColumns.TITLE,
617                         parseOptional(mmr.extractMetadata(METADATA_KEY_TITLE)));
618                 withOptionalValue(op, MediaColumns.IS_DRM,
619                         parseOptional(mmr.extractMetadata(METADATA_KEY_IS_DRM)));
620                 withOptionalValue(op, MediaColumns.DURATION,
621                         parseOptional(mmr.extractMetadata(METADATA_KEY_DURATION)));
622 
623                 withOptionalValue(op, AudioColumns.ARTIST,
624                         parseOptional(mmr.extractMetadata(METADATA_KEY_ARTIST)));
625                 withOptionalValue(op, AudioColumns.ALBUM_ARTIST,
626                         parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUMARTIST)));
627                 withOptionalValue(op, AudioColumns.COMPILATION,
628                         parseOptional(mmr.extractMetadata(METADATA_KEY_COMPILATION)));
629                 withOptionalValue(op, AudioColumns.COMPOSER,
630                         parseOptional(mmr.extractMetadata(METADATA_KEY_COMPOSER)));
631                 withOptionalValue(op, AudioColumns.ALBUM,
632                         parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUM)));
633                 withOptionalValue(op, AudioColumns.TRACK,
634                         parseOptional(mmr.extractMetadata(METADATA_KEY_CD_TRACK_NUMBER)));
635                 withOptionalValue(op, AudioColumns.YEAR,
636                         parseOptionalOrZero(mmr.extractMetadata(METADATA_KEY_YEAR)));
637                 withOptionalValue(op, AudioColumns.GENRE,
638                         parseOptional(mmr.extractMetadata(METADATA_KEY_GENRE)));
639             }
640 
641             // Also hunt around for XMP metadata
642             final IsoInterface iso = IsoInterface.fromFileDescriptor(is.getFD());
643             final XmpInterface xmp = XmpInterface.fromContainer(iso);
644             withXmpValues(op, xmp, mimeType);
645 
646         } catch (Exception e) {
647             throw new IOException(e);
648         }
649         return op.build();
650     }
651 
scanItemPlaylist(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName)652     private static @NonNull ContentProviderOperation scanItemPlaylist(long existingId, File file,
653             BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException {
654         final ContentProviderOperation.Builder op = newUpsert(
655                 MediaStore.Audio.Playlists.getContentUri(volumeName), existingId);
656         try {
657             withGenericValues(op, file, attrs, mimeType);
658             op.withValue(PlaylistsColumns.NAME, extractName(file));
659         } catch (Exception e) {
660             throw new IOException(e);
661         }
662         return op.build();
663     }
664 
scanItemVideo(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName)665     private static @NonNull ContentProviderOperation scanItemVideo(long existingId, File file,
666             BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException {
667         final ContentProviderOperation.Builder op = newUpsert(
668                 MediaStore.Video.Media.getContentUri(volumeName), existingId);
669 
670         withGenericValues(op, file, attrs, mimeType);
671         op.withValue(VideoColumns.ARTIST, UNKNOWN_STRING);
672         op.withValue(VideoColumns.ALBUM, file.getParentFile().getName());
673         op.withValue(VideoColumns.RESOLUTION, null);
674         op.withValue(VideoColumns.COLOR_STANDARD, null);
675         op.withValue(VideoColumns.COLOR_TRANSFER, null);
676         op.withValue(VideoColumns.COLOR_RANGE, null);
677 
678         try (FileInputStream is = new FileInputStream(file)) {
679             try (MediaMetadataRetriever mmr = new MediaMetadataRetriever()) {
680                 mmr.setDataSource(is.getFD());
681 
682                 withOptionalValue(op, MediaColumns.TITLE,
683                         parseOptional(mmr.extractMetadata(METADATA_KEY_TITLE)));
684                 withOptionalValue(op, MediaColumns.IS_DRM,
685                         parseOptional(mmr.extractMetadata(METADATA_KEY_IS_DRM)));
686                 withOptionalValue(op, MediaColumns.WIDTH,
687                         parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_WIDTH)));
688                 withOptionalValue(op, MediaColumns.HEIGHT,
689                         parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_HEIGHT)));
690                 withOptionalValue(op, MediaColumns.DURATION,
691                         parseOptional(mmr.extractMetadata(METADATA_KEY_DURATION)));
692                 withOptionalValue(op, MediaColumns.DATE_TAKEN,
693                         parseOptionalDate(mmr.extractMetadata(METADATA_KEY_DATE)));
694 
695                 withOptionalValue(op, VideoColumns.ARTIST,
696                         parseOptional(mmr.extractMetadata(METADATA_KEY_ARTIST)));
697                 withOptionalValue(op, VideoColumns.ALBUM,
698                         parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUM)));
699                 withOptionalValue(op, VideoColumns.RESOLUTION,
700                         parseOptionalResolution(mmr));
701                 withOptionalValue(op, VideoColumns.COLOR_STANDARD,
702                         parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_STANDARD)));
703                 withOptionalValue(op, VideoColumns.COLOR_TRANSFER,
704                         parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_TRANSFER)));
705                 withOptionalValue(op, VideoColumns.COLOR_RANGE,
706                         parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_RANGE)));
707             }
708 
709             // Also hunt around for XMP metadata
710             final IsoInterface iso = IsoInterface.fromFileDescriptor(is.getFD());
711             final XmpInterface xmp = XmpInterface.fromContainer(iso);
712             withXmpValues(op, xmp, mimeType);
713 
714         } catch (Exception e) {
715             throw new IOException(e);
716         }
717         return op.build();
718     }
719 
scanItemImage(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName)720     private static @NonNull ContentProviderOperation scanItemImage(long existingId, File file,
721             BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException {
722         final ContentProviderOperation.Builder op = newUpsert(
723                 MediaStore.Images.Media.getContentUri(volumeName), existingId);
724 
725         withGenericValues(op, file, attrs, mimeType);
726         op.withValue(ImageColumns.DESCRIPTION, null);
727 
728         try (FileInputStream is = new FileInputStream(file)) {
729             final ExifInterface exif = new ExifInterface(is);
730 
731             withOptionalValue(op, MediaColumns.WIDTH,
732                     parseOptionalOrZero(exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)));
733             withOptionalValue(op, MediaColumns.HEIGHT,
734                     parseOptionalOrZero(exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)));
735             withOptionalValue(op, MediaColumns.DATE_TAKEN,
736                     parseOptionalDateTaken(exif, lastModifiedTime(file, attrs) * 1000));
737             withOptionalValue(op, MediaColumns.ORIENTATION,
738                     parseOptionalOrientation(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION,
739                             ExifInterface.ORIENTATION_UNDEFINED)));
740 
741             withOptionalValue(op, ImageColumns.DESCRIPTION,
742                     parseOptional(exif.getAttribute(ExifInterface.TAG_IMAGE_DESCRIPTION)));
743 
744             // Also hunt around for XMP metadata
745             final XmpInterface xmp = XmpInterface.fromContainer(exif);
746             withXmpValues(op, xmp, mimeType);
747 
748         } catch (Exception e) {
749             throw new IOException(e);
750         }
751         return op.build();
752     }
753 
scanItemFile(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName)754     private static @NonNull ContentProviderOperation scanItemFile(long existingId, File file,
755             BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException {
756         final ContentProviderOperation.Builder op = newUpsert(
757                 MediaStore.Files.getContentUri(volumeName), existingId);
758         try {
759             withGenericValues(op, file, attrs, mimeType);
760         } catch (Exception e) {
761             throw new IOException(e);
762         }
763         return op.build();
764     }
765 
newUpsert(Uri uri, long existingId)766     private static @NonNull ContentProviderOperation.Builder newUpsert(Uri uri, long existingId) {
767         if (existingId == -1) {
768             return ContentProviderOperation.newInsert(uri)
769                     .withFailureAllowed(true);
770         } else {
771             return ContentProviderOperation.newUpdate(ContentUris.withAppendedId(uri, existingId))
772                     .withExpectedCount(1)
773                     .withFailureAllowed(true);
774         }
775     }
776 
extractExtension(File file)777     public static @Nullable String extractExtension(File file) {
778         final String name = file.getName();
779         final int lastDot = name.lastIndexOf('.');
780         return (lastDot == -1) ? null : name.substring(lastDot + 1);
781     }
782 
extractName(File file)783     public static @NonNull String extractName(File file) {
784         final String name = file.getName();
785         final int lastDot = name.lastIndexOf('.');
786         return (lastDot == -1) ? name : name.substring(0, lastDot);
787     }
788 
parseOptional(@ullable T value)789     private static @NonNull <T> Optional<T> parseOptional(@Nullable T value) {
790         if (value == null) {
791             return Optional.empty();
792         } else if (value instanceof String && ((String) value).length() == 0) {
793             return Optional.empty();
794         } else if (value instanceof String && ((String) value).equals("-1")) {
795             return Optional.empty();
796         } else if (value instanceof Number && ((Number) value).intValue() == -1) {
797             return Optional.empty();
798         } else {
799             return Optional.of(value);
800         }
801     }
802 
parseOptionalOrZero(@ullable T value)803     private static @NonNull <T> Optional<T> parseOptionalOrZero(@Nullable T value) {
804         if (value instanceof String && ((String) value).equals("0")) {
805             return Optional.empty();
806         } else if (value instanceof Number && ((Number) value).intValue() == 0) {
807             return Optional.empty();
808         } else {
809             return parseOptional(value);
810         }
811     }
812 
813     /**
814      * Try our best to calculate {@link MediaColumns#DATE_TAKEN} in reference to
815      * the epoch, making our best guess from unrelated fields when offset
816      * information isn't directly available.
817      */
parseOptionalDateTaken(@onNull ExifInterface exif, @CurrentTimeMillisLong long lastModifiedTime)818     static @NonNull Optional<Long> parseOptionalDateTaken(@NonNull ExifInterface exif,
819             @CurrentTimeMillisLong long lastModifiedTime) {
820         final long originalTime = exif.getDateTimeOriginal();
821         if (exif.hasAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL)) {
822             // We have known offset information, return it directly!
823             return Optional.of(originalTime);
824         } else {
825             // Otherwise we need to guess the offset from unrelated fields
826             final long smallestZone = 15 * MINUTE_IN_MILLIS;
827             final long gpsTime = exif.getGpsDateTime();
828             if (gpsTime > 0) {
829                 final long offset = gpsTime - originalTime;
830                 if (Math.abs(offset) < 24 * HOUR_IN_MILLIS) {
831                     final long rounded = Math.round((float) offset / smallestZone) * smallestZone;
832                     return Optional.of(originalTime + rounded);
833                 }
834             }
835             if (lastModifiedTime > 0) {
836                 final long offset = lastModifiedTime - originalTime;
837                 if (Math.abs(offset) < 24 * HOUR_IN_MILLIS) {
838                     final long rounded = Math.round((float) offset / smallestZone) * smallestZone;
839                     return Optional.of(originalTime + rounded);
840                 }
841             }
842             return Optional.empty();
843         }
844     }
845 
parseOptionalOrientation(int orientation)846     private static @NonNull Optional<Integer> parseOptionalOrientation(int orientation) {
847         switch (orientation) {
848             case ExifInterface.ORIENTATION_NORMAL: return Optional.of(0);
849             case ExifInterface.ORIENTATION_ROTATE_90: return Optional.of(90);
850             case ExifInterface.ORIENTATION_ROTATE_180: return Optional.of(180);
851             case ExifInterface.ORIENTATION_ROTATE_270: return Optional.of(270);
852             default: return Optional.empty();
853         }
854     }
855 
parseOptionalResolution( @onNull MediaMetadataRetriever mmr)856     private static @NonNull Optional<String> parseOptionalResolution(
857             @NonNull MediaMetadataRetriever mmr) {
858         final Optional<?> width = parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_WIDTH));
859         final Optional<?> height = parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_HEIGHT));
860         if (width.isPresent() && height.isPresent()) {
861             return Optional.of(width.get() + "\u00d7" + height.get());
862         } else {
863             return Optional.empty();
864         }
865     }
866 
parseOptionalDate(@ullable String date)867     private static @NonNull Optional<Long> parseOptionalDate(@Nullable String date) {
868         if (TextUtils.isEmpty(date)) return Optional.empty();
869         try {
870             final long value = sDateFormat.parse(date).getTime();
871             return (value > 0) ? Optional.of(value) : Optional.empty();
872         } catch (ParseException e) {
873             return Optional.empty();
874         }
875     }
876 
877     /**
878      * Maybe replace the MIME type from extension with the MIME type from the
879      * XMP metadata, but only when the top-level MIME type agrees.
880      */
881     @VisibleForTesting
maybeOverrideMimeType(@onNull String extMimeType, @Nullable String xmpMimeType)882     public static @NonNull String maybeOverrideMimeType(@NonNull String extMimeType,
883             @Nullable String xmpMimeType) {
884         // Ignore XMP when missing
885         if (TextUtils.isEmpty(xmpMimeType)) return extMimeType;
886 
887         // Ignore XMP when invalid
888         final int xmpSplit = xmpMimeType.indexOf('/');
889         if (xmpSplit == -1) return extMimeType;
890 
891         if (extMimeType.regionMatches(0, xmpMimeType, 0, xmpSplit + 1)) {
892             return xmpMimeType;
893         } else {
894             return extMimeType;
895         }
896     }
897 
898     /**
899      * Return last modified time of given file. This value is typically read
900      * from the given {@link BasicFileAttributes}, except in the case of
901      * read-only partitions, where {@link Build#TIME} is used instead.
902      */
lastModifiedTime(@onNull File file, @NonNull BasicFileAttributes attrs)903     public static @CurrentTimeSecondsLong long lastModifiedTime(@NonNull File file,
904             @NonNull BasicFileAttributes attrs) {
905         if (FileUtils.contains(Environment.getStorageDirectory(), file)) {
906             return attrs.lastModifiedTime().toMillis() / 1000;
907         } else {
908             return Build.TIME / 1000;
909         }
910     }
911 
912     /**
913      * Test if any parents of given directory should be considered hidden.
914      */
isDirectoryHiddenRecursive(File dir)915     static boolean isDirectoryHiddenRecursive(File dir) {
916         Trace.traceBegin(TRACE_TAG_DATABASE, "isDirectoryHiddenRecursive");
917         try {
918             while (dir != null) {
919                 if (isDirectoryHidden(dir)) {
920                     return true;
921                 }
922                 dir = dir.getParentFile();
923             }
924             return false;
925         } finally {
926             Trace.traceEnd(TRACE_TAG_DATABASE);
927         }
928     }
929 
930     /**
931      * Test if this given directory should be considered hidden.
932      */
isDirectoryHidden(File dir)933     static boolean isDirectoryHidden(File dir) {
934         final File nomedia = new File(dir, ".nomedia");
935 
936         // Handle well-known paths that should always be visible or invisible,
937         // regardless of .nomedia presence
938         if (PATTERN_VISIBLE.matcher(dir.getAbsolutePath()).matches()) {
939             nomedia.delete();
940             return false;
941         }
942         if (PATTERN_INVISIBLE.matcher(dir.getAbsolutePath()).matches()) {
943             try {
944                 nomedia.createNewFile();
945             } catch (IOException ignored) {
946             }
947             return true;
948         }
949 
950         // Otherwise fall back to directory name or .nomedia presence
951         final String name = dir.getName();
952         if (name.startsWith(".")) {
953             return true;
954         }
955         if (nomedia.exists()) {
956             return true;
957         }
958         return false;
959     }
960 
961     /**
962      * Test if this given {@link Uri} is a
963      * {@link android.provider.MediaStore.Audio.Playlists} item.
964      */
isPlaylist(Uri uri)965     static boolean isPlaylist(Uri uri) {
966         final List<String> path = uri.getPathSegments();
967         return (path.size() == 4) && path.get(1).equals("audio") && path.get(2).equals("playlists");
968     }
969 
970     /**
971      * Escape the given argument for use in a {@code LIKE} statement.
972      */
escapeForLike(String arg)973     static String escapeForLike(String arg) {
974         final StringBuilder sb = new StringBuilder();
975         for (int i = 0; i < arg.length(); i++) {
976             final char c = arg.charAt(i);
977             switch (c) {
978                 case '%': sb.append('\\');
979                 case '_': sb.append('\\');
980             }
981             sb.append(c);
982         }
983         return sb.toString();
984     }
985 }
986