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