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