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