blob: d9e9859c6c3c1970428e6b6af374d6066bf9e220 [file] [log] [blame]
/*
* 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_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_DURATION;
import static android.media.MediaMetadataRetriever.METADATA_KEY_GENRE;
import static android.media.MediaMetadataRetriever.METADATA_KEY_IS_DRM;
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_WIDTH;
import static android.media.MediaMetadataRetriever.METADATA_KEY_YEAR;
import static android.os.Trace.TRACE_TAG_DATABASE;
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.annotation.CurrentTimeMillisLong;
import android.annotation.CurrentTimeSecondsLong;
import android.annotation.NonNull;
import android.annotation.Nullable;
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.media.ExifInterface;
import android.media.MediaFile;
import android.media.MediaMetadataRetriever;
import android.mtp.MtpConstants;
import android.net.Uri;
import android.os.Build;
import android.os.CancellationSignal;
import android.os.Environment;
import android.os.FileUtils;
import android.os.OperationCanceledException;
import android.os.RemoteException;
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.Log;
import android.util.LongArray;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
import com.android.providers.media.util.IsoInterface;
import com.android.providers.media.util.XmpInterface;
import libcore.net.MimeUtils;
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.List;
import java.util.Locale;
import java.util.Optional;
import java.util.TimeZone;
import java.util.regex.Pattern;
/**
* Modern implementation of media scanner.
* <p>
* This is a bug-compatible reimplementation of the legacy media scanner, but
* written purely in managed code for better testability and long-term
* maintainability.
* <p>
* Initial tests shows it performing roughly on-par with the legacy scanner.
* <p>
* 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: add DRM support
// 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
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)$");
private final Context mContext;
/**
* Map from volume name to signals that can be used to cancel any active
* scan operations on those volumes.
*/
@GuardedBy("mSignals")
private final ArrayMap<String, CancellationSignal> mSignals = new ArrayMap<>();
public ModernMediaScanner(Context context) {
mContext = context;
}
@Override
public Context getContext() {
return mContext;
}
@Override
public void scanDirectory(File file) {
try (Scan scan = new Scan(file)) {
scan.run();
} catch (OperationCanceledException ignored) {
}
}
@Override
public Uri scanFile(File file) {
try (Scan scan = new Scan(file)) {
scan.run();
return scan.mFirstResult;
} 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<Path>, AutoCloseable {
private final ContentProviderClient mClient;
private final ContentResolver mResolver;
private final File mRoot;
private final String mVolumeName;
private final Uri mFilesUri;
private final CancellationSignal mSignal;
private final boolean mSingleFile;
private final ArrayList<ContentProviderOperation> mPending = new ArrayList<>();
private LongArray mScannedIds = new LongArray();
private LongArray mUnknownIds = new LongArray();
private LongArray mPlaylistIds = new LongArray();
private Uri mFirstResult;
public Scan(File root) {
Trace.traceBegin(TRACE_TAG_DATABASE, "ctor");
mClient = mContext.getContentResolver()
.acquireContentProviderClient(MediaStore.AUTHORITY);
mResolver = ContentResolver.wrap(mClient.getLocalContentProvider());
mRoot = root;
mVolumeName = MediaStore.getVolumeName(root);
mFilesUri = MediaStore.setIncludePending(MediaStore.Files.getContentUri(mVolumeName));
mSignal = getOrCreateSignal(mVolumeName);
mSingleFile = mRoot.isFile();
Trace.traceEnd(TRACE_TAG_DATABASE);
}
@Override
public void run() {
// 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
if (mPlaylistIds.size() > 0) {
resolvePlaylists();
}
}
private void walkFileTree() {
mSignal.throwIfCanceled();
if (!isDirectoryHiddenRecursive(mSingleFile ? mRoot.getParentFile() : mRoot)) {
Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "walkFileTree");
try {
Files.walkFileTree(mRoot.toPath(), this);
} catch (IOException e) {
// This should never happen, so yell loudly
throw new IllegalStateException(e);
} finally {
Trace.traceEnd(Trace.TRACE_TAG_DATABASE);
}
applyPending();
}
}
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.traceBegin(Trace.TRACE_TAG_DATABASE, "reconcile");
try (Cursor c = mResolver.query(mFilesUri,
new String[]{FileColumns._ID},
FileColumns.FORMAT + "!=? AND " + FileColumns.DATA + " LIKE ? ESCAPE '\\'",
new String[]{
// Ignore abstract playlists which don't have files on disk
String.valueOf(MtpConstants.FORMAT_ABSTRACT_AV_PLAYLIST),
escapeForLike(mRoot.getAbsolutePath()) + '%'
},
FileColumns._ID + " DESC", mSignal)) {
while (c.moveToNext()) {
final long id = c.getLong(0);
if (Arrays.binarySearch(scannedIds, id) < 0) {
mUnknownIds.add(id);
}
}
} finally {
Trace.traceEnd(Trace.TRACE_TAG_DATABASE);
}
// Third, clean all the unknown database entries found above
mSignal.throwIfCanceled();
Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "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();
mPending.add(ContentProviderOperation.newDelete(uri).build());
maybeApplyPending();
}
applyPending();
} finally {
Trace.traceEnd(Trace.TRACE_TAG_DATABASE);
}
}
private void resolvePlaylists() {
mSignal.throwIfCanceled();
for (int i = 0; i < mPlaylistIds.size(); i++) {
final Uri uri = MediaStore.Files.getContentUri(mVolumeName, mPlaylistIds.get(i));
try {
mPending.addAll(
PlaylistResolver.resolvePlaylist(mResolver, uri));
maybeApplyPending();
} catch (IOException e) {
if (LOGW) Log.w(TAG, "Ignoring troubled playlist: " + uri, e);
}
applyPending();
}
}
@Override
public void close() {
// Sanity check that we drained any pending operations
if (!mPending.isEmpty()) {
throw new IllegalStateException();
}
mClient.close();
}
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
throws IOException {
// Possibly bail before digging into each directory
mSignal.throwIfCanceled();
if (isDirectoryHidden(dir.toFile())) {
return FileVisitResult.SKIP_SUBTREE;
}
// 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);
// 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;
Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "checkChanged");
try (Cursor c = mResolver.query(mFilesUri,
new String[] { FileColumns._ID, FileColumns.DATE_MODIFIED, FileColumns.SIZE },
FileColumns.DATA + "=?", new String[] { realFile.getAbsolutePath() }, null)) {
if (c.moveToFirst()) {
existingId = c.getLong(0);
final long dateModified = c.getLong(1);
final long size = c.getLong(2);
// 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 (mFirstResult == null) {
mFirstResult = MediaStore.Files.getContentUri(mVolumeName, existingId);
}
final boolean sameTime = (lastModifiedTime(realFile, attrs) == dateModified);
final boolean sameSize = (attrs.size() == size);
if (attrs.isDirectory() || (sameTime && sameSize)) {
if (LOGV) Log.v(TAG, "Skipping unchanged " + file);
return FileVisitResult.CONTINUE;
}
}
} finally {
Trace.traceEnd(Trace.TRACE_TAG_DATABASE);
}
final ContentProviderOperation op;
Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "scanItem");
try {
op = scanItem(existingId, file.toFile(), attrs, mVolumeName);
} finally {
Trace.traceEnd(Trace.TRACE_TAG_DATABASE);
}
if (op != null) {
mPending.add(op);
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 {
return FileVisitResult.CONTINUE;
}
private void maybeApplyPending() {
if (mPending.size() > BATCH_SIZE) {
applyPending();
}
}
private void applyPending() {
Trace.traceBegin(Trace.TRACE_TAG_DATABASE, "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);
Uri uri = result.uri;
if (uri != null) {
if (mFirstResult == null) {
mFirstResult = uri;
}
final long id = ContentUris.parseId(uri);
mScannedIds.add(id);
}
// Some operations don't return a URI, so check the original if necessary
Uri uriToCheck = uri == null ? operation.getUri() : uri;
if (uriToCheck != null) {
if (isPlaylist(uriToCheck)) {
// If this was a playlist, remember it so we can resolve
// its contents once all other media has been scanned
mPlaylistIds.add(ContentUris.parseId(uriToCheck));
}
}
}
} catch (RemoteException | OperationApplicationException e) {
Log.w(TAG, "Failed to apply: " + e);
} finally {
mPending.clear();
Trace.traceEnd(Trace.TRACE_TAG_DATABASE);
}
}
}
/**
* 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 scanItem(long existingId, File file,
BasicFileAttributes attrs, String volumeName) {
final String name = file.getName();
if (name.startsWith(".")) {
if (LOGD) Log.d(TAG, "Ignoring hidden file: " + file);
return null;
}
try {
final String mimeType;
if (attrs.isDirectory()) {
mimeType = null;
} else {
mimeType = MediaFile.getMimeTypeForFile(file.getPath());
}
if (attrs.isDirectory()) {
return scanItemDirectory(existingId, file, attrs, mimeType, volumeName);
} else if (MediaFile.isPlayListMimeType(mimeType)) {
return scanItemPlaylist(existingId, file, attrs, mimeType, volumeName);
} else if (MediaFile.isAudioMimeType(mimeType)) {
return scanItemAudio(existingId, file, attrs, mimeType, volumeName);
} else if (MediaFile.isVideoMimeType(mimeType)) {
return scanItemVideo(existingId, file, attrs, mimeType, volumeName);
} else if (MediaFile.isImageMimeType(mimeType)) {
return scanItemImage(existingId, file, attrs, mimeType, volumeName);
} else {
return scanItemFile(existingId, file, attrs, mimeType, volumeName);
}
} catch (IOException e) {
if (LOGW) Log.w(TAG, "Ignoring troubled file: " + file, e);
return null;
}
}
/**
* Populate the given {@link ContentProviderOperation} with the generic
* {@link MediaColumns} values that can be determined directly from the file
* or its attributes.
*/
private static void withGenericValues(ContentProviderOperation.Builder op,
File file, BasicFileAttributes attrs, String mimeType) {
op.withValue(MediaColumns.DATA, file.getAbsolutePath());
op.withValue(MediaColumns.SIZE, attrs.size());
op.withValue(MediaColumns.TITLE, extractName(file));
op.withValue(MediaColumns.DATE_MODIFIED, lastModifiedTime(file, attrs));
op.withValue(MediaColumns.DATE_TAKEN, null);
op.withValue(MediaColumns.MIME_TYPE, mimeType);
op.withValue(MediaColumns.IS_DRM, 0);
op.withValue(MediaColumns.WIDTH, null);
op.withValue(MediaColumns.HEIGHT, null);
op.withValue(MediaColumns.DOCUMENT_ID, null);
op.withValue(MediaColumns.INSTANCE_ID, null);
op.withValue(MediaColumns.ORIGINAL_DOCUMENT_ID, null);
op.withValue(MediaColumns.DURATION, null);
op.withValue(MediaColumns.ORIENTATION, null);
}
/**
* 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) {
op.withValue(MediaColumns.MIME_TYPE,
maybeOverrideMimeType(mimeType, xmp.getFormat()));
op.withValue(MediaColumns.DOCUMENT_ID, xmp.getDocumentId());
op.withValue(MediaColumns.INSTANCE_ID, xmp.getInstanceId());
op.withValue(MediaColumns.ORIGINAL_DOCUMENT_ID, xmp.getOriginalDocumentId());
}
/**
* Overwrite a value in the given {@link ContentProviderOperation}, but only
* when the given {@link Optional} value is present.
*/
private static void withOptionalValue(ContentProviderOperation.Builder op,
String key, Optional<?> value) {
if (value.isPresent()) {
op.withValue(key, value.get());
}
}
private static @NonNull ContentProviderOperation scanItemDirectory(long existingId, File file,
BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException {
final ContentProviderOperation.Builder op = newUpsert(
MediaStore.Files.getContentUri(volumeName), existingId);
try {
withGenericValues(op, file, attrs, mimeType);
op.withValue(FileColumns.MEDIA_TYPE, 0);
op.withValue(FileColumns.FORMAT, MtpConstants.FORMAT_ASSOCIATION);
op.withValue(FileColumns.MIME_TYPE, null);
} catch (Exception e) {
throw new IOException(e);
}
return op.build();
}
private static ArrayMap<String, String> 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 scanItemAudio(long existingId, File file,
BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException {
final ContentProviderOperation.Builder op = newUpsert(
MediaStore.Audio.Media.getContentUri(volumeName), existingId);
withGenericValues(op, file, attrs, mimeType);
op.withValue(AudioColumns.ARTIST, UNKNOWN_STRING);
op.withValue(AudioColumns.ALBUM_ARTIST, null);
op.withValue(AudioColumns.COMPILATION, null);
op.withValue(AudioColumns.COMPOSER, null);
op.withValue(AudioColumns.ALBUM, file.getParentFile().getName());
op.withValue(AudioColumns.TRACK, null);
op.withValue(AudioColumns.YEAR, null);
op.withValue(AudioColumns.GENRE, null);
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());
withOptionalValue(op, MediaColumns.TITLE,
parseOptional(mmr.extractMetadata(METADATA_KEY_TITLE)));
withOptionalValue(op, MediaColumns.IS_DRM,
parseOptional(mmr.extractMetadata(METADATA_KEY_IS_DRM)));
withOptionalValue(op, MediaColumns.DURATION,
parseOptional(mmr.extractMetadata(METADATA_KEY_DURATION)));
withOptionalValue(op, AudioColumns.ARTIST,
parseOptional(mmr.extractMetadata(METADATA_KEY_ARTIST)));
withOptionalValue(op, AudioColumns.ALBUM_ARTIST,
parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUMARTIST)));
withOptionalValue(op, AudioColumns.COMPILATION,
parseOptional(mmr.extractMetadata(METADATA_KEY_COMPILATION)));
withOptionalValue(op, AudioColumns.COMPOSER,
parseOptional(mmr.extractMetadata(METADATA_KEY_COMPOSER)));
withOptionalValue(op, AudioColumns.ALBUM,
parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUM)));
withOptionalValue(op, AudioColumns.TRACK,
parseOptional(mmr.extractMetadata(METADATA_KEY_CD_TRACK_NUMBER)));
withOptionalValue(op, AudioColumns.YEAR,
parseOptionalOrZero(mmr.extractMetadata(METADATA_KEY_YEAR)));
withOptionalValue(op, AudioColumns.GENRE,
parseOptional(mmr.extractMetadata(METADATA_KEY_GENRE)));
}
// 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) {
throw new IOException(e);
}
return op.build();
}
private static @NonNull ContentProviderOperation scanItemPlaylist(long existingId, File file,
BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException {
final ContentProviderOperation.Builder op = newUpsert(
MediaStore.Audio.Playlists.getContentUri(volumeName), existingId);
try {
withGenericValues(op, file, attrs, mimeType);
op.withValue(PlaylistsColumns.NAME, extractName(file));
} catch (Exception e) {
throw new IOException(e);
}
return op.build();
}
private static @NonNull ContentProviderOperation scanItemVideo(long existingId, File file,
BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException {
final ContentProviderOperation.Builder op = newUpsert(
MediaStore.Video.Media.getContentUri(volumeName), existingId);
withGenericValues(op, file, attrs, mimeType);
op.withValue(VideoColumns.ARTIST, UNKNOWN_STRING);
op.withValue(VideoColumns.ALBUM, file.getParentFile().getName());
op.withValue(VideoColumns.RESOLUTION, null);
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());
withOptionalValue(op, MediaColumns.TITLE,
parseOptional(mmr.extractMetadata(METADATA_KEY_TITLE)));
withOptionalValue(op, MediaColumns.IS_DRM,
parseOptional(mmr.extractMetadata(METADATA_KEY_IS_DRM)));
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.DURATION,
parseOptional(mmr.extractMetadata(METADATA_KEY_DURATION)));
withOptionalValue(op, MediaColumns.DATE_TAKEN,
parseOptionalDate(mmr.extractMetadata(METADATA_KEY_DATE)));
withOptionalValue(op, VideoColumns.ARTIST,
parseOptional(mmr.extractMetadata(METADATA_KEY_ARTIST)));
withOptionalValue(op, VideoColumns.ALBUM,
parseOptional(mmr.extractMetadata(METADATA_KEY_ALBUM)));
withOptionalValue(op, VideoColumns.RESOLUTION,
parseOptionalResolution(mmr));
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) {
throw new IOException(e);
}
return op.build();
}
private static @NonNull ContentProviderOperation scanItemImage(long existingId, File file,
BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException {
final ContentProviderOperation.Builder op = newUpsert(
MediaStore.Images.Media.getContentUri(volumeName), existingId);
withGenericValues(op, file, attrs, mimeType);
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.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)));
// Also hunt around for XMP metadata
final XmpInterface xmp = XmpInterface.fromContainer(exif);
withXmpValues(op, xmp, mimeType);
} catch (Exception e) {
throw new IOException(e);
}
return op.build();
}
private static @NonNull ContentProviderOperation scanItemFile(long existingId, File file,
BasicFileAttributes attrs, String mimeType, String volumeName) throws IOException {
final ContentProviderOperation.Builder op = newUpsert(
MediaStore.Files.getContentUri(volumeName), existingId);
try {
withGenericValues(op, file, attrs, mimeType);
} catch (Exception e) {
throw new IOException(e);
}
return op.build();
}
private static @NonNull ContentProviderOperation.Builder newUpsert(Uri uri, long existingId) {
if (existingId == -1) {
return ContentProviderOperation.newInsert(uri)
.withFailureAllowed(true);
} else {
return ContentProviderOperation.newUpdate(ContentUris.withAppendedId(uri, existingId))
.withExpectedCount(1)
.withFailureAllowed(true);
}
}
public static @Nullable String extractExtension(File file) {
final String name = file.getName();
final int lastDot = name.lastIndexOf('.');
return (lastDot == -1) ? null : name.substring(lastDot + 1);
}
public static @NonNull String extractName(File file) {
final String name = file.getName();
final int lastDot = name.lastIndexOf('.');
return (lastDot == -1) ? name : name.substring(0, lastDot);
}
private static @NonNull <T> Optional<T> 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 Number && ((Number) value).intValue() == -1) {
return Optional.empty();
} else {
return Optional.of(value);
}
}
private static @NonNull <T> Optional<T> parseOptionalOrZero(@Nullable T value) {
if (value instanceof String && ((String) value).equals("0")) {
return Optional.empty();
} else if (value instanceof Number && ((Number) value).intValue() == 0) {
return Optional.empty();
} else {
return parseOptional(value);
}
}
/**
* 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.
*/
static @NonNull Optional<Long> parseOptionalDateTaken(@NonNull ExifInterface exif,
@CurrentTimeMillisLong long lastModifiedTime) {
final long originalTime = exif.getDateTimeOriginal();
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 = exif.getGpsDateTime();
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();
}
}
private static @NonNull Optional<Integer> 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();
}
}
private static @NonNull Optional<String> parseOptionalResolution(
@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();
}
}
private static @NonNull Optional<Long> parseOptionalDate(@Nullable String date) {
if (TextUtils.isEmpty(date)) return Optional.empty();
try {
final long value = sDateFormat.parse(date).getTime();
return (value > 0) ? Optional.of(value) : Optional.empty();
} catch (ParseException e) {
return Optional.empty();
}
}
/**
* Maybe replace the MIME type from extension with the MIME type from the
* XMP metadata, but only when the top-level MIME type agrees.
*/
@VisibleForTesting
public static @NonNull String maybeOverrideMimeType(@NonNull String extMimeType,
@Nullable String xmpMimeType) {
// Ignore XMP when missing
if (TextUtils.isEmpty(xmpMimeType)) return extMimeType;
// Ignore XMP when invalid
final int xmpSplit = xmpMimeType.indexOf('/');
if (xmpSplit == -1) return extMimeType;
if (extMimeType.regionMatches(0, xmpMimeType, 0, xmpSplit + 1)) {
return xmpMimeType;
} else {
return extMimeType;
}
}
/**
* 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 @CurrentTimeSecondsLong 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 directory should be considered hidden.
*/
static boolean isDirectoryHiddenRecursive(File dir) {
Trace.traceBegin(TRACE_TAG_DATABASE, "isDirectoryHiddenRecursive");
try {
while (dir != null) {
if (isDirectoryHidden(dir)) {
return true;
}
dir = dir.getParentFile();
}
return false;
} finally {
Trace.traceEnd(TRACE_TAG_DATABASE);
}
}
/**
* Test if this given directory should be considered hidden.
*/
static boolean isDirectoryHidden(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()) {
nomedia.delete();
return false;
}
if (PATTERN_INVISIBLE.matcher(dir.getAbsolutePath()).matches()) {
try {
nomedia.createNewFile();
} catch (IOException ignored) {
}
return true;
}
// Otherwise fall back to directory name or .nomedia presence
final String name = dir.getName();
if (name.startsWith(".")) {
return true;
}
if (nomedia.exists()) {
return true;
}
return false;
}
/**
* Test if this given {@link Uri} is a
* {@link android.provider.MediaStore.Audio.Playlists} item.
*/
static boolean isPlaylist(Uri uri) {
final List<String> path = uri.getPathSegments();
return (path.size() == 4) && path.get(1).equals("audio") && path.get(2).equals("playlists");
}
/**
* Escape the given argument for use in a {@code LIKE} statement.
*/
static String escapeForLike(String arg) {
final StringBuilder sb = new StringBuilder();
for (int i = 0; i < arg.length(); i++) {
final char c = arg.charAt(i);
switch (c) {
case '%': sb.append('\\');
case '_': sb.append('\\');
}
sb.append(c);
}
return sb.toString();
}
}