/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.providers.media.scan;
import static android.media.MediaMetadataRetriever.METADATA_KEY_ALBUM;
import static android.media.MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST;
import static android.media.MediaMetadataRetriever.METADATA_KEY_ARTIST;
import static android.media.MediaMetadataRetriever.METADATA_KEY_AUTHOR;
import static android.media.MediaMetadataRetriever.METADATA_KEY_BITRATE;
import static android.media.MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE;
import static android.media.MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER;
import static android.media.MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE;
import static android.media.MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD;
import static android.media.MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER;
import static android.media.MediaMetadataRetriever.METADATA_KEY_COMPILATION;
import static android.media.MediaMetadataRetriever.METADATA_KEY_COMPOSER;
import static android.media.MediaMetadataRetriever.METADATA_KEY_DATE;
import static android.media.MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER;
import static android.media.MediaMetadataRetriever.METADATA_KEY_DURATION;
import static android.media.MediaMetadataRetriever.METADATA_KEY_GENRE;
import static android.media.MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT;
import static android.media.MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH;
import static android.media.MediaMetadataRetriever.METADATA_KEY_MIMETYPE;
import static android.media.MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS;
import static android.media.MediaMetadataRetriever.METADATA_KEY_TITLE;
import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT;
import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION;
import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH;
import static android.media.MediaMetadataRetriever.METADATA_KEY_WRITER;
import static android.media.MediaMetadataRetriever.METADATA_KEY_YEAR;
import static android.provider.MediaStore.AUTHORITY;
import static android.provider.MediaStore.UNKNOWN_STRING;
import static android.text.format.DateUtils.HOUR_IN_MILLIS;
import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentProviderResult;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.OperationApplicationException;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.drm.DrmManagerClient;
import android.drm.DrmSupportInfo;
import android.media.ExifInterface;
import android.media.MediaMetadataRetriever;
import android.mtp.MtpConstants;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.CancellationSignal;
import android.os.Environment;
import android.os.OperationCanceledException;
import android.os.RemoteException;
import android.os.SystemClock;
import android.os.Trace;
import android.provider.MediaStore;
import android.provider.MediaStore.Audio.AudioColumns;
import android.provider.MediaStore.Audio.PlaylistsColumns;
import android.provider.MediaStore.Files.FileColumns;
import android.provider.MediaStore.Images.ImageColumns;
import android.provider.MediaStore.MediaColumns;
import android.provider.MediaStore.Video.VideoColumns;
import android.text.TextUtils;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.Log;
import android.util.Pair;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.providers.media.util.DatabaseUtils;
import com.android.providers.media.util.ExifUtils;
import com.android.providers.media.util.FileUtils;
import com.android.providers.media.util.IsoInterface;
import com.android.providers.media.util.Logging;
import com.android.providers.media.util.LongArray;
import com.android.providers.media.util.Metrics;
import com.android.providers.media.util.MimeUtils;
import com.android.providers.media.util.XmpInterface;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.FileVisitor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TimeZone;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Modern implementation of media scanner.
*
* This is a bug-compatible reimplementation of the legacy media scanner, but
* written purely in managed code for better testability and long-term
* maintainability.
*
* Initial tests shows it performing roughly on-par with the legacy scanner.
*
* In general, we start by populating metadata based on file attributes, and
* then overwrite with any valid metadata found using
* {@link MediaMetadataRetriever}, {@link ExifInterface}, and
* {@link XmpInterface}, each with increasing levels of trust.
*/
public class ModernMediaScanner implements MediaScanner {
private static final String TAG = "ModernMediaScanner";
private static final boolean LOGW = Log.isLoggable(TAG, Log.WARN);
private static final boolean LOGD = Log.isLoggable(TAG, Log.DEBUG);
private static final boolean LOGV = Log.isLoggable(TAG, Log.VERBOSE);
// TODO: refactor to use UPSERT once we have SQLite 3.24.0
// TODO: deprecate playlist editing
// TODO: deprecate PARENT column, since callers can't see directories
@GuardedBy("sDateFormat")
private static final SimpleDateFormat sDateFormat;
static {
sDateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss");
sDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
}
private static final int BATCH_SIZE = 32;
private static final Pattern PATTERN_VISIBLE = Pattern.compile(
"(?i)^/storage/[^/]+(?:/[0-9]+)?(?:/Android/sandbox/([^/]+))?$");
private static final Pattern PATTERN_INVISIBLE = Pattern.compile(
"(?i)^/storage/[^/]+(?:/[0-9]+)?(?:/Android/sandbox/([^/]+))?/" +
"(?:(?:Android/(?:data|obb)$)|(?:(?:Movies|Music|Pictures)/.thumbnails$))");
private static final Pattern PATTERN_YEAR = Pattern.compile("([1-9][0-9][0-9][0-9])");
private static final Pattern PATTERN_ALBUM_ART = Pattern.compile(
"(?i)(?:(?:^folder|(?:^AlbumArt(?:(?:_\\{.*\\}_)?(?:small|large))?))(?:\\.jpg$)|(?:\\._.*))");
private final Context mContext;
private final DrmManagerClient mDrmClient;
/**
* Map from volume name to signals that can be used to cancel any active
* scan operations on those volumes.
*/
@GuardedBy("mSignals")
private final ArrayMap mSignals = new ArrayMap<>();
/**
* Holder that contains a reference count of the number of threads
* interested in a specific directory, along with a lock to ensure that
* parallel scans don't overlap and confuse each other.
*/
private static class DirectoryLock {
public int count;
public final Lock lock = new ReentrantLock();
}
/**
* Map from directory to locks designed to ensure that parallel scans don't
* overlap and confuse each other.
*/
@GuardedBy("mDirectoryLocks")
private final Map mDirectoryLocks = new ArrayMap<>();
/**
* Set of MIME types that should be considered to be DRM, meaning we need to
* consult {@link DrmManagerClient} to obtain the actual MIME type.
*/
private final Set mDrmMimeTypes = new ArraySet<>();
public ModernMediaScanner(Context context) {
mContext = context;
mDrmClient = new DrmManagerClient(context);
// Dynamically collect the set of MIME types that should be considered
// to be DRM, as this can vary between devices
for (DrmSupportInfo info : mDrmClient.getAvailableDrmSupportInfo()) {
Iterator mimeTypes = info.getMimeTypeIterator();
while (mimeTypes.hasNext()) {
mDrmMimeTypes.add(mimeTypes.next());
}
}
}
@Override
public Context getContext() {
return mContext;
}
@Override
public void scanDirectory(File file, int reason) {
try (Scan scan = new Scan(file, reason, /*ownerPackage*/ null)) {
scan.run();
} catch (OperationCanceledException ignored) {
}
}
@Override
public Uri scanFile(File file, int reason) {
return scanFile(file, reason, /*ownerPackage*/ null);
}
@Override
public Uri scanFile(File file, int reason, @Nullable String ownerPackage) {
try (Scan scan = new Scan(file, reason, ownerPackage)) {
scan.run();
return scan.getFirstResult();
} catch (OperationCanceledException ignored) {
return null;
}
}
@Override
public void onDetachVolume(String volumeName) {
synchronized (mSignals) {
final CancellationSignal signal = mSignals.remove(volumeName);
if (signal != null) {
signal.cancel();
}
}
}
private CancellationSignal getOrCreateSignal(String volumeName) {
synchronized (mSignals) {
CancellationSignal signal = mSignals.get(volumeName);
if (signal == null) {
signal = new CancellationSignal();
mSignals.put(volumeName, signal);
}
return signal;
}
}
/**
* Individual scan request for a specific file or directory. When run it
* will traverse all included media files under the requested location,
* reconciling them against {@link MediaStore}.
*/
private class Scan implements Runnable, FileVisitor, AutoCloseable {
private final ContentProviderClient mClient;
private final ContentResolver mResolver;
private final File mRoot;
private final int mReason;
private final String mVolumeName;
private final Uri mFilesUri;
private final CancellationSignal mSignal;
private final String mOwnerPackage;
private final long mStartGeneration;
private final boolean mSingleFile;
private final Set mAcquiredDirectoryLocks = new ArraySet<>();
private final ArrayList mPending = new ArrayList<>();
private LongArray mScannedIds = new LongArray();
private LongArray mUnknownIds = new LongArray();
private long mFirstId = -1;
private int mFileCount;
private int mInsertCount;
private int mUpdateCount;
private int mDeleteCount;
/**
* Tracks hidden directory and hidden subdirectories in a directory tree. A positive count
* indicates that one or more of the current file's parents is a hidden directory.
*/
private int mHiddenDirCount;
public Scan(File root, int reason, @Nullable String ownerPackage) {
Trace.beginSection("ctor");
mClient = mContext.getContentResolver()
.acquireContentProviderClient(MediaStore.AUTHORITY);
mResolver = ContentResolver.wrap(mClient.getLocalContentProvider());
mRoot = root;
mReason = reason;
mVolumeName = FileUtils.getVolumeName(mContext, root);
mFilesUri = MediaStore.Files.getContentUri(mVolumeName);
mSignal = getOrCreateSignal(mVolumeName);
mStartGeneration = MediaStore.getGeneration(mResolver, mVolumeName);
mSingleFile = mRoot.isFile();
mOwnerPackage = ownerPackage;
Trace.endSection();
}
@Override
public void run() {
final long startTime = SystemClock.elapsedRealtime();
// First, scan everything that should be visible under requested
// location, tracking scanned IDs along the way
walkFileTree();
// Second, reconcile all items known in the database against all the
// items we scanned above
if (mSingleFile && mScannedIds.size() == 1) {
// We can safely skip this step if the scan targeted a single
// file which we scanned above
} else {
reconcileAndClean();
}
// Third, resolve any playlists that we scanned
resolvePlaylists();
if (!mSingleFile) {
final long durationMillis = SystemClock.elapsedRealtime() - startTime;
Metrics.logScan(mVolumeName, mReason, mFileCount, durationMillis,
mInsertCount, mUpdateCount, mDeleteCount);
}
}
private void walkFileTree() {
mSignal.throwIfCanceled();
final Pair isDirScannableAndHidden =
shouldScanPathAndIsPathHidden(mSingleFile ? mRoot.getParentFile() : mRoot);
if (isDirScannableAndHidden.first) {
// This directory is scannable.
Trace.beginSection("walkFileTree");
if (isDirScannableAndHidden.second) {
// This directory is hidden
mHiddenDirCount++;
}
if (mSingleFile) {
acquireDirectoryLock(mRoot.getParentFile().toPath());
}
try {
Files.walkFileTree(mRoot.toPath(), this);
applyPending();
} catch (IOException e) {
// This should never happen, so yell loudly
throw new IllegalStateException(e);
} finally {
if (mSingleFile) {
releaseDirectoryLock(mRoot.getParentFile().toPath());
}
Trace.endSection();
}
}
}
private void reconcileAndClean() {
final long[] scannedIds = mScannedIds.toArray();
Arrays.sort(scannedIds);
// The query phase is split from the delete phase so that our query
// remains stable if we need to paginate across multiple windows.
mSignal.throwIfCanceled();
Trace.beginSection("reconcile");
// Ignore abstract playlists which don't have files on disk
final String formatClause = "ifnull(" + FileColumns.FORMAT + ","
+ MtpConstants.FORMAT_UNDEFINED + ") != "
+ MtpConstants.FORMAT_ABSTRACT_AV_PLAYLIST;
final String dataClause = "(" + FileColumns.DATA + " LIKE ? ESCAPE '\\' OR "
+ FileColumns.DATA + " LIKE ? ESCAPE '\\')";
final String generationClause = FileColumns.GENERATION_ADDED + " <= "
+ mStartGeneration;
final Bundle queryArgs = new Bundle();
queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SELECTION,
formatClause + " AND " + dataClause + " AND " + generationClause);
final String pathEscapedForLike = DatabaseUtils.escapeForLike(mRoot.getAbsolutePath());
queryArgs.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS,
new String[] {pathEscapedForLike + "/%", pathEscapedForLike});
queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER,
FileColumns._ID + " DESC");
queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_FAVORITE, MediaStore.MATCH_INCLUDE);
try (Cursor c = mResolver.query(mFilesUri, new String[] { FileColumns._ID },
queryArgs, mSignal)) {
while (c.moveToNext()) {
final long id = c.getLong(0);
if (Arrays.binarySearch(scannedIds, id) < 0) {
mUnknownIds.add(id);
}
}
} finally {
Trace.endSection();
}
// Third, clean all the unknown database entries found above
mSignal.throwIfCanceled();
Trace.beginSection("clean");
try {
for (int i = 0; i < mUnknownIds.size(); i++) {
final long id = mUnknownIds.get(i);
if (LOGV) Log.v(TAG, "Cleaning " + id);
final Uri uri = MediaStore.Files.getContentUri(mVolumeName, id).buildUpon()
.appendQueryParameter(MediaStore.PARAM_DELETE_DATA, "false")
.build();
addPending(ContentProviderOperation.newDelete(uri).build());
maybeApplyPending();
}
applyPending();
} finally {
Trace.endSection();
}
}
private void resolvePlaylists() {
mSignal.throwIfCanceled();
// Playlists aren't supported on internal storage, so bail early
if (MediaStore.VOLUME_INTERNAL.equals(mVolumeName)) return;
final Uri playlistsUri = MediaStore.Audio.Playlists.getContentUri(mVolumeName);
final Bundle queryArgs = new Bundle();
queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SELECTION,
FileColumns.GENERATION_MODIFIED + " > " + mStartGeneration);
try (Cursor c = mResolver.query(playlistsUri, new String[] { FileColumns._ID },
queryArgs, mSignal)) {
while (c.moveToNext()) {
final long id = c.getLong(0);
MediaStore.resolvePlaylistMembers(mResolver,
ContentUris.withAppendedId(playlistsUri, id));
}
} finally {
Trace.endSection();
}
}
/**
* Create and acquire a lock on the given directory, giving the calling
* thread exclusive access to ensure that parallel scans don't overlap
* and confuse each other.
*/
private void acquireDirectoryLock(@NonNull Path dir) {
Trace.beginSection("acquireDirectoryLock");
DirectoryLock lock;
synchronized (mDirectoryLocks) {
lock = mDirectoryLocks.get(dir);
if (lock == null) {
lock = new DirectoryLock();
mDirectoryLocks.put(dir, lock);
}
lock.count++;
}
lock.lock.lock();
mAcquiredDirectoryLocks.add(dir);
Trace.endSection();
}
/**
* Release a currently held lock on the given directory, releasing any
* other waiting parallel scans to proceed, and cleaning up data
* structures if no other threads are waiting.
*/
private void releaseDirectoryLock(@NonNull Path dir) {
Trace.beginSection("releaseDirectoryLock");
DirectoryLock lock;
synchronized (mDirectoryLocks) {
lock = mDirectoryLocks.get(dir);
if (lock == null) {
throw new IllegalStateException();
}
if (--lock.count == 0) {
mDirectoryLocks.remove(dir);
}
}
lock.lock.unlock();
mAcquiredDirectoryLocks.remove(dir);
Trace.endSection();
}
@Override
public void close() {
// Sanity check that we drained any pending operations
if (!mPending.isEmpty()) {
throw new IllegalStateException();
}
// Release any locks we're still holding, typically when we
// encountered an exception; we snapshot the original list so we're
// not confused as it's mutated by release operations
for (Path dir : new ArraySet<>(mAcquiredDirectoryLocks)) {
releaseDirectoryLock(dir);
}
mClient.close();
}
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
throws IOException {
// Possibly bail before digging into each directory
mSignal.throwIfCanceled();
if (!shouldScanDirectory(dir.toFile())) {
return FileVisitResult.SKIP_SUBTREE;
}
// Acquire lock on this directory to ensure parallel scans don't
// overlap and confuse each other
acquireDirectoryLock(dir);
if (FileUtils.isDirectoryHidden(dir.toFile())) {
mHiddenDirCount++;
}
// Scan this directory as a normal file so that "parent" database
// entries are created
return visitFile(dir, attrs);
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
if (LOGV) Log.v(TAG, "Visiting " + file);
mFileCount++;
// Skip files that have already been scanned, and which haven't
// changed since they were last scanned
final File realFile = file.toFile();
long existingId = -1;
String actualMimeType;
if (attrs.isDirectory()) {
actualMimeType = null;
} else {
actualMimeType = MimeUtils.resolveMimeType(realFile);
}
// Resolve the MIME type of DRM files before scanning them; if we
// have trouble then we'll continue scanning as a generic file
final boolean isDrm = mDrmMimeTypes.contains(actualMimeType);
if (isDrm) {
actualMimeType = mDrmClient.getOriginalMimeType(realFile.getPath());
}
int actualMediaType = FileColumns.MEDIA_TYPE_NONE;
if (actualMimeType != null) {
actualMediaType = resolveMediaTypeFromFilePath(realFile, actualMimeType,
/*isHidden*/ mHiddenDirCount > 0);
}
Trace.beginSection("checkChanged");
final Bundle queryArgs = new Bundle();
queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SELECTION,
FileColumns.DATA + "=?");
queryArgs.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS,
new String[] { realFile.getAbsolutePath() });
queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_FAVORITE, MediaStore.MATCH_INCLUDE);
final String[] projection = new String[] {FileColumns._ID, FileColumns.DATE_MODIFIED,
FileColumns.SIZE, FileColumns.MIME_TYPE, FileColumns.MEDIA_TYPE,
FileColumns.IS_PENDING};
final Matcher matcher = FileUtils.PATTERN_EXPIRES_FILE.matcher(realFile.getName());
// If IS_PENDING is set by FUSE, we should scan the file and update IS_PENDING to zero.
// Pending files from FUSE will not be rewritten to contain expiry timestamp.
boolean isPendingFromFuse = !matcher.matches();
try (Cursor c = mResolver.query(mFilesUri, projection, queryArgs, mSignal)) {
if (c.moveToFirst()) {
existingId = c.getLong(0);
final long dateModified = c.getLong(1);
final long size = c.getLong(2);
final String mimeType = c.getString(3);
final int mediaType = c.getInt(4);
isPendingFromFuse &= c.getInt(5) != 0;
// Remember visiting this existing item, even if we skipped
// due to it being unchanged; this is needed so we don't
// delete the item during a later cleaning phase
mScannedIds.add(existingId);
// We also technically found our first result
if (mFirstId == -1) {
mFirstId = existingId;
}
final boolean sameTime = (lastModifiedTime(realFile, attrs) == dateModified);
final boolean sameSize = (attrs.size() == size);
final boolean sameMimeType = mimeType == null ? actualMimeType == null :
mimeType.equalsIgnoreCase(actualMimeType);
final boolean sameMediaType = (actualMediaType == mediaType);
final boolean isSame = sameTime && sameSize && sameMediaType && sameMimeType
&& !isPendingFromFuse;
if (attrs.isDirectory() || isSame) {
if (LOGV) Log.v(TAG, "Skipping unchanged " + file);
return FileVisitResult.CONTINUE;
}
}
} finally {
Trace.endSection();
}
final ContentProviderOperation.Builder op;
Trace.beginSection("scanItem");
try {
op = scanItem(existingId, realFile, attrs, actualMimeType, actualMediaType,
mVolumeName);
} finally {
Trace.endSection();
}
if (op != null) {
// Add owner package name to new insertions when package name is provided.
if (op.build().isInsert() && !attrs.isDirectory() && mOwnerPackage != null) {
op.withValue(MediaColumns.OWNER_PACKAGE_NAME, mOwnerPackage);
}
// Force DRM files to be marked as DRM, since the lower level
// stack may not set this correctly
if (isDrm) {
op.withValue(MediaColumns.IS_DRM, 1);
}
addPending(op.build());
maybeApplyPending();
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc)
throws IOException {
Log.w(TAG, "Failed to visit " + file + ": " + exc);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc)
throws IOException {
// We need to drain all pending changes related to this directory
// before releasing our lock below
applyPending();
if (FileUtils.isDirectoryHidden(dir.toFile())) {
mHiddenDirCount--;
}
// Now that we're finished scanning this directory, release lock to
// allow other parallel scans to proceed
releaseDirectoryLock(dir);
return FileVisitResult.CONTINUE;
}
private void addPending(ContentProviderOperation op) {
mPending.add(op);
if (op.isInsert()) mInsertCount++;
if (op.isUpdate()) mUpdateCount++;
if (op.isDelete()) mDeleteCount++;
}
private void maybeApplyPending() {
if (mPending.size() > BATCH_SIZE) {
applyPending();
}
}
private void applyPending() {
// Bail early when nothing pending
if (mPending.isEmpty()) return;
Trace.beginSection("applyPending");
try {
ContentProviderResult[] results = mResolver.applyBatch(AUTHORITY, mPending);
for (int index = 0; index < results.length; index++) {
ContentProviderResult result = results[index];
ContentProviderOperation operation = mPending.get(index);
if (result.exception != null) {
Log.w(TAG, "Failed to apply " + operation, result.exception);
}
Uri uri = result.uri;
if (uri != null) {
final long id = ContentUris.parseId(uri);
if (mFirstId == -1) {
mFirstId = id;
}
mScannedIds.add(id);
}
}
} catch (RemoteException | OperationApplicationException e) {
Log.w(TAG, "Failed to apply", e);
} finally {
mPending.clear();
Trace.endSection();
}
}
/**
* Return the first item encountered by this scan requested.
*
* Internally resolves to the relevant media collection where this item
* exists based on {@link FileColumns#MEDIA_TYPE}.
*/
public @Nullable Uri getFirstResult() {
if (mFirstId == -1) return null;
final Uri fileUri = MediaStore.Files.getContentUri(mVolumeName, mFirstId);
try (Cursor c = mResolver.query(fileUri,
new String[] { FileColumns.MEDIA_TYPE }, null, null)) {
if (c.moveToFirst()) {
switch (c.getInt(0)) {
case FileColumns.MEDIA_TYPE_AUDIO:
return MediaStore.Audio.Media.getContentUri(mVolumeName, mFirstId);
case FileColumns.MEDIA_TYPE_VIDEO:
return MediaStore.Video.Media.getContentUri(mVolumeName, mFirstId);
case FileColumns.MEDIA_TYPE_IMAGE:
return MediaStore.Images.Media.getContentUri(mVolumeName, mFirstId);
case FileColumns.MEDIA_TYPE_PLAYLIST:
return ContentUris.withAppendedId(
MediaStore.Audio.Playlists.getContentUri(mVolumeName),
mFirstId);
}
}
}
// Worst case, we can always use generic collection
return fileUri;
}
}
/**
* Scan the requested file, returning a {@link ContentProviderOperation}
* containing all indexed metadata, suitable for passing to a
* {@link SQLiteDatabase#replace} operation.
*/
private static @Nullable ContentProviderOperation.Builder scanItem(long existingId, File file,
BasicFileAttributes attrs, String mimeType, int mediaType, String volumeName) {
if (Objects.equals(file.getName(), ".nomedia")) {
if (LOGD) Log.d(TAG, "Ignoring .nomedia file: " + file);
return null;
}
if (attrs.isDirectory()) {
return scanItemDirectory(existingId, file, attrs, mimeType, volumeName);
}
switch (mediaType) {
case FileColumns.MEDIA_TYPE_AUDIO:
return scanItemAudio(existingId, file, attrs, mimeType, mediaType, volumeName);
case FileColumns.MEDIA_TYPE_VIDEO:
return scanItemVideo(existingId, file, attrs, mimeType, mediaType, volumeName);
case FileColumns.MEDIA_TYPE_IMAGE:
return scanItemImage(existingId, file, attrs, mimeType, mediaType, volumeName);
case FileColumns.MEDIA_TYPE_PLAYLIST:
return scanItemPlaylist(existingId, file, attrs, mimeType, mediaType, volumeName);
case FileColumns.MEDIA_TYPE_SUBTITLE:
return scanItemSubtitle(existingId, file, attrs, mimeType, mediaType, volumeName);
case FileColumns.MEDIA_TYPE_DOCUMENT:
return scanItemDocument(existingId, file, attrs, mimeType, mediaType, volumeName);
default:
return scanItemFile(existingId, file, attrs, mimeType, mediaType, volumeName);
}
}
/**
* Populate the given {@link ContentProviderOperation} with the generic
* {@link MediaColumns} values that can be determined directly from the file
* or its attributes.
*
* This is typically the first set of values defined so that we correctly
* clear any values that had been set by a previous scan and which are no
* longer present in the media item.
*/
private static void withGenericValues(ContentProviderOperation.Builder op,
File file, BasicFileAttributes attrs, String mimeType, Integer mediaType) {
withOptionalMimeTypeAndMediaType(op, Optional.ofNullable(mimeType),
Optional.ofNullable(mediaType));
op.withValue(MediaColumns.DATA, file.getAbsolutePath());
op.withValue(MediaColumns.SIZE, attrs.size());
op.withValue(MediaColumns.DATE_MODIFIED, lastModifiedTime(file, attrs));
op.withValue(MediaColumns.DATE_TAKEN, null);
op.withValue(MediaColumns.IS_DRM, 0);
op.withValue(MediaColumns.WIDTH, null);
op.withValue(MediaColumns.HEIGHT, null);
op.withValue(MediaColumns.RESOLUTION, null);
op.withValue(MediaColumns.DOCUMENT_ID, null);
op.withValue(MediaColumns.INSTANCE_ID, null);
op.withValue(MediaColumns.ORIGINAL_DOCUMENT_ID, null);
op.withValue(MediaColumns.ORIENTATION, null);
op.withValue(MediaColumns.CD_TRACK_NUMBER, null);
op.withValue(MediaColumns.ALBUM, null);
op.withValue(MediaColumns.ARTIST, null);
op.withValue(MediaColumns.AUTHOR, null);
op.withValue(MediaColumns.COMPOSER, null);
op.withValue(MediaColumns.GENRE, null);
op.withValue(MediaColumns.TITLE, FileUtils.extractFileName(file.getName()));
op.withValue(MediaColumns.YEAR, null);
op.withValue(MediaColumns.DURATION, null);
op.withValue(MediaColumns.NUM_TRACKS, null);
op.withValue(MediaColumns.WRITER, null);
op.withValue(MediaColumns.ALBUM_ARTIST, null);
op.withValue(MediaColumns.DISC_NUMBER, null);
op.withValue(MediaColumns.COMPILATION, null);
op.withValue(MediaColumns.BITRATE, null);
op.withValue(MediaColumns.CAPTURE_FRAMERATE, null);
}
/**
* Populate the given {@link ContentProviderOperation} with the generic
* {@link MediaColumns} values using the given
* {@link MediaMetadataRetriever}.
*/
private static void withRetrieverValues(ContentProviderOperation.Builder op,
MediaMetadataRetriever mmr, String mimeType) {
withOptionalMimeTypeAndMediaType(op,
parseOptionalMimeType(mimeType, mmr.extractMetadata(METADATA_KEY_MIMETYPE)),
/*optionalMediaType*/ Optional.empty());
withOptionalValue(op, MediaColumns.DATE_TAKEN,
parseOptionalDate(mmr.extractMetadata(METADATA_KEY_DATE)));
withOptionalValue(op, MediaColumns.CD_TRACK_NUMBER,
parseOptional(mmr.extractMetadata(METADATA_KEY_CD_TRACK_NUMBER)));
withOptionalValue(op, MediaColumns.ALBUM,
parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUM)));
withOptionalValue(op, MediaColumns.ARTIST, firstPresent(
parseOptional(mmr.extractMetadata(METADATA_KEY_ARTIST)),
parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUMARTIST))));
withOptionalValue(op, MediaColumns.AUTHOR,
parseOptional(mmr.extractMetadata(METADATA_KEY_AUTHOR)));
withOptionalValue(op, MediaColumns.COMPOSER,
parseOptional(mmr.extractMetadata(METADATA_KEY_COMPOSER)));
withOptionalValue(op, MediaColumns.GENRE,
parseOptional(mmr.extractMetadata(METADATA_KEY_GENRE)));
withOptionalValue(op, MediaColumns.TITLE,
parseOptional(mmr.extractMetadata(METADATA_KEY_TITLE)));
withOptionalValue(op, MediaColumns.YEAR,
parseOptionalYear(mmr.extractMetadata(METADATA_KEY_YEAR)));
withOptionalValue(op, MediaColumns.DURATION,
parseOptional(mmr.extractMetadata(METADATA_KEY_DURATION)));
withOptionalValue(op, MediaColumns.NUM_TRACKS,
parseOptional(mmr.extractMetadata(METADATA_KEY_NUM_TRACKS)));
withOptionalValue(op, MediaColumns.WRITER,
parseOptional(mmr.extractMetadata(METADATA_KEY_WRITER)));
withOptionalValue(op, MediaColumns.ALBUM_ARTIST,
parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUMARTIST)));
withOptionalValue(op, MediaColumns.DISC_NUMBER,
parseOptional(mmr.extractMetadata(METADATA_KEY_DISC_NUMBER)));
withOptionalValue(op, MediaColumns.COMPILATION,
parseOptional(mmr.extractMetadata(METADATA_KEY_COMPILATION)));
withOptionalValue(op, MediaColumns.BITRATE,
parseOptional(mmr.extractMetadata(METADATA_KEY_BITRATE)));
withOptionalValue(op, MediaColumns.CAPTURE_FRAMERATE,
parseOptional(mmr.extractMetadata(METADATA_KEY_CAPTURE_FRAMERATE)));
}
/**
* Populate the given {@link ContentProviderOperation} with the generic
* {@link MediaColumns} values using the given XMP metadata.
*/
private static void withXmpValues(ContentProviderOperation.Builder op,
XmpInterface xmp, String mimeType) {
withOptionalMimeTypeAndMediaType(op,
parseOptionalMimeType(mimeType, xmp.getFormat()),
/*optionalMediaType*/ Optional.empty());
op.withValue(MediaColumns.DOCUMENT_ID, xmp.getDocumentId());
op.withValue(MediaColumns.INSTANCE_ID, xmp.getInstanceId());
op.withValue(MediaColumns.ORIGINAL_DOCUMENT_ID, xmp.getOriginalDocumentId());
op.withValue(MediaColumns.XMP, xmp.getRedactedXmp());
}
/**
* Overwrite a value in the given {@link ContentProviderOperation}, but only
* when the given {@link Optional} value is present.
*/
private static void withOptionalValue(@NonNull ContentProviderOperation.Builder op,
@NonNull String key, @NonNull Optional> value) {
if (value.isPresent()) {
op.withValue(key, value.get());
}
}
/**
* Overwrite the {@link MediaColumns#MIME_TYPE} and
* {@link FileColumns#MEDIA_TYPE} values in the given
* {@link ContentProviderOperation}, but only when the given
* {@link Optional} optionalMimeType is present.
* If {@link Optional} optionalMediaType is not present, {@link FileColumns#MEDIA_TYPE} is
* resolved from given {@code optionalMimeType} when {@code optionalMimeType} is present.
*
* @param optionalMimeType An optional MIME type to apply to this operation.
* @param optionalMediaType An optional Media type to apply to this operation.
*/
private static void withOptionalMimeTypeAndMediaType(
@NonNull ContentProviderOperation.Builder op,
@NonNull Optional optionalMimeType,
@NonNull Optional optionalMediaType) {
if (optionalMimeType.isPresent()) {
final String mimeType = optionalMimeType.get();
op.withValue(MediaColumns.MIME_TYPE, mimeType);
if (optionalMediaType.isPresent()) {
op.withValue(FileColumns.MEDIA_TYPE, optionalMediaType.get());
} else {
op.withValue(FileColumns.MEDIA_TYPE, MimeUtils.resolveMediaType(mimeType));
}
}
}
private static @NonNull ContentProviderOperation.Builder scanItemDirectory(long existingId,
File file, BasicFileAttributes attrs, String mimeType, String volumeName) {
final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
// Directory doesn't have any MIME type or Media Type.
withGenericValues(op, file, attrs, mimeType, /*mediaType*/ null);
try {
op.withValue(FileColumns.FORMAT, MtpConstants.FORMAT_ASSOCIATION);
} catch (Exception e) {
logTroubleScanning(file, e);
}
return op;
}
private static ArrayMap sAudioTypes = new ArrayMap<>();
static {
sAudioTypes.put(Environment.DIRECTORY_RINGTONES, AudioColumns.IS_RINGTONE);
sAudioTypes.put(Environment.DIRECTORY_NOTIFICATIONS, AudioColumns.IS_NOTIFICATION);
sAudioTypes.put(Environment.DIRECTORY_ALARMS, AudioColumns.IS_ALARM);
sAudioTypes.put(Environment.DIRECTORY_PODCASTS, AudioColumns.IS_PODCAST);
sAudioTypes.put(Environment.DIRECTORY_AUDIOBOOKS, AudioColumns.IS_AUDIOBOOK);
sAudioTypes.put(Environment.DIRECTORY_MUSIC, AudioColumns.IS_MUSIC);
}
private static @NonNull ContentProviderOperation.Builder scanItemAudio(long existingId,
File file, BasicFileAttributes attrs, String mimeType, int mediaType,
String volumeName) {
final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
withGenericValues(op, file, attrs, mimeType, mediaType);
op.withValue(MediaColumns.ARTIST, UNKNOWN_STRING);
op.withValue(MediaColumns.ALBUM, file.getParentFile().getName());
final String lowPath = file.getAbsolutePath().toLowerCase(Locale.ROOT);
boolean anyMatch = false;
for (int i = 0; i < sAudioTypes.size(); i++) {
final boolean match = lowPath
.contains('/' + sAudioTypes.keyAt(i).toLowerCase(Locale.ROOT) + '/');
op.withValue(sAudioTypes.valueAt(i), match ? 1 : 0);
anyMatch |= match;
}
if (!anyMatch) {
op.withValue(AudioColumns.IS_MUSIC, 1);
}
try (FileInputStream is = new FileInputStream(file)) {
try (MediaMetadataRetriever mmr = new MediaMetadataRetriever()) {
mmr.setDataSource(is.getFD());
withRetrieverValues(op, mmr, mimeType);
withOptionalValue(op, AudioColumns.TRACK,
parseOptionalTrack(mmr));
}
// Also hunt around for XMP metadata
final IsoInterface iso = IsoInterface.fromFileDescriptor(is.getFD());
final XmpInterface xmp = XmpInterface.fromContainer(iso);
withXmpValues(op, xmp, mimeType);
} catch (Exception e) {
logTroubleScanning(file, e);
}
return op;
}
private static @NonNull ContentProviderOperation.Builder scanItemPlaylist(long existingId,
File file, BasicFileAttributes attrs, String mimeType, int mediaType,
String volumeName) {
final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
withGenericValues(op, file, attrs, mimeType, mediaType);
try {
op.withValue(PlaylistsColumns.NAME, FileUtils.extractFileName(file.getName()));
} catch (Exception e) {
logTroubleScanning(file, e);
}
return op;
}
private static @NonNull ContentProviderOperation.Builder scanItemSubtitle(long existingId,
File file, BasicFileAttributes attrs, String mimeType, int mediaType,
String volumeName) {
final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
withGenericValues(op, file, attrs, mimeType, mediaType);
return op;
}
private static @NonNull ContentProviderOperation.Builder scanItemDocument(long existingId,
File file, BasicFileAttributes attrs, String mimeType, int mediaType,
String volumeName) {
final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
withGenericValues(op, file, attrs, mimeType, mediaType);
return op;
}
private static @NonNull ContentProviderOperation.Builder scanItemVideo(long existingId,
File file, BasicFileAttributes attrs, String mimeType, int mediaType,
String volumeName) {
final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
withGenericValues(op, file, attrs, mimeType, mediaType);
op.withValue(MediaColumns.ARTIST, UNKNOWN_STRING);
op.withValue(MediaColumns.ALBUM, file.getParentFile().getName());
op.withValue(VideoColumns.COLOR_STANDARD, null);
op.withValue(VideoColumns.COLOR_TRANSFER, null);
op.withValue(VideoColumns.COLOR_RANGE, null);
try (FileInputStream is = new FileInputStream(file)) {
try (MediaMetadataRetriever mmr = new MediaMetadataRetriever()) {
mmr.setDataSource(is.getFD());
withRetrieverValues(op, mmr, mimeType);
withOptionalValue(op, MediaColumns.WIDTH,
parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_WIDTH)));
withOptionalValue(op, MediaColumns.HEIGHT,
parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_HEIGHT)));
withOptionalValue(op, MediaColumns.RESOLUTION,
parseOptionalVideoResolution(mmr));
withOptionalValue(op, MediaColumns.ORIENTATION,
parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_ROTATION)));
withOptionalValue(op, VideoColumns.COLOR_STANDARD,
parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_STANDARD)));
withOptionalValue(op, VideoColumns.COLOR_TRANSFER,
parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_TRANSFER)));
withOptionalValue(op, VideoColumns.COLOR_RANGE,
parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_RANGE)));
}
// Also hunt around for XMP metadata
final IsoInterface iso = IsoInterface.fromFileDescriptor(is.getFD());
final XmpInterface xmp = XmpInterface.fromContainer(iso);
withXmpValues(op, xmp, mimeType);
} catch (Exception e) {
logTroubleScanning(file, e);
}
return op;
}
private static @NonNull ContentProviderOperation.Builder scanItemImage(long existingId,
File file, BasicFileAttributes attrs, String mimeType, int mediaType,
String volumeName) {
final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
withGenericValues(op, file, attrs, mimeType, mediaType);
op.withValue(ImageColumns.DESCRIPTION, null);
try (FileInputStream is = new FileInputStream(file)) {
final ExifInterface exif = new ExifInterface(is);
withOptionalValue(op, MediaColumns.WIDTH,
parseOptionalOrZero(exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)));
withOptionalValue(op, MediaColumns.HEIGHT,
parseOptionalOrZero(exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)));
withOptionalValue(op, MediaColumns.RESOLUTION,
parseOptionalResolution(exif));
withOptionalValue(op, MediaColumns.DATE_TAKEN,
parseOptionalDateTaken(exif, lastModifiedTime(file, attrs) * 1000));
withOptionalValue(op, MediaColumns.ORIENTATION,
parseOptionalOrientation(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_UNDEFINED)));
withOptionalValue(op, ImageColumns.DESCRIPTION,
parseOptional(exif.getAttribute(ExifInterface.TAG_IMAGE_DESCRIPTION)));
withOptionalValue(op, ImageColumns.EXPOSURE_TIME,
parseOptional(exif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME)));
withOptionalValue(op, ImageColumns.F_NUMBER,
parseOptional(exif.getAttribute(ExifInterface.TAG_F_NUMBER)));
withOptionalValue(op, ImageColumns.ISO,
parseOptional(exif.getAttribute(ExifInterface.TAG_ISO_SPEED_RATINGS)));
withOptionalValue(op, ImageColumns.SCENE_CAPTURE_TYPE,
parseOptional(exif.getAttribute(ExifInterface.TAG_SCENE_CAPTURE_TYPE)));
// Also hunt around for XMP metadata
final XmpInterface xmp = XmpInterface.fromContainer(exif);
withXmpValues(op, xmp, mimeType);
} catch (Exception e) {
logTroubleScanning(file, e);
}
return op;
}
private static @NonNull ContentProviderOperation.Builder scanItemFile(long existingId,
File file, BasicFileAttributes attrs, String mimeType, int mediaType,
String volumeName) {
final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId);
withGenericValues(op, file, attrs, mimeType, mediaType);
return op;
}
private static @NonNull ContentProviderOperation.Builder newUpsert(
@NonNull String volumeName, long existingId) {
final Uri uri = MediaStore.Files.getContentUri(volumeName);
if (existingId == -1) {
return ContentProviderOperation.newInsert(uri)
.withExceptionAllowed(true);
} else {
return ContentProviderOperation.newUpdate(ContentUris.withAppendedId(uri, existingId))
.withExpectedCount(1)
.withExceptionAllowed(true);
}
}
/**
* Pick the first present {@link Optional} value from the given list.
*/
@SafeVarargs
private static @NonNull Optional firstPresent(@NonNull Optional... options) {
for (Optional option : options) {
if (option.isPresent()) {
return option;
}
}
return Optional.empty();
}
@VisibleForTesting
static @NonNull Optional parseOptional(@Nullable T value) {
if (value == null) {
return Optional.empty();
} else if (value instanceof String && ((String) value).length() == 0) {
return Optional.empty();
} else if (value instanceof String && ((String) value).equals("-1")) {
return Optional.empty();
} else if (value instanceof String && ((String) value).trim().length() == 0) {
return Optional.empty();
} else if (value instanceof Number && ((Number) value).intValue() == -1) {
return Optional.empty();
} else {
return Optional.of(value);
}
}
@VisibleForTesting
static @NonNull Optional parseOptionalOrZero(@Nullable T value) {
if (value instanceof String && isZero((String) value)) {
return Optional.empty();
} else if (value instanceof Number && ((Number) value).intValue() == 0) {
return Optional.empty();
} else {
return parseOptional(value);
}
}
@VisibleForTesting
static @NonNull Optional parseOptionalNumerator(@Nullable String value) {
final Optional parsedValue = parseOptional(value);
if (parsedValue.isPresent()) {
value = parsedValue.get();
final int fractionIndex = value.indexOf('/');
if (fractionIndex != -1) {
value = value.substring(0, fractionIndex);
}
try {
return Optional.of(Integer.parseInt(value));
} catch (NumberFormatException ignored) {
return Optional.empty();
}
} else {
return Optional.empty();
}
}
/**
* Try our best to calculate {@link MediaColumns#DATE_TAKEN} in reference to
* the epoch, making our best guess from unrelated fields when offset
* information isn't directly available.
*/
@VisibleForTesting
static @NonNull Optional parseOptionalDateTaken(@NonNull ExifInterface exif,
long lastModifiedTime) {
final long originalTime = ExifUtils.getDateTimeOriginal(exif);
if (exif.hasAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL)) {
// We have known offset information, return it directly!
return Optional.of(originalTime);
} else {
// Otherwise we need to guess the offset from unrelated fields
final long smallestZone = 15 * MINUTE_IN_MILLIS;
final long gpsTime = ExifUtils.getGpsDateTime(exif);
if (gpsTime > 0) {
final long offset = gpsTime - originalTime;
if (Math.abs(offset) < 24 * HOUR_IN_MILLIS) {
final long rounded = Math.round((float) offset / smallestZone) * smallestZone;
return Optional.of(originalTime + rounded);
}
}
if (lastModifiedTime > 0) {
final long offset = lastModifiedTime - originalTime;
if (Math.abs(offset) < 24 * HOUR_IN_MILLIS) {
final long rounded = Math.round((float) offset / smallestZone) * smallestZone;
return Optional.of(originalTime + rounded);
}
}
return Optional.empty();
}
}
@VisibleForTesting
static @NonNull Optional parseOptionalOrientation(int orientation) {
switch (orientation) {
case ExifInterface.ORIENTATION_NORMAL: return Optional.of(0);
case ExifInterface.ORIENTATION_ROTATE_90: return Optional.of(90);
case ExifInterface.ORIENTATION_ROTATE_180: return Optional.of(180);
case ExifInterface.ORIENTATION_ROTATE_270: return Optional.of(270);
default: return Optional.empty();
}
}
@VisibleForTesting
static @NonNull Optional parseOptionalVideoResolution(
@NonNull MediaMetadataRetriever mmr) {
final Optional> width = parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_WIDTH));
final Optional> height = parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_HEIGHT));
if (width.isPresent() && height.isPresent()) {
return Optional.of(width.get() + "\u00d7" + height.get());
} else {
return Optional.empty();
}
}
@VisibleForTesting
static @NonNull Optional parseOptionalImageResolution(
@NonNull MediaMetadataRetriever mmr) {
final Optional> width = parseOptional(mmr.extractMetadata(METADATA_KEY_IMAGE_WIDTH));
final Optional> height = parseOptional(mmr.extractMetadata(METADATA_KEY_IMAGE_HEIGHT));
if (width.isPresent() && height.isPresent()) {
return Optional.of(width.get() + "\u00d7" + height.get());
} else {
return Optional.empty();
}
}
@VisibleForTesting
static @NonNull Optional parseOptionalResolution(
@NonNull ExifInterface exif) {
final Optional> width = parseOptionalOrZero(
exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH));
final Optional> height = parseOptionalOrZero(
exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH));
if (width.isPresent() && height.isPresent()) {
return Optional.of(width.get() + "\u00d7" + height.get());
} else {
return Optional.empty();
}
}
@VisibleForTesting
static @NonNull Optional parseOptionalDate(@Nullable String date) {
if (TextUtils.isEmpty(date)) return Optional.empty();
try {
synchronized (sDateFormat) {
final long value = sDateFormat.parse(date).getTime();
return (value > 0) ? Optional.of(value) : Optional.empty();
}
} catch (ParseException e) {
return Optional.empty();
}
}
@VisibleForTesting
static @NonNull Optional parseOptionalYear(@Nullable String value) {
final Optional parsedValue = parseOptional(value);
if (parsedValue.isPresent()) {
final Matcher m = PATTERN_YEAR.matcher(parsedValue.get());
if (m.find()) {
return Optional.of(Integer.parseInt(m.group(1)));
} else {
return Optional.empty();
}
} else {
return Optional.empty();
}
}
@VisibleForTesting
static @NonNull Optional parseOptionalTrack(
@NonNull MediaMetadataRetriever mmr) {
final Optional disc = parseOptionalNumerator(
mmr.extractMetadata(METADATA_KEY_DISC_NUMBER));
final Optional track = parseOptionalNumerator(
mmr.extractMetadata(METADATA_KEY_CD_TRACK_NUMBER));
if (disc.isPresent() && track.isPresent()) {
return Optional.of((disc.get() * 1000) + track.get());
} else {
return track;
}
}
/**
* Maybe replace the MIME type from extension with the MIME type from the
* refined metadata, but only when the top-level MIME type agrees.
*/
@VisibleForTesting
static @NonNull Optional parseOptionalMimeType(@NonNull String fileMimeType,
@Nullable String refinedMimeType) {
// Ignore when missing
if (TextUtils.isEmpty(refinedMimeType)) return Optional.empty();
// Ignore when invalid
final int refinedSplit = refinedMimeType.indexOf('/');
if (refinedSplit == -1) return Optional.empty();
if (fileMimeType.regionMatches(true, 0, refinedMimeType, 0, refinedSplit + 1)) {
return Optional.of(refinedMimeType);
} else if ("video/mp4".equalsIgnoreCase(fileMimeType)
&& "audio/mp4".equalsIgnoreCase(refinedMimeType)) {
// We normally only allow MIME types to be customized when the
// top-level type agrees, but this one very narrow case is added to
// support a music service that was writing "m4a" files as "mp4".
return Optional.of(refinedMimeType);
} else {
return Optional.empty();
}
}
/**
* Return last modified time of given file. This value is typically read
* from the given {@link BasicFileAttributes}, except in the case of
* read-only partitions, where {@link Build#TIME} is used instead.
*/
public static long lastModifiedTime(@NonNull File file,
@NonNull BasicFileAttributes attrs) {
if (FileUtils.contains(Environment.getStorageDirectory(), file)) {
return attrs.lastModifiedTime().toMillis() / 1000;
} else {
return Build.TIME / 1000;
}
}
/**
* Test if any parents of given path should be scanned and test if any parents of given
* path should be considered hidden.
*/
static Pair shouldScanPathAndIsPathHidden(@NonNull File dir) {
Trace.beginSection("shouldScanPathAndIsPathHiodden");
try {
boolean isPathHidden = false;
while (dir != null) {
if (!shouldScanDirectory(dir)) {
// When the path is not scannable, we don't care if it's hidden or not.
return Pair.create(false, false);
}
isPathHidden = isPathHidden || FileUtils.isDirectoryHidden(dir);
dir = dir.getParentFile();
}
return Pair.create(true, isPathHidden);
} finally {
Trace.endSection();
}
}
@VisibleForTesting
static boolean shouldScanDirectory(@NonNull File dir) {
final File nomedia = new File(dir, ".nomedia");
// Handle well-known paths that should always be visible or invisible,
// regardless of .nomedia presence
if (PATTERN_VISIBLE.matcher(dir.getAbsolutePath()).matches()) {
// Well known paths can never be a hidden directory. Delete any non-standard nomedia
// presence in well known path.
nomedia.delete();
return true;
}
if (PATTERN_INVISIBLE.matcher(dir.getAbsolutePath()).matches()) {
// Create the .nomedia file in paths that are not scannable. This is useful when user
// ejects the SD card and brings it to an older device and its media scanner can
// now correctly identify these paths as not scannable.
try {
nomedia.createNewFile();
} catch (IOException ignored) {
}
return false;
}
return true;
}
/**
* @return {@link FileColumns#MEDIA_TYPE}, resolved based on the file path and given
* {@code mimeType}.
*/
private static int resolveMediaTypeFromFilePath(@NonNull File file, @NonNull String mimeType,
boolean isHidden) {
int mediaType = MimeUtils.resolveMediaType(mimeType);
if (isHidden || FileUtils.isFileHidden(file)) {
mediaType = FileColumns.MEDIA_TYPE_NONE;
}
if (mediaType == FileColumns.MEDIA_TYPE_IMAGE && isFileAlbumArt(file)) {
mediaType = FileColumns.MEDIA_TYPE_NONE;
}
return mediaType;
}
@VisibleForTesting
static boolean isFileAlbumArt(@NonNull File file) {
return PATTERN_ALBUM_ART.matcher(file.getName()).matches();
}
static boolean isZero(@NonNull String value) {
if (value.length() == 0) {
return false;
}
for (int i = 0; i < value.length(); i++) {
if (value.charAt(i) != '0') {
return false;
}
}
return true;
}
static void logTroubleScanning(@NonNull File file, @NonNull Exception e) {
if (LOGW) Log.w(TAG, "Trouble scanning " + file + ": " + e);
}
}