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