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_CD_TRACK_NUMBER; 23 import static android.media.MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE; 24 import static android.media.MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD; 25 import static android.media.MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER; 26 import static android.media.MediaMetadataRetriever.METADATA_KEY_COMPILATION; 27 import static android.media.MediaMetadataRetriever.METADATA_KEY_COMPOSER; 28 import static android.media.MediaMetadataRetriever.METADATA_KEY_DATE; 29 import static android.media.MediaMetadataRetriever.METADATA_KEY_DURATION; 30 import static android.media.MediaMetadataRetriever.METADATA_KEY_GENRE; 31 import static android.media.MediaMetadataRetriever.METADATA_KEY_IS_DRM; 32 import static android.media.MediaMetadataRetriever.METADATA_KEY_TITLE; 33 import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT; 34 import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH; 35 import static android.media.MediaMetadataRetriever.METADATA_KEY_YEAR; 36 import static android.os.Trace.TRACE_TAG_DATABASE; 37 import static android.provider.MediaStore.AUTHORITY; 38 import static android.provider.MediaStore.UNKNOWN_STRING; 39 import static android.text.format.DateUtils.HOUR_IN_MILLIS; 40 import static android.text.format.DateUtils.MINUTE_IN_MILLIS; 41 42 import android.annotation.CurrentTimeMillisLong; 43 import android.annotation.CurrentTimeSecondsLong; 44 import android.annotation.NonNull; 45 import android.annotation.Nullable; 46 import android.content.ContentProviderClient; 47 import android.content.ContentProviderOperation; 48 import android.content.ContentProviderResult; 49 import android.content.ContentResolver; 50 import android.content.ContentUris; 51 import android.content.Context; 52 import android.content.OperationApplicationException; 53 import android.database.Cursor; 54 import android.database.sqlite.SQLiteDatabase; 55 import android.media.ExifInterface; 56 import android.media.MediaFile; 57 import android.media.MediaMetadataRetriever; 58 import android.mtp.MtpConstants; 59 import android.net.Uri; 60 import android.os.Build; 61 import android.os.CancellationSignal; 62 import android.os.Environment; 63 import android.os.FileUtils; 64 import android.os.OperationCanceledException; 65 import android.os.RemoteException; 66 import android.os.Trace; 67 import android.provider.MediaStore; 68 import android.provider.MediaStore.Audio.AudioColumns; 69 import android.provider.MediaStore.Audio.PlaylistsColumns; 70 import android.provider.MediaStore.Files.FileColumns; 71 import android.provider.MediaStore.Images.ImageColumns; 72 import android.provider.MediaStore.MediaColumns; 73 import android.provider.MediaStore.Video.VideoColumns; 74 import android.text.TextUtils; 75 import android.util.ArrayMap; 76 import android.util.Log; 77 import android.util.LongArray; 78 79 import com.android.internal.annotations.GuardedBy; 80 import com.android.internal.annotations.VisibleForTesting; 81 import com.android.providers.media.util.IsoInterface; 82 import com.android.providers.media.util.XmpInterface; 83 84 import libcore.net.MimeUtils; 85 86 import java.io.File; 87 import java.io.FileInputStream; 88 import java.io.IOException; 89 import java.nio.file.FileVisitResult; 90 import java.nio.file.FileVisitor; 91 import java.nio.file.Files; 92 import java.nio.file.Path; 93 import java.nio.file.attribute.BasicFileAttributes; 94 import java.text.ParseException; 95 import java.text.SimpleDateFormat; 96 import java.util.ArrayList; 97 import java.util.Arrays; 98 import java.util.List; 99 import java.util.Locale; 100 import java.util.Optional; 101 import java.util.TimeZone; 102 import java.util.regex.Pattern; 103 104 /** 105 * Modern implementation of media scanner. 106 * <p> 107 * This is a bug-compatible reimplementation of the legacy media scanner, but 108 * written purely in managed code for better testability and long-term 109 * maintainability. 110 * <p> 111 * Initial tests shows it performing roughly on-par with the legacy scanner. 112 * <p> 113 * In general, we start by populating metadata based on file attributes, and 114 * then overwrite with any valid metadata found using 115 * {@link MediaMetadataRetriever}, {@link ExifInterface}, and 116 * {@link XmpInterface}, each with increasing levels of trust. 117 */ 118 public class ModernMediaScanner implements MediaScanner { 119 private static final String TAG = "ModernMediaScanner"; 120 private static final boolean LOGW = Log.isLoggable(TAG, Log.WARN); 121 private static final boolean LOGD = Log.isLoggable(TAG, Log.DEBUG); 122 private static final boolean LOGV = Log.isLoggable(TAG, Log.VERBOSE); 123 124 // TODO: add DRM support 125 126 // TODO: refactor to use UPSERT once we have SQLite 3.24.0 127 128 // TODO: deprecate playlist editing 129 // TODO: deprecate PARENT column, since callers can't see directories 130 131 private static final SimpleDateFormat sDateFormat; 132 133 static { 134 sDateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss"); 135 sDateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); 136 } 137 138 private static final int BATCH_SIZE = 32; 139 140 private static final Pattern PATTERN_VISIBLE = Pattern.compile( 141 "(?i)^/storage/[^/]+(?:/[0-9]+)?(?:/Android/sandbox/([^/]+))?$"); 142 private static final Pattern PATTERN_INVISIBLE = Pattern.compile( 143 "(?i)^/storage/[^/]+(?:/[0-9]+)?(?:/Android/sandbox/([^/]+))?/Android/(?:data|obb)$"); 144 145 private final Context mContext; 146 147 /** 148 * Map from volume name to signals that can be used to cancel any active 149 * scan operations on those volumes. 150 */ 151 @GuardedBy("mSignals") 152 private final ArrayMap<String, CancellationSignal> mSignals = new ArrayMap<>(); 153 ModernMediaScanner(Context context)154 public ModernMediaScanner(Context context) { 155 mContext = context; 156 } 157 158 @Override getContext()159 public Context getContext() { 160 return mContext; 161 } 162 163 @Override scanDirectory(File file)164 public void scanDirectory(File file) { 165 try (Scan scan = new Scan(file)) { 166 scan.run(); 167 } catch (OperationCanceledException ignored) { 168 } 169 } 170 171 @Override scanFile(File file)172 public Uri scanFile(File file) { 173 try (Scan scan = new Scan(file)) { 174 scan.run(); 175 return scan.mFirstResult; 176 } catch (OperationCanceledException ignored) { 177 return null; 178 } 179 } 180 181 @Override onDetachVolume(String volumeName)182 public void onDetachVolume(String volumeName) { 183 synchronized (mSignals) { 184 final CancellationSignal signal = mSignals.remove(volumeName); 185 if (signal != null) { 186 signal.cancel(); 187 } 188 } 189 } 190 getOrCreateSignal(String volumeName)191 private CancellationSignal getOrCreateSignal(String volumeName) { 192 synchronized (mSignals) { 193 CancellationSignal signal = mSignals.get(volumeName); 194 if (signal == null) { 195 signal = new CancellationSignal(); 196 mSignals.put(volumeName, signal); 197 } 198 return signal; 199 } 200 } 201 202 /** 203 * Individual scan request for a specific file or directory. When run it 204 * will traverse all included media files under the requested location, 205 * reconciling them against {@link MediaStore}. 206 */ 207 private class Scan implements Runnable, FileVisitor<Path>, AutoCloseable { 208 private final ContentProviderClient mClient; 209 private final ContentResolver mResolver; 210 211 private final File mRoot; 212 private final String mVolumeName; 213 private final Uri mFilesUri; 214 private final CancellationSignal mSignal; 215 216 private final boolean mSingleFile; 217 private final ArrayList<ContentProviderOperation> mPending = new ArrayList<>(); 218 private LongArray mScannedIds = new LongArray(); 219 private LongArray mUnknownIds = new LongArray(); 220 private LongArray mPlaylistIds = new LongArray(); 221 222 private Uri mFirstResult; 223 Scan(File root)224 public Scan(File root) { 225 Trace.traceBegin(TRACE_TAG_DATABASE, "ctor"); 226 227 mClient = mContext.getContentResolver() 228 .acquireContentProviderClient(MediaStore.AUTHORITY); 229 mResolver = ContentResolver.wrap(mClient.getLocalContentProvider()); 230 231 mRoot = root; 232 mVolumeName = MediaStore.getVolumeName(root); 233 mFilesUri = MediaStore.setIncludePending(MediaStore.Files.getContentUri(mVolumeName)); 234 mSignal = getOrCreateSignal(mVolumeName); 235 236 mSingleFile = mRoot.isFile(); 237 238 Trace.traceEnd(TRACE_TAG_DATABASE); 239 } 240 241 @Override run()242 public void run() { 243 // First, scan everything that should be visible under requested 244 // location, tracking scanned IDs along the way 245 walkFileTree(); 246 247 // Second, reconcile all items known in the database against all the 248 // items we scanned above 249 if (mSingleFile && mScannedIds.size() == 1) { 250 // We can safely skip this step if the scan targeted a single 251 // file which we scanned above 252 } else { 253 reconcileAndClean(); 254 } 255 256 // Third, resolve any playlists that we scanned 257 if (mPlaylistIds.size() > 0) { 258 resolvePlaylists(); 259 } 260 } 261 walkFileTree()262 private void walkFileTree() { 263 mSignal.throwIfCanceled(); 264 if (!isDirectoryHiddenRecursive(mSingleFile ? mRoot.getParentFile() : mRoot)) { 265 Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "walkFileTree"); 266 try { 267 Files.walkFileTree(mRoot.toPath(), this); 268 } catch (IOException e) { 269 // This should never happen, so yell loudly 270 throw new IllegalStateException(e); 271 } finally { 272 Trace.traceEnd(Trace.TRACE_TAG_DATABASE); 273 } 274 applyPending(); 275 } 276 } 277 reconcileAndClean()278 private void reconcileAndClean() { 279 final long[] scannedIds = mScannedIds.toArray(); 280 Arrays.sort(scannedIds); 281 282 // The query phase is split from the delete phase so that our query 283 // remains stable if we need to paginate across multiple windows. 284 mSignal.throwIfCanceled(); 285 Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "reconcile"); 286 try (Cursor c = mResolver.query(mFilesUri, 287 new String[]{FileColumns._ID}, 288 FileColumns.FORMAT + "!=? AND " + FileColumns.DATA + " LIKE ? ESCAPE '\\'", 289 new String[]{ 290 // Ignore abstract playlists which don't have files on disk 291 String.valueOf(MtpConstants.FORMAT_ABSTRACT_AV_PLAYLIST), 292 escapeForLike(mRoot.getAbsolutePath()) + '%' 293 }, 294 FileColumns._ID + " DESC", mSignal)) { 295 while (c.moveToNext()) { 296 final long id = c.getLong(0); 297 if (Arrays.binarySearch(scannedIds, id) < 0) { 298 mUnknownIds.add(id); 299 } 300 } 301 } finally { 302 Trace.traceEnd(Trace.TRACE_TAG_DATABASE); 303 } 304 305 // Third, clean all the unknown database entries found above 306 mSignal.throwIfCanceled(); 307 Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "clean"); 308 try { 309 for (int i = 0; i < mUnknownIds.size(); i++) { 310 final long id = mUnknownIds.get(i); 311 if (LOGV) Log.v(TAG, "Cleaning " + id); 312 final Uri uri = MediaStore.Files.getContentUri(mVolumeName, id).buildUpon() 313 .appendQueryParameter(MediaStore.PARAM_DELETE_DATA, "false") 314 .build(); 315 mPending.add(ContentProviderOperation.newDelete(uri).build()); 316 maybeApplyPending(); 317 } 318 applyPending(); 319 } finally { 320 Trace.traceEnd(Trace.TRACE_TAG_DATABASE); 321 } 322 } 323 resolvePlaylists()324 private void resolvePlaylists() { 325 mSignal.throwIfCanceled(); 326 for (int i = 0; i < mPlaylistIds.size(); i++) { 327 final Uri uri = MediaStore.Files.getContentUri(mVolumeName, mPlaylistIds.get(i)); 328 try { 329 mPending.addAll( 330 PlaylistResolver.resolvePlaylist(mResolver, uri)); 331 maybeApplyPending(); 332 } catch (IOException e) { 333 if (LOGW) Log.w(TAG, "Ignoring troubled playlist: " + uri, e); 334 } 335 applyPending(); 336 } 337 } 338 339 @Override close()340 public void close() { 341 // Sanity check that we drained any pending operations 342 if (!mPending.isEmpty()) { 343 throw new IllegalStateException(); 344 } 345 346 mClient.close(); 347 } 348 349 @Override preVisitDirectory(Path dir, BasicFileAttributes attrs)350 public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) 351 throws IOException { 352 // Possibly bail before digging into each directory 353 mSignal.throwIfCanceled(); 354 355 if (isDirectoryHidden(dir.toFile())) { 356 return FileVisitResult.SKIP_SUBTREE; 357 } 358 359 // Scan this directory as a normal file so that "parent" database 360 // entries are created 361 return visitFile(dir, attrs); 362 } 363 364 @Override visitFile(Path file, BasicFileAttributes attrs)365 public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) 366 throws IOException { 367 if (LOGV) Log.v(TAG, "Visiting " + file); 368 369 // Skip files that have already been scanned, and which haven't 370 // changed since they were last scanned 371 final File realFile = file.toFile(); 372 long existingId = -1; 373 Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "checkChanged"); 374 try (Cursor c = mResolver.query(mFilesUri, 375 new String[] { FileColumns._ID, FileColumns.DATE_MODIFIED, FileColumns.SIZE }, 376 FileColumns.DATA + "=?", new String[] { realFile.getAbsolutePath() }, null)) { 377 if (c.moveToFirst()) { 378 existingId = c.getLong(0); 379 final long dateModified = c.getLong(1); 380 final long size = c.getLong(2); 381 382 // Remember visiting this existing item, even if we skipped 383 // due to it being unchanged; this is needed so we don't 384 // delete the item during a later cleaning phase 385 mScannedIds.add(existingId); 386 387 // We also technically found our first result 388 if (mFirstResult == null) { 389 mFirstResult = MediaStore.Files.getContentUri(mVolumeName, existingId); 390 } 391 392 final boolean sameTime = (lastModifiedTime(realFile, attrs) == dateModified); 393 final boolean sameSize = (attrs.size() == size); 394 if (attrs.isDirectory() || (sameTime && sameSize)) { 395 if (LOGV) Log.v(TAG, "Skipping unchanged " + file); 396 return FileVisitResult.CONTINUE; 397 } 398 } 399 } finally { 400 Trace.traceEnd(Trace.TRACE_TAG_DATABASE); 401 } 402 403 final ContentProviderOperation op; 404 Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "scanItem"); 405 try { 406 op = scanItem(existingId, file.toFile(), attrs, mVolumeName); 407 } finally { 408 Trace.traceEnd(Trace.TRACE_TAG_DATABASE); 409 } 410 if (op != null) { 411 mPending.add(op); 412 maybeApplyPending(); 413 } 414 return FileVisitResult.CONTINUE; 415 } 416 417 @Override visitFileFailed(Path file, IOException exc)418 public FileVisitResult visitFileFailed(Path file, IOException exc) 419 throws IOException { 420 Log.w(TAG, "Failed to visit " + file + ": " + exc); 421 return FileVisitResult.CONTINUE; 422 } 423 424 @Override postVisitDirectory(Path dir, IOException exc)425 public FileVisitResult postVisitDirectory(Path dir, IOException exc) 426 throws IOException { 427 return FileVisitResult.CONTINUE; 428 } 429 maybeApplyPending()430 private void maybeApplyPending() { 431 if (mPending.size() > BATCH_SIZE) { 432 applyPending(); 433 } 434 } 435 applyPending()436 private void applyPending() { 437 Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "applyPending"); 438 try { 439 ContentProviderResult[] results = mResolver.applyBatch(AUTHORITY, mPending); 440 for (int index = 0; index < results.length; index++) { 441 ContentProviderResult result = results[index]; 442 ContentProviderOperation operation = mPending.get(index); 443 444 Uri uri = result.uri; 445 if (uri != null) { 446 if (mFirstResult == null) { 447 mFirstResult = uri; 448 } 449 final long id = ContentUris.parseId(uri); 450 mScannedIds.add(id); 451 } 452 453 // Some operations don't return a URI, so check the original if necessary 454 Uri uriToCheck = uri == null ? operation.getUri() : uri; 455 if (uriToCheck != null) { 456 if (isPlaylist(uriToCheck)) { 457 // If this was a playlist, remember it so we can resolve 458 // its contents once all other media has been scanned 459 mPlaylistIds.add(ContentUris.parseId(uriToCheck)); 460 } 461 } 462 } 463 } catch (RemoteException | OperationApplicationException e) { 464 Log.w(TAG, "Failed to apply: " + e); 465 } finally { 466 mPending.clear(); 467 Trace.traceEnd(Trace.TRACE_TAG_DATABASE); 468 } 469 } 470 } 471 472 /** 473 * Scan the requested file, returning a {@link ContentProviderOperation} 474 * containing all indexed metadata, suitable for passing to a 475 * {@link SQLiteDatabase#replace} operation. 476 */ scanItem(long existingId, File file, BasicFileAttributes attrs, String volumeName)477 private static @Nullable ContentProviderOperation scanItem(long existingId, File file, 478 BasicFileAttributes attrs, String volumeName) { 479 final String name = file.getName(); 480 if (name.startsWith(".")) { 481 if (LOGD) Log.d(TAG, "Ignoring hidden file: " + file); 482 return null; 483 } 484 485 try { 486 final String mimeType; 487 if (attrs.isDirectory()) { 488 mimeType = null; 489 } else { 490 mimeType = MediaFile.getMimeTypeForFile(file.getPath()); 491 } 492 493 if (attrs.isDirectory()) { 494 return scanItemDirectory(existingId, file, attrs, mimeType, volumeName); 495 } else if (MediaFile.isPlayListMimeType(mimeType)) { 496 return scanItemPlaylist(existingId, file, attrs, mimeType, volumeName); 497 } else if (MediaFile.isAudioMimeType(mimeType)) { 498 return scanItemAudio(existingId, file, attrs, mimeType, volumeName); 499 } else if (MediaFile.isVideoMimeType(mimeType)) { 500 return scanItemVideo(existingId, file, attrs, mimeType, volumeName); 501 } else if (MediaFile.isImageMimeType(mimeType)) { 502 return scanItemImage(existingId, file, attrs, mimeType, volumeName); 503 } else { 504 return scanItemFile(existingId, file, attrs, mimeType, volumeName); 505 } 506 } catch (IOException e) { 507 if (LOGW) Log.w(TAG, "Ignoring troubled file: " + file, e); 508 return null; 509 } 510 } 511 512 /** 513 * Populate the given {@link ContentProviderOperation} with the generic 514 * {@link MediaColumns} values that can be determined directly from the file 515 * or its attributes. 516 */ withGenericValues(ContentProviderOperation.Builder op, File file, BasicFileAttributes attrs, String mimeType)517 private static void withGenericValues(ContentProviderOperation.Builder op, 518 File file, BasicFileAttributes attrs, String mimeType) { 519 op.withValue(MediaColumns.DATA, file.getAbsolutePath()); 520 op.withValue(MediaColumns.SIZE, attrs.size()); 521 op.withValue(MediaColumns.TITLE, extractName(file)); 522 op.withValue(MediaColumns.DATE_MODIFIED, lastModifiedTime(file, attrs)); 523 op.withValue(MediaColumns.DATE_TAKEN, null); 524 op.withValue(MediaColumns.MIME_TYPE, mimeType); 525 op.withValue(MediaColumns.IS_DRM, 0); 526 op.withValue(MediaColumns.WIDTH, null); 527 op.withValue(MediaColumns.HEIGHT, null); 528 op.withValue(MediaColumns.DOCUMENT_ID, null); 529 op.withValue(MediaColumns.INSTANCE_ID, null); 530 op.withValue(MediaColumns.ORIGINAL_DOCUMENT_ID, null); 531 op.withValue(MediaColumns.DURATION, null); 532 op.withValue(MediaColumns.ORIENTATION, null); 533 } 534 535 /** 536 * Populate the given {@link ContentProviderOperation} with the generic 537 * {@link MediaColumns} values using the given XMP metadata. 538 */ withXmpValues(ContentProviderOperation.Builder op, XmpInterface xmp, String mimeType)539 private static void withXmpValues(ContentProviderOperation.Builder op, 540 XmpInterface xmp, String mimeType) { 541 op.withValue(MediaColumns.MIME_TYPE, 542 maybeOverrideMimeType(mimeType, xmp.getFormat())); 543 op.withValue(MediaColumns.DOCUMENT_ID, xmp.getDocumentId()); 544 op.withValue(MediaColumns.INSTANCE_ID, xmp.getInstanceId()); 545 op.withValue(MediaColumns.ORIGINAL_DOCUMENT_ID, xmp.getOriginalDocumentId()); 546 } 547 548 /** 549 * Overwrite a value in the given {@link ContentProviderOperation}, but only 550 * when the given {@link Optional} value is present. 551 */ withOptionalValue(ContentProviderOperation.Builder op, String key, Optional<?> value)552 private static void withOptionalValue(ContentProviderOperation.Builder op, 553 String key, Optional<?> value) { 554 if (value.isPresent()) { 555 op.withValue(key, value.get()); 556 } 557 } 558 scanItemDirectory(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName)559 private static @NonNull ContentProviderOperation scanItemDirectory(long existingId, File file, 560 BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException { 561 final ContentProviderOperation.Builder op = newUpsert( 562 MediaStore.Files.getContentUri(volumeName), existingId); 563 try { 564 withGenericValues(op, file, attrs, mimeType); 565 op.withValue(FileColumns.MEDIA_TYPE, 0); 566 op.withValue(FileColumns.FORMAT, MtpConstants.FORMAT_ASSOCIATION); 567 op.withValue(FileColumns.MIME_TYPE, null); 568 } catch (Exception e) { 569 throw new IOException(e); 570 } 571 return op.build(); 572 } 573 574 private static ArrayMap<String, String> sAudioTypes = new ArrayMap<>(); 575 576 static { sAudioTypes.put(Environment.DIRECTORY_RINGTONES, AudioColumns.IS_RINGTONE)577 sAudioTypes.put(Environment.DIRECTORY_RINGTONES, AudioColumns.IS_RINGTONE); sAudioTypes.put(Environment.DIRECTORY_NOTIFICATIONS, AudioColumns.IS_NOTIFICATION)578 sAudioTypes.put(Environment.DIRECTORY_NOTIFICATIONS, AudioColumns.IS_NOTIFICATION); sAudioTypes.put(Environment.DIRECTORY_ALARMS, AudioColumns.IS_ALARM)579 sAudioTypes.put(Environment.DIRECTORY_ALARMS, AudioColumns.IS_ALARM); sAudioTypes.put(Environment.DIRECTORY_PODCASTS, AudioColumns.IS_PODCAST)580 sAudioTypes.put(Environment.DIRECTORY_PODCASTS, AudioColumns.IS_PODCAST); sAudioTypes.put(Environment.DIRECTORY_AUDIOBOOKS, AudioColumns.IS_AUDIOBOOK)581 sAudioTypes.put(Environment.DIRECTORY_AUDIOBOOKS, AudioColumns.IS_AUDIOBOOK); sAudioTypes.put(Environment.DIRECTORY_MUSIC, AudioColumns.IS_MUSIC)582 sAudioTypes.put(Environment.DIRECTORY_MUSIC, AudioColumns.IS_MUSIC); 583 } 584 scanItemAudio(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName)585 private static @NonNull ContentProviderOperation scanItemAudio(long existingId, File file, 586 BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException { 587 final ContentProviderOperation.Builder op = newUpsert( 588 MediaStore.Audio.Media.getContentUri(volumeName), existingId); 589 590 withGenericValues(op, file, attrs, mimeType); 591 op.withValue(AudioColumns.ARTIST, UNKNOWN_STRING); 592 op.withValue(AudioColumns.ALBUM_ARTIST, null); 593 op.withValue(AudioColumns.COMPILATION, null); 594 op.withValue(AudioColumns.COMPOSER, null); 595 op.withValue(AudioColumns.ALBUM, file.getParentFile().getName()); 596 op.withValue(AudioColumns.TRACK, null); 597 op.withValue(AudioColumns.YEAR, null); 598 op.withValue(AudioColumns.GENRE, null); 599 600 final String lowPath = file.getAbsolutePath().toLowerCase(Locale.ROOT); 601 boolean anyMatch = false; 602 for (int i = 0; i < sAudioTypes.size(); i++) { 603 final boolean match = lowPath 604 .contains('/' + sAudioTypes.keyAt(i).toLowerCase(Locale.ROOT) + '/'); 605 op.withValue(sAudioTypes.valueAt(i), match ? 1 : 0); 606 anyMatch |= match; 607 } 608 if (!anyMatch) { 609 op.withValue(AudioColumns.IS_MUSIC, 1); 610 } 611 612 try (FileInputStream is = new FileInputStream(file)) { 613 try (MediaMetadataRetriever mmr = new MediaMetadataRetriever()) { 614 mmr.setDataSource(is.getFD()); 615 616 withOptionalValue(op, MediaColumns.TITLE, 617 parseOptional(mmr.extractMetadata(METADATA_KEY_TITLE))); 618 withOptionalValue(op, MediaColumns.IS_DRM, 619 parseOptional(mmr.extractMetadata(METADATA_KEY_IS_DRM))); 620 withOptionalValue(op, MediaColumns.DURATION, 621 parseOptional(mmr.extractMetadata(METADATA_KEY_DURATION))); 622 623 withOptionalValue(op, AudioColumns.ARTIST, 624 parseOptional(mmr.extractMetadata(METADATA_KEY_ARTIST))); 625 withOptionalValue(op, AudioColumns.ALBUM_ARTIST, 626 parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUMARTIST))); 627 withOptionalValue(op, AudioColumns.COMPILATION, 628 parseOptional(mmr.extractMetadata(METADATA_KEY_COMPILATION))); 629 withOptionalValue(op, AudioColumns.COMPOSER, 630 parseOptional(mmr.extractMetadata(METADATA_KEY_COMPOSER))); 631 withOptionalValue(op, AudioColumns.ALBUM, 632 parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUM))); 633 withOptionalValue(op, AudioColumns.TRACK, 634 parseOptional(mmr.extractMetadata(METADATA_KEY_CD_TRACK_NUMBER))); 635 withOptionalValue(op, AudioColumns.YEAR, 636 parseOptionalOrZero(mmr.extractMetadata(METADATA_KEY_YEAR))); 637 withOptionalValue(op, AudioColumns.GENRE, 638 parseOptional(mmr.extractMetadata(METADATA_KEY_GENRE))); 639 } 640 641 // Also hunt around for XMP metadata 642 final IsoInterface iso = IsoInterface.fromFileDescriptor(is.getFD()); 643 final XmpInterface xmp = XmpInterface.fromContainer(iso); 644 withXmpValues(op, xmp, mimeType); 645 646 } catch (Exception e) { 647 throw new IOException(e); 648 } 649 return op.build(); 650 } 651 scanItemPlaylist(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName)652 private static @NonNull ContentProviderOperation scanItemPlaylist(long existingId, File file, 653 BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException { 654 final ContentProviderOperation.Builder op = newUpsert( 655 MediaStore.Audio.Playlists.getContentUri(volumeName), existingId); 656 try { 657 withGenericValues(op, file, attrs, mimeType); 658 op.withValue(PlaylistsColumns.NAME, extractName(file)); 659 } catch (Exception e) { 660 throw new IOException(e); 661 } 662 return op.build(); 663 } 664 scanItemVideo(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName)665 private static @NonNull ContentProviderOperation scanItemVideo(long existingId, File file, 666 BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException { 667 final ContentProviderOperation.Builder op = newUpsert( 668 MediaStore.Video.Media.getContentUri(volumeName), existingId); 669 670 withGenericValues(op, file, attrs, mimeType); 671 op.withValue(VideoColumns.ARTIST, UNKNOWN_STRING); 672 op.withValue(VideoColumns.ALBUM, file.getParentFile().getName()); 673 op.withValue(VideoColumns.RESOLUTION, null); 674 op.withValue(VideoColumns.COLOR_STANDARD, null); 675 op.withValue(VideoColumns.COLOR_TRANSFER, null); 676 op.withValue(VideoColumns.COLOR_RANGE, null); 677 678 try (FileInputStream is = new FileInputStream(file)) { 679 try (MediaMetadataRetriever mmr = new MediaMetadataRetriever()) { 680 mmr.setDataSource(is.getFD()); 681 682 withOptionalValue(op, MediaColumns.TITLE, 683 parseOptional(mmr.extractMetadata(METADATA_KEY_TITLE))); 684 withOptionalValue(op, MediaColumns.IS_DRM, 685 parseOptional(mmr.extractMetadata(METADATA_KEY_IS_DRM))); 686 withOptionalValue(op, MediaColumns.WIDTH, 687 parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_WIDTH))); 688 withOptionalValue(op, MediaColumns.HEIGHT, 689 parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_HEIGHT))); 690 withOptionalValue(op, MediaColumns.DURATION, 691 parseOptional(mmr.extractMetadata(METADATA_KEY_DURATION))); 692 withOptionalValue(op, MediaColumns.DATE_TAKEN, 693 parseOptionalDate(mmr.extractMetadata(METADATA_KEY_DATE))); 694 695 withOptionalValue(op, VideoColumns.ARTIST, 696 parseOptional(mmr.extractMetadata(METADATA_KEY_ARTIST))); 697 withOptionalValue(op, VideoColumns.ALBUM, 698 parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUM))); 699 withOptionalValue(op, VideoColumns.RESOLUTION, 700 parseOptionalResolution(mmr)); 701 withOptionalValue(op, VideoColumns.COLOR_STANDARD, 702 parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_STANDARD))); 703 withOptionalValue(op, VideoColumns.COLOR_TRANSFER, 704 parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_TRANSFER))); 705 withOptionalValue(op, VideoColumns.COLOR_RANGE, 706 parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_RANGE))); 707 } 708 709 // Also hunt around for XMP metadata 710 final IsoInterface iso = IsoInterface.fromFileDescriptor(is.getFD()); 711 final XmpInterface xmp = XmpInterface.fromContainer(iso); 712 withXmpValues(op, xmp, mimeType); 713 714 } catch (Exception e) { 715 throw new IOException(e); 716 } 717 return op.build(); 718 } 719 scanItemImage(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName)720 private static @NonNull ContentProviderOperation scanItemImage(long existingId, File file, 721 BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException { 722 final ContentProviderOperation.Builder op = newUpsert( 723 MediaStore.Images.Media.getContentUri(volumeName), existingId); 724 725 withGenericValues(op, file, attrs, mimeType); 726 op.withValue(ImageColumns.DESCRIPTION, null); 727 728 try (FileInputStream is = new FileInputStream(file)) { 729 final ExifInterface exif = new ExifInterface(is); 730 731 withOptionalValue(op, MediaColumns.WIDTH, 732 parseOptionalOrZero(exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH))); 733 withOptionalValue(op, MediaColumns.HEIGHT, 734 parseOptionalOrZero(exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH))); 735 withOptionalValue(op, MediaColumns.DATE_TAKEN, 736 parseOptionalDateTaken(exif, lastModifiedTime(file, attrs) * 1000)); 737 withOptionalValue(op, MediaColumns.ORIENTATION, 738 parseOptionalOrientation(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 739 ExifInterface.ORIENTATION_UNDEFINED))); 740 741 withOptionalValue(op, ImageColumns.DESCRIPTION, 742 parseOptional(exif.getAttribute(ExifInterface.TAG_IMAGE_DESCRIPTION))); 743 744 // Also hunt around for XMP metadata 745 final XmpInterface xmp = XmpInterface.fromContainer(exif); 746 withXmpValues(op, xmp, mimeType); 747 748 } catch (Exception e) { 749 throw new IOException(e); 750 } 751 return op.build(); 752 } 753 scanItemFile(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName)754 private static @NonNull ContentProviderOperation scanItemFile(long existingId, File file, 755 BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException { 756 final ContentProviderOperation.Builder op = newUpsert( 757 MediaStore.Files.getContentUri(volumeName), existingId); 758 try { 759 withGenericValues(op, file, attrs, mimeType); 760 } catch (Exception e) { 761 throw new IOException(e); 762 } 763 return op.build(); 764 } 765 newUpsert(Uri uri, long existingId)766 private static @NonNull ContentProviderOperation.Builder newUpsert(Uri uri, long existingId) { 767 if (existingId == -1) { 768 return ContentProviderOperation.newInsert(uri) 769 .withFailureAllowed(true); 770 } else { 771 return ContentProviderOperation.newUpdate(ContentUris.withAppendedId(uri, existingId)) 772 .withExpectedCount(1) 773 .withFailureAllowed(true); 774 } 775 } 776 extractExtension(File file)777 public static @Nullable String extractExtension(File file) { 778 final String name = file.getName(); 779 final int lastDot = name.lastIndexOf('.'); 780 return (lastDot == -1) ? null : name.substring(lastDot + 1); 781 } 782 extractName(File file)783 public static @NonNull String extractName(File file) { 784 final String name = file.getName(); 785 final int lastDot = name.lastIndexOf('.'); 786 return (lastDot == -1) ? name : name.substring(0, lastDot); 787 } 788 parseOptional(@ullable T value)789 private static @NonNull <T> Optional<T> parseOptional(@Nullable T value) { 790 if (value == null) { 791 return Optional.empty(); 792 } else if (value instanceof String && ((String) value).length() == 0) { 793 return Optional.empty(); 794 } else if (value instanceof String && ((String) value).equals("-1")) { 795 return Optional.empty(); 796 } else if (value instanceof Number && ((Number) value).intValue() == -1) { 797 return Optional.empty(); 798 } else { 799 return Optional.of(value); 800 } 801 } 802 parseOptionalOrZero(@ullable T value)803 private static @NonNull <T> Optional<T> parseOptionalOrZero(@Nullable T value) { 804 if (value instanceof String && ((String) value).equals("0")) { 805 return Optional.empty(); 806 } else if (value instanceof Number && ((Number) value).intValue() == 0) { 807 return Optional.empty(); 808 } else { 809 return parseOptional(value); 810 } 811 } 812 813 /** 814 * Try our best to calculate {@link MediaColumns#DATE_TAKEN} in reference to 815 * the epoch, making our best guess from unrelated fields when offset 816 * information isn't directly available. 817 */ parseOptionalDateTaken(@onNull ExifInterface exif, @CurrentTimeMillisLong long lastModifiedTime)818 static @NonNull Optional<Long> parseOptionalDateTaken(@NonNull ExifInterface exif, 819 @CurrentTimeMillisLong long lastModifiedTime) { 820 final long originalTime = exif.getDateTimeOriginal(); 821 if (exif.hasAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL)) { 822 // We have known offset information, return it directly! 823 return Optional.of(originalTime); 824 } else { 825 // Otherwise we need to guess the offset from unrelated fields 826 final long smallestZone = 15 * MINUTE_IN_MILLIS; 827 final long gpsTime = exif.getGpsDateTime(); 828 if (gpsTime > 0) { 829 final long offset = gpsTime - originalTime; 830 if (Math.abs(offset) < 24 * HOUR_IN_MILLIS) { 831 final long rounded = Math.round((float) offset / smallestZone) * smallestZone; 832 return Optional.of(originalTime + rounded); 833 } 834 } 835 if (lastModifiedTime > 0) { 836 final long offset = lastModifiedTime - originalTime; 837 if (Math.abs(offset) < 24 * HOUR_IN_MILLIS) { 838 final long rounded = Math.round((float) offset / smallestZone) * smallestZone; 839 return Optional.of(originalTime + rounded); 840 } 841 } 842 return Optional.empty(); 843 } 844 } 845 parseOptionalOrientation(int orientation)846 private static @NonNull Optional<Integer> parseOptionalOrientation(int orientation) { 847 switch (orientation) { 848 case ExifInterface.ORIENTATION_NORMAL: return Optional.of(0); 849 case ExifInterface.ORIENTATION_ROTATE_90: return Optional.of(90); 850 case ExifInterface.ORIENTATION_ROTATE_180: return Optional.of(180); 851 case ExifInterface.ORIENTATION_ROTATE_270: return Optional.of(270); 852 default: return Optional.empty(); 853 } 854 } 855 parseOptionalResolution( @onNull MediaMetadataRetriever mmr)856 private static @NonNull Optional<String> parseOptionalResolution( 857 @NonNull MediaMetadataRetriever mmr) { 858 final Optional<?> width = parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_WIDTH)); 859 final Optional<?> height = parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_HEIGHT)); 860 if (width.isPresent() && height.isPresent()) { 861 return Optional.of(width.get() + "\u00d7" + height.get()); 862 } else { 863 return Optional.empty(); 864 } 865 } 866 parseOptionalDate(@ullable String date)867 private static @NonNull Optional<Long> parseOptionalDate(@Nullable String date) { 868 if (TextUtils.isEmpty(date)) return Optional.empty(); 869 try { 870 final long value = sDateFormat.parse(date).getTime(); 871 return (value > 0) ? Optional.of(value) : Optional.empty(); 872 } catch (ParseException e) { 873 return Optional.empty(); 874 } 875 } 876 877 /** 878 * Maybe replace the MIME type from extension with the MIME type from the 879 * XMP metadata, but only when the top-level MIME type agrees. 880 */ 881 @VisibleForTesting maybeOverrideMimeType(@onNull String extMimeType, @Nullable String xmpMimeType)882 public static @NonNull String maybeOverrideMimeType(@NonNull String extMimeType, 883 @Nullable String xmpMimeType) { 884 // Ignore XMP when missing 885 if (TextUtils.isEmpty(xmpMimeType)) return extMimeType; 886 887 // Ignore XMP when invalid 888 final int xmpSplit = xmpMimeType.indexOf('/'); 889 if (xmpSplit == -1) return extMimeType; 890 891 if (extMimeType.regionMatches(0, xmpMimeType, 0, xmpSplit + 1)) { 892 return xmpMimeType; 893 } else { 894 return extMimeType; 895 } 896 } 897 898 /** 899 * Return last modified time of given file. This value is typically read 900 * from the given {@link BasicFileAttributes}, except in the case of 901 * read-only partitions, where {@link Build#TIME} is used instead. 902 */ lastModifiedTime(@onNull File file, @NonNull BasicFileAttributes attrs)903 public static @CurrentTimeSecondsLong long lastModifiedTime(@NonNull File file, 904 @NonNull BasicFileAttributes attrs) { 905 if (FileUtils.contains(Environment.getStorageDirectory(), file)) { 906 return attrs.lastModifiedTime().toMillis() / 1000; 907 } else { 908 return Build.TIME / 1000; 909 } 910 } 911 912 /** 913 * Test if any parents of given directory should be considered hidden. 914 */ isDirectoryHiddenRecursive(File dir)915 static boolean isDirectoryHiddenRecursive(File dir) { 916 Trace.traceBegin(TRACE_TAG_DATABASE, "isDirectoryHiddenRecursive"); 917 try { 918 while (dir != null) { 919 if (isDirectoryHidden(dir)) { 920 return true; 921 } 922 dir = dir.getParentFile(); 923 } 924 return false; 925 } finally { 926 Trace.traceEnd(TRACE_TAG_DATABASE); 927 } 928 } 929 930 /** 931 * Test if this given directory should be considered hidden. 932 */ isDirectoryHidden(File dir)933 static boolean isDirectoryHidden(File dir) { 934 final File nomedia = new File(dir, ".nomedia"); 935 936 // Handle well-known paths that should always be visible or invisible, 937 // regardless of .nomedia presence 938 if (PATTERN_VISIBLE.matcher(dir.getAbsolutePath()).matches()) { 939 nomedia.delete(); 940 return false; 941 } 942 if (PATTERN_INVISIBLE.matcher(dir.getAbsolutePath()).matches()) { 943 try { 944 nomedia.createNewFile(); 945 } catch (IOException ignored) { 946 } 947 return true; 948 } 949 950 // Otherwise fall back to directory name or .nomedia presence 951 final String name = dir.getName(); 952 if (name.startsWith(".")) { 953 return true; 954 } 955 if (nomedia.exists()) { 956 return true; 957 } 958 return false; 959 } 960 961 /** 962 * Test if this given {@link Uri} is a 963 * {@link android.provider.MediaStore.Audio.Playlists} item. 964 */ isPlaylist(Uri uri)965 static boolean isPlaylist(Uri uri) { 966 final List<String> path = uri.getPathSegments(); 967 return (path.size() == 4) && path.get(1).equals("audio") && path.get(2).equals("playlists"); 968 } 969 970 /** 971 * Escape the given argument for use in a {@code LIKE} statement. 972 */ escapeForLike(String arg)973 static String escapeForLike(String arg) { 974 final StringBuilder sb = new StringBuilder(); 975 for (int i = 0; i < arg.length(); i++) { 976 final char c = arg.charAt(i); 977 switch (c) { 978 case '%': sb.append('\\'); 979 case '_': sb.append('\\'); 980 } 981 sb.append(c); 982 } 983 return sb.toString(); 984 } 985 } 986