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