blob: 09aa1b4daaf24d2dd9c5dba28a4036ce767916de [file] [log] [blame]
/*
* Copyright (C) 2006 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;
import static android.Manifest.permission.ACCESS_MEDIA_LOCATION;
import static android.app.AppOpsManager.permissionToOp;
import static android.app.PendingIntent.FLAG_CANCEL_CURRENT;
import static android.app.PendingIntent.FLAG_IMMUTABLE;
import static android.app.PendingIntent.FLAG_ONE_SHOT;
import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION;
import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS;
import static android.content.pm.PackageManager.PERMISSION_GRANTED;
import static android.database.Cursor.FIELD_TYPE_BLOB;
import static android.provider.MediaStore.Files.FileColumns.MEDIA_TYPE;
import static android.provider.MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE;
import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT;
import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_NONE;
import static android.provider.MediaStore.MATCH_DEFAULT;
import static android.provider.MediaStore.MATCH_EXCLUDE;
import static android.provider.MediaStore.MATCH_INCLUDE;
import static android.provider.MediaStore.MATCH_ONLY;
import static android.provider.MediaStore.MY_UID;
import static android.provider.MediaStore.PER_USER_RANGE;
import static android.provider.MediaStore.QUERY_ARG_DEFER_SCAN;
import static android.provider.MediaStore.QUERY_ARG_MATCH_FAVORITE;
import static android.provider.MediaStore.QUERY_ARG_MATCH_PENDING;
import static android.provider.MediaStore.QUERY_ARG_MATCH_TRASHED;
import static android.provider.MediaStore.QUERY_ARG_REDACTED_URI;
import static android.provider.MediaStore.QUERY_ARG_RELATED_URI;
import static android.provider.MediaStore.getVolumeName;
import static android.system.OsConstants.F_GETFL;
import static com.android.providers.media.DatabaseHelper.EXTERNAL_DATABASE_NAME;
import static com.android.providers.media.DatabaseHelper.INTERNAL_DATABASE_NAME;
import static com.android.providers.media.LocalCallingIdentity.APPOP_REQUEST_INSTALL_PACKAGES_FOR_SHARED_UID;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_ACCESS_MTP;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_INSTALL_PACKAGES;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_DELEGATOR;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_LEGACY_GRANTED;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_LEGACY_READ;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_LEGACY_WRITE;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_MANAGER;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_REDACTION_NEEDED;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_SELF;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_SHELL;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_SYSTEM_GALLERY;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_READ_AUDIO;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_READ_IMAGES;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_READ_VIDEO;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_WRITE_AUDIO;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_WRITE_EXTERNAL_STORAGE;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_WRITE_IMAGES;
import static com.android.providers.media.LocalCallingIdentity.PERMISSION_WRITE_VIDEO;
import static com.android.providers.media.PickerUriResolver.getMediaUri;
import static com.android.providers.media.scan.MediaScanner.REASON_DEMAND;
import static com.android.providers.media.scan.MediaScanner.REASON_IDLE;
import static com.android.providers.media.util.DatabaseUtils.bindList;
import static com.android.providers.media.util.FileUtils.DEFAULT_FOLDER_NAMES;
import static com.android.providers.media.util.FileUtils.PATTERN_PENDING_FILEPATH_FOR_SQL;
import static com.android.providers.media.util.FileUtils.buildPrimaryVolumeFile;
import static com.android.providers.media.util.FileUtils.extractDisplayName;
import static com.android.providers.media.util.FileUtils.extractFileExtension;
import static com.android.providers.media.util.FileUtils.extractFileName;
import static com.android.providers.media.util.FileUtils.extractPathOwnerPackageName;
import static com.android.providers.media.util.FileUtils.extractRelativePath;
import static com.android.providers.media.util.FileUtils.extractRelativePathForDirectory;
import static com.android.providers.media.util.FileUtils.extractTopLevelDir;
import static com.android.providers.media.util.FileUtils.extractVolumeName;
import static com.android.providers.media.util.FileUtils.extractVolumePath;
import static com.android.providers.media.util.FileUtils.getAbsoluteSanitizedPath;
import static com.android.providers.media.util.FileUtils.isCrossUserEnabled;
import static com.android.providers.media.util.FileUtils.isDataOrObbPath;
import static com.android.providers.media.util.FileUtils.isDownload;
import static com.android.providers.media.util.FileUtils.isExternalMediaDirectory;
import static com.android.providers.media.util.FileUtils.isObbOrChildPath;
import static com.android.providers.media.util.FileUtils.sanitizePath;
import static com.android.providers.media.util.Logging.LOGV;
import static com.android.providers.media.util.Logging.TAG;
import static com.android.providers.media.util.SyntheticPathUtils.REDACTED_URI_ID_PREFIX;
import static com.android.providers.media.util.SyntheticPathUtils.REDACTED_URI_ID_SIZE;
import static com.android.providers.media.util.SyntheticPathUtils.createSparseFile;
import static com.android.providers.media.util.SyntheticPathUtils.extractSyntheticRelativePathSegements;
import static com.android.providers.media.util.SyntheticPathUtils.getRedactedRelativePath;
import static com.android.providers.media.util.SyntheticPathUtils.isPickerPath;
import static com.android.providers.media.util.SyntheticPathUtils.isRedactedPath;
import static com.android.providers.media.util.SyntheticPathUtils.isSyntheticPath;
import android.annotation.IntDef;
import android.app.AppOpsManager;
import android.app.AppOpsManager.OnOpActiveChangedListener;
import android.app.AppOpsManager.OnOpChangedListener;
import android.app.DownloadManager;
import android.app.PendingIntent;
import android.app.RecoverableSecurityException;
import android.app.RemoteAction;
import android.app.admin.DevicePolicyManager;
import android.app.compat.CompatChanges;
import android.compat.annotation.ChangeId;
import android.compat.annotation.EnabledAfter;
import android.content.BroadcastReceiver;
import android.content.ClipData;
import android.content.ClipDescription;
import android.content.ContentProvider;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentProviderResult;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.OperationApplicationException;
import android.content.SharedPreferences;
import android.content.UriMatcher;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInstaller.SessionInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.PermissionGroupInfo;
import android.content.pm.ProviderInfo;
import android.content.res.AssetFileDescriptor;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.database.sqlite.SQLiteConstraintException;
import android.database.sqlite.SQLiteDatabase;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.Icon;
import android.icu.util.ULocale;
import android.media.ExifInterface;
import android.media.ThumbnailUtils;
import android.mtp.MtpConstants;
import android.net.Uri;
import android.os.Binder;
import android.os.Binder.ProxyTransactListener;
import android.os.Build;
import android.os.Bundle;
import android.os.CancellationSignal;
import android.os.Environment;
import android.os.IBinder;
import android.os.ParcelFileDescriptor;
import android.os.ParcelFileDescriptor.OnCloseListener;
import android.os.Parcelable;
import android.os.Process;
import android.os.RemoteException;
import android.os.SystemClock;
import android.os.Trace;
import android.os.UserHandle;
import android.os.UserManager;
import android.os.storage.StorageManager;
import android.os.storage.StorageManager.StorageVolumeCallback;
import android.os.storage.StorageVolume;
import android.preference.PreferenceManager;
import android.provider.BaseColumns;
import android.provider.CloudMediaProviderContract;
import android.provider.Column;
import android.provider.DeviceConfig;
import android.provider.DeviceConfig.OnPropertiesChangedListener;
import android.provider.DocumentsContract;
import android.provider.ExportedSince;
import android.provider.MediaStore;
import android.provider.MediaStore.Audio;
import android.provider.MediaStore.Audio.AudioColumns;
import android.provider.MediaStore.Audio.Playlists;
import android.provider.MediaStore.Downloads;
import android.provider.MediaStore.Files;
import android.provider.MediaStore.Files.FileColumns;
import android.provider.MediaStore.Images;
import android.provider.MediaStore.Images.ImageColumns;
import android.provider.MediaStore.MediaColumns;
import android.provider.MediaStore.Video;
import android.system.ErrnoException;
import android.system.Os;
import android.system.OsConstants;
import android.system.StructStat;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.LongSparseArray;
import android.util.Size;
import android.util.SparseArray;
import android.webkit.MimeTypeMap;
import androidx.annotation.GuardedBy;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import com.android.modules.utils.build.SdkLevel;
import com.android.modules.utils.BackgroundThread;
import com.android.providers.media.DatabaseHelper.OnFilesChangeListener;
import com.android.providers.media.DatabaseHelper.OnLegacyMigrationListener;
import com.android.providers.media.fuse.ExternalStorageServiceImpl;
import com.android.providers.media.fuse.FuseDaemon;
import com.android.providers.media.metrics.PulledMetrics;
import com.android.providers.media.photopicker.PickerDataLayer;
import com.android.providers.media.photopicker.PickerSyncController;
import com.android.providers.media.photopicker.data.ExternalDbFacade;
import com.android.providers.media.photopicker.data.PickerDbFacade;
import com.android.providers.media.playlist.Playlist;
import com.android.providers.media.scan.MediaScanner;
import com.android.providers.media.scan.ModernMediaScanner;
import com.android.providers.media.util.CachedSupplier;
import com.android.providers.media.util.DatabaseUtils;
import com.android.providers.media.util.FileUtils;
import com.android.providers.media.util.ForegroundThread;
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.PermissionUtils;
import com.android.providers.media.util.Preconditions;
import com.android.providers.media.util.SQLiteQueryBuilder;
import com.android.providers.media.util.SpecialFormatDetector;
import com.android.providers.media.util.UserCache;
import com.android.providers.media.util.XmpInterface;
import com.google.common.hash.Hashing;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Media content provider. See {@link android.provider.MediaStore} for details.
* Separate databases are kept for each external storage card we see (using the
* card's ID as an index). The content visible at content://media/external/...
* changes with the card.
*/
public class MediaProvider extends ContentProvider {
/**
* Enables checks to stop apps from inserting and updating to private files via media provider.
*/
@ChangeId
@EnabledAfter(targetSdkVersion = android.os.Build.VERSION_CODES.R)
static final long ENABLE_CHECKS_FOR_PRIVATE_FILES = 172100307L;
/**
* Regex of a selection string that matches a specific ID.
*/
static final Pattern PATTERN_SELECTION_ID = Pattern.compile(
"(?:image_id|video_id)\\s*=\\s*(\\d+)");
/** File access by uid requires the transcoding transform */
private static final int FLAG_TRANSFORM_TRANSCODING = 1 << 0;
/** File access by uid is a synthetic path corresponding to a redacted URI */
private static final int FLAG_TRANSFORM_REDACTION = 1 << 1;
/** File access by uid is a synthetic path corresponding to a picker URI */
private static final int FLAG_TRANSFORM_PICKER = 1 << 2;
/**
* These directory names aren't declared in Environment as final variables, and so we need to
* have the same values in separate final variables in order to have them considered constant
* expressions.
* These directory names are intentionally in lower case to ease the case insensitive path
* comparison.
*/
private static final String DIRECTORY_MUSIC_LOWER_CASE = "music";
private static final String DIRECTORY_PODCASTS_LOWER_CASE = "podcasts";
private static final String DIRECTORY_RINGTONES_LOWER_CASE = "ringtones";
private static final String DIRECTORY_ALARMS_LOWER_CASE = "alarms";
private static final String DIRECTORY_NOTIFICATIONS_LOWER_CASE = "notifications";
private static final String DIRECTORY_PICTURES_LOWER_CASE = "pictures";
private static final String DIRECTORY_MOVIES_LOWER_CASE = "movies";
private static final String DIRECTORY_DOWNLOADS_LOWER_CASE = "download";
private static final String DIRECTORY_DCIM_LOWER_CASE = "dcim";
private static final String DIRECTORY_DOCUMENTS_LOWER_CASE = "documents";
private static final String DIRECTORY_AUDIOBOOKS_LOWER_CASE = "audiobooks";
private static final String DIRECTORY_RECORDINGS_LOWER_CASE = "recordings";
private static final String DIRECTORY_ANDROID_LOWER_CASE = "android";
private static final String DIRECTORY_MEDIA = "media";
private static final String DIRECTORY_THUMBNAILS = ".thumbnails";
private static final List<String> PRIVATE_SUBDIRECTORIES_ANDROID = Arrays.asList("data", "obb");
/**
* Hard-coded filename where the current value of
* {@link DatabaseHelper#getOrCreateUuid} is persisted on a physical SD card
* to help identify stale thumbnail collections.
*/
private static final String FILE_DATABASE_UUID = ".database_uuid";
/**
* Specify what default directories the caller gets full access to. By default, the caller
* shouldn't get full access to any default dirs.
* But for example, we do an exception for System Gallery apps and allow them full access to:
* DCIM, Pictures, Movies.
*/
private static final String INCLUDED_DEFAULT_DIRECTORIES =
"android:included-default-directories";
/**
* Value indicating that operations should include database rows matching the criteria defined
* by this key only when calling package has write permission to the database row or column is
* {@column MediaColumns#IS_PENDING} and is set by FUSE.
* <p>
* Note that items <em>not</em> matching the criteria will also be included, and as part of this
* match no additional write permission checks are carried out for those items.
*/
private static final int MATCH_VISIBLE_FOR_FILEPATH = 32;
private static final int NON_HIDDEN_CACHE_SIZE = 50;
/**
* Where clause to match pending files from FUSE. Pending files from FUSE will not have
* PATTERN_PENDING_FILEPATH_FOR_SQL pattern.
*/
private static final String MATCH_PENDING_FROM_FUSE = String.format("lower(%s) NOT REGEXP '%s'",
MediaColumns.DATA, PATTERN_PENDING_FILEPATH_FOR_SQL);
/**
* This flag is replaced with {@link MediaStore#QUERY_ARG_DEFER_SCAN} from S onwards and only
* kept around for app compatibility in R.
*/
private static final String QUERY_ARG_DO_ASYNC_SCAN = "android:query-arg-do-async-scan";
/**
* Enable option to defer the scan triggered as part of MediaProvider#update()
*/
@ChangeId
@EnabledAfter(targetSdkVersion = android.os.Build.VERSION_CODES.R)
static final long ENABLE_DEFERRED_SCAN = 180326732L;
/**
* Enable option to include database rows of files from recently unmounted
* volume in MediaProvider#query
*/
@ChangeId
@EnabledAfter(targetSdkVersion = Build.VERSION_CODES.R)
static final long ENABLE_INCLUDE_ALL_VOLUMES = 182734110L;
/**
* Set of {@link Cursor} columns that refer to raw filesystem paths.
*/
private static final ArrayMap<String, Object> sDataColumns = new ArrayMap<>();
static {
sDataColumns.put(MediaStore.MediaColumns.DATA, null);
sDataColumns.put(MediaStore.Images.Thumbnails.DATA, null);
sDataColumns.put(MediaStore.Video.Thumbnails.DATA, null);
sDataColumns.put(MediaStore.Audio.PlaylistsColumns.DATA, null);
sDataColumns.put(MediaStore.Audio.AlbumColumns.ALBUM_ART, null);
}
private static final int sUserId = UserHandle.myUserId();
/**
* Please use {@link getDownloadsProviderAuthority()} instead of using this directly.
*/
private static final String DOWNLOADS_PROVIDER_AUTHORITY = "downloads";
@GuardedBy("mPendingOpenInfo")
private final Map<Integer, PendingOpenInfo> mPendingOpenInfo = new ArrayMap<>();
@GuardedBy("mNonHiddenPaths")
private final LRUCache<String, Integer> mNonHiddenPaths = new LRUCache<>(NON_HIDDEN_CACHE_SIZE);
public void updateVolumes() {
mVolumeCache.update();
// Update filters to reflect mounted volumes so users don't get
// confused by metadata from ejected volumes
ForegroundThread.getExecutor().execute(() -> {
mExternalDatabase.setFilterVolumeNames(mVolumeCache.getExternalVolumeNames());
});
}
public @NonNull MediaVolume getVolume(@NonNull String volumeName) throws FileNotFoundException {
return mVolumeCache.findVolume(volumeName, mCallingIdentity.get().getUser());
}
public @NonNull File getVolumePath(@NonNull String volumeName) throws FileNotFoundException {
// Ugly hack to keep unit tests passing, where we don't always have a
// Context to discover volumes with
if (getContext() == null) {
return Environment.getExternalStorageDirectory();
}
return mVolumeCache.getVolumePath(volumeName, mCallingIdentity.get().getUser());
}
public @NonNull String getVolumeId(@NonNull File file) throws FileNotFoundException {
return mVolumeCache.getVolumeId(file);
}
private @NonNull Collection<File> getAllowedVolumePaths(String volumeName)
throws FileNotFoundException {
// This method is used to verify whether a path belongs to a certain volume name;
// we can't always use the calling user's identity here to determine exactly which
// volume is meant, because the MediaScanner may scan paths belonging to another user,
// eg a clone user.
// So, for volumes like external_primary, just return allowed paths for all users.
List<UserHandle> users = mUserCache.getUsersCached();
ArrayList<File> allowedPaths = new ArrayList<>();
for (UserHandle user : users) {
Collection<File> volumeScanPaths = mVolumeCache.getVolumeScanPaths(volumeName, user);
allowedPaths.addAll(volumeScanPaths);
}
return allowedPaths;
}
/**
* Frees any cache held by MediaProvider.
*
* @param bytes number of bytes which need to be freed
*/
public void freeCache(long bytes) {
mTranscodeHelper.freeCache(bytes);
}
public void onAnrDelayStarted(@NonNull String packageName, int uid, int tid, int reason) {
mTranscodeHelper.onAnrDelayStarted(packageName, uid, tid, reason);
}
private volatile Locale mLastLocale = Locale.getDefault();
private StorageManager mStorageManager;
private AppOpsManager mAppOpsManager;
private PackageManager mPackageManager;
private DevicePolicyManager mDevicePolicyManager;
private UserManager mUserManager;
private PickerUriResolver mPickerUriResolver;
private UserCache mUserCache;
private VolumeCache mVolumeCache;
private int mExternalStorageAuthorityAppId;
private int mDownloadsAuthorityAppId;
private Size mThumbSize;
/**
* Map from UID to cached {@link LocalCallingIdentity}. Values are only
* maintained in this map while the UID is actively working with a
* performance-critical component, such as camera.
*/
@GuardedBy("mCachedCallingIdentity")
private final SparseArray<LocalCallingIdentity> mCachedCallingIdentity = new SparseArray<>();
private final OnOpActiveChangedListener mActiveListener = (code, uid, packageName, active) -> {
synchronized (mCachedCallingIdentity) {
if (active) {
// TODO moltmann: Set correct featureId
mCachedCallingIdentity.put(uid,
LocalCallingIdentity.fromExternal(getContext(), mUserCache, uid,
packageName, null));
} else {
mCachedCallingIdentity.remove(uid);
}
}
};
/**
* Map from UID to cached {@link LocalCallingIdentity}. Values are only
* maintained in this map until there's any change in the appops needed or packages
* used in the {@link LocalCallingIdentity}.
*/
@GuardedBy("mCachedCallingIdentityForFuse")
private final SparseArray<LocalCallingIdentity> mCachedCallingIdentityForFuse =
new SparseArray<>();
private OnOpChangedListener mModeListener =
(op, packageName) -> invalidateLocalCallingIdentityCache(packageName, "op " + op);
/**
* Retrieves a cached calling identity or creates a new one. Also, always sets the app-op
* description for the calling identity.
*/
private LocalCallingIdentity getCachedCallingIdentityForFuse(int uid) {
synchronized (mCachedCallingIdentityForFuse) {
PermissionUtils.setOpDescription("via FUSE");
LocalCallingIdentity identity = mCachedCallingIdentityForFuse.get(uid);
if (identity == null) {
identity = LocalCallingIdentity.fromExternal(getContext(), mUserCache, uid);
if (uidToUserId(uid) == sUserId) {
mCachedCallingIdentityForFuse.put(uid, identity);
} else {
// In some app cloning designs, MediaProvider user 0 may
// serve requests for apps running as a "clone" user; in
// those cases, don't keep a cache for the clone user, since
// we don't get any invalidation events for these users.
}
}
return identity;
}
}
/**
* Calling identity state about on the current thread. Populated on demand,
* and invalidated by {@link #onCallingPackageChanged()} when each remote
* call is finished.
*/
private final ThreadLocal<LocalCallingIdentity> mCallingIdentity = ThreadLocal
.withInitial(() -> {
PermissionUtils.setOpDescription("via MediaProvider");
synchronized (mCachedCallingIdentity) {
final LocalCallingIdentity cached = mCachedCallingIdentity
.get(Binder.getCallingUid());
return (cached != null) ? cached
: LocalCallingIdentity.fromBinder(getContext(), this, mUserCache);
}
});
/**
* We simply propagate the UID that is being tracked by
* {@link LocalCallingIdentity}, which means we accurately blame both
* incoming Binder calls and FUSE calls.
*/
private final ProxyTransactListener mTransactListener = new ProxyTransactListener() {
@Override
public Object onTransactStarted(IBinder binder, int transactionCode) {
if (LOGV) Trace.beginSection(Thread.currentThread().getStackTrace()[5].getMethodName());
return Binder.setCallingWorkSourceUid(mCallingIdentity.get().uid);
}
@Override
public void onTransactEnded(Object session) {
final long token = (long) session;
Binder.restoreCallingWorkSource(token);
if (LOGV) Trace.endSection();
}
};
// In memory cache of path<->id mappings, to speed up inserts during media scan
@GuardedBy("mDirectoryCache")
private final ArrayMap<String, Long> mDirectoryCache = new ArrayMap<>();
private static final String[] sDataOnlyColumn = new String[] {
FileColumns.DATA
};
private static final String ID_NOT_PARENT_CLAUSE =
"_id NOT IN (SELECT parent FROM files WHERE parent IS NOT NULL)";
private static final String CANONICAL = "canonical";
private static final String ALL_VOLUMES = "all_volumes";
private BroadcastReceiver mPackageReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
switch (intent.getAction()) {
case Intent.ACTION_PACKAGE_REMOVED:
case Intent.ACTION_PACKAGE_ADDED:
Uri uri = intent.getData();
String pkg = uri != null ? uri.getSchemeSpecificPart() : null;
if (pkg != null) {
invalidateLocalCallingIdentityCache(pkg, "package " + intent.getAction());
} else {
Log.w(TAG, "Failed to retrieve package from intent: " + intent.getAction());
}
break;
}
}
};
private void invalidateLocalCallingIdentityCache(String packageName, String reason) {
synchronized (mCachedCallingIdentityForFuse) {
try {
Log.i(TAG, "Invalidating LocalCallingIdentity cache for package " + packageName
+ ". Reason: " + reason);
mCachedCallingIdentityForFuse.remove(
getContext().getPackageManager().getPackageUid(packageName, 0));
} catch (NameNotFoundException ignored) {
}
}
}
private final void updateQuotaTypeForUri(@NonNull Uri uri, int mediaType) {
Trace.beginSection("updateQuotaTypeForUri");
File file;
try {
file = queryForDataFile(uri, null);
if (!file.exists()) {
// This can happen if an item is inserted in MediaStore before it is created
return;
}
if (mediaType == FileColumns.MEDIA_TYPE_NONE) {
// This might be because the file is hidden; but we still want to
// attribute its quota to the correct type, so get the type from
// the extension instead.
mediaType = MimeUtils.resolveMediaType(MimeUtils.resolveMimeType(file));
}
updateQuotaTypeForFileInternal(file, mediaType);
} catch (FileNotFoundException | IllegalArgumentException e) {
// Ignore
Log.w(TAG, "Failed to update quota for uri: " + uri, e);
return;
} finally {
Trace.endSection();
}
}
private final void updateQuotaTypeForFileInternal(File file, int mediaType) {
try {
switch (mediaType) {
case FileColumns.MEDIA_TYPE_AUDIO:
mStorageManager.updateExternalStorageFileQuotaType(file,
StorageManager.QUOTA_TYPE_MEDIA_AUDIO);
break;
case FileColumns.MEDIA_TYPE_VIDEO:
mStorageManager.updateExternalStorageFileQuotaType(file,
StorageManager.QUOTA_TYPE_MEDIA_VIDEO);
break;
case FileColumns.MEDIA_TYPE_IMAGE:
mStorageManager.updateExternalStorageFileQuotaType(file,
StorageManager.QUOTA_TYPE_MEDIA_IMAGE);
break;
default:
mStorageManager.updateExternalStorageFileQuotaType(file,
StorageManager.QUOTA_TYPE_MEDIA_NONE);
break;
}
} catch (IOException e) {
Log.w(TAG, "Failed to update quota type for " + file.getPath(), e);
}
}
/**
* Since these operations are in the critical path of apps working with
* media, we only collect the {@link Uri} that need to be notified, and all
* other side-effect operations are delegated to {@link BackgroundThread} so
* that we return as quickly as possible.
*/
private final OnFilesChangeListener mFilesListener = new OnFilesChangeListener() {
@Override
public void onInsert(@NonNull DatabaseHelper helper, @NonNull String volumeName, long id,
int mediaType, boolean isDownload, boolean isPending) {
handleInsertedRowForFuse(id);
acceptWithExpansion(helper::notifyInsert, volumeName, id, mediaType, isDownload);
helper.postBackground(() -> {
if (helper.isExternal()) {
// Update the quota type on the filesystem
Uri fileUri = MediaStore.Files.getContentUri(volumeName, id);
updateQuotaTypeForUri(fileUri, mediaType);
}
// Tell our SAF provider so it knows when views are no longer empty
MediaDocumentsProvider.onMediaStoreInsert(getContext(), volumeName, mediaType, id);
if (mExternalDbFacade.onFileInserted(mediaType, isPending)) {
mPickerSyncController.notifyMediaEvent();
}
});
}
@Override
public void onUpdate(@NonNull DatabaseHelper helper, @NonNull String volumeName,
long oldId, int oldMediaType, boolean oldIsDownload,
long newId, int newMediaType, boolean newIsDownload,
boolean oldIsTrashed, boolean newIsTrashed,
boolean oldIsPending, boolean newIsPending,
boolean oldIsFavorite, boolean newIsFavorite,
String oldOwnerPackage, String newOwnerPackage, String oldPath) {
final boolean isDownload = oldIsDownload || newIsDownload;
final Uri fileUri = MediaStore.Files.getContentUri(volumeName, oldId);
handleUpdatedRowForFuse(oldPath, oldOwnerPackage, oldId, newId);
handleOwnerPackageNameChange(oldPath, oldOwnerPackage, newOwnerPackage);
acceptWithExpansion(helper::notifyUpdate, volumeName, oldId, oldMediaType, isDownload);
helper.postBackground(() -> {
if (helper.isExternal()) {
// Update the quota type on the filesystem
updateQuotaTypeForUri(fileUri, newMediaType);
}
if (mExternalDbFacade.onFileUpdated(oldId, oldMediaType, newMediaType, oldIsTrashed,
newIsTrashed, oldIsPending, newIsPending, oldIsFavorite,
newIsFavorite)) {
mPickerSyncController.notifyMediaEvent();
}
});
if (newMediaType != oldMediaType) {
acceptWithExpansion(helper::notifyUpdate, volumeName, oldId, newMediaType,
isDownload);
helper.postBackground(() -> {
// Invalidate any thumbnails when the media type changes
invalidateThumbnails(fileUri);
});
}
}
@Override
public void onDelete(@NonNull DatabaseHelper helper, @NonNull String volumeName, long id,
int mediaType, boolean isDownload, String ownerPackageName, String path) {
handleDeletedRowForFuse(path, ownerPackageName, id);
acceptWithExpansion(helper::notifyDelete, volumeName, id, mediaType, isDownload);
// Remove cached transcoded file if any
mTranscodeHelper.deleteCachedTranscodeFile(id);
helper.postBackground(() -> {
// Item no longer exists, so revoke all access to it
Trace.beginSection("revokeUriPermission");
try {
acceptWithExpansion((uri) -> {
getContext().revokeUriPermission(uri, ~0);
}, volumeName, id, mediaType, isDownload);
} finally {
Trace.endSection();
}
switch (mediaType) {
case FileColumns.MEDIA_TYPE_PLAYLIST:
case FileColumns.MEDIA_TYPE_AUDIO:
if (helper.isExternal()) {
removePlaylistMembers(mediaType, id);
}
}
// Invalidate any thumbnails now that media is gone
invalidateThumbnails(MediaStore.Files.getContentUri(volumeName, id));
// Tell our SAF provider so it can revoke too
MediaDocumentsProvider.onMediaStoreDelete(getContext(), volumeName, mediaType, id);
if (mExternalDbFacade.onFileDeleted(id, mediaType)) {
mPickerSyncController.notifyMediaEvent();
}
});
}
};
private final UnaryOperator<String> mIdGenerator = path -> {
final long rowId = mCallingIdentity.get().getDeletedRowId(path);
if (rowId != -1 && isFuseThread()) {
return String.valueOf(rowId);
}
return null;
};
/** {@hide} */
public static final OnLegacyMigrationListener MIGRATION_LISTENER =
new OnLegacyMigrationListener() {
@Override
public void onStarted(ContentProviderClient client, String volumeName) {
MediaStore.startLegacyMigration(ContentResolver.wrap(client), volumeName);
}
@Override
public void onProgress(ContentProviderClient client, String volumeName,
long progress, long total) {
// TODO: notify blocked threads of progress once we can change APIs
}
@Override
public void onFinished(ContentProviderClient client, String volumeName) {
MediaStore.finishLegacyMigration(ContentResolver.wrap(client), volumeName);
}
};
/**
* Apply {@link Consumer#accept} to the given item.
* <p>
* Since media items can be exposed through multiple collections or views,
* this method expands the single item being accepted to also accept all
* relevant views.
*/
private void acceptWithExpansion(@NonNull Consumer<Uri> consumer, @NonNull String volumeName,
long id, int mediaType, boolean isDownload) {
switch (mediaType) {
case FileColumns.MEDIA_TYPE_AUDIO:
consumer.accept(MediaStore.Audio.Media.getContentUri(volumeName, id));
// Any changing audio items mean we probably need to invalidate all
// indexed views built from that media
consumer.accept(Audio.Genres.getContentUri(volumeName));
consumer.accept(Audio.Playlists.getContentUri(volumeName));
consumer.accept(Audio.Artists.getContentUri(volumeName));
consumer.accept(Audio.Albums.getContentUri(volumeName));
break;
case FileColumns.MEDIA_TYPE_VIDEO:
consumer.accept(MediaStore.Video.Media.getContentUri(volumeName, id));
break;
case FileColumns.MEDIA_TYPE_IMAGE:
consumer.accept(MediaStore.Images.Media.getContentUri(volumeName, id));
break;
case FileColumns.MEDIA_TYPE_PLAYLIST:
consumer.accept(ContentUris.withAppendedId(
MediaStore.Audio.Playlists.getContentUri(volumeName), id));
break;
}
// Also notify through any generic views
consumer.accept(MediaStore.Files.getContentUri(volumeName, id));
if (isDownload) {
consumer.accept(MediaStore.Downloads.getContentUri(volumeName, id));
}
// Rinse and repeat through any synthetic views
switch (volumeName) {
case MediaStore.VOLUME_INTERNAL:
case MediaStore.VOLUME_EXTERNAL:
// Already a top-level view, no need to expand
break;
default:
acceptWithExpansion(consumer, MediaStore.VOLUME_EXTERNAL,
id, mediaType, isDownload);
break;
}
}
/**
* Ensure that default folders are created on mounted storage devices.
* We only do this once per volume so we don't annoy the user if deleted
* manually.
*/
private void ensureDefaultFolders(@NonNull MediaVolume volume, @NonNull SQLiteDatabase db) {
if (volume.isStub()) {
// StubVolumes are managed from outside Android (e.g. from Chrome OS). So default
// folders should not be automatically created inside them.
return;
}
final String key = "created_default_folders_" + volume.getId();
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext());
if (prefs.getInt(key, 0) == 0) {
for (String folderName : DEFAULT_FOLDER_NAMES) {
final File folder = new File(volume.getPath(), folderName);
if (!folder.exists()) {
folder.mkdirs();
insertDirectory(db, folder.getAbsolutePath());
}
}
SharedPreferences.Editor editor = prefs.edit();
editor.putInt(key, 1);
editor.commit();
}
}
/**
* Ensure that any thumbnail collections on the given storage volume can be
* used with the given {@link DatabaseHelper}. If the
* {@link DatabaseHelper#getOrCreateUuid} doesn't match the UUID found on
* disk, then all thumbnails will be considered stable and will be deleted.
*/
private void ensureThumbnailsValid(@NonNull MediaVolume volume, @NonNull SQLiteDatabase db) {
if (volume.isStub()) {
// StubVolumes are managed from outside Android (e.g. from Chrome OS). So default
// folders and thumbnail directories should not be automatically created inside them,
// and there is no need to eusure the validity of thumbnails here.
return;
}
final String uuidFromDatabase = DatabaseHelper.getOrCreateUuid(db);
try {
for (File dir : getThumbnailDirectories(volume)) {
if (!dir.exists()) {
dir.mkdirs();
}
final File file = new File(dir, FILE_DATABASE_UUID);
final Optional<String> uuidFromDisk = FileUtils.readString(file);
final boolean updateUuid;
if (!uuidFromDisk.isPresent()) {
// For newly inserted volumes or upgrading of existing volumes,
// assume that our current UUID is valid
updateUuid = true;
} else if (!Objects.equals(uuidFromDatabase, uuidFromDisk.get())) {
// The UUID of database disagrees with the one on disk,
// which means we can't trust any thumbnails
Log.d(TAG, "Invalidating all thumbnails under " + dir);
FileUtils.walkFileTreeContents(dir.toPath(), this::deleteAndInvalidate);
updateUuid = true;
} else {
updateUuid = false;
}
if (updateUuid) {
FileUtils.writeString(file, Optional.of(uuidFromDatabase));
}
}
} catch (IOException e) {
Log.w(TAG, "Failed to ensure thumbnails valid for " + volume.getName(), e);
}
}
@Override
public void attachInfo(Context context, ProviderInfo info) {
Log.v(TAG, "Attached " + info.authority + " from " + info.applicationInfo.packageName);
mUriMatcher = new LocalUriMatcher(info.authority);
super.attachInfo(context, info);
}
@Override
public boolean onCreate() {
final Context context = getContext();
mUserCache = new UserCache(context);
// Shift call statistics back to the original caller
Binder.setProxyTransactListener(mTransactListener);
mStorageManager = context.getSystemService(StorageManager.class);
mAppOpsManager = context.getSystemService(AppOpsManager.class);
mPackageManager = context.getPackageManager();
mDevicePolicyManager = context.getSystemService(DevicePolicyManager.class);
mUserManager = context.getSystemService(UserManager.class);
mVolumeCache = new VolumeCache(context, mUserCache);
// Reasonable thumbnail size is half of the smallest screen edge width
final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
final int thumbSize = Math.min(metrics.widthPixels, metrics.heightPixels) / 2;
mThumbSize = new Size(thumbSize, thumbSize);
mMediaScanner = new ModernMediaScanner(context);
mInternalDatabase = new DatabaseHelper(context, INTERNAL_DATABASE_NAME, false, false,
Column.class, ExportedSince.class, Metrics::logSchemaChange, mFilesListener,
MIGRATION_LISTENER, mIdGenerator);
mExternalDatabase = new DatabaseHelper(context, EXTERNAL_DATABASE_NAME, false, false,
Column.class, ExportedSince.class, Metrics::logSchemaChange, mFilesListener,
MIGRATION_LISTENER, mIdGenerator);
mExternalDbFacade = new ExternalDbFacade(getContext(), mExternalDatabase);
mPickerDbFacade = new PickerDbFacade(context);
mPickerDataLayer = new PickerDataLayer(context, mPickerDbFacade);
mPickerSyncController = new PickerSyncController(context, mPickerDbFacade);
mPickerUriResolver = new PickerUriResolver(context, mPickerDbFacade);
if (SdkLevel.isAtLeastS()) {
mTranscodeHelper = new TranscodeHelperImpl(context, this);
} else {
mTranscodeHelper = new TranscodeHelperNoOp();
}
// Create dir for redacted and picker URI paths.
buildPrimaryVolumeFile(uidToUserId(MY_UID), getRedactedRelativePath()).mkdirs();
final IntentFilter packageFilter = new IntentFilter();
packageFilter.setPriority(10);
packageFilter.addDataScheme("package");
packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED);
packageFilter.addAction(Intent.ACTION_PACKAGE_REMOVED);
context.registerReceiver(mPackageReceiver, packageFilter);
// Watch for invalidation of cached volumes
mStorageManager.registerStorageVolumeCallback(context.getMainExecutor(),
new StorageVolumeCallback() {
@Override
public void onStateChanged(@NonNull StorageVolume volume) {
updateVolumes();
}
});
updateVolumes();
attachVolume(MediaVolume.fromInternal(), /* validate */ false);
for (MediaVolume volume : mVolumeCache.getExternalVolumes()) {
attachVolume(volume, /* validate */ false);
}
// Watch for performance-sensitive activity
mAppOpsManager.startWatchingActive(new String[] {
AppOpsManager.OPSTR_CAMERA
}, context.getMainExecutor(), mActiveListener);
mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_READ_EXTERNAL_STORAGE,
null /* all packages */, mModeListener);
mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_WRITE_EXTERNAL_STORAGE,
null /* all packages */, mModeListener);
mAppOpsManager.startWatchingMode(permissionToOp(ACCESS_MEDIA_LOCATION),
null /* all packages */, mModeListener);
// Legacy apps
mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_LEGACY_STORAGE,
null /* all packages */, mModeListener);
// File managers
mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_MANAGE_EXTERNAL_STORAGE,
null /* all packages */, mModeListener);
// Default gallery changes
mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_WRITE_MEDIA_IMAGES,
null /* all packages */, mModeListener);
mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_WRITE_MEDIA_VIDEO,
null /* all packages */, mModeListener);
try {
// Here we are forced to depend on the non-public API of AppOpsManager. If
// OPSTR_NO_ISOLATED_STORAGE app op is not defined in AppOpsManager, then this call will
// throw an IllegalArgumentException during MediaProvider startup. In combination with
// MediaProvider's CTS tests it should give us guarantees that OPSTR_NO_ISOLATED_STORAGE
// is defined.
mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_NO_ISOLATED_STORAGE,
null /* all packages */, mModeListener);
} catch (IllegalArgumentException e) {
Log.w(TAG, "Failed to start watching " + AppOpsManager.OPSTR_NO_ISOLATED_STORAGE, e);
}
ProviderInfo provider = mPackageManager.resolveContentProvider(
getDownloadsProviderAuthority(), PackageManager.MATCH_DIRECT_BOOT_AWARE
| PackageManager.MATCH_DIRECT_BOOT_UNAWARE);
if (provider != null) {
mDownloadsAuthorityAppId = UserHandle.getAppId(provider.applicationInfo.uid);
}
provider = mPackageManager.resolveContentProvider(getExternalStorageProviderAuthority(),
PackageManager.MATCH_DIRECT_BOOT_AWARE | PackageManager.MATCH_DIRECT_BOOT_UNAWARE);
if (provider != null) {
mExternalStorageAuthorityAppId = UserHandle.getAppId(provider.applicationInfo.uid);
}
PulledMetrics.initialize(context);
return true;
}
@Override
public void onCallingPackageChanged() {
// Identity of the current thread has changed, so invalidate caches
mCallingIdentity.remove();
}
public LocalCallingIdentity clearLocalCallingIdentity() {
// We retain the user part of the calling identity, since we are executing
// the call on behalf of that user, and we need to maintain the user context
// to correctly resolve things like volumes
UserHandle user = mCallingIdentity.get().getUser();
return clearLocalCallingIdentity(LocalCallingIdentity.fromSelfAsUser(getContext(), user));
}
public LocalCallingIdentity clearLocalCallingIdentity(LocalCallingIdentity replacement) {
final LocalCallingIdentity token = mCallingIdentity.get();
mCallingIdentity.set(replacement);
return token;
}
public void restoreLocalCallingIdentity(LocalCallingIdentity token) {
mCallingIdentity.set(token);
}
private boolean isPackageKnown(@NonNull String packageName) {
final PackageManager pm = getContext().getPackageManager();
// First, is the app actually installed?
try {
pm.getPackageInfo(packageName, PackageManager.MATCH_UNINSTALLED_PACKAGES);
return true;
} catch (NameNotFoundException ignored) {
}
// Second, is the app pending, probably from a backup/restore operation?
for (SessionInfo si : pm.getPackageInstaller().getAllSessions()) {
if (Objects.equals(packageName, si.getAppPackageName())) {
return true;
}
}
// I've never met this package in my life
return false;
}
public void onIdleMaintenance(@NonNull CancellationSignal signal) {
final long startTime = SystemClock.elapsedRealtime();
// Trim any stale log files before we emit new events below
Logging.trimPersistent();
// Scan all volumes to resolve any staleness
for (MediaVolume volume : mVolumeCache.getExternalVolumes()) {
// Possibly bail before digging into each volume
signal.throwIfCanceled();
try {
MediaService.onScanVolume(getContext(), volume, REASON_IDLE);
} catch (IOException e) {
Log.w(TAG, e);
}
// Ensure that our thumbnails are valid
mExternalDatabase.runWithTransaction((db) -> {
ensureThumbnailsValid(volume, db);
return null;
});
}
// Delete any stale thumbnails
final int staleThumbnails = mExternalDatabase.runWithTransaction((db) -> {
return pruneThumbnails(db, signal);
});
Log.d(TAG, "Pruned " + staleThumbnails + " unknown thumbnails");
// Finished orphaning any content whose package no longer exists
pruneStalePackages(signal);
// Delete the expired items or extend them on mounted volumes
final int[] result = deleteOrExtendExpiredItems(signal);
final int deletedExpiredMedia = result[0];
Log.d(TAG, "Deleted " + deletedExpiredMedia + " expired items");
Log.d(TAG, "Extended " + result[1] + " expired items");
// Forget any stale volumes
deleteStaleVolumes(signal);
// Populate _SPECIAL_FORMAT column for files which have column value as NULL
detectSpecialFormat(signal);
final long itemCount = mExternalDatabase.runWithTransaction((db) -> {
return DatabaseHelper.getItemCount(db);
});
final long durationMillis = (SystemClock.elapsedRealtime() - startTime);
Metrics.logIdleMaintenance(MediaStore.VOLUME_EXTERNAL, itemCount,
durationMillis, staleThumbnails, deletedExpiredMedia);
}
private void pruneStalePackages(CancellationSignal signal) {
final int stalePackages = mExternalDatabase.runWithTransaction((db) -> {
final ArraySet<String> unknownPackages = new ArraySet<>();
try (Cursor c = db.query(true, "files", new String[] { "owner_package_name" },
null, null, null, null, null, null, signal)) {
while (c.moveToNext()) {
final String packageName = c.getString(0);
if (TextUtils.isEmpty(packageName)) continue;
if (!isPackageKnown(packageName)) {
unknownPackages.add(packageName);
}
}
}
for (String packageName : unknownPackages) {
onPackageOrphaned(db, packageName);
}
return unknownPackages.size();
});
Log.d(TAG, "Pruned " + stalePackages + " unknown packages");
}
private void deleteStaleVolumes(CancellationSignal signal) {
mExternalDatabase.runWithTransaction((db) -> {
final Set<String> recentVolumeNames = MediaStore
.getRecentExternalVolumeNames(getContext());
final Set<String> knownVolumeNames = new ArraySet<>();
try (Cursor c = db.query(true, "files", new String[] { MediaColumns.VOLUME_NAME },
null, null, null, null, null, null, signal)) {
while (c.moveToNext()) {
knownVolumeNames.add(c.getString(0));
}
}
final Set<String> staleVolumeNames = new ArraySet<>();
staleVolumeNames.addAll(knownVolumeNames);
staleVolumeNames.removeAll(recentVolumeNames);
for (String staleVolumeName : staleVolumeNames) {
final int num = db.delete("files", FileColumns.VOLUME_NAME + "=?",
new String[] { staleVolumeName });
Log.d(TAG, "Forgot " + num + " stale items from " + staleVolumeName);
}
return null;
});
synchronized (mDirectoryCache) {
mDirectoryCache.clear();
}
}
@VisibleForTesting
void detectSpecialFormat(@NonNull CancellationSignal signal) {
mExternalDatabase.runWithTransaction((db) -> {
updateSpecialFormatColumn(db, signal);
return null;
});
}
private void updateSpecialFormatColumn(SQLiteDatabase db, @NonNull CancellationSignal signal) {
try (Cursor c = queryForPendingSpecialFormatColumns(db, signal)) {
while (c.moveToNext() && !signal.isCanceled()) {
final long id = c.getLong(0);
final String path = c.getString(1);
final ContentValues contentValues = getContentValuesForSpecialFormat(path);
if (contentValues == null) {
continue;
}
final String whereClause = MediaColumns._ID + "=?";
final String[] whereArgs = new String[]{String.valueOf(id)};
db.update("files", contentValues, whereClause, whereArgs);
}
}
}
private ContentValues getContentValuesForSpecialFormat(String path) {
ContentValues contentValues = new ContentValues();
final File file = new File(path);
if (!file.exists()) {
// Ignore if the file does not exist. This may happen if a file was
// inserted and then not opened, or if a file was deleted but db is not
// updated yet.
return null;
}
try {
contentValues.put(_SPECIAL_FORMAT, SpecialFormatDetector.detect(file));
} catch (Exception e) {
// we tried our best, no need to run special detection again and again if it
// throws exception once, it is likely to do so everytime.
Log.d(TAG, "Failed to detect special format for file: " + file, e);
contentValues.put(_SPECIAL_FORMAT, _SPECIAL_FORMAT_NONE);
}
return contentValues;
}
private Cursor queryForPendingSpecialFormatColumns(SQLiteDatabase db,
@NonNull CancellationSignal signal) {
// Run special detection for images only
final String selection = _SPECIAL_FORMAT + " IS NULL AND "
+ MEDIA_TYPE + "=" + MEDIA_TYPE_IMAGE;
final String[] projection = new String[] { MediaColumns._ID, MediaColumns.DATA };
return db.query(/* distinct */ true, "files", projection, selection, null, null, null,
null, null, signal);
}
/**
* Delete any expired content on mounted volumes. The expired content on unmounted
* volumes will be deleted when we forget any stale volumes; we're cautious about
* wildly changing clocks, so only delete items within the last week.
* If the items are expired more than one week, extend the expired time of them
* another one week to avoid data loss with incorrect time zone data. We will
* delete it when it is expired next time.
*
* @param signal the cancellation signal
* @return the integer array includes total deleted count and total extended count
*/
@NonNull
private int[] deleteOrExtendExpiredItems(@NonNull CancellationSignal signal) {
final long expiredOneWeek =
((System.currentTimeMillis() - DateUtils.WEEK_IN_MILLIS) / 1000);
final long now = (System.currentTimeMillis() / 1000);
final Long expiredTime = now + (FileUtils.DEFAULT_DURATION_EXTENDED / 1000);
final int result[] = mExternalDatabase.runWithTransaction((db) -> {
String selection = FileColumns.DATE_EXPIRES + " < " + now;
selection += " AND volume_name in " + bindList(MediaStore.getExternalVolumeNames(
getContext()).toArray());
String[] projection = new String[]{"volume_name", "_id",
FileColumns.DATE_EXPIRES, FileColumns.DATA};
try (Cursor c = db.query(true, "files", projection, selection, null, null, null, null,
null, signal)) {
int totalDeleteCount = 0;
int totalExtendedCount = 0;
int index = 0;
while (c.moveToNext()) {
final String volumeName = c.getString(0);
final long id = c.getLong(1);
final long dateExpires = c.getLong(2);
// we only delete the items that expire in one week
if (dateExpires > expiredOneWeek) {
totalDeleteCount += delete(Files.getContentUri(volumeName, id), null, null);
} else {
final String oriPath = c.getString(3);
final boolean success = extendExpiredItem(db, oriPath, id, expiredTime,
expiredTime + index);
if (success) {
totalExtendedCount++;
}
index++;
}
}
return new int[]{totalDeleteCount, totalExtendedCount};
}
});
return result;
}
/**
* Extend the expired items by renaming the file to new path with new timestamp and updating the
* database for {@link FileColumns#DATA} and {@link FileColumns#DATE_EXPIRES}. If there is
* UNIQUE constraint error for FileColumns.DATA, use adjustedExpiredTime and generate the new
* path by adjustedExpiredTime.
*/
private boolean extendExpiredItem(@NonNull SQLiteDatabase db, @NonNull String originalPath,
long id, long newExpiredTime, long adjustedExpiredTime) {
String newPath = FileUtils.getAbsoluteExtendedPath(originalPath, newExpiredTime);
if (newPath == null) {
Log.e(TAG, "Couldn't compute path for " + originalPath + " and expired time "
+ newExpiredTime);
return false;
}
try {
if (updateDatabaseForExpiredItem(db, newPath, id, newExpiredTime)) {
return renameInLowerFsAndInvalidateFuseDentry(originalPath, newPath);
}
return false;
} catch (SQLiteConstraintException e) {
final String errorMessage =
"Update database _data from " + originalPath + " to " + newPath + " failed.";
Log.d(TAG, errorMessage, e);
}
// When we update the database for newPath with newExpiredTime, if the new path already
// exists in the database, it may raise SQLiteConstraintException.
// If there are two expired items that have the same display name in the same directory,
// but they have different expired time. E.g. .trashed-123-A.jpg and .trashed-456-A.jpg.
// After we rename .trashed-123-A.jpg to .trashed-newExpiredTime-A.jpg, then we rename
// .trashed-456-A.jpg to .trashed-newExpiredTime-A.jpg, it raises the exception. For
// this case, we will retry it with the adjustedExpiredTime again.
newPath = FileUtils.getAbsoluteExtendedPath(originalPath, adjustedExpiredTime);
Log.i(TAG, "Retrying to extend expired item with the new path = " + newPath);
try {
if (updateDatabaseForExpiredItem(db, newPath, id, adjustedExpiredTime)) {
return renameInLowerFsAndInvalidateFuseDentry(originalPath, newPath);
}
} catch (SQLiteConstraintException e) {
// If we want to rename one expired item E.g. .trashed-123-A.jpg., and there is another
// non-expired trashed/pending item has the same name. E.g.
// .trashed-adjustedExpiredTime-A.jpg. When we rename .trashed-123-A.jpg to
// .trashed-adjustedExpiredTime-A.jpg, it raises the SQLiteConstraintException.
// The smallest unit of the expired time we use is second. It is a very rare case.
// When this case is happened, we can handle it in next idle maintenance.
final String errorMessage =
"Update database _data from " + originalPath + " to " + newPath + " failed.";
Log.d(TAG, errorMessage, e);
}
return false;
}
private boolean updateDatabaseForExpiredItem(@NonNull SQLiteDatabase db,
@NonNull String path, long id, long expiredTime) {
final String table = "files";
final String whereClause = MediaColumns._ID + "=?";
final String[] whereArgs = new String[]{String.valueOf(id)};
final ContentValues values = new ContentValues();
values.put(FileColumns.DATA, path);
values.put(FileColumns.DATE_EXPIRES, expiredTime);
final int count = db.update(table, values, whereClause, whereArgs);
return count == 1;
}
private boolean renameInLowerFsAndInvalidateFuseDentry(@NonNull String originalPath,
@NonNull String newPath) {
try {
Os.rename(originalPath, newPath);
invalidateFuseDentry(originalPath);
invalidateFuseDentry(newPath);
return true;
} catch (ErrnoException e) {
final String errorMessage = "Rename " + originalPath + " to " + newPath
+ " in lower file system for extending item failed.";
Log.e(TAG, errorMessage, e);
}
return false;
}
public void onIdleMaintenanceStopped() {
mMediaScanner.onIdleScanStopped();
}
/**
* Orphan any content of the given package. This will delete Android/media orphaned files from
* the database.
*/
public void onPackageOrphaned(String packageName) {
mExternalDatabase.runWithTransaction((db) -> {
onPackageOrphaned(db, packageName);
return null;
});
}
/**
* Orphan any content of the given package from the given database. This will delete
* Android/media orphaned files from the database.
*/
public void onPackageOrphaned(@NonNull SQLiteDatabase db, @NonNull String packageName) {
// Delete files from Android/media.
String relativePath = "Android/media/" + DatabaseUtils.escapeForLike(packageName) + "/%";
final int countDeleted = db.delete(
"files",
"relative_path LIKE ? ESCAPE '\\' AND owner_package_name=?",
new String[] {relativePath, packageName});
Log.d(TAG, "Deleted " + countDeleted + " Android/media items belonging to "
+ packageName + " on " + db.getPath());
// Orphan rest of files.
final ContentValues values = new ContentValues();
values.putNull(FileColumns.OWNER_PACKAGE_NAME);
final int countOrphaned = db.update("files", values,
"owner_package_name=?", new String[] { packageName });
if (countOrphaned > 0) {
Log.d(TAG, "Orphaned " + countOrphaned + " items belonging to "
+ packageName + " on " + db.getPath());
}
}
public void scanDirectory(File file, int reason) {
mMediaScanner.scanDirectory(file, reason);
}
public Uri scanFile(File file, int reason) {
return scanFile(file, reason, null);
}
public Uri scanFile(File file, int reason, String ownerPackage) {
return mMediaScanner.scanFile(file, reason, ownerPackage);
}
private Uri scanFileAsMediaProvider(File file, int reason) {
final LocalCallingIdentity tokenInner = clearLocalCallingIdentity();
try {
return scanFile(file, REASON_DEMAND);
} finally {
restoreLocalCallingIdentity(tokenInner);
}
}
/**
* Called when a new file is created through FUSE
*
* @param file path of the file that was created
*
* Called from JNI in jni/MediaProviderWrapper.cpp
*/
@Keep
public void onFileCreatedForFuse(String path) {
// Make sure we update the quota type of the file
BackgroundThread.getExecutor().execute(() -> {
File file = new File(path);
int mediaType = MimeUtils.resolveMediaType(MimeUtils.resolveMimeType(file));
updateQuotaTypeForFileInternal(file, mediaType);
});
}
private boolean isAppCloneUserPair(int userId1, int userId2) {
UserHandle user1 = UserHandle.of(userId1);
UserHandle user2 = UserHandle.of(userId2);
if (SdkLevel.isAtLeastS()) {
if (mUserCache.userSharesMediaWithParent(user1)
|| mUserCache.userSharesMediaWithParent(user2)) {
return true;
}
if (Build.VERSION.DEVICE_INITIAL_SDK_INT >= Build.VERSION_CODES.S) {
// If we're on S or higher, and we shipped with S or higher, only allow the new
// app cloning functionality
return false;
}
// else, fall back to deprecated solution below on updating devices
}
try {
Method isAppCloneUserPair = StorageManager.class.getMethod("isAppCloneUserPair",
int.class, int.class);
return (Boolean) isAppCloneUserPair.invoke(mStorageManager, userId1, userId2);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
Log.w(TAG, "isAppCloneUserPair failed. Users: " + userId1 + " and " + userId2);
return false;
}
}
/**
* Determines whether the passed in userId forms an app clone user pair with user 0.
*
* @param userId user ID to check
*
* Called from JNI in jni/MediaProviderWrapper.cpp
*/
@Keep
public boolean isAppCloneUserForFuse(int userId) {
if (!isCrossUserEnabled()) {
Log.d(TAG, "CrossUser not enabled.");
return false;
}
boolean result = isAppCloneUserPair(0, userId);
Log.w(TAG, "isAppCloneUserPair for user " + userId + ": " + result);
return result;
}
/**
* Determines if to allow FUSE_LOOKUP for uid. Might allow uids that don't belong to the
* MediaProvider user, depending on OEM configuration.
*
* @param uid linux uid to check
*
* Called from JNI in jni/MediaProviderWrapper.cpp
*/
@Keep
public boolean shouldAllowLookupForFuse(int uid, int pathUserId) {
int callingUserId = uidToUserId(uid);
if (!isCrossUserEnabled()) {
Log.d(TAG, "CrossUser not enabled. Users: " + callingUserId + " and " + pathUserId);
return false;
}
if (callingUserId != pathUserId && callingUserId != 0 && pathUserId != 0) {
Log.w(TAG, "CrossUser at least one user is 0 check failed. Users: " + callingUserId
+ " and " + pathUserId);
return false;
}
if (mUserCache.isWorkProfile(callingUserId) || mUserCache.isWorkProfile(pathUserId)) {
// Cross-user lookup not allowed if one user in the pair has a profile owner app
Log.w(TAG, "CrossUser work profile check failed. Users: " + callingUserId + " and "
+ pathUserId);
return false;
}
boolean result = isAppCloneUserPair(pathUserId, callingUserId);
if (result) {
Log.i(TAG, "CrossUser allowed. Users: " + callingUserId + " and " + pathUserId);
} else {
Log.w(TAG, "CrossUser isAppCloneUserPair check failed. Users: " + callingUserId
+ " and " + pathUserId);
}
return result;
}
/**
* Called from FUSE to transform a file
*
* A transform can change the file contents for {@code uid} from {@code src} to {@code dst}
* depending on {@code flags}. This allows the FUSE daemon serve different file contents for
* the same file to different apps.
*
* The only supported transform for now is transcoding which re-encodes a file taken in a modern
* format like HEVC to a legacy format like AVC.
*
* @param src file path to transform
* @param dst file path to save transformed file
* @param flags determines the kind of transform
* @param readUid app that called us requesting transform
* @param openUid app that originally made the open call
* @param mediaCapabilitiesUid app for which the transform decision was made,
* 0 if decision was made with openUid
*
* Called from JNI in jni/MediaProviderWrapper.cpp
*/
@Keep
public boolean transformForFuse(String src, String dst, int transforms, int transformsReason,
int readUid, int openUid, int mediaCapabilitiesUid) {
if ((transforms & FLAG_TRANSFORM_TRANSCODING) != 0) {
if (mTranscodeHelper.isTranscodeFileCached(src, dst)) {
Log.d(TAG, "Using transcode cache for " + src);
return true;
}
// In general we always mark the opener as causing transcoding.
// However, if the mediaCapabilitiesUid is available then we mark the reader as causing
// transcoding. This handles the case where a malicious app might want to take
// advantage of mediaCapabilitiesUid by setting it to another app's uid and reading the
// media contents itself; in such cases we'd mark the reader (malicious app) for the
// cost of transcoding.
//
// openUid readUid mediaCapabilitiesUid
// -------------------------------------------------------------------------------------
// using picker SAF app app
// abusive case bad app bad app victim
// modern to lega-
// -cy sharing modern legacy legacy
//
// we'd not be here in the below case.
// legacy to mode-
// -rn sharing legacy modern modern
int transcodeUid = openUid;
if (mediaCapabilitiesUid > 0) {
Log.d(TAG, "Fix up transcodeUid to " + readUid + ". openUid " + openUid
+ ", mediaCapabilitiesUid " + mediaCapabilitiesUid);
transcodeUid = readUid;
}
return mTranscodeHelper.transcode(src, dst, transcodeUid, transformsReason);
}
return true;
}
/**
* Called from FUSE to get {@link FileLookupResult} for a {@code path} and {@code uid}
*
* {@link FileLookupResult} contains transforms, transforms completion status and ioPath
* for transform lookup query for a file and uid.
*
* @param path file path to get transforms for
* @param uid app requesting IO form kernel
* @param tid FUSE thread id handling IO request from kernel
*
* Called from JNI in jni/MediaProviderWrapper.cpp
*/
@Keep
public FileLookupResult onFileLookupForFuse(String path, int uid, int tid) {
uid = getBinderUidForFuse(uid, tid);
final int userId = uidToUserId(uid);
if (isSyntheticPath(path, userId)) {
if (isRedactedPath(path, userId)) {
return handleRedactedFileLookup(uid, path);
} else if (isPickerPath(path, userId)) {
return handlePickerFileLookup(userId, uid, path);
}
throw new IllegalStateException("Unexpected synthetic path: " + path);
}
if (mTranscodeHelper.supportsTranscode(path)) {
return handleTranscodedFileLookup(path, uid, tid);
}
return new FileLookupResult(/* transforms */ 0, uid, /* ioPath */ "");
}
private FileLookupResult handleTranscodedFileLookup(String path, int uid, int tid) {
final int transformsReason;
final PendingOpenInfo info;
synchronized (mPendingOpenInfo) {
info = mPendingOpenInfo.get(tid);
}
if (info != null && info.uid == uid) {
transformsReason = info.transcodeReason;
} else {
transformsReason = mTranscodeHelper.shouldTranscode(path, uid, null /* bundle */);
}
if (transformsReason > 0) {
final String ioPath = mTranscodeHelper.prepareIoPath(path, uid);
final boolean transformsComplete = mTranscodeHelper.isTranscodeFileCached(path, ioPath);
return new FileLookupResult(FLAG_TRANSFORM_TRANSCODING, transformsReason, uid,
transformsComplete, /* transformsSupported */ true, ioPath);
}
return new FileLookupResult(/* transforms */ 0, transformsReason, uid,
/* transformsComplete */ true, /* transformsSupported */ true, "");
}
private FileLookupResult handleRedactedFileLookup(int uid, @NonNull String path) {
final LocalCallingIdentity token = clearLocalCallingIdentity();
final String fileName = extractFileName(path);
final DatabaseHelper helper;
try {
helper = getDatabaseForUri(FileUtils.getContentUriForPath(path));
} catch (VolumeNotFoundException e) {
throw new IllegalStateException("Volume not found for file: " + path);
}
try (final Cursor c = helper.runWithoutTransaction(
(db) -> db.query("files", new String[]{MediaColumns.DATA},
FileColumns.REDACTED_URI_ID + "=?", new String[]{fileName}, null, null,
null))) {
if (c.moveToFirst()) {
return new FileLookupResult(FLAG_TRANSFORM_REDACTION, uid, c.getString(0));
}
throw new IllegalStateException("Failed to fetch synthetic redacted path: " + path);
} finally {
restoreLocalCallingIdentity(token);
}
}
private FileLookupResult handlePickerFileLookup(int userId, int uid, @NonNull String path) {
final File file = new File(path);
final List<String> syntheticRelativePathSegments =
extractSyntheticRelativePathSegements(path, userId);
final int segmentCount = syntheticRelativePathSegments.size();
if (segmentCount < 1 || segmentCount > 4) {
throw new IllegalStateException("Unexpected synthetic picker path: " + file);
}
final String lastSegment = syntheticRelativePathSegments.get(segmentCount - 1);
boolean result = false;
switch (segmentCount) {
case 1:
// .../picker
if (lastSegment.equals("picker")) {
result = file.exists() || file.mkdir();
}
break;
case 2:
// .../picker/<authority>
result = preparePickerAuthorityPathSegment(file, lastSegment, uid);
break;
case 3:
// .../picker/<authority>/media
if (lastSegment.equals("media")) {
result = file.exists() || file.mkdir();
}
break;
case 4:
// .../picker/<authority>/media/<media-id.extension>
final String authority = syntheticRelativePathSegments.get(1);
result = preparePickerMediaIdPathSegment(file, authority, lastSegment);
break;
}
if (result) {
return new FileLookupResult(FLAG_TRANSFORM_PICKER, uid, path);
}
throw new IllegalStateException("Failed to prepare synthetic picker path: " + file);
}
private FileOpenResult handlePickerFileOpen(String path, int uid) {
final String[] segments = path.split("/");
if (segments.length != 10) {
Log.e(TAG, "Picker file open failed. Unexpected segments: " + path);
return new FileOpenResult(OsConstants.ENOENT /* status */, uid, /* transformsUid */ 0,
new long[0]);
}
// ['', 'storage', 'emulated', '0', 'transforms', 'synthetic', 'picker', '<host>',
// 'media', '<fileName>']
final String userId = segments[3];
final String fileName = segments[9];
final String host = segments[7];
final String authority = userId + "@" + host;
final int lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex == -1) {
Log.e(TAG, "Picker file open failed. No file extension: " + path);
return FileOpenResult.createError(OsConstants.ENOENT, uid);
}
final String mediaId = fileName.substring(0, lastDotIndex);
final Uri uri = getMediaUri(authority).buildUpon().appendPath(mediaId).build();
final ParcelFileDescriptor pfd;
try {
pfd = getContext().getContentResolver().openFile(uri, "r",
/* cancellationSignal */ null);
} catch (IOException e) {
Log.e(TAG, "Picker file open failed. Failed to open URI: " + uri, e);
return FileOpenResult.createError(OsConstants.ENOENT, uid);
}
try (FileInputStream fis = new FileInputStream(pfd.getFileDescriptor())) {
final String mimeType = MimeUtils.resolveMimeType(new File(path));
final long[] redactionRanges = getRedactionRanges(fis, mimeType).redactionRanges;
return new FileOpenResult(0 /* status */, uid, /* transformsUid */ 0,
/* nativeFd */ pfd.detachFd(), redactionRanges);
} catch (IOException e) {
Log.e(TAG, "Picker file open failed. No file extension: " + path, e);
return FileOpenResult.createError(OsConstants.ENOENT, uid);
}
}
private boolean preparePickerAuthorityPathSegment(File file, String authority, int uid) {
if (mPickerSyncController.isProviderEnabled(authority)) {
return file.mkdir();
}
return false;
}
private boolean preparePickerMediaIdPathSegment(File file, String authority, String fileName) {
final String mediaId = extractFileName(fileName);
try (Cursor cursor = mPickerDbFacade.queryMediaId(authority, mediaId)) {
if (cursor != null && cursor.moveToFirst()) {
final int sizeBytesIdx = cursor.getColumnIndex(
CloudMediaProviderContract.MediaColumns.SIZE_BYTES);
if (sizeBytesIdx != -1) {
return createSparseFile(file, cursor.getLong(sizeBytesIdx));
}
}
}
return false;
}
public int getBinderUidForFuse(int uid, int tid) {
if (uid != MY_UID) {
return uid;
}
synchronized (mPendingOpenInfo) {
PendingOpenInfo info = mPendingOpenInfo.get(tid);
if (info == null) {
return uid;
}
return info.uid;
}
}
private static int uidToUserId(int uid) {
return uid / PER_USER_RANGE;
}
/**
* Returns true if the app denoted by the given {@code uid} and {@code packageName} is allowed
* to clear other apps' cache directories.
*/
static boolean hasPermissionToClearCaches(Context context, ApplicationInfo ai) {
PermissionUtils.setOpDescription("clear app cache");
try {
return PermissionUtils.checkPermissionManager(context, /* pid */ -1, ai.uid,
ai.packageName, /* attributionTag */ null);
} finally {
PermissionUtils.clearOpDescription();
}
}
@VisibleForTesting
void computeAudioLocalizedValues(ContentValues values) {
try {
final String title = values.getAsString(AudioColumns.TITLE);
final String titleRes = values.getAsString(AudioColumns.TITLE_RESOURCE_URI);
if (!TextUtils.isEmpty(titleRes)) {
final String localized = getLocalizedTitle(titleRes);
if (!TextUtils.isEmpty(localized)) {
values.put(AudioColumns.TITLE, localized);
}
} else {
final String localized = getLocalizedTitle(title);
if (!TextUtils.isEmpty(localized)) {
values.put(AudioColumns.TITLE, localized);
values.put(AudioColumns.TITLE_RESOURCE_URI, title);
}
}
} catch (Exception e) {
Log.w(TAG, "Failed to localize title", e);
}
}
@VisibleForTesting
static void computeAudioKeyValues(ContentValues values) {
computeAudioKeyValue(values, AudioColumns.TITLE, AudioColumns.TITLE_KEY, /* focusId */
null, /* hashValue */ 0);
computeAudioKeyValue(values, AudioColumns.ARTIST, AudioColumns.ARTIST_KEY,
AudioColumns.ARTIST_ID, /* hashValue */ 0);
computeAudioKeyValue(values, AudioColumns.GENRE, AudioColumns.GENRE_KEY,
AudioColumns.GENRE_ID, /* hashValue */ 0);
computeAudioAlbumKeyValue(values);
}
/**
* To distinguish same-named albums, we append a hash. The hash is
* based on the "album artist" tag if present, otherwise on the path of
* the parent directory of the audio file.
*/
private static void computeAudioAlbumKeyValue(ContentValues values) {
int hashCode = 0;
final String albumArtist = values.getAsString(MediaColumns.ALBUM_ARTIST);
if (!TextUtils.isEmpty(albumArtist)) {
hashCode = albumArtist.hashCode();
} else {
final String path = values.getAsString(MediaColumns.DATA);
if (!TextUtils.isEmpty(path)) {
hashCode = path.substring(0, path.lastIndexOf('/')).hashCode();
}
}
computeAudioKeyValue(values, AudioColumns.ALBUM, AudioColumns.ALBUM_KEY,
AudioColumns.ALBUM_ID, hashCode);
}
private static void computeAudioKeyValue(@NonNull ContentValues values, @NonNull String focus,
@Nullable String focusKey, @Nullable String focusId, int hashValue) {
if (focusKey != null) values.remove(focusKey);
if (focusId != null) values.remove(focusId);
final String value = values.getAsString(focus);
if (TextUtils.isEmpty(value)) return;
final String key = Audio.keyFor(value);
if (key == null) return;
if (focusKey != null) {
values.put(focusKey, key);
}
if (focusId != null) {
// Many apps break if we generate negative IDs, so trim off the
// highest bit to ensure we're always unsigned
final long id = Hashing.farmHashFingerprint64().hashString(key + hashValue,
StandardCharsets.UTF_8).asLong() & ~(1L << 63);
values.put(focusId, id);
}
}
@Override
public Uri canonicalize(Uri uri) {
final boolean allowHidden = isCallingPackageAllowedHidden();
final int match = matchUri(uri, allowHidden);
// Skip when we have nothing to canonicalize
if ("1".equals(uri.getQueryParameter(CANONICAL))) {
return uri;
}
try (Cursor c = queryForSingleItem(uri, null, null, null, null)) {
switch (match) {
case AUDIO_MEDIA_ID: {
final String title = getDefaultTitleFromCursor(c);
if (!TextUtils.isEmpty(title)) {
final Uri.Builder builder = uri.buildUpon();
builder.appendQueryParameter(AudioColumns.TITLE, title);
builder.appendQueryParameter(CANONICAL, "1");
return builder.build();
}
break;
}
case VIDEO_MEDIA_ID:
case IMAGES_MEDIA_ID: {
final String documentId = c
.getString(c.getColumnIndexOrThrow(MediaColumns.DOCUMENT_ID));
if (!TextUtils.isEmpty(documentId)) {
final Uri.Builder builder = uri.buildUpon();
builder.appendQueryParameter(MediaColumns.DOCUMENT_ID, documentId);
builder.appendQueryParameter(CANONICAL, "1");
return builder.build();
}
break;
}
}
} catch (FileNotFoundException e) {
Log.w(TAG, e.getMessage());
}
return null;
}
@Override
public Uri uncanonicalize(Uri uri) {
final boolean allowHidden = isCallingPackageAllowedHidden();
final int match = matchUri(uri, allowHidden);
// Skip when we have nothing to uncanonicalize
if (!"1".equals(uri.getQueryParameter(CANONICAL))) {
return uri;
}
// Extract values and then clear to avoid recursive lookups
final String title = uri.getQueryParameter(AudioColumns.TITLE);
final String documentId = uri.getQueryParameter(MediaColumns.DOCUMENT_ID);
uri = uri.buildUpon().clearQuery().build();
switch (match) {
case AUDIO_MEDIA_ID: {
// First check for an exact match
try (Cursor c = queryForSingleItem(uri, null, null, null, null)) {
if (Objects.equals(title, getDefaultTitleFromCursor(c))) {
return uri;
}
} catch (FileNotFoundException e) {
Log.w(TAG, "Trouble resolving " + uri + "; falling back to search: " + e);
}
// Otherwise fallback to searching
final Uri baseUri = ContentUris.removeId(uri);
try (Cursor c = queryForSingleItem(baseUri,
new String[] { BaseColumns._ID },
AudioColumns.TITLE + "=?", new String[] { title }, null)) {
return ContentUris.withAppendedId(baseUri, c.getLong(0));
} catch (FileNotFoundException e) {
Log.w(TAG, "Failed to resolve " + uri + ": " + e);
return null;
}
}
case VIDEO_MEDIA_ID:
case IMAGES_MEDIA_ID: {
// First check for an exact match
try (Cursor c = queryForSingleItem(uri, null, null, null, null)) {
if (Objects.equals(title, getDefaultTitleFromCursor(c))) {
return uri;
}
} catch (FileNotFoundException e) {
Log.w(TAG, "Trouble resolving " + uri + "; falling back to search: " + e);
}
// Otherwise fallback to searching
final Uri baseUri = ContentUris.removeId(uri);
try (Cursor c = queryForSingleItem(baseUri,
new String[] { BaseColumns._ID },
MediaColumns.DOCUMENT_ID + "=?", new String[] { documentId }, null)) {
return ContentUris.withAppendedId(baseUri, c.getLong(0));
} catch (FileNotFoundException e) {
Log.w(TAG, "Failed to resolve " + uri + ": " + e);
return null;
}
}
}
return uri;
}
private Uri safeUncanonicalize(Uri uri) {
Uri newUri = uncanonicalize(uri);
if (newUri != null) {
return newUri;
}
return uri;
}
/**
* @return where clause to exclude database rows where
* <ul>
* <li> {@code column} is set or
* <li> {@code column} is {@link MediaColumns#IS_PENDING} and is set by FUSE and not owned by
* calling package.
* <li> {@code column} is {@link MediaColumns#IS_PENDING}, is unset and is waiting for
* metadata update from a deferred scan.
* </ul>
*/
private String getWhereClauseForMatchExclude(@NonNull String column) {
if (column.equalsIgnoreCase(MediaColumns.IS_PENDING)) {
// Don't include rows that are pending for metadata
final String pendingForMetadata = FileColumns._MODIFIER + "="
+ FileColumns._MODIFIER_CR_PENDING_METADATA;
final String notPending = String.format("(%s=0 AND NOT %s)", column,
pendingForMetadata);
final String matchSharedPackagesClause = FileColumns.OWNER_PACKAGE_NAME + " IN "
+ getSharedPackages();
// Include owned pending files from Fuse
final String pendingFromFuse = String.format("(%s=1 AND %s AND %s)", column,
MATCH_PENDING_FROM_FUSE, matchSharedPackagesClause);
return "(" + notPending + " OR " + pendingFromFuse + ")";
}
return column + "=0";
}
/**
* @return where clause to include database rows where
* <ul>
* <li> {@code column} is not set or
* <li> {@code column} is set and calling package has write permission to corresponding db row
* or {@code column} is {@link MediaColumns#IS_PENDING} and is set by FUSE.
* </ul>
* The method is used to match db rows corresponding to writable pending and trashed files.
*/
@Nullable
private String getWhereClauseForMatchableVisibleFromFilePath(@NonNull Uri uri,
@NonNull String column) {
if (isCallingPackageLegacyWrite() || checkCallingPermissionGlobal(uri, /*forWrite*/ true)) {
// No special filtering needed
return null;
}
final String callingPackage = getCallingPackageOrSelf();
final ArrayList<String> options = new ArrayList<>();
switch(matchUri(uri, isCallingPackageAllowedHidden())) {
case IMAGES_MEDIA_ID:
case IMAGES_MEDIA:
case IMAGES_THUMBNAILS_ID:
case IMAGES_THUMBNAILS:
if (checkCallingPermissionImages(/*forWrite*/ true, callingPackage)) {
// No special filtering needed
return null;
}
break;
case AUDIO_MEDIA_ID:
case AUDIO_MEDIA:
case AUDIO_PLAYLISTS_ID:
case AUDIO_PLAYLISTS:
if (checkCallingPermissionAudio(/*forWrite*/ true, callingPackage)) {
// No special filtering needed
return null;
}
break;
case VIDEO_MEDIA_ID:
case VIDEO_MEDIA:
case VIDEO_THUMBNAILS_ID:
case VIDEO_THUMBNAILS:
if (checkCallingPermissionVideo(/*firWrite*/ true, callingPackage)) {
// No special filtering needed
return null;
}
break;
case DOWNLOADS_ID:
case DOWNLOADS:
// No app has special permissions for downloads.
break;
case FILES_ID:
case FILES:
if (checkCallingPermissionAudio(/*forWrite*/ true, callingPackage)) {
// Allow apps with audio permission to include audio* media types.
options.add(DatabaseUtils.bindSelection("media_type=?",
FileColumns.MEDIA_TYPE_AUDIO));
options.add(DatabaseUtils.bindSelection("media_type=?",
FileColumns.MEDIA_TYPE_PLAYLIST));
options.add(DatabaseUtils.bindSelection("media_type=?",
FileColumns.MEDIA_TYPE_SUBTITLE));
}
if (checkCallingPermissionVideo(/*forWrite*/ true, callingPackage)) {
// Allow apps with video permission to include video* media types.
options.add(DatabaseUtils.bindSelection("media_type=?",
FileColumns.MEDIA_TYPE_VIDEO));
options.add(DatabaseUtils.bindSelection("media_type=?",
FileColumns.MEDIA_TYPE_SUBTITLE));
}
if (checkCallingPermissionImages(/*forWrite*/ true, callingPackage)) {
// Allow apps with images permission to include images* media types.
options.add(DatabaseUtils.bindSelection("media_type=?",
FileColumns.MEDIA_TYPE_IMAGE));
}
break;
default:
// is_pending, is_trashed are not applicable for rest of the media tables.
return null;
}
final String matchSharedPackagesClause = FileColumns.OWNER_PACKAGE_NAME + " IN "
+ getSharedPackages();
options.add(DatabaseUtils.bindSelection(matchSharedPackagesClause));
if (column.equalsIgnoreCase(MediaColumns.IS_PENDING)) {
// Include all pending files from Fuse
options.add(MATCH_PENDING_FROM_FUSE);
}
final String matchWritableRowsClause = String.format("%s=0 OR (%s=1 AND %s)", column,
column, TextUtils.join(" OR ", options));
return matchWritableRowsClause;
}
/**
* Gets list of files in {@code path} from media provider database.
*
* @param path path of the directory.
* @param uid UID of the calling process.
* @return a list of file names in the given directory path.
* An empty list is returned if no files are visible to the calling app or the given directory
* does not have any files.
* A list with ["/"] is returned if the path is not indexed by MediaProvider database or
* calling package is a legacy app and has appropriate storage permissions for the given path.
* In both scenarios file names should be obtained from lower file system.
* A list with empty string[""] is returned if the calling package doesn't have access to the
* given path.
*
* <p>Directory names are always obtained from lower file system.
*
* Called from JNI in jni/MediaProviderWrapper.cpp
*/
@Keep
public String[] getFilesInDirectoryForFuse(String path, int uid) {
final LocalCallingIdentity token =
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path);
try {
if (isPrivatePackagePathNotAccessibleByCaller(path)) {
return new String[] {""};
}
if (shouldBypassFuseRestrictions(/*forWrite*/ false, path)) {
return new String[] {"/"};
}
// Do not allow apps to list Android/data or Android/obb dirs.
// On primary volumes, apps that get special access to these directories get it via
// mount views of lowerfs. On secondary volumes, such apps would return early from
// shouldBypassFuseRestrictions above.
if (isDataOrObbPath(path)) {
return new String[] {""};
}
// Legacy apps that made is this far don't have the right storage permission and hence
// are not allowed to access anything other than their external app directory
if (isCallingPackageRequestingLegacy()) {
return new String[] {""};
}
// Get relative path for the contents of given directory.
String relativePath = extractRelativePathForDirectory(path);
if (relativePath == null) {
// Path is /storage/emulated/, if relativePath is null, MediaProvider doesn't
// have any details about the given directory. Use lower file system to obtain
// files and directories in the given directory.
return new String[] {"/"};
}
// For all other paths, get file names from media provider database.
// Return media and non-media files visible to the calling package.
ArrayList<String> fileNamesList = new ArrayList<>();
// Only FileColumns.DATA contains actual name of the file.
String[] projection = {MediaColumns.DATA};
Bundle queryArgs = new Bundle();
queryArgs.putString(QUERY_ARG_SQL_SELECTION, MediaColumns.RELATIVE_PATH +
" =? and mime_type not like 'null'");
queryArgs.putStringArray(QUERY_ARG_SQL_SELECTION_ARGS, new String[] {relativePath});
// Get database entries for files from MediaProvider database with
// MediaColumns.RELATIVE_PATH as the given path.
try (final Cursor cursor = query(FileUtils.getContentUriForPath(path), projection,
queryArgs, null)) {
while(cursor.moveToNext()) {
fileNamesList.add(extractDisplayName(cursor.getString(0)));
}
}
return fileNamesList.toArray(new String[fileNamesList.size()]);
} finally {
restoreLocalCallingIdentity(token);
}
}
/**
* Scan files during directory renames for the following reasons:
* <ul>
* <li>Because we don't update db rows for directories, we scan the oldPath to discard stale
* directory db rows. This prevents conflicts during subsequent db operations with oldPath.
* <li>We need to scan newPath as well, because the new directory may have become hidden
* or unhidden, in which case we need to update the media types of the contained files
* </ul>
*/
private void scanRenamedDirectoryForFuse(@NonNull String oldPath, @NonNull String newPath) {
scanFileAsMediaProvider(new File(oldPath), REASON_DEMAND);
scanFileAsMediaProvider(new File(newPath), REASON_DEMAND);
}
/**
* Checks if given {@code mimeType} is supported in {@code path}.
*/
private boolean isMimeTypeSupportedInPath(String path, String mimeType) {
final String supportedPrimaryMimeType;
final int match = matchUri(getContentUriForFile(path, mimeType), true);
switch (match) {
case AUDIO_MEDIA:
supportedPrimaryMimeType = "audio";
break;
case VIDEO_MEDIA:
supportedPrimaryMimeType = "video";
break;
case IMAGES_MEDIA:
supportedPrimaryMimeType = "image";
break;
default:
supportedPrimaryMimeType = ClipDescription.MIMETYPE_UNKNOWN;
}
return (supportedPrimaryMimeType.equalsIgnoreCase(ClipDescription.MIMETYPE_UNKNOWN) ||
MimeUtils.startsWithIgnoreCase(mimeType, supportedPrimaryMimeType));
}
/**
* Removes owner package for the renamed path if the calling package doesn't own the db row
*
* When oldPath is renamed to newPath, if newPath exists in the database, and caller is not the
* owner of the file, owner package is set to 'null'. This prevents previous owner of newPath
* from accessing renamed file.
* @return {@code true} if
* <ul>
* <li> there is no corresponding database row for given {@code path}
* <li> shared calling package is the owner of the database row
* <li> owner package name is already set to 'null'
* <li> updating owner package name to 'null' was successful.
* </ul>
* Returns {@code false} otherwise.
*/
private boolean maybeRemoveOwnerPackageForFuseRename(@NonNull DatabaseHelper helper,
@NonNull String path) {
final Uri uri = FileUtils.getContentUriForPath(path);
final int match = matchUri(uri, isCallingPackageAllowedHidden());
final String ownerPackageName;
final String selection = MediaColumns.DATA + " =? AND "
+ MediaColumns.OWNER_PACKAGE_NAME + " != 'null'";
final String[] selectionArgs = new String[] {path};
final SQLiteQueryBuilder qbForQuery =
getQueryBuilder(TYPE_QUERY, match, uri, Bundle.EMPTY, null);
try (Cursor c = qbForQuery.query(helper, new String[] {FileColumns.OWNER_PACKAGE_NAME},
selection, selectionArgs, null, null, null, null, null)) {
if (!c.moveToFirst()) {
// We don't need to remove owner_package from db row if path doesn't exist in
// database or owner_package is already set to 'null'
return true;
}
ownerPackageName = c.getString(0);
if (isCallingIdentitySharedPackageName(ownerPackageName)) {
// We don't need to remove owner_package from db row if calling package is the owner
// of the database row
return true;
}
}
final SQLiteQueryBuilder qbForUpdate =
getQueryBuilder(TYPE_UPDATE, match, uri, Bundle.EMPTY, null);
ContentValues values = new ContentValues();
values.put(FileColumns.OWNER_PACKAGE_NAME, "null");
return qbForUpdate.update(helper, values, selection, selectionArgs) == 1;
}
private boolean updateDatabaseForFuseRename(@NonNull DatabaseHelper helper,
@NonNull String oldPath, @NonNull String newPath, @NonNull ContentValues values) {
return updateDatabaseForFuseRename(helper, oldPath, newPath, values, Bundle.EMPTY);
}
private boolean updateDatabaseForFuseRename(@NonNull DatabaseHelper helper,
@NonNull String oldPath, @NonNull String newPath, @NonNull ContentValues values,
@NonNull Bundle qbExtras) {
return updateDatabaseForFuseRename(helper, oldPath, newPath, values, qbExtras,
FileUtils.getContentUriForPath(oldPath));
}
/**
* Updates database entry for given {@code path} with {@code values}
*/
private boolean updateDatabaseForFuseRename(@NonNull DatabaseHelper helper,
@NonNull String oldPath, @NonNull String newPath, @NonNull ContentValues values,
@NonNull Bundle qbExtras, Uri uriOldPath) {
boolean allowHidden = isCallingPackageAllowedHidden();
final SQLiteQueryBuilder qbForUpdate = getQueryBuilder(TYPE_UPDATE,
matchUri(uriOldPath, allowHidden), uriOldPath, qbExtras, null);
if (values.containsKey(FileColumns._MODIFIER)) {
qbForUpdate.allowColumn(FileColumns._MODIFIER);
}
final String selection = MediaColumns.DATA + " =? ";
int count = 0;
boolean retryUpdateWithReplace = false;
try {
// TODO(b/146777893): System gallery apps can rename a media directory containing
// non-media files. This update doesn't support updating non-media files that are not
// owned by system gallery app.
count = qbForUpdate.update(helper, values, selection, new String[]{oldPath});
} catch (SQLiteConstraintException e) {
Log.w(TAG, "Database update failed while renaming " + oldPath, e);
retryUpdateWithReplace = true;
}
if (retryUpdateWithReplace) {
if (deleteForFuseRename(helper, oldPath, newPath, qbExtras, selection, allowHidden)) {
Log.i(TAG, "Retrying database update after deleting conflicting entry");
count = qbForUpdate.update(helper, values, selection, new String[]{oldPath});
} else {
return false;
}
}
return count == 1;
}
private boolean deleteForFuseRename(DatabaseHelper helper, String oldPath,
String newPath, Bundle qbExtras, String selection, boolean allowHidden) {
// We are replacing file in newPath with file in oldPath. If calling package has
// write permission for newPath, delete existing database entry and retry update.
final Uri uriNewPath = FileUtils.getContentUriForPath(oldPath);
final SQLiteQueryBuilder qbForDelete = getQueryBuilder(TYPE_DELETE,
matchUri(uriNewPath, allowHidden), uriNewPath, qbExtras, null);
if (qbForDelete.delete(helper, selection, new String[] {newPath}) == 1) {
return true;
}
// Check if delete can be done using other URI grants
final String[] projection = new String[] {
FileColumns.MEDIA_TYPE,
FileColumns.DATA,
FileColumns._ID,
FileColumns.IS_DOWNLOAD,
FileColumns.MIME_TYPE,
};
return
deleteWithOtherUriGrants(
FileUtils.getContentUriForPath(newPath),
helper, projection, selection, new String[] {newPath}, qbExtras) == 1;
}
/**
* Gets {@link ContentValues} for updating database entry to {@code path}.
*/
private ContentValues getContentValuesForFuseRename(String path, String newMimeType,
boolean wasHidden, boolean isHidden, boolean isSameMimeType) {
ContentValues values = new ContentValues();
values.put(MediaColumns.MIME_TYPE, newMimeType);
values.put(MediaColumns.DATA, path);
if (isHidden) {
values.put(FileColumns.MEDIA_TYPE, FileColumns.MEDIA_TYPE_NONE);
} else {
int mediaType = MimeUtils.resolveMediaType(newMimeType);
values.put(FileColumns.MEDIA_TYPE, mediaType);
}
if ((!isHidden && wasHidden) || !isSameMimeType) {
// Set the modifier as MODIFIER_FUSE so that apps can scan the file to update the
// metadata. Otherwise, scan will skip scanning this file because rename() doesn't
// change lastModifiedTime and scan assumes there is no change in the file.
values.put(FileColumns._MODIFIER, FileColumns._MODIFIER_FUSE);
}
final boolean allowHidden = isCallingPackageAllowedHidden();
if (!newMimeType.equalsIgnoreCase("null") &&
matchUri(getContentUriForFile(path, newMimeType), allowHidden) == AUDIO_MEDIA) {
computeAudioLocalizedValues(values);
computeAudioKeyValues(values);
}
FileUtils.computeValuesFromData(values, isFuseThread());
return values;
}
private ArrayList<String> getIncludedDefaultDirectories() {
final ArrayList<String> includedDefaultDirs = new ArrayList<>();
if (checkCallingPermissionVideo(/*forWrite*/ true, null)) {
includedDefaultDirs.add(Environment.DIRECTORY_DCIM);
includedDefaultDirs.add(Environment.DIRECTORY_PICTURES);
includedDefaultDirs.add(Environment.DIRECTORY_MOVIES);
} else if (checkCallingPermissionImages(/*forWrite*/ true, null)) {
includedDefaultDirs.add(Environment.DIRECTORY_DCIM);
includedDefaultDirs.add(Environment.DIRECTORY_PICTURES);
}
return includedDefaultDirs;
}
/**
* Gets all files in the given {@code path} and subdirectories of the given {@code path}.
*/
private ArrayList<String> getAllFilesForRenameDirectory(String oldPath) {
final String selection = FileColumns.DATA + " LIKE ? ESCAPE '\\'"
+ " and mime_type not like 'null'";
final String[] selectionArgs = new String[] {DatabaseUtils.escapeForLike(oldPath) + "/%"};
ArrayList<String> fileList = new ArrayList<>();
final LocalCallingIdentity token = clearLocalCallingIdentity();
try (final Cursor c = query(FileUtils.getContentUriForPath(oldPath),
new String[] {MediaColumns.DATA}, selection, selectionArgs, null)) {
while (c.moveToNext()) {
String filePath = c.getString(0);
filePath = filePath.replaceFirst(Pattern.quote(oldPath + "/"), "");
fileList.add(filePath);
}
} finally {
restoreLocalCallingIdentity(token);
}
return fileList;
}
/**
* Gets files in the given {@code path} and subdirectories of the given {@code path} for which
* calling package has write permissions.
*
* This method throws {@code IllegalArgumentException} if the directory has one or more
* files for which calling package doesn't have write permission or if file type is not
* supported in {@code newPath}
*/
private ArrayList<String> getWritableFilesForRenameDirectory(String oldPath, String newPath)
throws IllegalArgumentException {
// Try a simple check to see if the caller has full access to the given collections first
// before falling back to performing a query to probe for access.
final String oldRelativePath = extractRelativePathForDirectory(oldPath);
final String newRelativePath = extractRelativePathForDirectory(newPath);
boolean hasFullAccessToOldPath = false;
boolean hasFullAccessToNewPath = false;
for (String defaultDir : getIncludedDefaultDirectories()) {
if (oldRelativePath.startsWith(defaultDir)) hasFullAccessToOldPath = true;
if (newRelativePath.startsWith(defaultDir)) hasFullAccessToNewPath = true;
}
if (hasFullAccessToNewPath && hasFullAccessToOldPath) {
return getAllFilesForRenameDirectory(oldPath);
}
final int countAllFilesInDirectory;
final String selection = FileColumns.DATA + " LIKE ? ESCAPE '\\'"
+ " and mime_type not like 'null'";
final String[] selectionArgs = new String[] {DatabaseUtils.escapeForLike(oldPath) + "/%"};
final Uri uriOldPath = FileUtils.getContentUriForPath(oldPath);
final LocalCallingIdentity token = clearLocalCallingIdentity();
try (final Cursor c = query(uriOldPath, new String[] {MediaColumns._ID}, selection,
selectionArgs, null)) {
// get actual number of files in the given directory.
countAllFilesInDirectory = c.getCount();
} finally {
restoreLocalCallingIdentity(token);
}
final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE,
matchUri(uriOldPath, isCallingPackageAllowedHidden()), uriOldPath, Bundle.EMPTY,
null);
final DatabaseHelper helper;
try {
helper = getDatabaseForUri(uriOldPath);
} catch (VolumeNotFoundException e) {
throw new IllegalStateException("Volume not found while querying files for renaming "
+ oldPath);
}
ArrayList<String> fileList = new ArrayList<>();
final String[] projection = {MediaColumns.DATA, MediaColumns.MIME_TYPE};
try (Cursor c = qb.query(helper, projection, selection, selectionArgs, null, null, null,
null, null)) {
// Check if the calling package has write permission to all files in the given
// directory. If calling package has write permission to all files in the directory, the
// query with update uri should return same number of files as previous query.
if (c.getCount() != countAllFilesInDirectory) {
throw new IllegalArgumentException("Calling package doesn't have write permission "
+ " to rename one or more files in " + oldPath);
}
while(c.moveToNext()) {
String filePath = c.getString(0);
filePath = filePath.replaceFirst(Pattern.quote(oldPath + "/"), "");
final String mimeType = c.getString(1);
if (!isMimeTypeSupportedInPath(newPath + "/" + filePath, mimeType)) {
throw new IllegalArgumentException("Can't rename " + oldPath + "/" + filePath
+ ". Mime type " + mimeType + " not supported in " + newPath);
}
fileList.add(filePath);
}
}
return fileList;
}
private int renameInLowerFs(String oldPath, String newPath) {
try {
Os.rename(oldPath, newPath);
return 0;
} catch (ErrnoException e) {
final String errorMessage = "Rename " + oldPath + " to " + newPath + " failed.";
Log.e(TAG, errorMessage, e);
return e.errno;
}
}
/**
* Rename directory from {@code oldPath} to {@code newPath}.
*
* Renaming a directory is only allowed if calling package has write permission to all files in
* the given directory tree and all file types in the given directory tree are supported by the
* top level directory of new path. Renaming a directory is split into three steps:
* 1. Check calling package's permissions for all files in the given directory tree. Also check
* file type support for all files in the {@code newPath}.
* 2. Try updating database for all files in the directory.
* 3. Rename the directory in lower file system. If rename in the lower file system is
* successful, commit database update.
*
* @param oldPath path of the directory to be renamed.
* @param newPath new path of directory to be renamed.
* @return 0 on successful rename, appropriate negated errno value if the rename is not allowed.
* <ul>
* <li>{@link OsConstants#EPERM} Renaming a directory with file types not supported by
* {@code newPath} or renaming a directory with files for which calling package doesn't have
* write permission.
* This method can also return errno returned from {@code Os.rename} function.
*/
private int renameDirectoryCheckedForFuse(String oldPath, String newPath) {
final ArrayList<String> fileList;
try {
fileList = getWritableFilesForRenameDirectory(oldPath, newPath);
} catch (IllegalArgumentException e) {
final String errorMessage = "Rename " + oldPath + " to " + newPath + " failed. ";
Log.e(TAG, errorMessage, e);
return OsConstants.EPERM;
}
return renameDirectoryUncheckedForFuse(oldPath, newPath, fileList);
}
private int renameDirectoryUncheckedForFuse(String oldPath, String newPath,
ArrayList<String> fileList) {
final DatabaseHelper helper;
try {
helper = getDatabaseForUri(FileUtils.getContentUriForPath(oldPath));
} catch (VolumeNotFoundException e) {
throw new IllegalStateException("Volume not found while trying to update database for "
+ oldPath, e);
}
helper.beginTransaction();
try {
final Bundle qbExtras = new Bundle();
qbExtras.putStringArrayList(INCLUDED_DEFAULT_DIRECTORIES,
getIncludedDefaultDirectories());
final boolean wasHidden = FileUtils.isDirectoryHidden(new File(oldPath));
final boolean isHidden = FileUtils.isDirectoryHidden(new File(newPath));
for (String filePath : fileList) {
final String newFilePath = newPath + "/" + filePath;
final String mimeType = MimeUtils.resolveMimeType(new File(newFilePath));
if(!updateDatabaseForFuseRename(helper, oldPath + "/" + filePath, newFilePath,
getContentValuesForFuseRename(newFilePath, mimeType, wasHidden, isHidden,
/* isSameMimeType */ true),
qbExtras)) {
Log.e(TAG, "Calling package doesn't have write permission to rename file.");
return OsConstants.EPERM;
}
}
// Rename the directory in lower file system.
int errno = renameInLowerFs(oldPath, newPath);
if (errno == 0) {
helper.setTransactionSuccessful();
} else {
return errno;
}
} finally {
helper.endTransaction();
}
// Directory movement might have made new/old path hidden.
scanRenamedDirectoryForFuse(oldPath, newPath);
return 0;
}
/**
* Rename a file from {@code oldPath} to {@code newPath}.
*
* Renaming a file is split into three parts:
* 1. Check if {@code newPath} supports new file type.
* 2. Try updating database entry from {@code oldPath} to {@code newPath}. This update may fail
* if calling package doesn't have write permission for {@code oldPath} and {@code newPath}.
* 3. Rename the file in lower file system. If Rename in lower file system succeeds, commit
* database update.
* @param oldPath path of the file to be renamed.
* @param newPath new path of the file to be renamed.
* @return 0 on successful rename, appropriate negated errno value if the rename is not allowed.
* <ul>
* <li>{@link OsConstants#EPERM} Calling package doesn't have write permission for
* {@code oldPath} or {@code newPath}, or file type is not supported by {@code newPath}.
* This method can also return errno returned from {@code Os.rename} function.
*/
private int renameFileCheckedForFuse(String oldPath, String newPath) {
// Check if new mime type is supported in new path.
final String newMimeType = MimeUtils.resolveMimeType(new File(newPath));
if (!isMimeTypeSupportedInPath(newPath, newMimeType)) {
return OsConstants.EPERM;
}
return renameFileForFuse(oldPath, newPath, /* bypassRestrictions */ false) ;
}
private int renameFileUncheckedForFuse(String oldPath, String newPath) {
return renameFileForFuse(oldPath, newPath, /* bypassRestrictions */ true) ;
}
private static boolean shouldFileBeHidden(@NonNull File file) {
if (FileUtils.isFileHidden(file)) {
return true;
}
File parent = file.getParentFile();
while (parent != null) {
if (FileUtils.isDirectoryHidden(parent)) {
return true;
}
parent = parent.getParentFile();
}
return false;
}
private int renameFileForFuse(String oldPath, String newPath, boolean bypassRestrictions) {
final DatabaseHelper helper;
try {
helper = getDatabaseForUri(FileUtils.getContentUriForPath(oldPath));
} catch (VolumeNotFoundException e) {
throw new IllegalStateException("Failed to update database row with " + oldPath, e);
}
final boolean wasHidden = shouldFileBeHidden(new File(oldPath));
final boolean isHidden = shouldFileBeHidden(new File(newPath));
helper.beginTransaction();
try {
final String newMimeType = MimeUtils.resolveMimeType(new File(newPath));
final String oldMimeType = MimeUtils.resolveMimeType(new File(oldPath));
final boolean isSameMimeType = newMimeType.equalsIgnoreCase(oldMimeType);
ContentValues contentValues = getContentValuesForFuseRename(newPath, newMimeType,
wasHidden, isHidden, isSameMimeType);
if (!updateDatabaseForFuseRename(helper, oldPath, newPath, contentValues)) {
if (!bypassRestrictions) {
// Check for other URI format grants for oldPath only. Check right before
// returning EPERM, to leave positive case performance unaffected.
if (!renameWithOtherUriGrants(helper, oldPath, newPath, contentValues)) {
Log.e(TAG, "Calling package doesn't have write permission to rename file.");
return OsConstants.EPERM;
}
} else if (!maybeRemoveOwnerPackageForFuseRename(helper, newPath)) {
Log.wtf(TAG, "Couldn't clear owner package name for " + newPath);
return OsConstants.EPERM;
}
}
// Try renaming oldPath to newPath in lower file system.
int errno = renameInLowerFs(oldPath, newPath);
if (errno == 0) {
helper.setTransactionSuccessful();
} else {
return errno;
}
} finally {
helper.endTransaction();
}
// The above code should have taken are of the mime/media type of the new file,
// even if it was moved to/from a hidden directory.
// This leaves cases where the source/dest of the move is a .nomedia file itself. Eg:
// 1) /sdcard/foo/.nomedia => /sdcard/foo/bar.mp3
// in this case, the code above has given bar.mp3 the correct mime type, but we should
// still can /sdcard/foo, because it's now no longer hidden
// 2) /sdcard/foo/.nomedia => /sdcard/bar/.nomedia
// in this case, we need to scan both /sdcard/foo and /sdcard/bar/
// 3) /sdcard/foo/bar.mp3 => /sdcard/foo/.nomedia
// in this case, we need to scan all of /sdcard/foo
if (extractDisplayName(oldPath).equals(".nomedia")) {
scanFileAsMediaProvider(new File(oldPath).getParentFile(), REASON_DEMAND);
}
if (extractDisplayName(newPath).equals(".nomedia")) {
scanFileAsMediaProvider(new File(newPath).getParentFile(), REASON_DEMAND);
}
return 0;
}
/**
* Rename file by checking for other URI grants on oldPath
*
* We don't support replace scenario by checking for other URI grants on newPath (if it exists).
*/
private boolean renameWithOtherUriGrants(DatabaseHelper helper, String oldPath, String newPath,
ContentValues contentValues) {
final Uri oldPathGrantedUri = getOtherUriGrantsForPath(oldPath, /* forWrite */ true);
if (oldPathGrantedUri == null) {
return false;
}
return updateDatabaseForFuseRename(helper, oldPath, newPath, contentValues, Bundle.EMPTY,
oldPathGrantedUri);
}
/**
* Rename file/directory without imposing any restrictions.
*
* We don't impose any rename restrictions for apps that bypass scoped storage restrictions.
* However, we update database entries for renamed files to keep the database consistent.
*/
private int renameUncheckedForFuse(String oldPath, String newPath) {
if (new File(oldPath).isFile()) {
return renameFileUncheckedForFuse(oldPath, newPath);
} else {
return renameDirectoryUncheckedForFuse(oldPath, newPath,
getAllFilesForRenameDirectory(oldPath));
}
}
/**
* Rename file or directory from {@code oldPath} to {@code newPath}.
*
* @param oldPath path of the file or directory to be renamed.
* @param newPath new path of the file or directory to be renamed.
* @param uid UID of the calling package.
* @return 0 on successful rename, appropriate errno value if the rename is not allowed.
* <ul>
* <li>{@link OsConstants#ENOENT} Renaming a non-existing file or renaming a file from path that
* is not indexed by MediaProvider database.
* <li>{@link OsConstants#EPERM} Renaming a default directory or renaming a file to a file type
* not supported by new path.
* This method can also return errno returned from {@code Os.rename} function.
*
* Called from JNI in jni/MediaProviderWrapper.cpp
*/
@Keep
public int renameForFuse(String oldPath, String newPath, int uid) {
final String errorMessage = "Rename " + oldPath + " to " + newPath + " failed. ";
final LocalCallingIdentity token =
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), oldPath);
try {
if (isPrivatePackagePathNotAccessibleByCaller(oldPath)
|| isPrivatePackagePathNotAccessibleByCaller(newPath)) {
return OsConstants.EACCES;
}
if (!newPath.equals(getAbsoluteSanitizedPath(newPath))) {
Log.e(TAG, "New path name contains invalid characters.");
return OsConstants.EPERM;
}
if (shouldBypassDatabaseAndSetDirtyForFuse(uid, newPath)) {
return renameInLowerFs(oldPath, newPath);
}
if (shouldBypassFuseRestrictions(/*forWrite*/ true, oldPath)
&& shouldBypassFuseRestrictions(/*forWrite*/ true, newPath)) {
return renameUncheckedForFuse(oldPath, newPath);
}
// Legacy apps that made is this far don't have the right storage permission and hence
// are not allowed to access anything other than their external app directory
if (isCallingPackageRequestingLegacy()) {
return OsConstants.EACCES;
}
final String[] oldRelativePath = sanitizePath(extractRelativePath(oldPath));
final String[] newRelativePath = sanitizePath(extractRelativePath(newPath));
if (oldRelativePath.length == 0 || newRelativePath.length == 0) {
// Rename not allowed on paths that can't be translated to RELATIVE_PATH.
Log.e(TAG, errorMessage + "Invalid path.");
return OsConstants.EPERM;
}
if (oldRelativePath.length == 1 && TextUtils.isEmpty(oldRelativePath[0])) {
// Allow rename of files/folders other than default directories.
final String displayName = extractDisplayName(oldPath);
for (String defaultFolder : DEFAULT_FOLDER_NAMES) {
if (displayName.equals(defaultFolder)) {
Log.e(TAG, errorMessage + oldPath + " is a default folder."
+ " Renaming a default folder is not allowed.");
return OsConstants.EPERM;
}
}
}
if (newRelativePath.length == 1 && TextUtils.isEmpty(newRelativePath[0])) {
Log.e(TAG, errorMessage + newPath + " is in root folder."
+ " Renaming a file/directory to root folder is not allowed");
return OsConstants.EPERM;
}
// TODO(b/177049768): We shouldn't use getExternalStorageDirectory for these checks.
final File directoryAndroid = new File(Environment.getExternalStorageDirectory(),
DIRECTORY_ANDROID_LOWER_CASE);
final File directoryAndroidMedia = new File(directoryAndroid, DIRECTORY_MEDIA);
if (directoryAndroidMedia.getAbsolutePath().equalsIgnoreCase(oldPath)) {
// Don't allow renaming 'Android/media' directory.
// Android/[data|obb] are bind mounted and these paths don't go through FUSE.
Log.e(TAG, errorMessage + oldPath + " is a default folder in app external "
+ "directory. Renaming a default folder is not allowed.");
return OsConstants.EPERM;
} else if (FileUtils.contains(directoryAndroid, new File(newPath))) {
if (newRelativePath.length == 1) {
// New path is Android/*. Path is directly under Android. Don't allow moving
// files and directories to Android/.
Log.e(TAG, errorMessage + newPath + " is in app external directory. "
+ "Renaming a file/directory to app external directory is not "
+ "allowed.");
return OsConstants.EPERM;
} else if(!FileUtils.contains(directoryAndroidMedia, new File(newPath))) {
// New path is Android/*/*. Don't allow moving of files or directories
// to app external directory other than media directory.
Log.e(TAG, errorMessage + newPath + " is not in external media directory."
+ "File/directory can only be renamed to a path in external media "
+ "directory. Renaming file/directory to path in other external "
+ "directories is not allowed");
return OsConstants.EPERM;
}
}
// Continue renaming files/directories if rename of oldPath to newPath is allowed.
if (new File(oldPath).isFile()) {
return renameFileCheckedForFuse(oldPath, newPath);
} else {
return renameDirectoryCheckedForFuse(oldPath, newPath);
}
} finally {
restoreLocalCallingIdentity(token);
}
}
@Override
public int checkUriPermission(@NonNull Uri uri, int uid,
/* @Intent.AccessUriMode */ int modeFlags) {
final LocalCallingIdentity token = clearLocalCallingIdentity(
LocalCallingIdentity.fromExternal(getContext(), mUserCache, uid));
if (isRedactedUri(uri)) {
if ((modeFlags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0) {
// we don't allow write grants on redacted uris.
return PackageManager.PERMISSION_DENIED;
}
uri = getUriForRedactedUri(uri);
}
if (isPickerUri(uri)) {
// Do not allow implicit access (by the virtue of ownership/permission) to picker uris.
// Picker uris should have explicit permission grants.
// If the calling app A has an explicit grant on picker uri, UriGrantsManagerService
// will check the grant status and allow app A to grant the uri to app B (without
// calling into MediaProvider)
return PackageManager.PERMISSION_DENIED;
}
try {
final boolean allowHidden = isCallingPackageAllowedHidden();
final int table = matchUri(uri, allowHidden);
final DatabaseHelper helper;
try {
helper = getDatabaseForUri(uri);
} catch (VolumeNotFoundException e) {
return PackageManager.PERMISSION_DENIED;
}
final int type;
if ((modeFlags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0) {
type = TYPE_UPDATE;
} else {
type = TYPE_QUERY;
}
final SQLiteQueryBuilder qb = getQueryBuilder(type, table, uri, Bundle.EMPTY, null);
try (Cursor c = qb.query(helper,
new String[] { BaseColumns._ID }, null, null, null, null, null, null, null)) {
if (c.getCount() == 1) {
c.moveToFirst();
final long cursorId = c.getLong(0);
long uriId = -1;
try {
uriId = ContentUris.parseId(uri);
} catch (NumberFormatException ignored) {
// if the id is not a number, the uri doesn't have a valid ID at the end of
// the uri, (i.e., uri is uri of the table not of the item/row)
}
if (uriId != -1 && cursorId == uriId) {
return PackageManager.PERMISSION_GRANTED;
}
}
}
// For the uri with id cases, if it isn't returned in above query section, the result
// isn't as expected. Don't grant the permission.
switch (table) {
case AUDIO_MEDIA_ID:
case IMAGES_MEDIA_ID:
case VIDEO_MEDIA_ID:
case DOWNLOADS_ID:
case FILES_ID:
case AUDIO_MEDIA_ID_GENRES_ID:
case AUDIO_GENRES_ID:
case AUDIO_PLAYLISTS_ID:
case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
case AUDIO_ARTISTS_ID:
case AUDIO_ALBUMS_ID:
return PackageManager.PERMISSION_DENIED;
default:
// continue below
}
// If the uri is a valid content uri and doesn't have a valid ID at the end of the uri,
// (i.e., uri is uri of the table not of the item/row), and app doesn't request prefix
// grant, we are willing to grant this uri permission since this doesn't grant them any
// extra access. This grant will only grant permissions on given uri, it will not grant
// access to db rows of the corresponding table.
if ((modeFlags & Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) == 0) {
return PackageManager.PERMISSION_GRANTED;
}
} finally {
restoreLocalCallingIdentity(token);
}
return PackageManager.PERMISSION_DENIED;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
String sortOrder) {
return query(uri, projection,
DatabaseUtils.createSqlQueryBundle(selection, selectionArgs, sortOrder), null);
}
@Override
public Cursor query(Uri uri, String[] projection, Bundle queryArgs, CancellationSignal signal) {
return query(uri, projection, queryArgs, signal, /* forSelf */ false);
}
private Cursor query(Uri uri, String[] projection, Bundle queryArgs,
CancellationSignal signal, boolean forSelf) {
Trace.beginSection("query");
try {
return queryInternal(uri, projection, queryArgs, signal, forSelf);
} catch (FallbackException e) {
return e.translateForQuery(getCallingPackageTargetSdkVersion());
} finally {
Trace.endSection();
}
}
private Cursor queryInternal(Uri uri, String[] projection, Bundle queryArgs,
CancellationSignal signal, boolean forSelf) throws FallbackException {
if (isPickerUri(uri)) {
return mPickerUriResolver.query(uri, projection, queryArgs, signal,
mCallingIdentity.get().pid, mCallingIdentity.get().uid);
}
final String volumeName = getVolumeName(uri);
PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), volumeName);
queryArgs = (queryArgs != null) ? queryArgs : new Bundle();
// INCLUDED_DEFAULT_DIRECTORIES extra should only be set inside MediaProvider.
queryArgs.remove(INCLUDED_DEFAULT_DIRECTORIES);
final ArraySet<String> honoredArgs = new ArraySet<>();
DatabaseUtils.resolveQueryArgs(queryArgs, honoredArgs::add, this::ensureCustomCollator);
Uri redactedUri = null;
// REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider.
queryArgs.remove(QUERY_ARG_REDACTED_URI);
if (isRedactedUri(uri)) {
redactedUri = uri;
uri = getUriForRedactedUri(uri);
queryArgs.putParcelable(QUERY_ARG_REDACTED_URI, redactedUri);
}
uri = safeUncanonicalize(uri);
final int targetSdkVersion = getCallingPackageTargetSdkVersion();
final boolean allowHidden = isCallingPackageAllowedHidden();
final int table = matchUri(uri, allowHidden);
//Log.v(TAG, "query: uri="+uri+", selection="+selection);
// handle MEDIA_SCANNER before calling getDatabaseForUri()
if (table == MEDIA_SCANNER) {
// create a cursor to return volume currently being scanned by the media scanner
MatrixCursor c = new MatrixCursor(new String[] {MediaStore.MEDIA_SCANNER_VOLUME});
c.addRow(new String[] {mMediaScannerVolume});
return c;
}
// Used temporarily (until we have unique media IDs) to get an identifier
// for the current sd card, so that the music app doesn't have to use the
// non-public getFatVolumeId method
if (table == FS_ID) {
MatrixCursor c = new MatrixCursor(new String[] {"fsid"});
c.addRow(new Integer[] {mVolumeId});
return c;
}
if (table == VERSION) {
MatrixCursor c = new MatrixCursor(new String[] {"version"});
c.addRow(new Integer[] {DatabaseHelper.getDatabaseVersion(getContext())});
return c;
}
// TODO(b/195008831): Add test to verify that apps can't access
if (table == PICKER_INTERNAL_MEDIA) {
return mPickerDataLayer.fetchMedia(queryArgs);
} else if (table == PICKER_INTERNAL_ALBUMS) {
return mPickerDataLayer.fetchAlbums(queryArgs);
}
final DatabaseHelper helper = getDatabaseForUri(uri);
final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_QUERY, table, uri, queryArgs,
honoredArgs::add);
if (targetSdkVersion < Build.VERSION_CODES.R) {
// Some apps are abusing "ORDER BY" clauses to inject "LIMIT"
// clauses; gracefully lift them out.
DatabaseUtils.recoverAbusiveSortOrder(queryArgs);
// Some apps are abusing the Uri query parameters to inject LIMIT
// clauses; gracefully lift them out.
DatabaseUtils.recoverAbusiveLimit(uri, queryArgs);
}
if (targetSdkVersion < Build.VERSION_CODES.Q) {
// Some apps are abusing the "WHERE" clause by injecting "GROUP BY"
// clauses; gracefully lift them out.
DatabaseUtils.recoverAbusiveSelection(queryArgs);
// Some apps are abusing the first column to inject "DISTINCT";
// gracefully lift them out.
if ((projection != null) && (projection.length > 0)
&& projection[0].startsWith("DISTINCT ")) {
projection[0] = projection[0].substring("DISTINCT ".length());
qb.setDistinct(true);
}
// Some apps are generating thumbnails with getThumbnail(), but then
// ignoring the returned Bitmap and querying the raw table; give
// them a row with enough information to find the original image.
final String selection = queryArgs.getString(QUERY_ARG_SQL_SELECTION);
if ((table == IMAGES_THUMBNAILS || table == VIDEO_THUMBNAILS)
&& !TextUtils.isEmpty(selection)) {
final Matcher matcher = PATTERN_SELECTION_ID.matcher(selection);
if (matcher.matches()) {
final long id = Long.parseLong(matcher.group(1));
final Uri fullUri;
if (table == IMAGES_THUMBNAILS) {
fullUri = ContentUris.withAppendedId(
Images.Media.getContentUri(volumeName), id);
} else if (table == VIDEO_THUMBNAILS) {
fullUri = ContentUris.withAppendedId(
Video.Media.getContentUri(volumeName), id);
} else {
throw new IllegalArgumentException();
}
final MatrixCursor cursor = new MatrixCursor(projection);
final File file = ContentResolver.encodeToFile(
fullUri.buildUpon().appendPath("thumbnail").build());
final String data = file.getAbsolutePath();
cursor.newRow().add(MediaColumns._ID, null)
.add(Images.Thumbnails.IMAGE_ID, id)
.add(Video.Thumbnails.VIDEO_ID, id)
.add(MediaColumns.DATA, data);
return cursor;
}
}
}
// Update locale if necessary.
if (helper.isInternal() && !Locale.getDefault().equals(mLastLocale)) {
Log.i(TAG, "Updating locale within queryInternal");
onLocaleChanged(false);
}
final Cursor c = qb.query(helper, projection, queryArgs, signal);
if (c != null && !forSelf) {
// As a performance optimization, only configure notifications when
// resulting cursor will leave our process
final boolean callerIsRemote = mCallingIdentity.get().pid != android.os.Process.myPid();
if (callerIsRemote && !isFuseThread()) {
c.setNotificationUri(getContext().getContentResolver(), uri);
}
final Bundle extras = new Bundle();
extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS,
honoredArgs.toArray(new String[honoredArgs.size()]));
c.setExtras(extras);
}
// Query was on a redacted URI, update the sensitive information such as the _ID, DATA etc.
if (redactedUri != null && c != null) {
try {
return getRedactedUriCursor(redactedUri, c);
} finally {
c.close();
}
}
return c;
}
private boolean isUriSupportedForRedaction(Uri uri) {
final int match = matchUri(uri, true);
return REDACTED_URI_SUPPORTED_TYPES.contains(match);
}
private Cursor getRedactedUriCursor(Uri redactedUri, @NonNull Cursor c) {
final HashSet<String> columnNames = new HashSet<>(Arrays.asList(c.getColumnNames()));
final MatrixCursor redactedUriCursor = new MatrixCursor(c.getColumnNames());
final String redactedUriId = redactedUri.getLastPathSegment();
if (!c.moveToFirst()) {
return redactedUriCursor;
}
// NOTE: It is safe to assume that there will only be one entry corresponding to a
// redacted URI as it corresponds to a unique DB entry.
if (c.getCount() != 1) {
throw new AssertionError("Two rows corresponding to " + redactedUri.toString()
+ " found, when only one expected");
}
final MatrixCursor.RowBuilder row = redactedUriCursor.newRow();
for (String columnName : c.getColumnNames()) {
final int colIndex = c.getColumnIndex(columnName);
if (c.getType(colIndex) == FIELD_TYPE_BLOB) {
row.add(c.getBlob(colIndex));
} else {
row.add(c.getString(colIndex));
}
}
String ext = getFileExtensionFromCursor(c, columnNames);
ext = ext == null ? "" : "." + ext;
final String displayName = redactedUriId + ext;
final String data = buildPrimaryVolumeFile(uidToUserId(Binder.getCallingUid()),
getRedactedRelativePath(), displayName).getAbsolutePath();
updateRow(columnNames, MediaColumns._ID, row, redactedUriId);
updateRow(columnNames, MediaColumns.DISPLAY_NAME, row, displayName);
updateRow(columnNames, MediaColumns.RELATIVE_PATH, row, getRedactedRelativePath());
updateRow(columnNames, MediaColumns.BUCKET_DISPLAY_NAME, row, getRedactedRelativePath());
updateRow(columnNames, MediaColumns.DATA, row, data);
updateRow(columnNames, MediaColumns.DOCUMENT_ID, row, null);
updateRow(columnNames, MediaColumns.INSTANCE_ID, row, null);
updateRow(columnNames, MediaColumns.BUCKET_ID, row, null);
return redactedUriCursor;
}
@Nullable
private static String getFileExtensionFromCursor(@NonNull Cursor c,
@NonNull HashSet<String> columnNames) {
if (columnNames.contains(MediaColumns.DATA)) {
return extractFileExtension(c.getString(c.getColumnIndex(MediaColumns.DATA)));
}
if (columnNames.contains(MediaColumns.DISPLAY_NAME)) {
return extractFileExtension(c.getString(c.getColumnIndex(MediaColumns.DISPLAY_NAME)));
}
return null;
}
private void updateRow(HashSet<String> columnNames, String columnName,
MatrixCursor.RowBuilder row, Object val) {
if (columnNames.contains(columnName)) {
row.add(columnName, val);
}
}
private Uri getUriForRedactedUri(Uri redactedUri) {
final Uri.Builder builder = redactedUri.buildUpon();
builder.path(null);
final List<String> segments = redactedUri.getPathSegments();
for (int i = 0; i < segments.size() - 1; i++) {
builder.appendPath(segments.get(i));
}
DatabaseHelper helper;
try {
helper = getDatabaseForUri(redactedUri);
} catch (VolumeNotFoundException e) {
throw e.rethrowAsIllegalArgumentException();
}
try (final Cursor c = helper.runWithoutTransaction(
(db) -> db.query("files", new String[]{MediaColumns._ID},
FileColumns.REDACTED_URI_ID + "=?",
new String[]{redactedUri.getLastPathSegment()}, null, null, null))) {
if (!c.moveToFirst()) {
throw new IllegalArgumentException(
"Uri: " + redactedUri.toString() + " not found.");
}
builder.appendPath(c.getString(0));
return builder.build();
}
}
private boolean isRedactedUri(Uri uri) {
String id = uri.getLastPathSegment();
return id != null && id.startsWith(REDACTED_URI_ID_PREFIX)
&& id.length() == REDACTED_URI_ID_SIZE;
}
@Override
public String getType(Uri url) {
final int match = matchUri(url, true);
switch (match) {
case IMAGES_MEDIA_ID:
case AUDIO_MEDIA_ID:
case AUDIO_PLAYLISTS_ID:
case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
case VIDEO_MEDIA_ID:
case DOWNLOADS_ID:
case FILES_ID:
final LocalCallingIdentity token = clearLocalCallingIdentity();
try (Cursor cursor = queryForSingleItem(url,
new String[] { MediaColumns.MIME_TYPE }, null, null, null)) {
return cursor.getString(0);
} catch (FileNotFoundException e) {
throw new IllegalArgumentException(e.getMessage());
} finally {
restoreLocalCallingIdentity(token);
}
case IMAGES_MEDIA:
case IMAGES_THUMBNAILS:
return Images.Media.CONTENT_TYPE;
case AUDIO_ALBUMART_ID:
case AUDIO_ALBUMART_FILE_ID:
case IMAGES_THUMBNAILS_ID:
case VIDEO_THUMBNAILS_ID:
return "image/jpeg";
case AUDIO_MEDIA:
case AUDIO_GENRES_ID_MEMBERS:
case AUDIO_PLAYLISTS_ID_MEMBERS:
return Audio.Media.CONTENT_TYPE;
case AUDIO_GENRES:
case AUDIO_MEDIA_ID_GENRES:
return Audio.Genres.CONTENT_TYPE;
case AUDIO_GENRES_ID:
case AUDIO_MEDIA_ID_GENRES_ID:
return Audio.Genres.ENTRY_CONTENT_TYPE;
case AUDIO_PLAYLISTS:
return Audio.Playlists.CONTENT_TYPE;
case VIDEO_MEDIA:
return Video.Media.CONTENT_TYPE;
case DOWNLOADS:
return Downloads.CONTENT_TYPE;
case PICKER_ID:
return mPickerUriResolver.getType(url);
}
throw new IllegalStateException("Unknown URL : " + url);
}
@VisibleForTesting
void ensureFileColumns(@NonNull Uri uri, @NonNull ContentValues values)
throws VolumeArgumentException, VolumeNotFoundException {
final LocalUriMatcher matcher = new LocalUriMatcher(MediaStore.AUTHORITY);
final int match = matcher.matchUri(uri, true);
ensureNonUniqueFileColumns(match, uri, Bundle.EMPTY, values, null /* currentPath */);
}
private void ensureUniqueFileColumns(int match, @NonNull Uri uri, @NonNull Bundle extras,
@NonNull ContentValues values, @Nullable String currentPath)
throws VolumeArgumentException, VolumeNotFoundException {
ensureFileColumns(match, uri, extras, values, true, currentPath);
}
private void ensureNonUniqueFileColumns(int match, @NonNull Uri uri,
@NonNull Bundle extras, @NonNull ContentValues values, @Nullable String currentPath)
throws VolumeArgumentException, VolumeNotFoundException {
ensureFileColumns(match, uri, extras, values, false, currentPath);
}
/**
* Get the various file-related {@link MediaColumns} in the given
* {@link ContentValues} into a consistent condition. Also validates that defined
* columns are valid for the given {@link Uri}, such as ensuring that only
* {@code image/*} can be inserted into
* {@link android.provider.MediaStore.Images}.
*/
private void ensureFileColumns(int match, @NonNull Uri uri, @NonNull Bundle extras,
@NonNull ContentValues values, boolean makeUnique, @Nullable String currentPath)
throws VolumeArgumentException, VolumeNotFoundException {
Trace.beginSection("ensureFileColumns");
Objects.requireNonNull(uri);
Objects.requireNonNull(extras);
Objects.requireNonNull(values);
// Figure out defaults based on Uri being modified
String defaultMimeType = ClipDescription.MIMETYPE_UNKNOWN;
int defaultMediaType = FileColumns.MEDIA_TYPE_NONE;
String defaultPrimary = Environment.DIRECTORY_DOWNLOADS;
String defaultSecondary = null;
List<String> allowedPrimary = Arrays.asList(
Environment.DIRECTORY_DOWNLOADS,
Environment.DIRECTORY_DOCUMENTS);
switch (match) {
case AUDIO_MEDIA:
case AUDIO_MEDIA_ID:
defaultMimeType = "audio/mpeg";
defaultMediaType = FileColumns.MEDIA_TYPE_AUDIO;
defaultPrimary = Environment.DIRECTORY_MUSIC;
if (SdkLevel.isAtLeastS()) {
allowedPrimary = Arrays.asList(
Environment.DIRECTORY_ALARMS,
Environment.DIRECTORY_AUDIOBOOKS,
Environment.DIRECTORY_MUSIC,
Environment.DIRECTORY_NOTIFICATIONS,
Environment.DIRECTORY_PODCASTS,
Environment.DIRECTORY_RECORDINGS,
Environment.DIRECTORY_RINGTONES);
} else {
allowedPrimary = Arrays.asList(
Environment.DIRECTORY_ALARMS,
Environment.DIRECTORY_AUDIOBOOKS,
Environment.DIRECTORY_MUSIC,
Environment.DIRECTORY_NOTIFICATIONS,
Environment.DIRECTORY_PODCASTS,
FileUtils.DIRECTORY_RECORDINGS,
Environment.DIRECTORY_RINGTONES);
}
break;
case VIDEO_MEDIA:
case VIDEO_MEDIA_ID:
defaultMimeType = "video/mp4";
defaultMediaType = FileColumns.MEDIA_TYPE_VIDEO;
defaultPrimary = Environment.DIRECTORY_MOVIES;
allowedPrimary = Arrays.asList(
Environment.DIRECTORY_DCIM,
Environment.DIRECTORY_MOVIES,
Environment.DIRECTORY_PICTURES);
break;
case IMAGES_MEDIA:
case IMAGES_MEDIA_ID:
defaultMimeType = "image/jpeg";
defaultMediaType = FileColumns.MEDIA_TYPE_IMAGE;
defaultPrimary = Environment.DIRECTORY_PICTURES;
allowedPrimary = Arrays.asList(
Environment.DIRECTORY_DCIM,
Environment.DIRECTORY_PICTURES);
break;
case AUDIO_ALBUMART:
case AUDIO_ALBUMART_ID:
defaultMimeType = "image/jpeg";
defaultMediaType = FileColumns.MEDIA_TYPE_IMAGE;
defaultPrimary = Environment.DIRECTORY_MUSIC;
allowedPrimary = Arrays.asList(defaultPrimary);
defaultSecondary = DIRECTORY_THUMBNAILS;
break;
case VIDEO_THUMBNAILS:
case VIDEO_THUMBNAILS_ID:
defaultMimeType = "image/jpeg";
defaultMediaType = FileColumns.MEDIA_TYPE_IMAGE;
defaultPrimary = Environment.DIRECTORY_MOVIES;
allowedPrimary = Arrays.asList(defaultPrimary);
defaultSecondary = DIRECTORY_THUMBNAILS;
break;
case IMAGES_THUMBNAILS:
case IMAGES_THUMBNAILS_ID:
defaultMimeType = "image/jpeg";
defaultMediaType = FileColumns.MEDIA_TYPE_IMAGE;
defaultPrimary = Environment.DIRECTORY_PICTURES;
allowedPrimary = Arrays.asList(defaultPrimary);
defaultSecondary = DIRECTORY_THUMBNAILS;
break;
case AUDIO_PLAYLISTS:
case AUDIO_PLAYLISTS_ID:
defaultMimeType = "audio/mpegurl";
defaultMediaType = FileColumns.MEDIA_TYPE_PLAYLIST;
defaultPrimary = Environment.DIRECTORY_MUSIC;
allowedPrimary = Arrays.asList(
Environment.DIRECTORY_MUSIC,
Environment.DIRECTORY_MOVIES);
break;
case DOWNLOADS:
case DOWNLOADS_ID:
defaultPrimary = Environment.DIRECTORY_DOWNLOADS;
allowedPrimary = Arrays.asList(defaultPrimary);
break;
case FILES:
case FILES_ID:
// Use defaults above
break;
default:
Log.w(TAG, "Unhandled location " + uri + "; assuming generic files");
break;
}
final String resolvedVolumeName = resolveVolumeName(uri);
if (TextUtils.isEmpty(values.getAsString(MediaColumns.DATA))
&& MediaStore.VOLUME_INTERNAL.equals(resolvedVolumeName)) {
// TODO: promote this to top-level check
throw new UnsupportedOperationException(
"Writing to internal storage is not supported.");
}
// Force values when raw path provided
if (!TextUtils.isEmpty(values.getAsString(MediaColumns.DATA))) {
FileUtils.computeValuesFromData(values, isFuseThread());
}
final boolean isTargetSdkROrHigher =
getCallingPackageTargetSdkVersion() >= Build.VERSION_CODES.R;
final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME);
final String mimeTypeFromExt = TextUtils.isEmpty(displayName) ? null :
MimeUtils.resolveMimeType(new File(displayName));
if (TextUtils.isEmpty(values.getAsString(MediaColumns.MIME_TYPE))) {
if (isTargetSdkROrHigher) {
// Extract the MIME type from the display name if we couldn't resolve it from the
// raw path
if (mimeTypeFromExt != null) {
values.put(MediaColumns.MIME_TYPE, mimeTypeFromExt);
} else {
// We couldn't resolve mimeType, it means that both display name and MIME type
// were missing in values, so we use defaultMimeType.
values.put(MediaColumns.MIME_TYPE, defaultMimeType);
}
} else if (defaultMediaType == FileColumns.MEDIA_TYPE_NONE) {
values.put(MediaColumns.MIME_TYPE, mimeTypeFromExt);
} else {
// We don't use mimeTypeFromExt to preserve legacy behavior.
values.put(MediaColumns.MIME_TYPE, defaultMimeType);
}
}
String mimeType = values.getAsString(MediaColumns.MIME_TYPE);
if (defaultMediaType == FileColumns.MEDIA_TYPE_NONE) {
// We allow any mimeType for generic uri with default media type as MEDIA_TYPE_NONE.
} else if (mimeType != null &&
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) == null) {
if (mimeTypeFromExt != null &&
defaultMediaType == MimeUtils.resolveMediaType(mimeTypeFromExt)) {
// If mimeType from extension matches the defaultMediaType of uri, we use mimeType
// from file extension as mimeType. This is an effort to guess the mimeType when we
// get unsupported mimeType.
// Note: We can't force defaultMimeType because when we force defaultMimeType, we
// will force the file extension as well. For example, if DISPLAY_NAME=Foo.png and
// mimeType="image/*". If we force mimeType to be "image/jpeg", we append the file
// name with the new file extension i.e., "Foo.png.jpg" where as the expected file
// name was "Foo.png"
values.put(MediaColumns.MIME_TYPE, mimeTypeFromExt);
} else if (isTargetSdkROrHigher) {
// We are here because given mimeType is unsupported also we couldn't guess valid
// mimeType from file extension.
throw new IllegalArgumentException("Unsupported MIME type " + mimeType);
} else {
// We can't throw error for legacy apps, so we try to use defaultMimeType.
values.put(MediaColumns.MIME_TYPE, defaultMimeType);
}
}
// Give ourselves reasonable defaults when missing
if (TextUtils.isEmpty(values.getAsString(MediaColumns.DISPLAY_NAME))) {
values.put(MediaColumns.DISPLAY_NAME,
String.valueOf(System.currentTimeMillis()));
}
final Integer formatObject = values.getAsInteger(FileColumns.FORMAT);
final int format = formatObject == null ? 0 : formatObject.intValue();
if (format == MtpConstants.FORMAT_ASSOCIATION) {
values.putNull(MediaColumns.MIME_TYPE);
}
mimeType = values.getAsString(MediaColumns.MIME_TYPE);
// Quick check MIME type against table
if (mimeType != null) {
PulledMetrics.logMimeTypeAccess(getCallingUidOrSelf(), mimeType);
final int actualMediaType = MimeUtils.resolveMediaType(mimeType);
if (defaultMediaType == FileColumns.MEDIA_TYPE_NONE) {
// Give callers an opportunity to work with playlists and
// subtitles using the generic files table
switch (actualMediaType) {
case FileColumns.MEDIA_TYPE_PLAYLIST:
defaultMimeType = "audio/mpegurl";
defaultMediaType = FileColumns.MEDIA_TYPE_PLAYLIST;
defaultPrimary = Environment.DIRECTORY_MUSIC;
allowedPrimary = new ArrayList<>(allowedPrimary);
allowedPrimary.add(Environment.DIRECTORY_MUSIC);
allowedPrimary.add(Environment.DIRECTORY_MOVIES);
break;
case FileColumns.MEDIA_TYPE_SUBTITLE:
defaultMimeType = "application/x-subrip";
defaultMediaType = FileColumns.MEDIA_TYPE_SUBTITLE;
defaultPrimary = Environment.DIRECTORY_MOVIES;
allowedPrimary = new ArrayList<>(allowedPrimary);
allowedPrimary.add(Environment.DIRECTORY_MUSIC);
allowedPrimary.add(Environment.DIRECTORY_MOVIES);
break;
}
} else if (defaultMediaType != actualMediaType) {
final String[] split = defaultMimeType.split("/");
throw new IllegalArgumentException(
"MIME type " + mimeType + " cannot be inserted into " + uri
+ "; expected MIME type under " + split[0] + "/*");
}
}
// Use default directories when missing
if (TextUtils.isEmpty(values.getAsString(MediaColumns.RELATIVE_PATH))) {
if (defaultSecondary != null) {
values.put(MediaColumns.RELATIVE_PATH,
defaultPrimary + '/' + defaultSecondary + '/');
} else {
values.put(MediaColumns.RELATIVE_PATH,
defaultPrimary + '/');
}
}
// Generate path when undefined
if (TextUtils.isEmpty(values.getAsString(MediaColumns.DATA))) {
File volumePath;
try {
volumePath = getVolumePath(resolvedVolumeName);
} catch (FileNotFoundException e) {
throw new IllegalArgumentException(e);
}
FileUtils.sanitizeValues(values, /*rewriteHiddenFileName*/ !isFuseThread());
FileUtils.computeDataFromValues(values, volumePath, isFuseThread());
// Create result file
File res = new File(values.getAsString(MediaColumns.DATA));
try {
if (makeUnique) {
res = FileUtils.buildUniqueFile(res.getParentFile(),
mimeType, res.getName());
} else {
res = FileUtils.buildNonUniqueFile(res.getParentFile(),
mimeType, res.getName());
}
} catch (FileNotFoundException e) {
throw new IllegalStateException(
"Failed to build unique file: " + res + " " + values);
}
// Require that content lives under well-defined directories to help
// keep the user's content organized
// Start by saying unchanged directories are valid
final String currentDir = (currentPath != null)
? new File(currentPath).getParent() : null;
boolean validPath = res.getParent().equals(currentDir);
// Next, consider allowing based on allowed primary directory
final String[] relativePath = values.getAsString(MediaColumns.RELATIVE_PATH).split("/");
final String primary = extractTopLevelDir(relativePath);
if (!validPath) {
validPath = containsIgnoreCase(allowedPrimary, primary);
}
// Next, consider allowing paths when referencing a related item
final Uri relatedUri = extras.getParcelable(QUERY_ARG_RELATED_URI);
if (!validPath && relatedUri != null) {
try (Cursor c = queryForSingleItem(relatedUri, new String[] {
MediaColumns.MIME_TYPE,
MediaColumns.RELATIVE_PATH,
}, null, null, null)) {
// If top-level MIME type matches, and relative path
// matches, then allow caller to place things here
final String expectedType = MimeUtils.extractPrimaryType(
c.getString(0));
final String actualType = MimeUtils.extractPrimaryType(
values.getAsString(MediaColumns.MIME_TYPE));
if (!Objects.equals(expectedType, actualType)) {
throw new IllegalArgumentException("Placement of " + actualType
+ " item not allowed in relation to " + expectedType + " item");
}
final String expectedPath = c.getString(1);
final String actualPath = values.getAsString(MediaColumns.RELATIVE_PATH);
if (!Objects.equals(expectedPath, actualPath)) {
throw new IllegalArgumentException("Placement of " + actualPath
+ " item not allowed in relation to " + expectedPath + " item");
}
// If we didn't see any trouble above, then we'll allow it
validPath = true;
} catch (FileNotFoundException e) {
Log.w(TAG, "Failed to find related item " + relatedUri + ": " + e);
}
}
// Consider allowing external media directory of calling package
if (!validPath) {
final String pathOwnerPackage = extractPathOwnerPackageName(res.getAbsolutePath());
if (pathOwnerPackage != null) {
validPath = isExternalMediaDirectory(res.getAbsolutePath()) &&
isCallingIdentitySharedPackageName(pathOwnerPackage);
}
}
// Allow apps with MANAGE_EXTERNAL_STORAGE to create files anywhere
if (!validPath) {
validPath = isCallingPackageManager();
}
// Allow system gallery to create image/video files.
if (!validPath) {
// System gallery can create image/video files in any existing directory, it can
// also create subdirectories in any existing top-level directory. However, system
// gallery is not allowed to create non-default top level directory.
final boolean createNonDefaultTopLevelDir = primary != null &&
!FileUtils.buildPath(volumePath, primary).exists();
validPath = !createNonDefaultTopLevelDir &&
canAccessMediaFile(res.getAbsolutePath(), /*allowLegacy*/ false);
}
// Nothing left to check; caller can't use this path
if (!validPath) {
throw new IllegalArgumentException(
"Primary directory " + primary + " not allowed for " + uri
+ "; allowed directories are " + allowedPrimary);
}
boolean isFuseThread = isFuseThread();
// Check if the following are true:
// 1. Not a FUSE thread
// 2. |res| is a child of a default dir and the default dir is missing
// If true, we want to update the mTime of the volume root, after creating the dir
// on the lower filesystem. This fixes some FileManagers relying on the mTime change
// for UI updates
File defaultDirVolumePath =
isFuseThread ? null : checkDefaultDirMissing(resolvedVolumeName, res);
// Ensure all parent folders of result file exist
res.getParentFile().mkdirs();
if (!res.getParentFile().exists()) {
throw new IllegalStateException("Failed to create directory: " + res);
}
touchFusePath(defaultDirVolumePath);
values.put(MediaColumns.DATA, res.getAbsolutePath());
// buildFile may have changed the file name, compute values to extract new DISPLAY_NAME.
// Note: We can't extract displayName from res.getPath() because for pending & trashed
// files DISPLAY_NAME will not be same as file name.
FileUtils.computeValuesFromData(values, isFuseThread);
} else {
assertFileColumnsConsistent(match, uri, values);
}
assertPrivatePathNotInValues(values);
// Drop columns that aren't relevant for special tables
switch (match) {
case AUDIO_ALBUMART:
case VIDEO_THUMBNAILS:
case IMAGES_THUMBNAILS:
final Set<String> valid = getProjectionMap(MediaStore.Images.Thumbnails.class)
.keySet();
for (String key : new ArraySet<>(values.keySet())) {
if (!valid.contains(key)) {
values.remove(key);
}
}
break;
}
Trace.endSection();
}
/**
* Check that values don't contain any external private path.
* NOTE: The checks are gated on targetSDK S.
*/
private void assertPrivatePathNotInValues(ContentValues values)
throws IllegalArgumentException {
if (!CompatChanges.isChangeEnabled(ENABLE_CHECKS_FOR_PRIVATE_FILES,
Binder.getCallingUid())) {
// For legacy apps, let the behaviour be as it is.
return;
}
ArrayList<String> relativePaths = new ArrayList<String>();
relativePaths.add(extractRelativePath(values.getAsString(MediaColumns.DATA)));
relativePaths.add(values.getAsString(MediaColumns.RELATIVE_PATH));
/**
* Don't allow apps to insert/update database row to files in Android/data or
* Android/obb dirs. These are app private directories and files in these private
* directories can't be added to public media collection.
*/
for (final String relativePath : relativePaths) {
if (relativePath == null) continue;
final String[] relativePathSegments = relativePath.split("/", 3);
final String primary =
(relativePathSegments.length > 0) ? relativePathSegments[0] : null;
final String secondary =
(relativePathSegments.length > 1) ? relativePathSegments[1] : "";
if (DIRECTORY_ANDROID_LOWER_CASE.equalsIgnoreCase(primary)
&& PRIVATE_SUBDIRECTORIES_ANDROID.contains(
secondary.toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException(
"Inserting private file: " + relativePath + " is not allowed.");
}
}
}
/**
* @return the default dir if {@code file} is a child of default dir and it's missing,
* {@code null} otherwise.
*/
private File checkDefaultDirMissing(String volumeName, File file) {
String topLevelDir = FileUtils.extractTopLevelDir(file.getPath());
if (topLevelDir != null && FileUtils.isDefaultDirectoryName(topLevelDir)) {
try {
File volumePath = getVolumePath(volumeName);
if (!new File(volumePath, topLevelDir).exists()) {
return volumePath;
}
} catch (FileNotFoundException e) {
Log.w(TAG, "Failed to checkDefaultDirMissing for " + file, e);
}
}
return null;
}
/** Updates mTime of {@code path} on the FUSE filesystem */
private void touchFusePath(@Nullable File path) {
if (path != null) {
// Touch root of volume to update mTime on FUSE filesystem
// This allows FileManagers that may be relying on mTime changes to update their UI
File fusePath = getFuseFile(path);
if (fusePath != null) {
Log.i(TAG, "Touching FUSE path " + fusePath);
fusePath.setLastModified(System.currentTimeMillis());
}
}
}
/**
* Check that any requested {@link MediaColumns#DATA} paths actually
* live on the storage volume being targeted.
*/
private void assertFileColumnsConsistent(int match, Uri uri, ContentValues values)
throws VolumeArgumentException, VolumeNotFoundException {
if (!values.containsKey(MediaColumns.DATA)) return;
final String volumeName = resolveVolumeName(uri);
try {
// Quick check that the requested path actually lives on volume
final Collection<File> allowed = getAllowedVolumePaths(volumeName);
final File actual = new File(values.getAsString(MediaColumns.DATA))
.getCanonicalFile();
if (!FileUtils.contains(allowed, actual)) {
throw new VolumeArgumentException(actual, allowed);
}
} catch (IOException e) {
throw new VolumeNotFoundException(volumeName);
}
}
@Override
public int bulkInsert(Uri uri, ContentValues[] values) {
final int targetSdkVersion = getCallingPackageTargetSdkVersion();
final boolean allowHidden = isCallingPackageAllowedHidden();
final int match = matchUri(uri, allowHidden);
if (match == VOLUMES) {
return super.bulkInsert(uri, values);
}
if (match == AUDIO_PLAYLISTS_ID || match == AUDIO_PLAYLISTS_ID_MEMBERS) {
final String resolvedVolumeName = resolveVolumeName(uri);
final long playlistId = Long.parseLong(uri.getPathSegments().get(3));
final Uri playlistUri = ContentUris.withAppendedId(
MediaStore.Audio.Playlists.getContentUri(resolvedVolumeName), playlistId);
final String audioVolumeName =
MediaStore.VOLUME_INTERNAL.equals(resolvedVolumeName)
? MediaStore.VOLUME_INTERNAL : MediaStore.VOLUME_EXTERNAL;
// Require that caller has write access to underlying media
enforceCallingPermission(playlistUri, Bundle.EMPTY, true);
for (ContentValues each : values) {
final long audioId = each.getAsLong(Audio.Playlists.Members.AUDIO_ID);
final Uri audioUri = Audio.Media.getContentUri(audioVolumeName, audioId);
enforceCallingPermission(audioUri, Bundle.EMPTY, false);
}
return bulkInsertPlaylist(playlistUri, values);
}
final DatabaseHelper helper;
try {
helper = getDatabaseForUri(uri);
} catch (VolumeNotFoundException e) {
return e.translateForUpdateDelete(targetSdkVersion);
}
helper.beginTransaction();
try {
final int result = super.bulkInsert(uri, values);
helper.setTransactionSuccessful();
return result;
} finally {
helper.endTransaction();
}
}
private int bulkInsertPlaylist(@NonNull Uri uri, @NonNull ContentValues[] values) {
Trace.beginSection("bulkInsertPlaylist");
try {
try {
return addPlaylistMembers(uri, values);
} catch (SQLiteConstraintException e) {
if (getCallingPackageTargetSdkVersion() >= Build.VERSION_CODES.R) {
throw e;
} else {
return 0;
}
}
} catch (FallbackException e) {
return e.translateForBulkInsert(getCallingPackageTargetSdkVersion());
} finally {
Trace.endSection();
}
}
private long insertDirectory(@NonNull SQLiteDatabase db, @NonNull String path) {
if (LOGV) Log.v(TAG, "inserting directory " + path);
ContentValues values = new ContentValues();
values.put(FileColumns.FORMAT, MtpConstants.FORMAT_ASSOCIATION);
values.put(FileColumns.DATA, path);
values.put(FileColumns.PARENT, getParent(db, path));
values.put(FileColumns.OWNER_PACKAGE_NAME, extractPathOwnerPackageName(path));
values.put(FileColumns.VOLUME_NAME, extractVolumeName(path));
values.put(FileColumns.RELATIVE_PATH, extractRelativePath(path));
values.put(FileColumns.DISPLAY_NAME, extractDisplayName(path));
values.put(FileColumns.IS_DOWNLOAD, isDownload(path) ? 1 : 0);
File file = new File(path);
if (file.exists()) {
values.put(FileColumns.DATE_MODIFIED, file.lastModified() / 1000);
}
return db.insert("files", FileColumns.DATE_MODIFIED, values);
}
private long getParent(@NonNull SQLiteDatabase db, @NonNull String path) {
final String parentPath = new File(path).getParent();
if (Objects.equals("/", parentPath)) {
return -1;
} else {
synchronized (mDirectoryCache) {
Long id = mDirectoryCache.get(parentPath);
if (id != null) {
return id;
}
}
final long id;
try (Cursor c = db.query("files", new String[] { FileColumns._ID },
FileColumns.DATA + "=?", new String[] { parentPath }, null, null, null)) {
if (c.moveToFirst()) {
id = c.getLong(0);
} else {
id = insertDirectory(db, parentPath);
}
}
synchronized (mDirectoryCache) {
mDirectoryCache.put(parentPath, id);
}
return id;
}
}
/**
* @param c the Cursor whose title to retrieve
* @return the result of {@link #getDefaultTitle(String)} if the result is valid; otherwise
* the value of the {@code MediaStore.Audio.Media.TITLE} column
*/
private String getDefaultTitleFromCursor(Cursor c) {
String title = null;
final int columnIndex = c.getColumnIndex("title_resource_uri");
// Necessary to check for existence because we may be reading from an old DB version
if (columnIndex > -1) {
final String titleResourceUri = c.getString(columnIndex);
if (titleResourceUri != null) {
try {
title = getDefaultTitle(titleResourceUri);
} catch (Exception e) {
// Best attempt only
}
}
}
if (title == null) {
title = c.getString(c.getColumnIndex(MediaStore.Audio.Media.TITLE));
}
return title;
}
/**
* @param title_resource_uri The title resource for which to retrieve the default localization
* @return The title localized to {@code Locale.US}, or {@code null} if unlocalizable
* @throws Exception Thrown if the title appears to be localizable, but the localization failed
* for any reason. For example, the application from which the localized title is fetched is not
* installed, or it does not have the resource which needs to be localized
*/
private String getDefaultTitle(String title_resource_uri) throws Exception{
try {
return getTitleFromResourceUri(title_resource_uri, false);
} catch (Exception e) {
Log.e(TAG, "Error getting default title for " + title_resource_uri, e);
throw e;
}
}
/**
* @param title_resource_uri The title resource to localize
* @return The localized title, or {@code null} if unlocalizable
* @throws Exception Thrown if the title appears to be localizable, but the localization failed
* for any reason. For example, the application from which the localized title is fetched is not
* installed, or it does not have the resource which needs to be localized
*/
private String getLocalizedTitle(String title_resource_uri) throws Exception {
try {
return getTitleFromResourceUri(title_resource_uri, true);
} catch (Exception e) {
Log.e(TAG, "Error getting localized title for " + title_resource_uri, e);
throw e;
}
}
/**
* Localizable titles conform to this URI pattern:
* Scheme: {@link ContentResolver.SCHEME_ANDROID_RESOURCE}
* Authority: Package Name of ringtone title provider
* First Path Segment: Type of resource (must be "string")
* Second Path Segment: Resource name of title
*
* @param title_resource_uri The title resource to retrieve
* @param localize Whether or not to localize the title
* @return The title, or {@code null} if unlocalizable
* @throws Exception Thrown if the title appears to be localizable, but the localization failed
* for any reason. For example, the application from which the localized title is fetched is not
* installed, or it does not have the resource which needs to be localized
*/
private String getTitleFromResourceUri(String title_resource_uri, boolean localize)
throws Exception {
if (TextUtils.isEmpty(title_resource_uri)) {
return null;
}
final Uri titleUri = Uri.parse(title_resource_uri);
final String scheme = titleUri.getScheme();
if (!ContentResolver.SCHEME_ANDROID_RESOURCE.equals(scheme)) {
return null;
}
final List<String> pathSegments = titleUri.getPathSegments();
if (pathSegments.size() != 2) {
Log.e(TAG, "Error getting localized title for " + title_resource_uri
+ ", must have 2 path segments");
return null;
}
final String type = pathSegments.get(0);
if (!"string".equals(type)) {
Log.e(TAG, "Error getting localized title for " + title_resource_uri
+ ", first path segment must be \"string\"");
return null;
}
final String packageName = titleUri.getAuthority();
final Resources resources;
if (localize) {
resources = mPackageManager.getResourcesForApplication(packageName);
} else {
final Context packageContext = getContext().createPackageContext(packageName, 0);
final Configuration configuration = packageContext.getResources().getConfiguration();
configuration.setLocale(Locale.US);
resources = packageContext.createConfigurationContext(configuration).getResources();
}
final String resourceIdentifier = pathSegments.get(1);
final int id = resources.getIdentifier(resourceIdentifier, type, packageName);
return resources.getString(id);
}
public void onLocaleChanged() {
onLocaleChanged(true);
}
private void onLocaleChanged(boolean forceUpdate) {
mInternalDatabase.runWithTransaction((db) -> {
if (forceUpdate || !mLastLocale.equals(Locale.getDefault())) {
localizeTitles(db);
mLastLocale = Locale.getDefault();
}
return null;
});
}
private void localizeTitles(@NonNull SQLiteDatabase db) {
try (Cursor c = db.query("files", new String[]{"_id", "title_resource_uri"},
"title_resource_uri IS NOT NULL", null, null, null, null)) {
while (c.moveToNext()) {
final String id = c.getString(0);
final String titleResourceUri = c.getString(1);
final ContentValues values = new ContentValues();
try {
values.put(AudioColumns.TITLE_RESOURCE_URI, titleResourceUri);
computeAudioLocalizedValues(values);
computeAudioKeyValues(values);
db.update("files", values, "_id=?", new String[]{id});
} catch (Exception e) {
Log.e(TAG, "Error updating localized title for " + titleResourceUri
+ ", keeping old localization");
}
}
}
}
private Uri insertFile(@NonNull SQLiteQueryBuilder qb, @NonNull DatabaseHelper helper,
int match, @NonNull Uri uri, @NonNull Bundle extras, @NonNull ContentValues values,
int mediaType) throws VolumeArgumentException, VolumeNotFoundException {
boolean wasPathEmpty = !values.containsKey(MediaStore.MediaColumns.DATA)
|| TextUtils.isEmpty(values.getAsString(MediaStore.MediaColumns.DATA));
// Make sure all file-related columns are defined
ensureUniqueFileColumns(match, uri, extras, values, null);
switch (mediaType) {
case FileColumns.MEDIA_TYPE_AUDIO: {
computeAudioLocalizedValues(values);
computeAudioKeyValues(values);
break;
}
}
// compute bucket_id and bucket_display_name for all files
String path = values.getAsString(MediaStore.MediaColumns.DATA);
FileUtils.computeValuesFromData(values, isFuseThread());
values.put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis() / 1000);
String title = values.getAsString(MediaStore.MediaColumns.TITLE);
if (title == null && path != null) {
title = extractFileName(path);
}
values.put(FileColumns.TITLE, title);
String mimeType = null;
int format = MtpConstants.FORMAT_ASSOCIATION;
if (path != null && new File(path).isDirectory()) {
values.put(FileColumns.FORMAT, MtpConstants.FORMAT_ASSOCIATION);
values.putNull(MediaStore.MediaColumns.MIME_TYPE);
} else {
mimeType = values.getAsString(MediaStore.MediaColumns.MIME_TYPE);
final Integer formatObject = values.getAsInteger(FileColumns.FORMAT);
format = (formatObject == null ? 0 : formatObject.intValue());
}
if (format == 0) {
format = MimeUtils.resolveFormatCode(mimeType);
}
if (path != null && path.endsWith("/")) {
// TODO: convert to using FallbackException once VERSION_CODES.S is defined
Log.e(TAG, "directory has trailing slash: " + path);
return null;
}
if (format != 0) {
values.put(FileColumns.FORMAT, format);
}
if (mimeType == null && path != null && format != MtpConstants.FORMAT_ASSOCIATION) {
mimeType = MimeUtils.resolveMimeType(new File(path));
}
if (mimeType != null) {
values.put(FileColumns.MIME_TYPE, mimeType);
if (isCallingPackageSelf() && values.containsKey(FileColumns.MEDIA_TYPE)) {
// Leave FileColumns.MEDIA_TYPE untouched if the caller is ModernMediaScanner and
// FileColumns.MEDIA_TYPE is already populated.
} else if (isFuseThread() && path != null && shouldFileBeHidden(new File(path))) {
// We should only mark MEDIA_TYPE as MEDIA_TYPE_NONE for Fuse Thread.
// MediaProvider#insert() returns the uri by appending the "rowId" to the given
// uri, hence to ensure the correct working of the returned uri, we shouldn't
// change the MEDIA_TYPE in insert operation and let scan change it for us.
values.put(FileColumns.MEDIA_TYPE, FileColumns.MEDIA_TYPE_NONE);
} else {
values.put(FileColumns.MEDIA_TYPE, MimeUtils.resolveMediaType(mimeType));
}
} else {
values.put(FileColumns.MEDIA_TYPE, mediaType);
}
qb.allowColumn(FileColumns._MODIFIER);
if (isCallingPackageSelf() && values.containsKey(FileColumns._MODIFIER)) {
// We can't identify if the call is coming from media scan, hence
// we let ModernMediaScanner send FileColumns._MODIFIER value.
} else if (isFuseThread()) {
values.put(FileColumns._MODIFIER, FileColumns._MODIFIER_FUSE);
} else {
values.put(FileColumns._MODIFIER, FileColumns._MODIFIER_CR);
}
// There is no meaning of an owner in the internal storage. It is shared by all users.
// So we only set the user_id field in the database for external storage.
qb.allowColumn(FileColumns._USER_ID);
int ownerUserId = FileUtils.extractUserId(path);
if (helper.isExternal()) {
if (isAppCloneUserForFuse(ownerUserId)) {
values.put(FileColumns._USER_ID, ownerUserId);
} else {
values.put(FileColumns._USER_ID, sUserId);
}
}
final long rowId;
Uri newUri = uri;
{
if (mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) {
String name = values.getAsString(Audio.Playlists.NAME);
if (name == null && path == null) {
// MediaScanner will compute the name from the path if we have one
throw new IllegalArgumentException(
"no name was provided when inserting abstract playlist");
}
} else {
if (path == null) {
// path might be null for playlists created on the device
// or transfered via MTP
throw new IllegalArgumentException(
"no path was provided when inserting new file");
}
}
// make sure modification date and size are set
if (path != null) {
File file = new File(path);
if (file.exists()) {
values.put(FileColumns.DATE_MODIFIED, file.lastModified() / 1000);
if (!values.containsKey(FileColumns.SIZE)) {
values.put(FileColumns.SIZE, file.length());
}
}
// Checking if the file/directory is hidden can be expensive based on the depth of
// the directory tree. Call shouldFileBeHidden() only when the caller of insert()
// cares about returned uri.
if (!isCallingPackageSelf() && !isFuseThread() && shouldFileBeHidden(file)) {
newUri = MediaStore.Files.getContentUri(MediaStore.getVolumeName(uri));
}
}
rowId = insertAllowingUpsert(qb, helper, values, path);
}
if (format == MtpConstants.FORMAT_ASSOCIATION) {
synchronized (mDirectoryCache) {
mDirectoryCache.put(path, rowId);
}
}
return ContentUris.withAppendedId(newUri, rowId);
}
/**
* Inserts a new row in MediaProvider database with {@code values}. Treats insert as upsert for
* double inserts from same package.
*/
private long insertAllowingUpsert(@NonNull SQLiteQueryBuilder qb,
@NonNull DatabaseHelper helper, @NonNull ContentValues values, String path)
throws SQLiteConstraintException {
return helper.runWithTransaction((db) -> {
Long parent = values.getAsLong(FileColumns.PARENT);
if (parent == null) {
if (path != null) {
final long parentId = getParent(db, path);
values.put(FileColumns.PARENT, parentId);
}
}
try {
return qb.insert(helper, values);
} catch (SQLiteConstraintException e) {
final String packages = getAllowedPackagesForUpsert(
values.getAsString(MediaColumns.OWNER_PACKAGE_NAME));
SQLiteQueryBuilder qbForUpsert = getQueryBuilderForUpsert(path);
final long rowId = getIdIfPathOwnedByPackages(qbForUpsert, helper, path, packages);
// Apps sometimes create a file via direct path and then insert it into
// MediaStore via ContentResolver. The former should create a database entry,
// so we have to treat the latter as an upsert.
// TODO(b/149917493) Perform all INSERT operations as UPSERT.
if (rowId != -1 && qbForUpsert.update(helper, values, "_id=?",
new String[]{Long.toString(rowId)}) == 1) {
return rowId;
}
// Rethrow SQLiteConstraintException on failed upsert.
throw e;
}
});
}
/**
* @return row id of the entry with path {@code path} if the owner is one of {@code packages}.
*/
private long getIdIfPathOwnedByPackages(@NonNull SQLiteQueryBuilder qb,
@NonNull DatabaseHelper helper, String path, String packages) {
final String[] projection = new String[] {FileColumns._ID};
final String ownerPackageMatchClause = DatabaseUtils.bindSelection(
MediaColumns.OWNER_PACKAGE_NAME + " IN " + packages);
final String selection = FileColumns.DATA + " =? AND " + ownerPackageMatchClause;
try (Cursor c = qb.query(helper, projection, selection, new String[] {path}, null, null,
null, null, null)) {
if (c.moveToFirst()) {
return c.getLong(0);
}
}
return -1;
}
/**
* Gets packages that should match to upsert a db row.
*
* A database row can be upserted if
* <ul>
* <li> Calling package or one of the shared packages owns the db row.
* <li> {@code givenOwnerPackage} owns the db row. This is useful when DownloadProvider
* requests upsert on behalf of another app
* </ul>
*/
private String getAllowedPackagesForUpsert(@Nullable String givenOwnerPackage) {
ArrayList<String> packages = new ArrayList<>();
packages.addAll(Arrays.asList(mCallingIdentity.get().getSharedPackageNames()));
// If givenOwnerPackage is CallingIdentity, packages list would already have shared package
// names of givenOwnerPackage. If givenOwnerPackage is not CallingIdentity, since
// DownloadProvider can upsert a row on behalf of app, we should include all shared packages
// of givenOwnerPackage.
if (givenOwnerPackage != null && isCallingPackageDelegator() &&
!isCallingIdentitySharedPackageName(givenOwnerPackage)) {
// Allow DownloadProvider to Upsert if givenOwnerPackage is owner of the db row.
packages.addAll(Arrays.asList(getSharedPackagesForPackage(givenOwnerPackage)));
}
return bindList((Object[]) packages.toArray());
}
/**
* @return {@link SQLiteQueryBuilder} for upsert with Files uri. This disables strict columns
* check to allow upsert to update any column with Files uri.
*/
private SQLiteQueryBuilder getQueryBuilderForUpsert(@NonNull String path) {
final boolean allowHidden = isCallingPackageAllowedHidden();
Bundle extras = new Bundle();
extras.putInt(QUERY_ARG_MATCH_PENDING, MATCH_INCLUDE);
extras.putInt(QUERY_ARG_MATCH_TRASHED, MATCH_INCLUDE);
// When Fuse inserts a file to database it doesn't set is_download column. When app tries
// insert with Downloads uri, upsert fails because getIdIfPathExistsForCallingPackage can't
// find a row ID with is_download=1. Use Files uri to get queryBuilder & update any existing
// row irrespective of is_download=1.
final Uri uri = FileUtils.getContentUriForPath(path);
SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE, matchUri(uri, allowHidden), uri,
extras, null);
// We won't be able to update columns that are not part of projection map of Files table. We
// have already checked strict columns in previous insert operation which failed with
// exception. Any malicious column usage would have got caught in insert operation, hence we
// can safely disable strict column check for upsert.
qb.setStrictColumns(false);
return qb;
}
private void maybePut(@NonNull ContentValues values, @NonNull String key,
@Nullable String value) {
if (value != null) {
values.put(key, value);
}
}
private boolean maybeMarkAsDownload(@NonNull ContentValues values) {
final String path = values.getAsString(MediaColumns.DATA);
if (path != null && isDownload(path)) {
values.put(FileColumns.IS_DOWNLOAD, 1);
return true;
}
return false;
}
private static @NonNull String resolveVolumeName(@NonNull Uri uri) {
final String volumeName = getVolumeName(uri);
if (MediaStore.VOLUME_EXTERNAL.equals(volumeName)) {
return MediaStore.VOLUME_EXTERNAL_PRIMARY;
} else {
return volumeName;
}
}
/**
* @deprecated all operations should be routed through the overload that
* accepts a {@link Bundle} of extras.
*/
@Override
@Deprecated
public Uri insert(Uri uri, ContentValues values) {
return insert(uri, values, null);
}
@Override
public @Nullable Uri insert(@NonNull Uri uri, @Nullable ContentValues values,
@Nullable Bundle extras) {
Trace.beginSection("insert");
try {
try {
return insertInternal(uri, values, extras);
} catch (SQLiteConstraintException e) {
if (getCallingPackageTargetSdkVersion() >= Build.VERSION_CODES.R) {
throw e;
} else {
return null;
}
}
} catch (FallbackException e) {
return e.translateForInsert(getCallingPackageTargetSdkVersion());
} finally {
Trace.endSection();
}
}
private @Nullable Uri insertInternal(@NonNull Uri uri, @Nullable ContentValues initialValues,
@Nullable Bundle extras) throws FallbackException {
final String originalVolumeName = getVolumeName(uri);
PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), originalVolumeName);
extras = (extras != null) ? extras : new Bundle();
// REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider.
extras.remove(QUERY_ARG_REDACTED_URI);
// INCLUDED_DEFAULT_DIRECTORIES extra should only be set inside MediaProvider.
extras.remove(INCLUDED_DEFAULT_DIRECTORIES);
final boolean allowHidden = isCallingPackageAllowedHidden();
final int match = matchUri(uri, allowHidden);
final int targetSdkVersion = getCallingPackageTargetSdkVersion();
final String resolvedVolumeName = resolveVolumeName(uri);
// handle MEDIA_SCANNER before calling getDatabaseForUri()
if (match == MEDIA_SCANNER) {
mMediaScannerVolume = initialValues.getAsString(MediaStore.MEDIA_SCANNER_VOLUME);
final DatabaseHelper helper = getDatabaseForUri(
MediaStore.Files.getContentUri(mMediaScannerVolume));
helper.mScanStartTime = SystemClock.elapsedRealtime();
return MediaStore.getMediaScannerUri();
}
if (match == VOLUMES) {
String name = initialValues.getAsString("name");
MediaVolume volume = null;
try {
volume = getVolume(name);
Uri attachedVolume = attachVolume(volume, /* validate */ true);
if (mMediaScannerVolume != null && mMediaScannerVolume.equals(name)) {
final DatabaseHelper helper = getDatabaseForUri(
MediaStore.Files.getContentUri(mMediaScannerVolume));
helper.mScanStartTime = SystemClock.elapsedRealtime();
}
return attachedVolume;
} catch (FileNotFoundException e) {
Log.w(TAG, "Couldn't find volume with name " + volume.getName());
return null;
}
}
final DatabaseHelper helper = getDatabaseForUri(uri);
switch (match) {
case AUDIO_PLAYLISTS_ID:
case AUDIO_PLAYLISTS_ID_MEMBERS: {
final long playlistId = Long.parseLong(uri.getPathSegments().get(3));
final Uri playlistUri = ContentUris.withAppendedId(
MediaStore.Audio.Playlists.getContentUri(resolvedVolumeName), playlistId);
final long audioId = initialValues
.getAsLong(MediaStore.Audio.Playlists.Members.AUDIO_ID);
final String audioVolumeName =
MediaStore.VOLUME_INTERNAL.equals(resolvedVolumeName)
? MediaStore.VOLUME_INTERNAL : MediaStore.VOLUME_EXTERNAL;
final Uri audioUri = ContentUris.withAppendedId(
MediaStore.Audio.Media.getContentUri(audioVolumeName), audioId);
// Require that caller has write access to underlying media
enforceCallingPermission(playlistUri, Bundle.EMPTY, true);
enforceCallingPermission(audioUri, Bundle.EMPTY, false);
// Playlist contents are always persisted directly into playlist
// files on disk to ensure that we can reliably migrate between
// devices and recover from database corruption
final long id = addPlaylistMembers(playlistUri, initialValues);
acceptWithExpansion(helper::notifyInsert, resolvedVolumeName, playlistId,
FileColumns.MEDIA_TYPE_PLAYLIST, false);
return ContentUris.withAppendedId(MediaStore.Audio.Playlists.Members
.getContentUri(originalVolumeName, playlistId), id);
}
}
String path = null;
String ownerPackageName = null;
if (initialValues != null) {
// IDs are forever; nobody should be editing them
initialValues.remove(MediaColumns._ID);
// Expiration times are hard-coded; let's derive them
FileUtils.computeDateExpires(initialValues);
// Ignore or augment incoming raw filesystem paths
for (String column : sDataColumns.keySet()) {
if (!initialValues.containsKey(column)) continue;
if (isCallingPackageSelf() || isCallingPackageLegacyWrite()) {
// Mutation allowed
} else if (isCallingPackageManager()) {
// Apps with MANAGE_EXTERNAL_STORAGE have all files access, hence they are
// allowed to insert files anywhere.
} else {
Log.w(TAG, "Ignoring mutation of " + column + " from "
+ getCallingPackageOrSelf());
initialValues.remove(column);
}
}
path = initialValues.getAsString(MediaStore.MediaColumns.DATA);
if (!isCallingPackageSelf()) {
initialValues.remove(FileColumns.IS_DOWNLOAD);
}
// We no longer track location metadata
if (initialValues.containsKey(ImageColumns.LATITUDE)) {
initialValues.putNull(ImageColumns.LATITUDE);
}
if (initialValues.containsKey(ImageColumns.LONGITUDE)) {
initialValues.putNull(ImageColumns.LONGITUDE);
}
if (getCallingPackageTargetSdkVersion() <= Build.VERSION_CODES.Q) {
// These columns are removed in R.
if (initialValues.containsKey("primary_directory")) {
initialValues.remove("primary_directory");
}
if (initialValues.containsKey("secondary_directory")) {
initialValues.remove("secondary_directory");
}
}
if (isCallingPackageSelf() || isCallingPackageShell()) {
// When media inserted by ourselves during a scan, or by the
// shell, the best we can do is guess ownership based on path
// when it's not explicitly provided
ownerPackageName = initialValues.getAsString(FileColumns.OWNER_PACKAGE_NAME);
if (TextUtils.isEmpty(ownerPackageName)) {
ownerPackageName = extractPathOwnerPackageName(path);
}
} else if (isCallingPackageDelegator()) {
// When caller is a delegator, we handle ownership as a hybrid
// of the two other cases: we're willing to accept any ownership
// transfer attempted during insert, but we fall back to using
// the Binder identity if they don't request a specific owner
ownerPackageName = initialValues.getAsString(FileColumns.OWNER_PACKAGE_NAME);
if (TextUtils.isEmpty(ownerPackageName)) {
ownerPackageName = getCallingPackageOrSelf();
}
} else {
// Remote callers have no direct control over owner column; we force
// it be whoever is creating the content.
initialValues.remove(FileColumns.OWNER_PACKAGE_NAME);
ownerPackageName = getCallingPackageOrSelf();
}
}
long rowId = -1;
Uri newUri = null;
final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_INSERT, match, uri, extras, null);
switch (match) {
case IMAGES_MEDIA: {
maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName);
final boolean isDownload = maybeMarkAsDownload(initialValues);
newUri = insertFile(qb, helper, match, uri, extras, initialValues,
FileColumns.MEDIA_TYPE_IMAGE);
break;
}
case IMAGES_THUMBNAILS: {
if (helper.isInternal()) {
throw new UnsupportedOperationException(
"Writing to internal storage is not supported.");
}
// Require that caller has write access to underlying media
final long imageId = initialValues.getAsLong(MediaStore.Images.Thumbnails.IMAGE_ID);
enforceCallingPermission(ContentUris.withAppendedId(
MediaStore.Images.Media.getContentUri(resolvedVolumeName), imageId),
extras, true);
ensureUniqueFileColumns(match, uri, extras, initialValues, null);
rowId = qb.insert(helper, initialValues);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(Images.Thumbnails.
getContentUri(originalVolumeName), rowId);
}
break;
}
case VIDEO_THUMBNAILS: {
if (helper.isInternal()) {
throw new UnsupportedOperationException(
"Writing to internal storage is not supported.");
}
// Require that caller has write access to underlying media
final long videoId = initialValues.getAsLong(MediaStore.Video.Thumbnails.VIDEO_ID);
enforceCallingPermission(ContentUris.withAppendedId(
MediaStore.Video.Media.getContentUri(resolvedVolumeName), videoId),
Bundle.EMPTY, true);
ensureUniqueFileColumns(match, uri, extras, initialValues, null);
rowId = qb.insert(helper, initialValues);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(Video.Thumbnails.
getContentUri(originalVolumeName), rowId);
}
break;
}
case AUDIO_MEDIA: {
maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName);
final boolean isDownload = maybeMarkAsDownload(initialValues);
newUri = insertFile(qb, helper, match, uri, extras, initialValues,
FileColumns.MEDIA_TYPE_AUDIO);
break;
}
case AUDIO_MEDIA_ID_GENRES: {
throw new FallbackException("Genres are read-only", Build.VERSION_CODES.R);
}
case AUDIO_GENRES: {
throw new FallbackException("Genres are read-only", Build.VERSION_CODES.R);
}
case AUDIO_GENRES_ID_MEMBERS: {
throw new FallbackException("Genres are read-only", Build.VERSION_CODES.R);
}
case AUDIO_PLAYLISTS: {
maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName);
final boolean isDownload = maybeMarkAsDownload(initialValues);
ContentValues values = new ContentValues(initialValues);
values.put(MediaStore.Audio.Playlists.DATE_ADDED, System.currentTimeMillis() / 1000);
// Playlist names are stored as display names, but leave
// values untouched if the caller is ModernMediaScanner
if (!isCallingPackageSelf()) {
if (values.containsKey(Playlists.NAME)) {
values.put(MediaColumns.DISPLAY_NAME, values.getAsString(Playlists.NAME));
}
if (!values.containsKey(MediaColumns.MIME_TYPE)) {
values.put(MediaColumns.MIME_TYPE, "audio/mpegurl");
}
}
newUri = insertFile(qb, helper, match, uri, extras, values,
FileColumns.MEDIA_TYPE_PLAYLIST);
if (newUri != null) {
// Touch empty playlist file on disk so its ready for renames
if (Binder.getCallingUid() != android.os.Process.myUid()) {
try (OutputStream out = ContentResolver.wrap(this)
.openOutputStream(newUri)) {
} catch (IOException ignored) {
}
}
}
break;
}
case VIDEO_MEDIA: {
maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName);
final boolean isDownload = maybeMarkAsDownload(initialValues);
newUri = insertFile(qb, helper, match, uri, extras, initialValues,
FileColumns.MEDIA_TYPE_VIDEO);
break;
}
case AUDIO_ALBUMART: {
if (helper.isInternal()) {
throw new UnsupportedOperationException("no internal album art allowed");
}
ensureUniqueFileColumns(match, uri, extras, initialValues, null);
rowId = qb.insert(helper, initialValues);
if (rowId > 0) {
newUri = ContentUris.withAppendedId(uri, rowId);
}
break;
}
case FILES: {
maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName);
final boolean isDownload = maybeMarkAsDownload(initialValues);
final String mimeType = initialValues.getAsString(MediaColumns.MIME_TYPE);
final int mediaType = MimeUtils.resolveMediaType(mimeType);
newUri = insertFile(qb, helper, match, uri, extras, initialValues,
mediaType);
break;
}
case DOWNLOADS:
maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName);
initialValues.put(FileColumns.IS_DOWNLOAD, 1);
newUri = insertFile(qb, helper, match, uri, extras, initialValues,
FileColumns.MEDIA_TYPE_NONE);
break;
default:
throw new UnsupportedOperationException("Invalid URI " + uri);
}
// Remember that caller is owner of this item, to speed up future
// permission checks for this caller
mCallingIdentity.get().setOwned(rowId, true);
if (path != null && path.toLowerCase(Locale.ROOT).endsWith("/.nomedia")) {
scanFileAsMediaProvider(new File(path).getParentFile(), REASON_DEMAND);
}
return newUri;
}
@Override
public ContentProviderResult[] applyBatch(ArrayList<ContentProviderOperation> operations)
throws OperationApplicationException {
// Open transactions on databases for requested volumes
final Set<DatabaseHelper> transactions = new ArraySet<>();
try {
for (ContentProviderOperation op : operations) {
final DatabaseHelper helper = getDatabaseForUri(op.getUri());
if (transactions.contains(helper)) continue;
if (!helper.isTransactionActive()) {
helper.beginTransaction();
transactions.add(helper);
} else {
// We normally don't allow nested transactions (since we
// don't have a good way to selectively roll them back) but
// if the incoming operation is ignoring exceptions, then we
// don't need to worry about partial rollback and can
// piggyback on the larger active transaction
if (!op.isExceptionAllowed()) {
throw new IllegalStateException("Nested transactions not supported");
}
}
}
final ContentProviderResult[] result = super.applyBatch(operations);
for (DatabaseHelper helper : transactions) {
helper.setTransactionSuccessful();
}
return result;
} catch (VolumeNotFoundException e) {
throw e.rethrowAsIllegalArgumentException();
} finally {
for (DatabaseHelper helper : transactions) {
helper.endTransaction();
}
}
}
private void appendWhereStandaloneMatch(@NonNull SQLiteQueryBuilder qb,
@NonNull String column, /* @Match */ int match, Uri uri) {
switch (match) {
case MATCH_INCLUDE:
// No special filtering needed
break;
case MATCH_EXCLUDE:
appendWhereStandalone(qb, getWhereClauseForMatchExclude(column));
break;
case MATCH_ONLY:
appendWhereStandalone(qb, column + "=?", 1);
break;
case MATCH_VISIBLE_FOR_FILEPATH:
final String whereClause =
getWhereClauseForMatchableVisibleFromFilePath(uri, column);
if (whereClause != null) {
appendWhereStandalone(qb, whereClause);
}
break;
default:
throw new IllegalArgumentException();
}
}
private static void appendWhereStandalone(@NonNull SQLiteQueryBuilder qb,
@Nullable String selection, @Nullable Object... selectionArgs) {
qb.appendWhereStandalone(DatabaseUtils.bindSelection(selection, selectionArgs));
}
private static void appendWhereStandaloneFilter(@NonNull SQLiteQueryBuilder qb,
@NonNull String[] columns, @Nullable String filter) {
if (TextUtils.isEmpty(filter)) return;
for (String filterWord : filter.split("\\s+")) {
appendWhereStandalone(qb, String.join("||", columns) + " LIKE ? ESCAPE '\\'",
"%" + DatabaseUtils.escapeForLike(Audio.keyFor(filterWord)) + "%");
}
}
/**
* Gets {@link LocalCallingIdentity} for the calling package
* TODO(b/170465810) Change the method name after refactoring.
*/
LocalCallingIdentity getCachedCallingIdentityForTranscoding(int uid) {
return getCachedCallingIdentityForFuse(uid);
}
@Deprecated
private String getSharedPackages() {
final String[] sharedPackageNames = mCallingIdentity.get().getSharedPackageNames();
return bindList((Object[]) sharedPackageNames);
}
/**
* Gets shared packages names for given {@code packageName}
*/
private String[] getSharedPackagesForPackage(String packageName) {
try {
final int packageUid = getContext().getPackageManager()
.getPackageUid(packageName, 0);
return getContext().getPackageManager().getPackagesForUid(packageUid);
} catch (NameNotFoundException ignored) {
return new String[] {packageName};
}
}
private static final int TYPE_QUERY = 0;
private static final int TYPE_INSERT = 1;
private static final int TYPE_UPDATE = 2;
private static final int TYPE_DELETE = 3;
/**
* Creating a new method for Transcoding to avoid any merge conflicts.
* TODO(b/170465810): Remove this when getQueryBuilder code is refactored.
*/
@NonNull SQLiteQueryBuilder getQueryBuilderForTranscoding(int type, int match,
@NonNull Uri uri, @NonNull Bundle extras, @Nullable Consumer<String> honored) {
// Force MediaProvider calling identity when accessing the db from transcoding to avoid
// generating 'strict' SQL e.g forcing owner_package_name matches
// We already handle the required permission checks for the app before we get here
final LocalCallingIdentity token = clearLocalCallingIdentity();
try {
return getQueryBuilder(type, match, uri, extras, honored);
} finally {
restoreLocalCallingIdentity(token);
}
}
/**
* Generate a {@link SQLiteQueryBuilder} that is filtered based on the
* runtime permissions and/or {@link Uri} grants held by the caller.
* <ul>
* <li>If caller holds a {@link Uri} grant, access is allowed according to
* that grant.
* <li>If caller holds the write permission for a collection, they can
* read/write all contents of that collection.
* <li>If caller holds the read permission for a collection, they can read
* all contents of that collection, but writes are limited to content they
* own.
* <li>If caller holds no permissions for a collection, all reads/write are
* limited to content they own.
* </ul>
*/
private @NonNull SQLiteQueryBuilder getQueryBuilder(int type, int match,
@NonNull Uri uri, @NonNull Bundle extras, @Nullable Consumer<String> honored) {
Trace.beginSection("getQueryBuilder");
try {
return getQueryBuilderInternal(type, match, uri, extras, honored);
} finally {
Trace.endSection();
}
}
private @NonNull SQLiteQueryBuilder getQueryBuilderInternal(int type, int match,
@NonNull Uri uri, @NonNull Bundle extras, @Nullable Consumer<String> honored) {
final boolean forWrite;
switch (type) {
case TYPE_QUERY: forWrite = false; break;
case TYPE_INSERT: forWrite = true; break;
case TYPE_UPDATE: forWrite = true; break;
case TYPE_DELETE: forWrite = true; break;
default: throw new IllegalStateException();
}
final SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
if (uri.getBooleanQueryParameter("distinct", false)) {
qb.setDistinct(true);
}
qb.setStrict(true);
if (isCallingPackageSelf()) {
// When caller is system, such as the media scanner, we're willing
// to let them access any columns they want
} else {
qb.setTargetSdkVersion(getCallingPackageTargetSdkVersion());
qb.setStrictColumns(true);
qb.setStrictGrammar(true);
}
// TODO: throw when requesting a currently unmounted volume
final String volumeName = MediaStore.getVolumeName(uri);
final String includeVolumes;
if (MediaStore.VOLUME_EXTERNAL.equals(volumeName)) {
includeVolumes = bindList(mVolumeCache.getExternalVolumeNames().toArray());
} else {
includeVolumes = bindList(volumeName);
}
final String sharedPackages = getSharedPackages();
final String matchSharedPackagesClause = FileColumns.OWNER_PACKAGE_NAME + " IN "
+ sharedPackages;
boolean allowGlobal;
final Uri redactedUri = extras.getParcelable(QUERY_ARG_REDACTED_URI);
if (redactedUri != null) {
if (forWrite) {
throw new UnsupportedOperationException(
"Writes on: " + redactedUri.toString() + " are not supported");
}
allowGlobal = checkCallingPermissionGlobal(redactedUri, false);
} else {
allowGlobal = checkCallingPermissionGlobal(uri, forWrite);
}
final boolean allowLegacy =
forWrite ? isCallingPackageLegacyWrite() : isCallingPackageLegacyRead();
final boolean allowLegacyRead = allowLegacy && !forWrite;
int matchPending = extras.getInt(QUERY_ARG_MATCH_PENDING, MATCH_DEFAULT);
int matchTrashed = extras.getInt(QUERY_ARG_MATCH_TRASHED, MATCH_DEFAULT);
int matchFavorite = extras.getInt(QUERY_ARG_MATCH_FAVORITE, MATCH_DEFAULT);
final ArrayList<String> includedDefaultDirs = extras.getStringArrayList(
INCLUDED_DEFAULT_DIRECTORIES);
// Handle callers using legacy arguments
if (MediaStore.getIncludePending(uri)) matchPending = MATCH_INCLUDE;
// Resolve any remaining default options
final int defaultMatchForPendingAndTrashed;
if (isFuseThread()) {
// Write operations always check for file ownership, we don't need additional write
// permission check for is_pending and is_trashed.
defaultMatchForPendingAndTrashed =
forWrite ? MATCH_INCLUDE : MATCH_VISIBLE_FOR_FILEPATH;
} else {
defaultMatchForPendingAndTrashed = MATCH_EXCLUDE;
}
if (matchPending == MATCH_DEFAULT) matchPending = defaultMatchForPendingAndTrashed;
if (matchTrashed == MATCH_DEFAULT) matchTrashed = defaultMatchForPendingAndTrashed;
if (matchFavorite == MATCH_DEFAULT) matchFavorite = MATCH_INCLUDE;
// Handle callers using legacy filtering
final String filter = uri.getQueryParameter("filter");
// Only accept ALL_VOLUMES parameter up until R, because we're not convinced we want
// to commit to this as an API.
final boolean includeAllVolumes = shouldIncludeRecentlyUnmountedVolumes(uri, extras);
final String callingPackage = getCallingPackageOrSelf();
switch (match) {
case IMAGES_MEDIA_ID:
appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3));
matchPending = MATCH_INCLUDE;
matchTrashed = MATCH_INCLUDE;
// fall-through
case IMAGES_MEDIA: {
if (type == TYPE_QUERY) {
qb.setTables("images");
qb.setProjectionMap(
getProjectionMap(Images.Media.class));
} else {
qb.setTables("files");
qb.setProjectionMap(
getProjectionMap(Images.Media.class, Files.FileColumns.class));
appendWhereStandalone(qb, FileColumns.MEDIA_TYPE + "=?",
FileColumns.MEDIA_TYPE_IMAGE);
}
if (!allowGlobal && !checkCallingPermissionImages(forWrite, callingPackage)) {
appendWhereStandalone(qb, matchSharedPackagesClause);
}
appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, matchPending, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, matchTrashed, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri);
if (honored != null) {
honored.accept(QUERY_ARG_MATCH_PENDING);
honored.accept(QUERY_ARG_MATCH_TRASHED);
honored.accept(QUERY_ARG_MATCH_FAVORITE);
}
if (!includeAllVolumes) {
appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes);
}
break;
}
case IMAGES_THUMBNAILS_ID:
appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3));
// fall-through
case IMAGES_THUMBNAILS: {
qb.setTables("thumbnails");
final ArrayMap<String, String> projectionMap = new ArrayMap<>(
getProjectionMap(Images.Thumbnails.class));
projectionMap.put(Images.Thumbnails.THUMB_DATA,
"NULL AS " + Images.Thumbnails.THUMB_DATA);
qb.setProjectionMap(projectionMap);
if (!allowGlobal && !checkCallingPermissionImages(forWrite, callingPackage)) {
appendWhereStandalone(qb,
"image_id IN (SELECT _id FROM images WHERE "
+ matchSharedPackagesClause + ")");
}
break;
}
case AUDIO_MEDIA_ID:
appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3));
matchPending = MATCH_INCLUDE;
matchTrashed = MATCH_INCLUDE;
// fall-through
case AUDIO_MEDIA: {
if (type == TYPE_QUERY) {
qb.setTables("audio");
qb.setProjectionMap(
getProjectionMap(Audio.Media.class));
} else {
qb.setTables("files");
qb.setProjectionMap(
getProjectionMap(Audio.Media.class, Files.FileColumns.class));
appendWhereStandalone(qb, FileColumns.MEDIA_TYPE + "=?",
FileColumns.MEDIA_TYPE_AUDIO);
}
if (!allowGlobal && !checkCallingPermissionAudio(forWrite, callingPackage)) {
// Apps without Audio permission can only see their own
// media, but we also let them see ringtone-style media to
// support legacy use-cases.
appendWhereStandalone(qb,
DatabaseUtils.bindSelection(matchSharedPackagesClause
+ " OR is_ringtone=1 OR is_alarm=1 OR is_notification=1"));
}
appendWhereStandaloneFilter(qb, new String[] {
AudioColumns.ARTIST_KEY, AudioColumns.ALBUM_KEY, AudioColumns.TITLE_KEY
}, filter);
appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, matchPending, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, matchTrashed, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri);
if (honored != null) {
honored.accept(QUERY_ARG_MATCH_PENDING);
honored.accept(QUERY_ARG_MATCH_TRASHED);
honored.accept(QUERY_ARG_MATCH_FAVORITE);
}
if (!includeAllVolumes) {
appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes);
}
break;
}
case AUDIO_MEDIA_ID_GENRES_ID:
appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(5));
// fall-through
case AUDIO_MEDIA_ID_GENRES: {
if (type == TYPE_QUERY) {
qb.setTables("audio_genres");
qb.setProjectionMap(getProjectionMap(Audio.Genres.class));
} else {
throw new UnsupportedOperationException("Genres cannot be directly modified");
}
appendWhereStandalone(qb, "_id IN (SELECT genre_id FROM " +
"audio WHERE _id=?)", uri.getPathSegments().get(3));
if (!allowGlobal && !checkCallingPermissionAudio(false, callingPackage)) {
// We don't have a great way to filter parsed metadata by
// owner, so callers need to hold READ_MEDIA_AUDIO
appendWhereStandalone(qb, "0");
}
break;
}
case AUDIO_GENRES_ID:
appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3));
// fall-through
case AUDIO_GENRES: {
qb.setTables("audio_genres");
qb.setProjectionMap(getProjectionMap(Audio.Genres.class));
if (!allowGlobal && !checkCallingPermissionAudio(false, callingPackage)) {
// We don't have a great way to filter parsed metadata by
// owner, so callers need to hold READ_MEDIA_AUDIO
appendWhereStandalone(qb, "0");
}
break;
}
case AUDIO_GENRES_ID_MEMBERS:
appendWhereStandalone(qb, "genre_id=?", uri.getPathSegments().get(3));
// fall-through
case AUDIO_GENRES_ALL_MEMBERS: {
if (type == TYPE_QUERY) {
qb.setTables("audio");
final ArrayMap<String, String> projectionMap = new ArrayMap<>(
getProjectionMap(Audio.Genres.Members.class));
projectionMap.put(Audio.Genres.Members.AUDIO_ID,
"_id AS " + Audio.Genres.Members.AUDIO_ID);
qb.setProjectionMap(projectionMap);
} else {
throw new UnsupportedOperationException("Genres cannot be directly modified");
}
appendWhereStandaloneFilter(qb, new String[] {
AudioColumns.ARTIST_KEY, AudioColumns.ALBUM_KEY, AudioColumns.TITLE_KEY
}, filter);
if (!allowGlobal && !checkCallingPermissionAudio(false, callingPackage)) {
// We don't have a great way to filter parsed metadata by
// owner, so callers need to hold READ_MEDIA_AUDIO
appendWhereStandalone(qb, "0");
}
break;
}
case AUDIO_PLAYLISTS_ID:
appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3));
matchPending = MATCH_INCLUDE;
matchTrashed = MATCH_INCLUDE;
// fall-through
case AUDIO_PLAYLISTS: {
if (type == TYPE_QUERY) {
qb.setTables("audio_playlists");
qb.setProjectionMap(
getProjectionMap(Audio.Playlists.class));
} else {
qb.setTables("files");
qb.setProjectionMap(
getProjectionMap(Audio.Playlists.class, Files.FileColumns.class));
appendWhereStandalone(qb, FileColumns.MEDIA_TYPE + "=?",
FileColumns.MEDIA_TYPE_PLAYLIST);
}
if (!allowGlobal && !checkCallingPermissionAudio(forWrite, callingPackage)) {
appendWhereStandalone(qb, matchSharedPackagesClause);
}
appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, matchPending, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, matchTrashed, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri);
if (honored != null) {
honored.accept(QUERY_ARG_MATCH_PENDING);
honored.accept(QUERY_ARG_MATCH_TRASHED);
honored.accept(QUERY_ARG_MATCH_FAVORITE);
}
if (!includeAllVolumes) {
appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes);
}
break;
}
case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
appendWhereStandalone(qb, "audio_playlists_map._id=?",
uri.getPathSegments().get(5));
// fall-through
case AUDIO_PLAYLISTS_ID_MEMBERS: {
appendWhereStandalone(qb, "playlist_id=?", uri.getPathSegments().get(3));
if (type == TYPE_QUERY) {
qb.setTables("audio_playlists_map, audio");
final ArrayMap<String, String> projectionMap = new ArrayMap<>(
getProjectionMap(Audio.Playlists.Members.class));
projectionMap.put(Audio.Playlists.Members._ID,
"audio_playlists_map._id AS " + Audio.Playlists.Members._ID);
qb.setProjectionMap(projectionMap);
appendWhereStandalone(qb, "audio._id = audio_id");
// Since we use audio table along with audio_playlists_map
// for querying, we should only include database rows of
// the attached volumes.
if (!includeAllVolumes) {
appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN "
+ includeVolumes);
}
} else {
qb.setTables("audio_playlists_map");
qb.setProjectionMap(getProjectionMap(Audio.Playlists.Members.class));
}
appendWhereStandaloneFilter(qb, new String[] {
AudioColumns.ARTIST_KEY, AudioColumns.ALBUM_KEY, AudioColumns.TITLE_KEY
}, filter);
if (!allowGlobal && !checkCallingPermissionAudio(false, callingPackage)) {
// We don't have a great way to filter parsed metadata by
// owner, so callers need to hold READ_MEDIA_AUDIO
appendWhereStandalone(qb, "0");
}
break;
}
case AUDIO_ALBUMART_ID:
appendWhereStandalone(qb, "album_id=?", uri.getPathSegments().get(3));
// fall-through
case AUDIO_ALBUMART: {
qb.setTables("album_art");
final ArrayMap<String, String> projectionMap = new ArrayMap<>(
getProjectionMap(Audio.Thumbnails.class));
projectionMap.put(Audio.Thumbnails._ID,
"album_id AS " + Audio.Thumbnails._ID);
qb.setProjectionMap(projectionMap);
if (!allowGlobal && !checkCallingPermissionAudio(false, callingPackage)) {
// We don't have a great way to filter parsed metadata by
// owner, so callers need to hold READ_MEDIA_AUDIO
appendWhereStandalone(qb, "0");
}
break;
}
case AUDIO_ARTISTS_ID_ALBUMS: {
if (type == TYPE_QUERY) {
qb.setTables("audio_artists_albums");
qb.setProjectionMap(getProjectionMap(Audio.Artists.Albums.class));
final String artistId = uri.getPathSegments().get(3);
appendWhereStandalone(qb, "artist_id=?", artistId);
} else {
throw new UnsupportedOperationException("Albums cannot be directly modified");
}
appendWhereStandaloneFilter(qb, new String[] {
AudioColumns.ALBUM_KEY
}, filter);
if (!allowGlobal && !checkCallingPermissionAudio(false, callingPackage)) {
// We don't have a great way to filter parsed metadata by
// owner, so callers need to hold READ_MEDIA_AUDIO
appendWhereStandalone(qb, "0");
}
break;
}
case AUDIO_ARTISTS_ID:
appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3));
// fall-through
case AUDIO_ARTISTS: {
if (type == TYPE_QUERY) {
qb.setTables("audio_artists");
qb.setProjectionMap(getProjectionMap(Audio.Artists.class));
} else {
throw new UnsupportedOperationException("Artists cannot be directly modified");
}
appendWhereStandaloneFilter(qb, new String[] {
AudioColumns.ARTIST_KEY
}, filter);
if (!allowGlobal && !checkCallingPermissionAudio(false, callingPackage)) {
// We don't have a great way to filter parsed metadata by
// owner, so callers need to hold READ_MEDIA_AUDIO
appendWhereStandalone(qb, "0");
}
break;
}
case AUDIO_ALBUMS_ID:
appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3));
// fall-through
case AUDIO_ALBUMS: {
if (type == TYPE_QUERY) {
qb.setTables("audio_albums");
qb.setProjectionMap(getProjectionMap(Audio.Albums.class));
} else {
throw new UnsupportedOperationException("Albums cannot be directly modified");
}
appendWhereStandaloneFilter(qb, new String[] {
AudioColumns.ARTIST_KEY, AudioColumns.ALBUM_KEY
}, filter);
if (!allowGlobal && !checkCallingPermissionAudio(false, callingPackage)) {
// We don't have a great way to filter parsed metadata by
// owner, so callers need to hold READ_MEDIA_AUDIO
appendWhereStandalone(qb, "0");
}
break;
}
case VIDEO_MEDIA_ID:
appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3));
matchPending = MATCH_INCLUDE;
matchTrashed = MATCH_INCLUDE;
// fall-through
case VIDEO_MEDIA: {
if (type == TYPE_QUERY) {
qb.setTables("video");
qb.setProjectionMap(
getProjectionMap(Video.Media.class));
} else {
qb.setTables("files");
qb.setProjectionMap(
getProjectionMap(Video.Media.class, Files.FileColumns.class));
appendWhereStandalone(qb, FileColumns.MEDIA_TYPE + "=?",
FileColumns.MEDIA_TYPE_VIDEO);
}
if (!allowGlobal && !checkCallingPermissionVideo(forWrite, callingPackage)) {
appendWhereStandalone(qb, matchSharedPackagesClause);
}
appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, matchPending, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, matchTrashed, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri);
if (honored != null) {
honored.accept(QUERY_ARG_MATCH_PENDING);
honored.accept(QUERY_ARG_MATCH_TRASHED);
honored.accept(QUERY_ARG_MATCH_FAVORITE);
}
if (!includeAllVolumes) {
appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes);
}
break;
}
case VIDEO_THUMBNAILS_ID:
appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3));
// fall-through
case VIDEO_THUMBNAILS: {
qb.setTables("videothumbnails");
qb.setProjectionMap(getProjectionMap(Video.Thumbnails.class));
if (!allowGlobal && !checkCallingPermissionVideo(forWrite, callingPackage)) {
appendWhereStandalone(qb,
"video_id IN (SELECT _id FROM video WHERE " +
matchSharedPackagesClause + ")");
}
break;
}
case FILES_ID:
appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(2));
matchPending = MATCH_INCLUDE;
matchTrashed = MATCH_INCLUDE;
// fall-through
case FILES: {
qb.setTables("files");
qb.setProjectionMap(getProjectionMap(Files.FileColumns.class));
final ArrayList<String> options = new ArrayList<>();
if (!allowGlobal && !allowLegacyRead) {
options.add(DatabaseUtils.bindSelection(matchSharedPackagesClause));
if (allowLegacy) {
options.add(DatabaseUtils.bindSelection("volume_name=?",
MediaStore.VOLUME_EXTERNAL_PRIMARY));
}
if (checkCallingPermissionAudio(forWrite, callingPackage)) {
options.add(DatabaseUtils.bindSelection("media_type=?",
FileColumns.MEDIA_TYPE_AUDIO));
options.add(DatabaseUtils.bindSelection("media_type=?",
FileColumns.MEDIA_TYPE_PLAYLIST));
options.add(DatabaseUtils.bindSelection("media_type=?",
FileColumns.MEDIA_TYPE_SUBTITLE));
options.add(matchSharedPackagesClause
+ " AND media_type=0 AND mime_type LIKE 'audio/%'");
}
if (checkCallingPermissionVideo(forWrite, callingPackage)) {
options.add(DatabaseUtils.bindSelection("media_type=?",
FileColumns.MEDIA_TYPE_VIDEO));
options.add(DatabaseUtils.bindSelection("media_type=?",
FileColumns.MEDIA_TYPE_SUBTITLE));
options.add(matchSharedPackagesClause
+ " AND media_type=0 AND mime_type LIKE 'video/%'");
}
if (checkCallingPermissionImages(forWrite, callingPackage)) {
options.add(DatabaseUtils.bindSelection("media_type=?",
FileColumns.MEDIA_TYPE_IMAGE));
options.add(matchSharedPackagesClause
+ " AND media_type=0 AND mime_type LIKE 'image/%'");
}
if (includedDefaultDirs != null) {
for (String defaultDir : includedDefaultDirs) {
options.add(FileColumns.RELATIVE_PATH + " LIKE '" + defaultDir + "/%'");
}
}
}
if (options.size() > 0) {
appendWhereStandalone(qb, TextUtils.join(" OR ", options));
}
appendWhereStandaloneFilter(qb, new String[] {
AudioColumns.ARTIST_KEY, AudioColumns.ALBUM_KEY, AudioColumns.TITLE_KEY
}, filter);
appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, matchPending, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, matchTrashed, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri);
if (honored != null) {
honored.accept(QUERY_ARG_MATCH_PENDING);
honored.accept(QUERY_ARG_MATCH_TRASHED);
honored.accept(QUERY_ARG_MATCH_FAVORITE);
}
if (!includeAllVolumes) {
appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes);
}
break;
}
case DOWNLOADS_ID:
appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(2));
matchPending = MATCH_INCLUDE;
matchTrashed = MATCH_INCLUDE;
// fall-through
case DOWNLOADS: {
if (type == TYPE_QUERY) {
qb.setTables("downloads");
qb.setProjectionMap(
getProjectionMap(Downloads.class));
} else {
qb.setTables("files");
qb.setProjectionMap(
getProjectionMap(Downloads.class, Files.FileColumns.class));
appendWhereStandalone(qb, FileColumns.IS_DOWNLOAD + "=1");
}
final ArrayList<String> options = new ArrayList<>();
if (!allowGlobal && !allowLegacyRead) {
options.add(DatabaseUtils.bindSelection(matchSharedPackagesClause));
if (allowLegacy) {
options.add(DatabaseUtils.bindSelection("volume_name=?",
MediaStore.VOLUME_EXTERNAL_PRIMARY));
}
}
if (options.size() > 0) {
appendWhereStandalone(qb, TextUtils.join(" OR ", options));
}
appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, matchPending, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, matchTrashed, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri);
if (honored != null) {
honored.accept(QUERY_ARG_MATCH_PENDING);
honored.accept(QUERY_ARG_MATCH_TRASHED);
honored.accept(QUERY_ARG_MATCH_FAVORITE);
}
if (!includeAllVolumes) {
appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes);
}
break;
}
default:
throw new UnsupportedOperationException(
"Unknown or unsupported URL: " + uri.toString());
}
// To ensure we're enforcing our security model, all operations must
// have a projection map configured
if (qb.getProjectionMap() == null) {
throw new IllegalStateException("All queries must have a projection map");
}
// If caller is an older app, we're willing to let through a
// greylist of technically invalid columns
if (getCallingPackageTargetSdkVersion() < Build.VERSION_CODES.Q) {
qb.setProjectionGreylist(sGreylist);
}
return qb;
}
/**
* @return {@code true} if app requests to include database rows from
* recently unmounted volume.
* {@code false} otherwise.
*/
private boolean shouldIncludeRecentlyUnmountedVolumes(Uri uri, Bundle extras) {
if (isFuseThread()) {
// File path requests don't require to query from unmounted volumes.
return false;
}
boolean isIncludeVolumesChangeEnabled = SdkLevel.isAtLeastS() &&
CompatChanges.isChangeEnabled(ENABLE_INCLUDE_ALL_VOLUMES, Binder.getCallingUid());
if ("1".equals(uri.getQueryParameter(ALL_VOLUMES))) {
// Support uri parameter only in R OS and below. Apps should use
// MediaStore#QUERY_ARG_RECENTLY_UNMOUNTED_VOLUMES on S OS onwards.
if (!isIncludeVolumesChangeEnabled) {
return true;
}
throw new IllegalArgumentException("Unsupported uri parameter \"all_volumes\"");
}
if (isIncludeVolumesChangeEnabled) {
// MediaStore#QUERY_ARG_INCLUDE_RECENTLY_UNMOUNTED_VOLUMES is only supported on S OS and
// for app targeting targetSdk>=S.
return extras.getBoolean(MediaStore.QUERY_ARG_INCLUDE_RECENTLY_UNMOUNTED_VOLUMES,
false);
}
return false;
}
/**
* Determine if given {@link Uri} has a
* {@link MediaColumns#OWNER_PACKAGE_NAME} column.
*/
private boolean hasOwnerPackageName(Uri uri) {
// It's easier to maintain this as an inverted list
final int table = matchUri(uri, true);
switch (table) {
case IMAGES_THUMBNAILS_ID:
case IMAGES_THUMBNAILS:
case VIDEO_THUMBNAILS_ID:
case VIDEO_THUMBNAILS:
case AUDIO_ALBUMART:
case AUDIO_ALBUMART_ID:
case AUDIO_ALBUMART_FILE_ID:
return false;
default:
return true;
}
}
/**
* @deprecated all operations should be routed through the overload that
* accepts a {@link Bundle} of extras.
*/
@Override
@Deprecated
public int delete(Uri uri, String selection, String[] selectionArgs) {
return delete(uri,
DatabaseUtils.createSqlQueryBundle(selection, selectionArgs, null));
}
@Override
public int delete(@NonNull Uri uri, @Nullable Bundle extras) {
Trace.beginSection("delete");
try {
return deleteInternal(uri, extras);
} catch (FallbackException e) {
return e.translateForUpdateDelete(getCallingPackageTargetSdkVersion());
} finally {
Trace.endSection();
}
}
private int deleteInternal(@NonNull Uri uri, @Nullable Bundle extras)
throws FallbackException {
final String volumeName = getVolumeName(uri);
PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), volumeName);
extras = (extras != null) ? extras : new Bundle();
// REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider.
extras.remove(QUERY_ARG_REDACTED_URI);
if (isRedactedUri(uri)) {
// we don't support deletion on redacted uris.
return 0;
}
// INCLUDED_DEFAULT_DIRECTORIES extra should only be set inside MediaProvider.
extras.remove(INCLUDED_DEFAULT_DIRECTORIES);
uri = safeUncanonicalize(uri);
final boolean allowHidden = isCallingPackageAllowedHidden();
final int match = matchUri(uri, allowHidden);
switch (match) {
case AUDIO_MEDIA_ID:
case AUDIO_PLAYLISTS_ID:
case VIDEO_MEDIA_ID:
case IMAGES_MEDIA_ID:
case DOWNLOADS_ID:
case FILES_ID: {
if (!isFuseThread() && getCachedCallingIdentityForFuse(Binder.getCallingUid()).
removeDeletedRowId(Long.parseLong(uri.getLastPathSegment()))) {
// Apps sometimes delete the file via filePath and then try to delete the db row
// using MediaProvider#delete. Since we would have already deleted the db row
// during the filePath operation, the latter will result in a security
// exception. Apps which don't expect an exception will break here. Since we
// have already deleted the db row, silently return zero as deleted count.
return 0;
}
}
break;
default:
// For other match types, given uri will not correspond to a valid file.
break;
}
final String userWhere = extras.getString(QUERY_ARG_SQL_SELECTION);
final String[] userWhereArgs = extras.getStringArray(QUERY_ARG_SQL_SELECTION_ARGS);
int count = 0;
final int targetSdkVersion = getCallingPackageTargetSdkVersion();
// handle MEDIA_SCANNER before calling getDatabaseForUri()
if (match == MEDIA_SCANNER) {
if (mMediaScannerVolume == null) {
return 0;
}
final DatabaseHelper helper = getDatabaseForUri(
MediaStore.Files.getContentUri(mMediaScannerVolume));
helper.mScanStopTime = SystemClock.elapsedRealtime();
mMediaScannerVolume = null;
return 1;
}
if (match == VOLUMES_ID) {
detachVolume(uri);
count = 1;
}
final DatabaseHelper helper = getDatabaseForUri(uri);
switch (match) {
case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
extras.putString(QUERY_ARG_SQL_SELECTION,
BaseColumns._ID + "=" + uri.getPathSegments().get(5));
// fall-through
case AUDIO_PLAYLISTS_ID_MEMBERS: {
final long playlistId = Long.parseLong(uri.getPathSegments().get(3));
final Uri playlistUri = ContentUris.withAppendedId(
MediaStore.Audio.Playlists.getContentUri(volumeName), playlistId);
// Playlist contents are always persisted directly into playlist
// files on disk to ensure that we can reliably migrate between
// devices and recover from database corruption
int numOfRemovedPlaylistMembers = removePlaylistMembers(playlistUri, extras);
if (numOfRemovedPlaylistMembers > 0) {
acceptWithExpansion(helper::notifyDelete, volumeName, playlistId,
FileColumns.MEDIA_TYPE_PLAYLIST, false);
}
return numOfRemovedPlaylistMembers;
}
}
final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_DELETE, match, uri, extras, null);
{
// Give callers interacting with a specific media item a chance to
// escalate access if they don't already have it
switch (match) {
case AUDIO_MEDIA_ID:
case VIDEO_MEDIA_ID:
case IMAGES_MEDIA_ID:
enforceCallingPermission(uri, extras, true);
}
final String[] projection = new String[] {
FileColumns.MEDIA_TYPE,
FileColumns.DATA,
FileColumns._ID,
FileColumns.IS_DOWNLOAD,
FileColumns.MIME_TYPE,
};
final boolean isFilesTable = qb.getTables().equals("files");
final LongSparseArray<String> deletedDownloadIds = new LongSparseArray<>();
final int[] countPerMediaType = new int[FileColumns.MEDIA_TYPE_COUNT];
if (isFilesTable) {
String deleteparam = uri.getQueryParameter(MediaStore.PARAM_DELETE_DATA);
if (deleteparam == null || ! deleteparam.equals("false")) {
Cursor c = qb.query(helper, projection, userWhere, userWhereArgs,
null, null, null, null, null);
try {
while (c.moveToNext()) {
final int mediaType = c.getInt(0);
final String data = c.getString(1);
final long id = c.getLong(2);
final int isDownload = c.getInt(3);
final String mimeType = c.getString(4);
// TODO(b/188782594) Consider logging mime type access on delete too.
// Forget that caller is owner of this item
mCallingIdentity.get().setOwned(id, false);
deleteIfAllowed(uri, extras, data);
int res = qb.delete(helper, BaseColumns._ID + "=" + id, null);
count += res;
// Avoid ArrayIndexOutOfBounds if more mediaTypes are added,
// but mediaTypeSize is not updated
if (res > 0 && mediaType < countPerMediaType.length) {
countPerMediaType[mediaType] += res;
}
if (isDownload == 1) {
deletedDownloadIds.put(id, mimeType);
}
}
} finally {
FileUtils.closeQuietly(c);
}
// Do not allow deletion if the file/object is referenced as parent
// by some other entries. It could cause database corruption.
appendWhereStandalone(qb, ID_NOT_PARENT_CLAUSE);
}
}
switch (match) {
case AUDIO_GENRES_ID_MEMBERS:
throw new FallbackException("Genres are read-only", Build.VERSION_CODES.R);
case IMAGES_THUMBNAILS_ID:
case IMAGES_THUMBNAILS:
case VIDEO_THUMBNAILS_ID:
case VIDEO_THUMBNAILS:
// Delete the referenced files first.
Cursor c = qb.query(helper, sDataOnlyColumn, userWhere, userWhereArgs, null,
null, null, null, null);
if (c != null) {
try {
while (c.moveToNext()) {
deleteIfAllowed(uri, extras, c.getString(0));
}
} finally {
FileUtils.closeQuietly(c);
}
}
count += deleteRecursive(qb, helper, userWhere, userWhereArgs);
break;
default:
count += deleteRecursive(qb, helper, userWhere, userWhereArgs);
break;
}
if (deletedDownloadIds.size() > 0) {
notifyDownloadManagerOnDelete(helper, deletedDownloadIds);
}
// Check for other URI format grants for File API call only. Check right before
// returning count = 0, to leave positive cases performance unaffected.
if (count == 0 && isFuseThread()) {
count += deleteWithOtherUriGrants(uri, helper, projection, userWhere, userWhereArgs,
extras);
}
if (isFilesTable && !isCallingPackageSelf()) {
Metrics.logDeletion(volumeName, mCallingIdentity.get().uid,
getCallingPackageOrSelf(), count, countPerMediaType);
}
}
return count;
}
private int deleteWithOtherUriGrants(@NonNull Uri uri, DatabaseHelper helper,
String[] projection, String userWhere, String[] userWhereArgs,
@Nullable Bundle extras) {
try {
Cursor c = queryForSingleItemAsMediaProvider(uri, projection, userWhere, userWhereArgs,
null);
final int mediaType = c.getInt(0);
final String data = c.getString(1);
final long id = c.getLong(2);
final int isDownload = c.getInt(3);
final String mimeType = c.getString(4);
final Uri uriGranted = getOtherUriGrantsForPath(data, mediaType, Long.toString(id),
/* forWrite */ true);
if (uriGranted != null) {
// 1. delete file
deleteIfAllowed(uriGranted, extras, data);
// 2. delete file row from the db
final boolean allowHidden = isCallingPackageAllowedHidden();
final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_DELETE,
matchUri(uriGranted, allowHidden), uriGranted, extras, null);
int count = qb.delete(helper, BaseColumns._ID + "=" + id, null);
if (isDownload == 1) {
final LongSparseArray<String> deletedDownloadIds = new LongSparseArray<>();
deletedDownloadIds.put(id, mimeType);
notifyDownloadManagerOnDelete(helper, deletedDownloadIds);
}
return count;
}
} catch (FileNotFoundException ignored) {
// Do nothing. Returns 0 files deleted.
}
return 0;
}
private void notifyDownloadManagerOnDelete(DatabaseHelper helper,
LongSparseArray<String> deletedDownloadIds) {
// Do this on a background thread, since we don't want to make binder
// calls as part of a FUSE call.
helper.postBackground(() -> {
DownloadManager dm = getContext().getSystemService(DownloadManager.class);
if (dm != null) {
dm.onMediaStoreDownloadsDeleted(deletedDownloadIds);
}
});
}
/**
* Executes identical delete repeatedly within a single transaction until
* stability is reached. Combined with {@link #ID_NOT_PARENT_CLAUSE}, this
* can be used to recursively delete all matching entries, since it only
* deletes parents when no references remaining.
*/
private int deleteRecursive(SQLiteQueryBuilder qb, DatabaseHelper helper, String userWhere,
String[] userWhereArgs) {
return (int) helper.runWithTransaction((db) -> {
synchronized (mDirectoryCache) {
mDirectoryCache.clear();
}
int n = 0;
int total = 0;
do {
n = qb.delete(helper, userWhere, userWhereArgs);
total += n;
} while (n > 0);
return total;
});
}
@Nullable
@VisibleForTesting
Uri getRedactedUri(@NonNull Uri uri) {
if (!isUriSupportedForRedaction(uri)) {
return null;
}
DatabaseHelper helper;
try {
helper = getDatabaseForUri(uri);
} catch (VolumeNotFoundException e) {
throw e.rethrowAsIllegalArgumentException();
}
try (final Cursor c = helper.runWithoutTransaction(
(db) -> db.query("files",
new String[]{FileColumns.REDACTED_URI_ID}, FileColumns._ID + "=?",
new String[]{uri.getLastPathSegment()}, null, null, null))) {
// Database entry for uri not found.
if (!c.moveToFirst()) return null;
String redactedUriID = c.getString(c.getColumnIndex(FileColumns.REDACTED_URI_ID));
if (redactedUriID == null) {
// No redacted has even been created for this uri. Create a new redacted URI ID for
// the uri and store it in the DB.
redactedUriID = REDACTED_URI_ID_PREFIX + UUID.randomUUID().toString().replace("-",
"");
ContentValues cv = new ContentValues();
cv.put(FileColumns.REDACTED_URI_ID, redactedUriID);
int rowsAffected = helper.runWithTransaction(
(db) -> db.update("files", cv, FileColumns._ID + "=?",
new String[]{uri.getLastPathSegment()}));
if (rowsAffected == 0) {
// this shouldn't happen ideally, only reason this might happen is if the db
// entry got deleted in b/w in which case we should return null.
return null;
}
}
// Create and return a uri with ID = redactedUriID.
final Uri.Builder builder = ContentUris.removeId(uri).buildUpon();
builder.appendPath(redactedUriID);
return builder.build();
}
}
@NonNull
@VisibleForTesting
List<Uri> getRedactedUri(@NonNull List<Uri> uris) {
ArrayList<Uri> redactedUris = new ArrayList<>();
for (Uri uri : uris) {
redactedUris.add(getRedactedUri(uri));
}
return redactedUris;
}
@Override
public Bundle call(String method, String arg, Bundle extras) {
Trace.beginSection("call");
try {
return callInternal(method, arg, extras);
} finally {
Trace.endSection();
}
}
private Bundle callInternal(String method, String arg, Bundle extras) {
switch (method) {
case MediaStore.RESOLVE_PLAYLIST_MEMBERS_CALL: {
final LocalCallingIdentity token = clearLocalCallingIdentity();
final CallingIdentity providerToken = clearCallingIdentity();
try {
final Uri playlistUri = extras.getParcelable(MediaStore.EXTRA_URI);
resolvePlaylistMembers(playlistUri);
} finally {
restoreCallingIdentity(providerToken);
restoreLocalCallingIdentity(token);
}
return null;
}
case MediaStore.RUN_IDLE_MAINTENANCE_CALL: {
// Protect ourselves from random apps by requiring a generic
// permission held by common debugging components, such as shell
getContext().enforceCallingOrSelfPermission(
android.Manifest.permission.DUMP, TAG);
final LocalCallingIdentity token = clearLocalCallingIdentity();
final CallingIdentity providerToken = clearCallingIdentity();
try {
onIdleMaintenance(new CancellationSignal());
} finally {
restoreCallingIdentity(providerToken);
restoreLocalCallingIdentity(token);
}
return null;
}
case MediaStore.WAIT_FOR_IDLE_CALL: {
// TODO(b/195009139): Remove after overriding wait for idle in test to sync picker
// Syncing the picker while waiting for idle fixes tests with the picker db
// flag enabled because the picker db is in a consistent state with the external
// db after the sync
syncPicker();
ForegroundThread.waitForIdle();
final CountDownLatch latch = new CountDownLatch(1);
BackgroundThread.getExecutor().execute(() -> {
latch.countDown();
});
try {
latch.await(30, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
return null;
}
case MediaStore.SCAN_FILE_CALL:
case MediaStore.SCAN_VOLUME_CALL: {
final int userId = uidToUserId(Binder.getCallingUid());
final LocalCallingIdentity token = clearLocalCallingIdentity();
final CallingIdentity providerToken = clearCallingIdentity();
try {
final Bundle res = new Bundle();
switch (method) {
case MediaStore.SCAN_FILE_CALL: {
final File file = new File(arg);
res.putParcelable(Intent.EXTRA_STREAM, scanFile(file, REASON_DEMAND));
break;
}
case MediaStore.SCAN_VOLUME_CALL: {
final String volumeName = arg;
try {
MediaVolume volume = mVolumeCache.findVolume(volumeName,
UserHandle.of(userId));
MediaService.onScanVolume(getContext(), volume, REASON_DEMAND);
} catch (FileNotFoundException e) {
Log.w(TAG, "Failed to find volume " + volumeName, e);
}
break;
}
}
return res;
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
restoreCallingIdentity(providerToken);
restoreLocalCallingIdentity(token);
}
}
case MediaStore.GET_VERSION_CALL: {
final String volumeName = extras.getString(Intent.EXTRA_TEXT);
final DatabaseHelper helper;
try {
helper = getDatabaseForUri(MediaStore.Files.getContentUri(volumeName));
} catch (VolumeNotFoundException e) {
throw e.rethrowAsIllegalArgumentException();
}
final String version = helper.runWithoutTransaction((db) -> {
return db.getVersion() + ":" + DatabaseHelper.getOrCreateUuid(db);
});
final Bundle res = new Bundle();
res.putString(Intent.EXTRA_TEXT, version);
return res;
}
case MediaStore.GET_GENERATION_CALL: {
final String volumeName = extras.getString(Intent.EXTRA_TEXT);
final DatabaseHelper helper;
try {
helper = getDatabaseForUri(MediaStore.Files.getContentUri(volumeName));
} catch (VolumeNotFoundException e) {
throw e.rethrowAsIllegalArgumentException();
}
final long generation = helper.runWithoutTransaction((db) -> {
return DatabaseHelper.getGeneration(db);
});
final Bundle res = new Bundle();
res.putLong(Intent.EXTRA_INDEX, generation);
return res;
}
case MediaStore.GET_DOCUMENT_URI_CALL: {
final Uri mediaUri = extras.getParcelable(MediaStore.EXTRA_URI);
enforceCallingPermission(mediaUri, extras, false);
final Uri fileUri;
final LocalCallingIdentity token = clearLocalCallingIdentity();
try {
fileUri = Uri.fromFile(queryForDataFile(mediaUri, null));
} catch (FileNotFoundException e) {
throw new IllegalArgumentException(e);
} finally {
restoreLocalCallingIdentity(token);
}
try (ContentProviderClient client = getContext().getContentResolver()
.acquireUnstableContentProviderClient(
getExternalStorageProviderAuthority())) {
extras.putParcelable(MediaStore.EXTRA_URI, fileUri);
return client.call(method, null, extras);
} catch (RemoteException e) {
throw new IllegalStateException(e);
}
}
case MediaStore.GET_MEDIA_URI_CALL: {
final Uri documentUri = extras.getParcelable(MediaStore.EXTRA_URI);
getContext().enforceCallingUriPermission(documentUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION, TAG);
final int callingPid = mCallingIdentity.get().pid;
final int callingUid = mCallingIdentity.get().uid;
final String callingPackage = getCallingPackage();
final CallingIdentity token = clearCallingIdentity();
final String authority = documentUri.getAuthority();
if (!authority.equals(MediaDocumentsProvider.AUTHORITY) &&
!authority.equals(DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY)) {
throw new IllegalArgumentException("Provider for this Uri is not supported.");
}
try (ContentProviderClient client = getContext().getContentResolver()
.acquireUnstableContentProviderClient(authority)) {
final Bundle clientRes = client.call(method, null, extras);
final Uri fileUri = clientRes.getParcelable(MediaStore.EXTRA_URI);
final Bundle res = new Bundle();
final Uri mediaStoreUri = fileUri.getAuthority().equals(MediaStore.AUTHORITY) ?
fileUri : queryForMediaUri(new File(fileUri.getPath()), null);
copyUriPermissionGrants(documentUri, mediaStoreUri, callingPid,
callingUid, callingPackage);
res.putParcelable(MediaStore.EXTRA_URI, mediaStoreUri);
return res;
} catch (FileNotFoundException e) {
throw new IllegalArgumentException(e);
} catch (RemoteException e) {
throw new IllegalStateException(e);
} finally {
restoreCallingIdentity(token);
}
}
case MediaStore.GET_REDACTED_MEDIA_URI_CALL: {
final Uri uri = extras.getParcelable(MediaStore.EXTRA_URI);
// NOTE: It is ok to update the DB and return a redacted URI for the cases when
// the user code only has read access, hence we don't check for write permission.
enforceCallingPermission(uri, Bundle.EMPTY, false);
final LocalCallingIdentity token = clearLocalCallingIdentity();
try {
final Bundle res = new Bundle();
res.putParcelable(MediaStore.EXTRA_URI, getRedactedUri(uri));
return res;
} finally {
restoreLocalCallingIdentity(token);
}
}
case MediaStore.GET_REDACTED_MEDIA_URI_LIST_CALL: {
final List<Uri> uris = extras.getParcelableArrayList(MediaStore.EXTRA_URI_LIST);
// NOTE: It is ok to update the DB and return a redacted URI for the cases when
// the user code only has read access, hence we don't check for write permission.
enforceCallingPermission(uris, false);
final LocalCallingIdentity token = clearLocalCallingIdentity();
try {
final Bundle res = new Bundle();
res.putParcelableArrayList(MediaStore.EXTRA_URI_LIST,
(ArrayList<? extends Parcelable>) getRedactedUri(uris));
return res;
} finally {
restoreLocalCallingIdentity(token);
}
}
case MediaStore.CREATE_WRITE_REQUEST_CALL:
case MediaStore.CREATE_FAVORITE_REQUEST_CALL:
case MediaStore.CREATE_TRASH_REQUEST_CALL:
case MediaStore.CREATE_DELETE_REQUEST_CALL: {
final PendingIntent pi = createRequest(method, extras);
final Bundle res = new Bundle();
res.putParcelable(MediaStore.EXTRA_RESULT, pi);
return res;
}
case MediaStore.IS_SYSTEM_GALLERY_CALL:
final LocalCallingIdentity token = clearLocalCallingIdentity();
try {
String packageName = arg;
int uid = extras.getInt(MediaStore.EXTRA_IS_SYSTEM_GALLERY_UID);
boolean isSystemGallery = PermissionUtils.checkWriteImagesOrVideoAppOps(
getContext(), uid, packageName, getContext().getAttributionTag());
Bundle res = new Bundle();
res.putBoolean(MediaStore.EXTRA_IS_SYSTEM_GALLERY_RESPONSE, isSystemGallery);
return res;
} finally {
restoreLocalCallingIdentity(token);
}
case MediaStore.SET_CLOUD_PROVIDER_CALL:
// TODO(b/190713331): Remove after initial development
final String cloudProvider = extras.getString(MediaStore.EXTRA_CLOUD_PROVIDER);
Log.i(TAG, "Developer initiated cloud provider switch: " + cloudProvider);
mPickerSyncController.setCloudProvider(cloudProvider);
// fall through
case MediaStore.SYNC_PROVIDERS_CALL:
syncPicker();
return new Bundle();
default:
throw new UnsupportedOperationException("Unsupported call: " + method);
}
}
private void syncPicker() {
// Clear the binder calling identity so that we can sync the unexported
// local_provider while running as MediaProvider
final long t = Binder.clearCallingIdentity();
try {
// TODO(b/190713331): Remove after initial development
Log.v(TAG, "Developer initiated provider sync");
mPickerSyncController.syncPicker();
} finally {
Binder.restoreCallingIdentity(t);
}
}
private AssetFileDescriptor getOriginalMediaFormatFileDescriptor(Bundle extras)
throws FileNotFoundException {
try (ParcelFileDescriptor inputPfd =
extras.getParcelable(MediaStore.EXTRA_FILE_DESCRIPTOR)) {
final File file = getFileFromFileDescriptor(inputPfd);
if (!mTranscodeHelper.supportsTranscode(file.getPath())) {
// Note that we should be checking if a file is a modern format and not just
// that it supports transcoding, unfortunately, checking modern format
// requires either a db query or media scan which can lead to ANRs if apps
// or the system implicitly call this method as part of a
// MediaPlayer#setDataSource.
throw new FileNotFoundException("Input file descriptor is already original");
}
FuseDaemon fuseDaemon = getFuseDaemonForFile(file);
String outputPath = fuseDaemon.getOriginalMediaFormatFilePath(inputPfd);
if (TextUtils.isEmpty(outputPath)) {
throw new FileNotFoundException("Invalid path for original media format file");
}
int posixMode = Os.fcntlInt(inputPfd.getFileDescriptor(), F_GETFL,
0 /* args */);
int modeBits = FileUtils.translateModePosixToPfd(posixMode);
int uid = Binder.getCallingUid();
ParcelFileDescriptor pfd = openWithFuse(outputPath, uid, 0 /* mediaCapabilitiesUid */,
modeBits, true /* shouldRedact */, false /* shouldTranscode */,
0 /* transcodeReason */);
return new AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH);
} catch (IOException e) {
Log.w(TAG, "Failed to fetch original file descriptor", e);
throw new FileNotFoundException("Failed to fetch original file descriptor");
} catch (ErrnoException e) {
Log.w(TAG, "Failed to fetch access mode for file descriptor", e);
throw new FileNotFoundException("Failed to fetch access mode for file descriptor");
}
}
/**
* Grant similar read/write access for mediaStoreUri as the caller has for documentsUri.
*
* Note: This function assumes that read permission check for documentsUri is already enforced.
* Note: This function currently does not check/grant for persisted Uris. Support for this can
* be added eventually, but the calling application will have to call
* ContentResolver#takePersistableUriPermission(Uri, int) for the mediaStoreUri to persist.
*
* @param documentsUri DocumentsProvider format content Uri
* @param mediaStoreUri MediaStore format content Uri
* @param callingPid pid of the caller
* @param callingUid uid of the caller
* @param callingPackage package name of the caller
*/
private void copyUriPermissionGrants(Uri documentsUri, Uri mediaStoreUri,
int callingPid, int callingUid, String callingPackage) {
// No need to check for read permission, as we enforce it already.
int modeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION;
if (getContext().checkUriPermission(documentsUri, callingPid, callingUid,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION) == PERMISSION_GRANTED) {
modeFlags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
}
getContext().grantUriPermission(callingPackage, mediaStoreUri, modeFlags);
}
static List<Uri> collectUris(ClipData clipData) {
final ArrayList<Uri> res = new ArrayList<>();
for (int i = 0; i < clipData.getItemCount(); i++) {
res.add(clipData.getItemAt(i).getUri());
}
return res;
}
/**
* Return the filesystem path of the real file on disk that is represented
* by the given {@link ParcelFileDescriptor}.
*
* Copied from {@link ParcelFileDescriptor#getFile}
*/
private static File getFileFromFileDescriptor(ParcelFileDescriptor fileDescriptor)
throws IOException {
try {
final String path = Os.readlink("/proc/self/fd/" + fileDescriptor.getFd());
if (OsConstants.S_ISREG(Os.stat(path).st_mode)) {
return new File(path);
} else {
throw new IOException("Not a regular file: " + path);
}
} catch (ErrnoException e) {
throw e.rethrowAsIOException();
}
}
/**
* Generate the {@link PendingIntent} for the given grant request. This
* method also checks the incoming arguments for security purposes
* before creating the privileged {@link PendingIntent}.
*/
private @NonNull PendingIntent createRequest(@NonNull String method, @NonNull Bundle extras) {
final ClipData clipData = extras.getParcelable(MediaStore.EXTRA_CLIP_DATA);
final List<Uri> uris = collectUris(clipData);
for (Uri uri : uris) {
final int match = matchUri(uri, false);
switch (match) {
case IMAGES_MEDIA_ID:
case AUDIO_MEDIA_ID:
case VIDEO_MEDIA_ID:
case AUDIO_PLAYLISTS_ID:
// Caller is requesting a specific media item by its ID,
// which means it's valid for requests
break;
case FILES_ID:
// Allow only subtitle files
if (!isSubtitleFile(uri)) {
throw new IllegalArgumentException(
"All requested items must be Media items");
}
break;
default:
throw new IllegalArgumentException(
"All requested items must be referenced by specific ID");
}
}
// Enforce that limited set of columns can be mutated
final ContentValues values = extras.getParcelable(MediaStore.EXTRA_CONTENT_VALUES);
final List<String> allowedColumns;
switch (method) {
case MediaStore.CREATE_FAVORITE_REQUEST_CALL:
allowedColumns = Arrays.asList(
MediaColumns.IS_FAVORITE);
break;
case MediaStore.CREATE_TRASH_REQUEST_CALL:
allowedColumns = Arrays.asList(
MediaColumns.IS_TRASHED);
break;
default:
allowedColumns = Arrays.asList();
break;
}
if (values != null) {
for (String key : values.keySet()) {
if (!allowedColumns.contains(key)) {
throw new IllegalArgumentException("Invalid column " + key);
}
}
}
final Context context = getContext();
final Intent intent = new Intent(method, null, context, PermissionActivity.class);
intent.putExtras(extras);
return PendingIntent.getActivity(context, PermissionActivity.REQUEST_CODE, intent,
FLAG_ONE_SHOT | FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE);
}
/**
* @return true if the given Files uri has media_type=MEDIA_TYPE_SUBTITLE
*/
private boolean isSubtitleFile(Uri uri) {
final LocalCallingIdentity tokenInner = clearLocalCallingIdentity();
try (Cursor cursor = queryForSingleItem(uri, new String[]{FileColumns.MEDIA_TYPE}, null,
null, null)) {
return cursor.getInt(0) == FileColumns.MEDIA_TYPE_SUBTITLE;
} catch (FileNotFoundException e) {
Log.e(TAG, "Couldn't find database row for requested uri " + uri, e);
} finally {
restoreLocalCallingIdentity(tokenInner);
}
return false;
}
/**
* Ensure that all local databases have a custom collator registered for the
* given {@link ULocale} locale.
*
* @return the corresponding custom collation name to be used in
* {@code ORDER BY} clauses.
*/
private @NonNull String ensureCustomCollator(@NonNull String locale) {
// Quick check that requested locale looks reasonable
new ULocale(locale);
final String collationName = "custom_" + locale.replaceAll("[^a-zA-Z]", "");
synchronized (mCustomCollators) {
if (!mCustomCollators.contains(collationName)) {
for (DatabaseHelper helper : new DatabaseHelper[] {
mInternalDatabase,
mExternalDatabase
}) {
helper.runWithoutTransaction((db) -> {
db.execPerConnectionSQL("SELECT icu_load_collation(?, ?);",
new String[] { locale, collationName });
return null;
});
}
mCustomCollators.add(collationName);
}
}
return collationName;
}
private int pruneThumbnails(@NonNull SQLiteDatabase db, @NonNull CancellationSignal signal) {
int prunedCount = 0;
// Determine all known media items
final LongArray knownIds = new LongArray();
try (Cursor c = db.query(true, "files", new String[] { BaseColumns._ID },
null, null, null, null, null, null, signal)) {
while (c.moveToNext()) {
knownIds.add(c.getLong(0));
}
}
final long[] knownIdsRaw = knownIds.toArray();
Arrays.sort(knownIdsRaw);
for (MediaVolume volume : mVolumeCache.getExternalVolumes()) {
final List<File> thumbDirs;
try {
thumbDirs = getThumbnailDirectories(volume);
} catch (FileNotFoundException e) {
Log.w(TAG, "Failed to resolve volume " + volume.getName(), e);
continue;
}
// Reconcile all thumbnails, deleting stale items
for (File thumbDir : thumbDirs) {
// Possibly bail before digging into each directory
signal.throwIfCanceled();
final File[] files = thumbDir.listFiles();
for (File thumbFile : (files != null) ? files : new File[0]) {
if (Objects.equals(thumbFile.getName(), FILE_DATABASE_UUID)) continue;
final String name = FileUtils.extractFileName(thumbFile.getName());
try {
final long id = Long.parseLong(name);
if (Arrays.binarySearch(knownIdsRaw, id) >= 0) {
// Thumbnail belongs to known media, keep it
continue;
}
} catch (NumberFormatException e) {
}
Log.v(TAG, "Deleting stale thumbnail " + thumbFile);
deleteAndInvalidate(thumbFile);
prunedCount++;
}
}
}
// Also delete stale items from legacy tables
db.execSQL("delete from thumbnails "
+ "where image_id not in (select _id from images)");
db.execSQL("delete from videothumbnails "
+ "where video_id not in (select _id from video)");
return prunedCount;
}
abstract class Thumbnailer {
final String directoryName;
public Thumbnailer(String directoryName) {
this.directoryName = directoryName;
}
private File getThumbnailFile(Uri uri) throws IOException {
final String volumeName = resolveVolumeName(uri);
final File volumePath = getVolumePath(volumeName);
return FileUtils.buildPath(volumePath, directoryName,
DIRECTORY_THUMBNAILS, ContentUris.parseId(uri) + ".jpg");
}
public abstract Bitmap getThumbnailBitmap(Uri uri, CancellationSignal signal)
throws IOException;
public ParcelFileDescriptor ensureThumbnail(Uri uri, CancellationSignal signal)
throws IOException {
// First attempt to fast-path by opening the thumbnail; if it
// doesn't exist we fall through to create it below
final File thumbFile = getThumbnailFile(uri);
try {
return FileUtils.openSafely(thumbFile,
ParcelFileDescriptor.MODE_READ_ONLY);
} catch (FileNotFoundException ignored) {
}
final File thumbDir = thumbFile.getParentFile();
thumbDir.mkdirs();
// When multiple threads race for the same thumbnail, the second
// thread could return a file with a thumbnail still in
// progress. We could add heavy per-ID locking to mitigate this
// rare race condition, but it's simpler to have both threads
// generate the same thumbnail using temporary files and rename
// them into place once finished.
final File thumbTempFile = File.createTempFile("thumb", null, thumbDir);
ParcelFileDescriptor thumbWrite = null;
ParcelFileDescriptor thumbRead = null;
try {
// Open our temporary file twice: once for local writing, and
// once for remote reading. Both FDs point at the same
// underlying inode on disk, so they're stable across renames
// to avoid race conditions between threads.
thumbWrite = FileUtils.openSafely(thumbTempFile,
ParcelFileDescriptor.MODE_WRITE_ONLY | ParcelFileDescriptor.MODE_CREATE);
thumbRead = FileUtils.openSafely(thumbTempFile,
ParcelFileDescriptor.MODE_READ_ONLY);
final Bitmap thumbnail = getThumbnailBitmap(uri, signal);
thumbnail.compress(Bitmap.CompressFormat.JPEG, 90,
new FileOutputStream(thumbWrite.getFileDescriptor()));
try {
// Use direct syscall for better failure logs
Os.rename(thumbTempFile.getAbsolutePath(), thumbFile.getAbsolutePath());
} catch (ErrnoException e) {
e.rethrowAsIOException();
}
// Everything above went peachy, so return a duplicate of our
// already-opened read FD to keep our finally logic below simple
return thumbRead.dup();
} finally {
// Regardless of success or failure, try cleaning up any
// remaining temporary file and close all our local FDs
FileUtils.closeQuietly(thumbWrite);
FileUtils.closeQuietly(thumbRead);
deleteAndInvalidate(thumbTempFile);
}
}
public void invalidateThumbnail(Uri uri) throws IOException {
deleteAndInvalidate(getThumbnailFile(uri));
}
}
private Thumbnailer mAudioThumbnailer = new Thumbnailer(Environment.DIRECTORY_MUSIC) {
@Override
public Bitmap getThumbnailBitmap(Uri uri, CancellationSignal signal) throws IOException {
return ThumbnailUtils.createAudioThumbnail(queryForDataFile(uri, signal),
mThumbSize, signal);
}
};
private Thumbnailer mVideoThumbnailer = new Thumbnailer(Environment.DIRECTORY_MOVIES) {
@Override
public Bitmap getThumbnailBitmap(Uri uri, CancellationSignal signal) throws IOException {
return ThumbnailUtils.createVideoThumbnail(queryForDataFile(uri, signal),
mThumbSize, signal);
}
};
private Thumbnailer mImageThumbnailer = new Thumbnailer(Environment.DIRECTORY_PICTURES) {
@Override
public Bitmap getThumbnailBitmap(Uri uri, CancellationSignal signal) throws IOException {
return ThumbnailUtils.createImageThumbnail(queryForDataFile(uri, signal),
mThumbSize, signal);
}
};
private List<File> getThumbnailDirectories(MediaVolume volume) throws FileNotFoundException {
final File volumePath = volume.getPath();
return Arrays.asList(
FileUtils.buildPath(volumePath, Environment.DIRECTORY_MUSIC, DIRECTORY_THUMBNAILS),
FileUtils.buildPath(volumePath, Environment.DIRECTORY_MOVIES, DIRECTORY_THUMBNAILS),
FileUtils.buildPath(volumePath, Environment.DIRECTORY_PICTURES,
DIRECTORY_THUMBNAILS));
}
private void invalidateThumbnails(Uri uri) {
Trace.beginSection("invalidateThumbnails");
try {
invalidateThumbnailsInternal(uri);
} finally {
Trace.endSection();
}
}
private void invalidateThumbnailsInternal(Uri uri) {
final long id = ContentUris.parseId(uri);
try {
mAudioThumbnailer.invalidateThumbnail(uri);
mVideoThumbnailer.invalidateThumbnail(uri);
mImageThumbnailer.invalidateThumbnail(uri);
} catch (IOException ignored) {
}
final DatabaseHelper helper;
try {
helper = getDatabaseForUri(uri);
} catch (VolumeNotFoundException e) {
Log.w(TAG, e);
return;
}
helper.runWithTransaction((db) -> {
final String idString = Long.toString(id);
try (Cursor c = db.rawQuery("select _data from thumbnails where image_id=?"
+ " union all select _data from videothumbnails where video_id=?",
new String[] { idString, idString })) {
while (c.moveToNext()) {
String path = c.getString(0);
deleteIfAllowed(uri, Bundle.EMPTY, path);
}
}
db.execSQL("delete from thumbnails where image_id=?", new String[] { idString });
db.execSQL("delete from videothumbnails where video_id=?", new String[] { idString });
return null;
});
}
/**
* @deprecated all operations should be routed through the overload that
* accepts a {@link Bundle} of extras.
*/
@Override
@Deprecated
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
return update(uri, values,
DatabaseUtils.createSqlQueryBundle(selection, selectionArgs, null));
}
@Override
public int update(@NonNull Uri uri, @Nullable ContentValues values,
@Nullable Bundle extras) {
Trace.beginSection("update");
try {
return updateInternal(uri, values, extras);
} catch (FallbackException e) {
return e.translateForUpdateDelete(getCallingPackageTargetSdkVersion());
} finally {
Trace.endSection();
}
}
private int updateInternal(@NonNull Uri uri, @Nullable ContentValues initialValues,
@Nullable Bundle extras) throws FallbackException {
final String volumeName = getVolumeName(uri);
PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), volumeName);
extras = (extras != null) ? extras : new Bundle();
// REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider.
extras.remove(QUERY_ARG_REDACTED_URI);
if (isRedactedUri(uri)) {
// we don't support update on redacted uris.
return 0;
}
// Related items are only considered for new media creation, and they
// can't be leveraged to move existing content into blocked locations
extras.remove(QUERY_ARG_RELATED_URI);
// INCLUDED_DEFAULT_DIRECTORIES extra should only be set inside MediaProvider.
extras.remove(INCLUDED_DEFAULT_DIRECTORIES);
final String userWhere = extras.getString(QUERY_ARG_SQL_SELECTION);
final String[] userWhereArgs = extras.getStringArray(QUERY_ARG_SQL_SELECTION_ARGS);
// Limit the hacky workaround to camera targeting Q and below, to allow newer versions
// of camera that does the right thing to work correctly.
if ("com.google.android.GoogleCamera".equals(getCallingPackageOrSelf())
&& getCallingPackageTargetSdkVersion() <= Build.VERSION_CODES.Q) {
if (matchUri(uri, false) == IMAGES_MEDIA_ID) {
Log.w(TAG, "Working around app bug in b/111966296");
uri = MediaStore.Files.getContentUri("external", ContentUris.parseId(uri));
} else if (matchUri(uri, false) == VIDEO_MEDIA_ID) {
Log.w(TAG, "Working around app bug in b/112246630");
uri = MediaStore.Files.getContentUri("external", ContentUris.parseId(uri));
}
}
uri = safeUncanonicalize(uri);
int count;
final int targetSdkVersion = getCallingPackageTargetSdkVersion();
final boolean allowHidden = isCallingPackageAllowedHidden();
final int match = matchUri(uri, allowHidden);
final DatabaseHelper helper = getDatabaseForUri(uri);
switch (match) {
case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
extras.putString(QUERY_ARG_SQL_SELECTION,
BaseColumns._ID + "=" + uri.getPathSegments().get(5));
// fall-through
case AUDIO_PLAYLISTS_ID_MEMBERS: {
final long playlistId = Long.parseLong(uri.getPathSegments().get(3));
final Uri playlistUri = ContentUris.withAppendedId(
MediaStore.Audio.Playlists.getContentUri(volumeName), playlistId);
if (uri.getBooleanQueryParameter("move", false)) {
// Convert explicit request into query; sigh, moveItem()
// uses zero-based indexing instead of one-based indexing
final int from = Integer.parseInt(uri.getPathSegments().get(5)) + 1;
final int to = initialValues.getAsInteger(Playlists.Members.PLAY_ORDER) + 1;
extras.putString(QUERY_ARG_SQL_SELECTION,
Playlists.Members.PLAY_ORDER + "=" + from);
initialValues.put(Playlists.Members.PLAY_ORDER, to);
}
// Playlist contents are always persisted directly into playlist
// files on disk to ensure that we can reliably migrate between
// devices and recover from database corruption
final int index;
if (initialValues.containsKey(Playlists.Members.PLAY_ORDER)) {
index = movePlaylistMembers(playlistUri, initialValues, extras);
} else {
index = resolvePlaylistIndex(playlistUri, extras);
}
if (initialValues.containsKey(Playlists.Members.AUDIO_ID)) {
final Bundle queryArgs = new Bundle();
queryArgs.putString(QUERY_ARG_SQL_SELECTION,
Playlists.Members.PLAY_ORDER + "=" + (index + 1));
removePlaylistMembers(playlistUri, queryArgs);
final ContentValues values = new ContentValues();
values.put(Playlists.Members.AUDIO_ID,
initialValues.getAsString(Playlists.Members.AUDIO_ID));
values.put(Playlists.Members.PLAY_ORDER, (index + 1));
addPlaylistMembers(playlistUri, values);
}
acceptWithExpansion(helper::notifyUpdate, volumeName, playlistId,
FileColumns.MEDIA_TYPE_PLAYLIST, false);
return 1;
}
}
final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE, match, uri, extras, null);
// Give callers interacting with a specific media item a chance to
// escalate access if they don't already have it
switch (match) {
case AUDIO_MEDIA_ID:
case VIDEO_MEDIA_ID:
case IMAGES_MEDIA_ID:
enforceCallingPermission(uri, extras, true);
}
boolean triggerInvalidate = false;
boolean triggerScan = false;
boolean isUriPublished = false;
if (initialValues != null) {
// IDs are forever; nobody should be editing them
initialValues.remove(MediaColumns._ID);
// Expiration times are hard-coded; let's derive them
FileUtils.computeDateExpires(initialValues);
// Ignore or augment incoming raw filesystem paths
for (String column : sDataColumns.keySet()) {
if (!initialValues.containsKey(column)) continue;
if (isCallingPackageSelf() || isCallingPackageLegacyWrite()) {
// Mutation allowed
} else {
Log.w(TAG, "Ignoring mutation of " + column + " from "
+ getCallingPackageOrSelf());
initialValues.remove(column);
}
}
// Enforce allowed ownership transfers
if (initialValues.containsKey(MediaColumns.OWNER_PACKAGE_NAME)) {
if (isCallingPackageSelf() || isCallingPackageShell()) {
// When the caller is the media scanner or the shell, we let
// them change ownership however they see fit; nothing to do
} else if (isCallingPackageDelegator()) {
// When the caller is a delegator, allow them to shift
// ownership only when current owner, or when ownerless
final String currentOwner;
final String proposedOwner = initialValues
.getAsString(MediaColumns.OWNER_PACKAGE_NAME);
final Uri genericUri = MediaStore.Files.getContentUri(volumeName,
ContentUris.parseId(uri));
try (Cursor c = queryForSingleItem(genericUri,
new String[] { MediaColumns.OWNER_PACKAGE_NAME }, null, null, null)) {
currentOwner = c.getString(0);
} catch (FileNotFoundException e) {
throw new IllegalStateException(e);
}
final boolean transferAllowed = (currentOwner == null)
|| Arrays.asList(getSharedPackagesForPackage(getCallingPackageOrSelf()))
.contains(currentOwner);
if (transferAllowed) {
Log.v(TAG, "Ownership transfer from " + currentOwner + " to "
+ proposedOwner + " allowed");
} else {
Log.w(TAG, "Ownership transfer from " + currentOwner + " to "
+ proposedOwner + " blocked");
initialValues.remove(MediaColumns.OWNER_PACKAGE_NAME);
}
} else {
// Otherwise no ownership changes are allowed
initialValues.remove(MediaColumns.OWNER_PACKAGE_NAME);
}
}
if (!isCallingPackageSelf()) {
Trace.beginSection("filter");
// We default to filtering mutable columns, except when we know
// the single item being updated is pending; when it's finally
// published we'll overwrite these values.
final Uri finalUri = uri;
final Supplier<Boolean> isPending = new CachedSupplier<>(() -> {
return isPending(finalUri);
});
// Column values controlled by media scanner aren't writable by
// apps, since any edits here don't reflect the metadata on
// disk, and they'd be overwritten during a rescan.
for (String column : new ArraySet<>(initialValues.keySet())) {
if (sMutableColumns.contains(column)) {
// Mutation normally allowed
} else if (isPending.get()) {
// Mutation relaxed while pending
} else {
Log.w(TAG, "Ignoring mutation of " + column + " from "
+ getCallingPackageOrSelf());
initialValues.remove(column);
triggerScan = true;
}
// If we're publishing this item, perform a blocking scan to
// make sure metadata is updated
if (MediaColumns.IS_PENDING.equals(column)) {
triggerScan = true;
isUriPublished = true;
// Explicitly clear columns used to ignore no-op scans,
// since we need to force a scan on publish
initialValues.putNull(MediaColumns.DATE_MODIFIED);
initialValues.putNull(MediaColumns.SIZE);
}
}
Trace.endSection();
}
if ("files".equals(qb.getTables())) {
maybeMarkAsDownload(initialValues);
}
// We no longer track location metadata
if (initialValues.containsKey(ImageColumns.LATITUDE)) {
initialValues.putNull(ImageColumns.LATITUDE);
}
if (initialValues.containsKey(ImageColumns.LONGITUDE)) {
initialValues.putNull(ImageColumns.LONGITUDE);
}
if (getCallingPackageTargetSdkVersion() <= Build.VERSION_CODES.Q) {
// These columns are removed in R.
if (initialValues.containsKey("primary_directory")) {
initialValues.remove("primary_directory");
}
if (initialValues.containsKey("secondary_directory")) {
initialValues.remove("secondary_directory");
}
}
}
// If we're not updating anything, then we can skip
if (initialValues.isEmpty()) return 0;
final boolean isThumbnail;
switch (match) {
case IMAGES_THUMBNAILS:
case IMAGES_THUMBNAILS_ID:
case VIDEO_THUMBNAILS:
case VIDEO_THUMBNAILS_ID:
case AUDIO_ALBUMART:
case AUDIO_ALBUMART_ID:
isThumbnail = true;
break;
default:
isThumbnail = false;
break;
}
switch (match) {
case AUDIO_PLAYLISTS:
case AUDIO_PLAYLISTS_ID:
// Playlist names are stored as display names, but leave
// values untouched if the caller is ModernMediaScanner
if (!isCallingPackageSelf()) {
if (initialValues.containsKey(Playlists.NAME)) {
initialValues.put(MediaColumns.DISPLAY_NAME,
initialValues.getAsString(Playlists.NAME));
}
if (!initialValues.containsKey(MediaColumns.MIME_TYPE)) {
initialValues.put(MediaColumns.MIME_TYPE, "audio/mpegurl");
}
}
break;
}
// If we're touching columns that would change placement of a file,
// blend in current values and recalculate path
final boolean allowMovement = extras.getBoolean(MediaStore.QUERY_ARG_ALLOW_MOVEMENT,
!isCallingPackageSelf());
if (containsAny(initialValues.keySet(), sPlacementColumns)
&& !initialValues.containsKey(MediaColumns.DATA)
&& !isThumbnail
&& allowMovement) {
Trace.beginSection("movement");
// We only support movement under well-defined collections
switch (match) {
case AUDIO_MEDIA_ID:
case AUDIO_PLAYLISTS_ID:
case VIDEO_MEDIA_ID:
case IMAGES_MEDIA_ID:
case DOWNLOADS_ID:
case FILES_ID:
break;
default:
throw new IllegalArgumentException("Movement of " + uri
+ " which isn't part of well-defined collection not allowed");
}
final LocalCallingIdentity token = clearLocalCallingIdentity();
final Uri genericUri = MediaStore.Files.getContentUri(volumeName,
ContentUris.parseId(uri));
try (Cursor c = queryForSingleItem(genericUri,
sPlacementColumns.toArray(new String[0]), userWhere, userWhereArgs, null)) {
for (int i = 0; i < c.getColumnCount(); i++) {
final String column = c.getColumnName(i);
if (!initialValues.containsKey(column)) {
initialValues.put(column, c.getString(i));
}
}
} catch (FileNotFoundException e) {
throw new IllegalStateException(e);
} finally {
restoreLocalCallingIdentity(token);
}
// Regenerate path using blended values; this will throw if caller
// is attempting to place file into invalid location
final String beforePath = initialValues.getAsString(MediaColumns.DATA);
final String beforeVolume = extractVolumeName(beforePath);
final String beforeOwner = extractPathOwnerPackageName(beforePath);
initialValues.remove(MediaColumns.DATA);
ensureNonUniqueFileColumns(match, uri, extras, initialValues, beforePath);
final String probePath = initialValues.getAsString(MediaColumns.DATA);
final String probeVolume = extractVolumeName(probePath);
final String probeOwner = extractPathOwnerPackageName(probePath);
if (Objects.equals(beforePath, probePath)) {
Log.d(TAG, "Identical paths " + beforePath + "; not moving");
} else if (!Objects.equals(beforeVolume, probeVolume)) {
throw new IllegalArgumentException("Changing volume from " + beforePath + " to "
+ probePath + " not allowed");
} else if (!isUpdateAllowedForOwnedPath(beforeOwner, probeOwner, beforePath,
probePath)) {
throw new IllegalArgumentException("Changing ownership from " + beforePath + " to "
+ probePath + " not allowed");
} else {
// Now that we've confirmed an actual movement is taking place,
// ensure we have a unique destination
initialValues.remove(MediaColumns.DATA);
ensureUniqueFileColumns(match, uri, extras, initialValues, beforePath);
String afterPath = initialValues.getAsString(MediaColumns.DATA);
if (isCrossUserEnabled()) {
String afterVolume = extractVolumeName(afterPath);
String afterVolumePath = extractVolumePath(afterPath);
String beforeVolumePath = extractVolumePath(beforePath);
if (MediaStore.VOLUME_EXTERNAL_PRIMARY.equals(beforeVolume)
&& beforeVolume.equals(afterVolume)
&& !beforeVolumePath.equals(afterVolumePath)) {
// On cross-user enabled devices, it can happen that a rename intended as
// /storage/emulated/999/foo -> /storage/emulated/999/foo can end up as
// /storage/emulated/999/foo -> /storage/emulated/0/foo. We now fix-up
afterPath = afterPath.replaceFirst(afterVolumePath, beforeVolumePath);
}
}
Log.d(TAG, "Moving " + beforePath + " to " + afterPath);
try {
Os.rename(beforePath, afterPath);
invalidateFuseDentry(beforePath);
invalidateFuseDentry(afterPath);
} catch (ErrnoException e) {
if (e.errno == OsConstants.ENOENT) {
Log.d(TAG, "Missing file at " + beforePath + "; continuing anyway");
} else {
throw new IllegalStateException(e);
}
}
initialValues.put(MediaColumns.DATA, afterPath);
// Some indexed metadata may have been derived from the path on
// disk, so scan this item again to update it
triggerScan = true;
}
Trace.endSection();
}
assertPrivatePathNotInValues(initialValues);
// Make sure any updated paths look consistent
assertFileColumnsConsistent(match, uri, initialValues);
if (initialValues.containsKey(FileColumns.DATA)) {
// If we're changing paths, invalidate any thumbnails
triggerInvalidate = true;
// If the new file exists, trigger a scan to adjust any metadata
// that might be derived from the path
final String data = initialValues.getAsString(FileColumns.DATA);
if (!TextUtils.isEmpty(data) && new File(data).exists()) {
triggerScan = true;
}
}
// If we're already doing this update from an internal scan, no need to
// kick off another no-op scan
if (isCallingPackageSelf()) {
triggerScan = false;
}
// Since the update mutation may prevent us from matching items after
// it's applied, we need to snapshot affected IDs here
final LongArray updatedIds = new LongArray();
if (triggerInvalidate || triggerScan) {
Trace.beginSection("snapshot");
final LocalCallingIdentity token = clearLocalCallingIdentity();
try (Cursor c = qb.query(helper, new String[] { FileColumns._ID },
userWhere, userWhereArgs, null, null, null, null, null)) {
while (c.moveToNext()) {
updatedIds.add(c.getLong(0));
}
} finally {
restoreLocalCallingIdentity(token);
Trace.endSection();
}
}
final ContentValues values = new ContentValues(initialValues);
switch (match) {
case AUDIO_MEDIA_ID:
case AUDIO_PLAYLISTS_ID:
case VIDEO_MEDIA_ID:
case IMAGES_MEDIA_ID:
case FILES_ID:
case DOWNLOADS_ID: {
FileUtils.computeValuesFromData(values, isFuseThread());
break;
}
}
if (initialValues.containsKey(FileColumns.MEDIA_TYPE)) {
final int mediaType = initialValues.getAsInteger(FileColumns.MEDIA_TYPE);
switch (mediaType) {
case FileColumns.MEDIA_TYPE_AUDIO: {
computeAudioLocalizedValues(values);
computeAudioKeyValues(values);
break;
}
}
}
boolean deferScan = false;
if (triggerScan) {
if (SdkLevel.isAtLeastS() &&
CompatChanges.isChangeEnabled(ENABLE_DEFERRED_SCAN, Binder.getCallingUid())) {
if (extras.containsKey(QUERY_ARG_DO_ASYNC_SCAN)) {
throw new IllegalArgumentException("Unsupported argument " +
QUERY_ARG_DO_ASYNC_SCAN + " used in extras");
}
deferScan = extras.getBoolean(QUERY_ARG_DEFER_SCAN, false);
if (deferScan && initialValues.containsKey(MediaColumns.IS_PENDING) &&
(initialValues.getAsInteger(MediaColumns.IS_PENDING) == 1)) {
// if the scan runs in async, ensure that the database row is excluded in
// default query until the metadata is updated by deferred scan.
// Apps will still be able to see this database row when queried with
// QUERY_ARG_MATCH_PENDING=MATCH_INCLUDE
values.put(FileColumns._MODIFIER, FileColumns._MODIFIER_CR_PENDING_METADATA);
qb.allowColumn(FileColumns._MODIFIER);
}
} else {
// Allow apps to use QUERY_ARG_DO_ASYNC_SCAN if the device is R or app is targeting
// targetSDK<=R.
deferScan = extras.getBoolean(QUERY_ARG_DO_ASYNC_SCAN, false);
}
}
count = updateAllowingReplace(qb, helper, values, userWhere, userWhereArgs);
// If the caller tried (and failed) to update metadata, the file on disk
// might have changed, to scan it to collect the latest metadata.
if (triggerInvalidate || triggerScan) {
Trace.beginSection("invalidate");
final LocalCallingIdentity token = clearLocalCallingIdentity();
try {
for (int i = 0; i < updatedIds.size(); i++) {
final long updatedId = updatedIds.get(i);
final Uri updatedUri = Files.getContentUri(volumeName, updatedId);
helper.postBackground(() -> {
invalidateThumbnails(updatedUri);
});
if (triggerScan) {
try (Cursor c = queryForSingleItem(updatedUri,
new String[] { FileColumns.DATA }, null, null, null)) {
final File file = new File(c.getString(0));
final boolean notifyTranscodeHelper = isUriPublished;
if (deferScan) {
helper.postBackground(() -> {
scanFileAsMediaProvider(file, REASON_DEMAND);
if (notifyTranscodeHelper) {
notifyTranscodeHelperOnUriPublished(updatedUri);
}
});
} else {
helper.postBlocking(() -> {
scanFileAsMediaProvider(file, REASON_DEMAND);
if (notifyTranscodeHelper) {
notifyTranscodeHelperOnUriPublished(updatedUri);
}
});
}
} catch (Exception e) {
Log.w(TAG, "Failed to update metadata for " + updatedUri, e);
}
}
}
} finally {
restoreLocalCallingIdentity(token);
Trace.endSection();
}
}
return count;
}
private boolean isUpdateAllowedForOwnedPath(@Nullable String srcOwner,
@Nullable String destOwner, @NonNull String srcPath, @NonNull String destPath) {
// 1. Allow if the update is within owned path
// update() from /sdcard/Android/media/com.foo/ABC/image.jpeg to
// /sdcard/Android/media/com.foo/XYZ/image.jpeg - Allowed
if(Objects.equals(srcOwner, destOwner)) {
return true;
}
// 2. Check if the calling package is a special app which has global access
if (isCallingPackageManager() ||
(canAccessMediaFile(srcPath, /* allowLegacy */ false) &&
(canAccessMediaFile(destPath, /* allowLegacy */ false)))) {
return true;
}
// 3. Allow update from srcPath if the source is not a owned path or calling package is the
// owner of the source path or calling package shares the UID with the owner of the source
// path
// update() from /sdcard/DCIM/Foo.jpeg - Allowed
// update() from /sdcard/Android/media/com.foo/image.jpeg - Allowed for
// callingPackage=com.foo, not allowed for callingPackage=com.bar
final boolean isSrcUpdateAllowed = srcOwner == null
|| isCallingIdentitySharedPackageName(srcOwner);
// 4. Allow update to dstPath if the destination is not a owned path or calling package is
// the owner of the destination path or calling package shares the UID with the owner of the
// destination path
// update() to /sdcard/Pictures/image.jpeg - Allowed
// update() to /sdcard/Android/media/com.foo/image.jpeg - Allowed for
// callingPackage=com.foo, not allowed for callingPackage=com.bar
final boolean isDestUpdateAllowed = destOwner == null
|| isCallingIdentitySharedPackageName(destOwner);
return isSrcUpdateAllowed && isDestUpdateAllowed;
}
private void notifyTranscodeHelperOnUriPublished(Uri uri) {
BackgroundThread.getExecutor().execute(() -> {
final LocalCallingIdentity token = clearLocalCallingIdentity();
try {
mTranscodeHelper.onUriPublished(uri);
} finally {
restoreLocalCallingIdentity(token);
}
});
}
private void notifyTranscodeHelperOnFileOpen(String path, String ioPath, int uid,
int transformsReason) {
BackgroundThread.getExecutor().execute(() -> {
final LocalCallingIdentity token = clearLocalCallingIdentity();
try {
mTranscodeHelper.onFileOpen(path, ioPath, uid, transformsReason);
} finally {
restoreLocalCallingIdentity(token);
}
});
}
/**
* Update row(s) that match {@code userWhere} in MediaProvider database with {@code values}.
* Treats update as replace for updates with conflicts.
*/
private int updateAllowingReplace(@NonNull SQLiteQueryBuilder qb,
@NonNull DatabaseHelper helper, @NonNull ContentValues values, String userWhere,
String[] userWhereArgs) throws SQLiteConstraintException {
return helper.runWithTransaction((db) -> {
try {
return qb.update(helper, values, userWhere, userWhereArgs);
} catch (SQLiteConstraintException e) {
// b/155320967 Apps sometimes create a file via file path and then update another
// explicitly inserted db row to this file. We have to resolve this update with a
// replace.
if (getCallingPackageTargetSdkVersion() >= Build.VERSION_CODES.R) {
// We don't support replace for non-legacy apps. Non legacy apps should have
// clearer interactions with MediaProvider.
throw e;
}
final String path = values.getAsString(FileColumns.DATA);
// We will only handle UNIQUE constraint error for FileColumns.DATA. We will not try
// update and replace if no file exists for conflicting db row.
if (path == null || !new File(path).exists()) {
throw e;
}
final Uri uri = FileUtils.getContentUriForPath(path);
final boolean allowHidden = isCallingPackageAllowedHidden();
// The db row which caused UNIQUE constraint error may not match all column values
// of the given queryBuilder, hence using a generic queryBuilder with Files uri.
Bundle extras = new Bundle();
extras.putInt(QUERY_ARG_MATCH_PENDING, MATCH_INCLUDE);
extras.putInt(QUERY_ARG_MATCH_TRASHED, MATCH_INCLUDE);
final SQLiteQueryBuilder qbForReplace = getQueryBuilder(TYPE_DELETE,
matchUri(uri, allowHidden), uri, extras, null);
final long rowId = getIdIfPathOwnedByPackages(qbForReplace, helper, path,
getSharedPackages());
if (rowId != -1 && qbForReplace.delete(helper, "_id=?",
new String[] {Long.toString(rowId)}) == 1) {
Log.i(TAG, "Retrying database update after deleting conflicting entry");
return qb.update(helper, values, userWhere, userWhereArgs);
}
// Rethrow SQLiteConstraintException if app doesn't own the conflicting db row.
throw e;
}
});
}
/**
* Update the internal table of {@link MediaStore.Audio.Playlists.Members}
* by parsing the playlist file on disk and resolving it against scanned
* audio items.
* <p>
* When a playlist references a missing audio item, the associated
* {@link Playlists.Members#PLAY_ORDER} is skipped, leaving a gap to ensure
* that the playlist entry is retained to avoid user data loss.
*/
private void resolvePlaylistMembers(@NonNull Uri playlistUri) {
Trace.beginSection("resolvePlaylistMembers");
try {
final DatabaseHelper helper;
try {
helper = getDatabaseForUri(playlistUri);
} catch (VolumeNotFoundException e) {
throw e.rethrowAsIllegalArgumentException();
}
helper.runWithTransaction((db) -> {
resolvePlaylistMembersInternal(playlistUri, db);
return null;
});
} finally {
Trace.endSection();
}
}
private void resolvePlaylistMembersInternal(@NonNull Uri playlistUri,
@NonNull SQLiteDatabase db) {
try {
// Refresh playlist members based on what we parse from disk
final long playlistId = ContentUris.parseId(playlistUri);
final Map<String, Long> membersMap = getAllPlaylistMembers(playlistId);
db.delete("audio_playlists_map", "playlist_id=" + playlistId, null);
final Path playlistPath = queryForDataFile(playlistUri, null).toPath();
final Playlist playlist = new Playlist();
playlist.read(playlistPath.toFile());
final List<Path> members = playlist.asList();
for (int i = 0; i < members.size(); i++) {
try {
final Path audioPath = playlistPath.getParent().resolve(members.get(i));
final long audioId = queryForPlaylistMember(audioPath, membersMap);
final ContentValues values = new ContentValues();
values.put(Playlists.Members.PLAY_ORDER, i + 1);
values.put(Playlists.Members.PLAYLIST_ID, playlistId);
values.put(Playlists.Members.AUDIO_ID, audioId);
db.insert("audio_playlists_map", null, values);
} catch (IOException e) {
Log.w(TAG, "Failed to resolve playlist member", e);
}
}
} catch (IOException e) {
Log.w(TAG, "Failed to refresh playlist", e);
}
}
private Map<String, Long> getAllPlaylistMembers(long playlistId) {
final Map<String, Long> membersMap = new ArrayMap<>();
final Uri uri = Playlists.Members.getContentUri(MediaStore.VOLUME_EXTERNAL, playlistId);
final String[] projection = new String[] {
Playlists.Members.DATA,
Playlists.Members.AUDIO_ID
};
try (Cursor c = query(uri, projection, null, null)) {
if (c == null) {
Log.e(TAG, "Cursor is null, failed to create cached playlist member info.");
return membersMap;
}
while (c.moveToNext()) {
membersMap.put(c.getString(0), c.getLong(1));
}
}
return membersMap;
}
/**
* Make two attempts to query this playlist member: first based on the exact
* path, and if that fails, fall back to picking a single item matching the
* display name. When there are multiple items with the same display name,
* we can't resolve between them, and leave this member unresolved.
*/
private long queryForPlaylistMember(@NonNull Path path, @NonNull Map<String, Long> membersMap)
throws IOException {
final String data = path.toFile().getCanonicalPath();
if (membersMap.containsKey(data)) {
return membersMap.get(data);
}
final Uri audioUri = Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL);
try (Cursor c = queryForSingleItem(audioUri,
new String[] { BaseColumns._ID }, MediaColumns.DATA + "=?",
new String[] { data }, null)) {
return c.getLong(0);
} catch (FileNotFoundException ignored) {
}
try (Cursor c = queryForSingleItem(audioUri,
new String[] { BaseColumns._ID }, MediaColumns.DISPLAY_NAME + "=?",
new String[] { path.toFile().getName() }, null)) {
return c.getLong(0);
} catch (FileNotFoundException ignored) {
}
throw new FileNotFoundException();
}
/**
* Add the given audio item to the given playlist. Defaults to adding at the
* end of the playlist when no {@link Playlists.Members#PLAY_ORDER} is
* defined.
*/
private long addPlaylistMembers(@NonNull Uri playlistUri, @NonNull ContentValues values)
throws FallbackException {
final long audioId = values.getAsLong(Audio.Playlists.Members.AUDIO_ID);
final String volumeName = MediaStore.VOLUME_INTERNAL.equals(getVolumeName(playlistUri))
? MediaStore.VOLUME_INTERNAL : MediaStore.VOLUME_EXTERNAL;
final Uri audioUri = Audio.Media.getContentUri(volumeName, audioId);
Integer playOrder = values.getAsInteger(Playlists.Members.PLAY_ORDER);
playOrder = (playOrder != null) ? (playOrder - 1) : Integer.MAX_VALUE;
try {
final File playlistFile = queryForDataFile(playlistUri, null);
final File audioFile = queryForDataFile(audioUri, null);
final Playlist playlist = new Playlist();
playlist.read(playlistFile);
playOrder = playlist.add(playOrder,
playlistFile.toPath().getParent().relativize(audioFile.toPath()));
playlist.write(playlistFile);
invalidateFuseDentry(playlistFile);
resolvePlaylistMembers(playlistUri);
// Callers are interested in the actual ID we generated
final Uri membersUri = Playlists.Members.getContentUri(volumeName,
ContentUris.parseId(playlistUri));
try (Cursor c = query(membersUri, new String[] { BaseColumns._ID },
Playlists.Members.PLAY_ORDER + "=" + (playOrder + 1), null, null)) {
c.moveToFirst();
return c.getLong(0);
}
} catch (IOException e) {
throw new FallbackException("Failed to update playlist", e,
android.os.Build.VERSION_CODES.R);
}
}
private int addPlaylistMembers(@NonNull Uri playlistUri, @NonNull ContentValues[] initialValues)
throws FallbackException {
final String volumeName = getVolumeName(playlistUri);
final String audioVolumeName =
MediaStore.VOLUME_INTERNAL.equals(volumeName)
? MediaStore.VOLUME_INTERNAL : MediaStore.VOLUME_EXTERNAL;
try {
final File playlistFile = queryForDataFile(playlistUri, null);
final Playlist playlist = new Playlist();
playlist.read(playlistFile);
for (ContentValues values : initialValues) {
final long audioId = values.getAsLong(Audio.Playlists.Members.AUDIO_ID);
final Uri audioUri = Audio.Media.getContentUri(audioVolumeName, audioId);
final File audioFile = queryForDataFile(audioUri, null);
Integer playOrder = values.getAsInteger(Playlists.Members.PLAY_ORDER);
playOrder = (playOrder != null) ? (playOrder - 1) : Integer.MAX_VALUE;
playlist.add(playOrder,
playlistFile.toPath().getParent().relativize(audioFile.toPath()));
}
playlist.write(playlistFile);
resolvePlaylistMembers(playlistUri);
} catch (IOException e) {
throw new FallbackException("Failed to update playlist", e,
android.os.Build.VERSION_CODES.R);
}
return initialValues.length;
}
/**
* Move an audio item within the given playlist.
*/
private int movePlaylistMembers(@NonNull Uri playlistUri, @NonNull ContentValues values,
@NonNull Bundle queryArgs) throws FallbackException {
final int fromIndex = resolvePlaylistIndex(playlistUri, queryArgs);
final int toIndex = values.getAsInteger(Playlists.Members.PLAY_ORDER) - 1;
if (fromIndex == -1) {
throw new FallbackException("Failed to resolve playlist member " + queryArgs,
android.os.Build.VERSION_CODES.R);
}
try {
final File playlistFile = queryForDataFile(playlistUri, null);
final Playlist playlist = new Playlist();
playlist.read(playlistFile);
final int finalIndex = playlist.move(fromIndex, toIndex);
playlist.write(playlistFile);
invalidateFuseDentry(playlistFile);
resolvePlaylistMembers(playlistUri);
return finalIndex;
} catch (IOException e) {
throw new FallbackException("Failed to update playlist", e,
android.os.Build.VERSION_CODES.R);
}
}
/**
* Removes an audio item or multiple audio items(if targetSDK<R) from the given playlist.
*/
private int removePlaylistMembers(@NonNull Uri playlistUri, @NonNull Bundle queryArgs)
throws FallbackException {
final int[] indexes = resolvePlaylistIndexes(playlistUri, queryArgs);
try {
final File playlistFile = queryForDataFile(playlistUri, null);
final Playlist playlist = new Playlist();
playlist.read(playlistFile);
final int count;
if (indexes.length == 0) {
// This means either no playlist members match the query or VolumeNotFoundException
// was thrown. So we don't have anything to delete.
count = 0;
} else {
count = playlist.removeMultiple(indexes);
}
playlist.write(playlistFile);
invalidateFuseDentry(playlistFile);
resolvePlaylistMembers(playlistUri);
return count;
} catch (IOException e) {
throw new FallbackException("Failed to update playlist", e,
android.os.Build.VERSION_CODES.R);
}
}
/**
* Remove an audio item from the given playlist since the playlist file or the audio file is
* already removed.
*/
private void removePlaylistMembers(int mediaType, long id) {
final DatabaseHelper helper;
try {
helper = getDatabaseForUri(Audio.Media.EXTERNAL_CONTENT_URI);
} catch (VolumeNotFoundException e) {
Log.w(TAG, e);
return;
}
helper.runWithTransaction((db) -> {
final String where;
if (mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) {
where = "playlist_id=?";
} else {
where = "audio_id=?";
}
db.delete("audio_playlists_map", where, new String[] { "" + id });
return null;
});
}
/**
* Resolve query arguments that are designed to select specific playlist
* items using the playlist's {@link Playlists.Members#PLAY_ORDER}.
*
* @return an array of the indexes that match the query.
*/
private int[] resolvePlaylistIndexes(@NonNull Uri playlistUri, @NonNull Bundle queryArgs) {
final Uri membersUri = Playlists.Members.getContentUri(
getVolumeName(playlistUri), ContentUris.parseId(playlistUri));
final DatabaseHelper helper;
final SQLiteQueryBuilder qb;
try {
helper = getDatabaseForUri(membersUri);
qb = getQueryBuilder(TYPE_DELETE, AUDIO_PLAYLISTS_ID_MEMBERS,
membersUri, queryArgs, null);
} catch (VolumeNotFoundException ignored) {
return new int[0];
}
try (Cursor c = qb.query(helper,
new String[] { Playlists.Members.PLAY_ORDER }, queryArgs, null)) {
if ((c.getCount() >= 1) && c.moveToFirst()) {
int size = c.getCount();
int[] res = new int[size];
for (int i = 0; i < size; ++i) {
res[i] = c.getInt(0) - 1;
c.moveToNext();
}
return res;
} else {
// Cursor size is 0
return new int[0];
}
}
}
/**
* Resolve query arguments that are designed to select a specific playlist
* item using its {@link Playlists.Members#PLAY_ORDER}.
*
* @return if there's only 1 item that matches the query, returns its index. Returns -1
* otherwise.
*/
private int resolvePlaylistIndex(@NonNull Uri playlistUri, @NonNull Bundle queryArgs) {
int[] indexes = resolvePlaylistIndexes(playlistUri, queryArgs);
if (indexes.length == 1) {
return indexes[0];
}
return -1;
}
private boolean isPickerUri(Uri uri) {
// TODO(b/188394433): move this method to PickerResolver in the spirit of not
// adding picker logic to MediaProvider
final int match = matchUri(uri, /* allowHidden */ isCallingPackageAllowedHidden());
return match == PICKER_ID;
}
public boolean isPickerUnreliableVolumeUri(Uri uri, boolean allowHidden) {
final int match = matchUri(uri, allowHidden);
return match == PICKER_UNRELIABLE_VOLUME;
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
return openFileCommon(uri, mode, /*signal*/ null, /*opts*/ null);
}
@Override
public ParcelFileDescriptor openFile(Uri uri, String mode, CancellationSignal signal)
throws FileNotFoundException {
return openFileCommon(uri, mode, signal, /*opts*/ null);
}
private ParcelFileDescriptor openFileCommon(Uri uri, String mode, CancellationSignal signal,
@Nullable Bundle opts)
throws FileNotFoundException {
opts = opts == null ? new Bundle() : opts;
// REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider.
opts.remove(QUERY_ARG_REDACTED_URI);
if (isRedactedUri(uri)) {
opts.putParcelable(QUERY_ARG_REDACTED_URI, uri);
uri = getUriForRedactedUri(uri);
}
uri = safeUncanonicalize(uri);
if (isPickerUri(uri)) {
final int callingPid = mCallingIdentity.get().pid;
final int callingUid = mCallingIdentity.get().uid;
return mPickerUriResolver.openFile(uri, mode, signal, callingPid, callingUid);
}
final boolean allowHidden = isCallingPackageAllowedHidden();
final int match = matchUri(uri, allowHidden);
final String volumeName = getVolumeName(uri);
// Handle some legacy cases where we need to redirect thumbnails
try {
switch (match) {
case AUDIO_ALBUMART_ID: {
final long albumId = Long.parseLong(uri.getPathSegments().get(3));
final Uri targetUri = ContentUris
.withAppendedId(Audio.Albums.getContentUri(volumeName), albumId);
return ensureThumbnail(targetUri, signal);
}
case AUDIO_ALBUMART_FILE_ID: {
final long audioId = Long.parseLong(uri.getPathSegments().get(3));
final Uri targetUri = ContentUris
.withAppendedId(Audio.Media.getContentUri(volumeName), audioId);
return ensureThumbnail(targetUri, signal);
}
case VIDEO_MEDIA_ID_THUMBNAIL: {
final long videoId = Long.parseLong(uri.getPathSegments().get(3));
final Uri targetUri = ContentUris
.withAppendedId(Video.Media.getContentUri(volumeName), videoId);
return ensureThumbnail(targetUri, signal);
}
case IMAGES_MEDIA_ID_THUMBNAIL: {
final long imageId = Long.parseLong(uri.getPathSegments().get(3));
final Uri targetUri = ContentUris
.withAppendedId(Images.Media.getContentUri(volumeName), imageId);
return ensureThumbnail(targetUri, signal);
}
}
} finally {
// We have to log separately here because openFileAndEnforcePathPermissionsHelper calls
// a public MediaProvider API and so logs the access there.
PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), volumeName);
}
return openFileAndEnforcePathPermissionsHelper(uri, match, mode, signal, opts);
}
@Override
public AssetFileDescriptor openTypedAssetFile(Uri uri, String mimeTypeFilter, Bundle opts)
throws FileNotFoundException {
return openTypedAssetFileCommon(uri, mimeTypeFilter, opts, null);
}
@Override
public AssetFileDescriptor openTypedAssetFile(Uri uri, String mimeTypeFilter, Bundle opts,
CancellationSignal signal) throws FileNotFoundException {
return openTypedAssetFileCommon(uri, mimeTypeFilter, opts, signal);
}
private AssetFileDescriptor openTypedAssetFileCommon(Uri uri, String mimeTypeFilter,
Bundle opts, CancellationSignal signal) throws FileNotFoundException {
uri = safeUncanonicalize(uri);
if (opts != null && opts.containsKey(MediaStore.EXTRA_FILE_DESCRIPTOR)) {
// This is called as part of MediaStore#getOriginalMediaFormatFileDescriptor
// We don't need to use the |uri| because the input fd already identifies the file and
// we actually don't have a valid URI, we are going to identify the file via the fd.
// While identifying the file, we also perform the following security checks.
// 1. Find the FUSE file with the associated inode
// 2. Verify that the binder caller opened it
// 3. Verify the access level the fd is opened with (r/w)
// 4. Open the original (non-transcoded) file *with* redaction enabled and the access
// level from #3
// 5. Return the fd from #4 to the app or throw an exception if any of the conditions
// are not met
return getOriginalMediaFormatFileDescriptor(opts);
}
// This is needed for thumbnail resolution as it doesn't go through openFileCommon
if (isPickerUri(uri)) {
final int callingPid = mCallingIdentity.get().pid;
final int callingUid = mCallingIdentity.get().uid;
return mPickerUriResolver.openTypedAssetFile(uri, mimeTypeFilter, opts, signal,
callingPid, callingUid);
}
// TODO: enforce that caller has access to this uri
// Offer thumbnail of media, when requested
final boolean wantsThumb = (opts != null) && opts.containsKey(ContentResolver.EXTRA_SIZE)
&& MimeUtils.startsWithIgnoreCase(mimeTypeFilter, "image/");
if (wantsThumb) {
final ParcelFileDescriptor pfd = ensureThumbnail(uri, signal);
return new AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH);
}
// Worst case, return the underlying file
return new AssetFileDescriptor(openFileCommon(uri, "r", signal, opts), 0,
AssetFileDescriptor.UNKNOWN_LENGTH);
}
private ParcelFileDescriptor ensureThumbnail(Uri uri, CancellationSignal signal)
throws FileNotFoundException {
final boolean allowHidden = isCallingPackageAllowedHidden();
final int match = matchUri(uri, allowHidden);
Trace.beginSection("ensureThumbnail");
final LocalCallingIdentity token = clearLocalCallingIdentity();
try {
switch (match) {
case AUDIO_ALBUMS_ID: {
final String volumeName = MediaStore.getVolumeName(uri);
final Uri baseUri = MediaStore.Audio.Media.getContentUri(volumeName);
final long albumId = ContentUris.parseId(uri);
try (Cursor c = query(baseUri, new String[] { MediaStore.Audio.Media._ID },
MediaStore.Audio.Media.ALBUM_ID + "=" + albumId, null, null, signal)) {
if (c.moveToFirst()) {
final long audioId = c.getLong(0);
final Uri targetUri = ContentUris.withAppendedId(baseUri, audioId);
return mAudioThumbnailer.ensureThumbnail(targetUri, signal);
} else {
throw new FileNotFoundException("No media for album " + uri);
}
}
}
case AUDIO_MEDIA_ID:
return mAudioThumbnailer.ensureThumbnail(uri, signal);
case VIDEO_MEDIA_ID:
return mVideoThumbnailer.ensureThumbnail(uri, signal);
case IMAGES_MEDIA_ID:
return mImageThumbnailer.ensureThumbnail(uri, signal);
case FILES_ID:
case DOWNLOADS_ID: {
// When item is referenced in a generic way, resolve to actual type
final int mediaType = MimeUtils.resolveMediaType(getType(uri));
switch (mediaType) {
case FileColumns.MEDIA_TYPE_AUDIO:
return mAudioThumbnailer.ensureThumbnail(uri, signal);
case FileColumns.MEDIA_TYPE_VIDEO:
return mVideoThumbnailer.ensureThumbnail(uri, signal);
case FileColumns.MEDIA_TYPE_IMAGE:
return mImageThumbnailer.ensureThumbnail(uri, signal);
default:
throw new FileNotFoundException();
}
}
default:
throw new FileNotFoundException();
}
} catch (IOException e) {
Log.w(TAG, e);
throw new FileNotFoundException(e.getMessage());
} finally {
restoreLocalCallingIdentity(token);
Trace.endSection();
}
}
/**
* Update the metadata columns for the image residing at given {@link Uri}
* by reading data from the underlying image.
*/
private void updateImageMetadata(ContentValues values, File file) {
final BitmapFactory.Options bitmapOpts = new BitmapFactory.Options();
bitmapOpts.inJustDecodeBounds = true;
BitmapFactory.decodeFile(file.getAbsolutePath(), bitmapOpts);
values.put(MediaColumns.WIDTH, bitmapOpts.outWidth);
values.put(MediaColumns.HEIGHT, bitmapOpts.outHeight);
}
private void handleInsertedRowForFuse(long rowId) {
if (isFuseThread()) {
// Removes restored row ID saved list.
mCallingIdentity.get().removeDeletedRowId(rowId);
}
}
private void handleUpdatedRowForFuse(@NonNull String oldPath, @NonNull String ownerPackage,
long oldRowId, long newRowId) {
if (oldRowId == newRowId) {
// Update didn't delete or add row ID. We don't need to save row ID or remove saved
// deleted ID.
return;
}
handleDeletedRowForFuse(oldPath, ownerPackage, oldRowId);
handleInsertedRowForFuse(newRowId);
}
private void handleDeletedRowForFuse(@NonNull String path, @NonNull String ownerPackage,
long rowId) {
if (!isFuseThread()) {
return;
}
// Invalidate saved owned ID's of the previous owner of the deleted path, this prevents old
// owner from gaining access to newly created file with restored row ID.
if (!ownerPackage.equals("null") && !ownerPackage.equals(getCallingPackageOrSelf())) {
invalidateLocalCallingIdentityCache(ownerPackage, "owned_database_row_deleted:"
+ path);
}
// Saves row ID corresponding to deleted path. Saved row ID will be restored on subsequent
// create or rename.
mCallingIdentity.get().addDeletedRowId(path, rowId);
}
private void handleOwnerPackageNameChange(@NonNull String oldPath,
@NonNull String oldOwnerPackage, @NonNull String newOwnerPackage) {
if (Objects.equals(oldOwnerPackage, newOwnerPackage)) {
return;
}
// Invalidate saved owned ID's of the previous owner of the renamed path, this prevents old
// owner from gaining access to replaced file.
invalidateLocalCallingIdentityCache(oldOwnerPackage, "owner_package_changed:" + oldPath);
}
/**
* Return the {@link MediaColumns#DATA} field for the given {@code Uri}.
*/
File queryForDataFile(Uri uri, CancellationSignal signal)
throws FileNotFoundException {
return queryForDataFile(uri, null, null, signal);
}
/**
* Return the {@link MediaColumns#DATA} field for the given {@code Uri}.
*/
File queryForDataFile(Uri uri, String selection, String[] selectionArgs,
CancellationSignal signal) throws FileNotFoundException {
try (Cursor cursor = queryForSingleItem(uri, new String[] { MediaColumns.DATA },
selection, selectionArgs, signal)) {
final String data = cursor.getString(0);
if (TextUtils.isEmpty(data)) {
throw new FileNotFoundException("Missing path for " + uri);
} else {
return new File(data);
}
}
}
/**
* Return the {@link Uri} for the given {@code File}.
*/
Uri queryForMediaUri(File file, CancellationSignal signal) throws FileNotFoundException {
final String volumeName = FileUtils.getVolumeName(getContext(), file);
final Uri uri = Files.getContentUri(volumeName);
try (Cursor cursor = queryForSingleItem(uri, new String[] { MediaColumns._ID },
MediaColumns.DATA + "=?", new String[] { file.getAbsolutePath() }, signal)) {
return ContentUris.withAppendedId(uri, cursor.getLong(0));
}
}
/**
* Query the given {@link Uri} as MediaProvider, expecting only a single item to be found.
*
* @throws FileNotFoundException if no items were found, or multiple items
* were found, or there was trouble reading the data.
*/
Cursor queryForSingleItemAsMediaProvider(Uri uri, String[] projection, String selection,
String[] selectionArgs, CancellationSignal signal)
throws FileNotFoundException {
final LocalCallingIdentity tokenInner = clearLocalCallingIdentity();
try {
return queryForSingleItem(uri, projection, selection, selectionArgs, signal);
} finally {
restoreLocalCallingIdentity(tokenInner);
}
}
/**
* Query the given {@link Uri}, expecting only a single item to be found.
*
* @throws FileNotFoundException if no items were found, or multiple items
* were found, or there was trouble reading the data.
*/
Cursor queryForSingleItem(Uri uri, String[] projection, String selection,
String[] selectionArgs, CancellationSignal signal) throws FileNotFoundException {
final Cursor c = query(uri, projection,
DatabaseUtils.createSqlQueryBundle(selection, selectionArgs, null), signal, true);
if (c == null) {
throw new FileNotFoundException("Missing cursor for " + uri);
} else if (c.getCount() < 1) {
FileUtils.closeQuietly(c);
throw new FileNotFoundException("No item at " + uri);
} else if (c.getCount() > 1) {
FileUtils.closeQuietly(c);
throw new FileNotFoundException("Multiple items at " + uri);
}
if (c.moveToFirst()) {
return c;
} else {
FileUtils.closeQuietly(c);
throw new FileNotFoundException("Failed to read row from " + uri);
}
}
/**
* Compares {@code itemOwner} with package name of {@link LocalCallingIdentity} and throws
* {@link IllegalStateException} if it doesn't match.
* Make sure to set calling identity properly before calling.
*/
private void requireOwnershipForItem(@Nullable String itemOwner, Uri item) {
final boolean hasOwner = (itemOwner != null);
final boolean callerIsOwner = Objects.equals(getCallingPackageOrSelf(), itemOwner);
if (hasOwner && !callerIsOwner) {
throw new IllegalStateException(
"Only owner is able to interact with pending/trashed item " + item);
}
}
public File getFuseFile(File file) {
String filePath = file.getPath().replaceFirst(
"/storage/", "/mnt/user/" + UserHandle.myUserId() + "/");
return new File(filePath);
}
private ParcelFileDescriptor openWithFuse(String filePath, int uid, int mediaCapabilitiesUid,
int modeBits, boolean shouldRedact, boolean shouldTranscode, int transcodeReason)
throws FileNotFoundException {
Log.d(TAG, "Open with FUSE. FilePath: " + filePath
+ ". Uid: " + uid
+ ". Media Capabilities Uid: " + mediaCapabilitiesUid
+ ". ShouldRedact: " + shouldRedact
+ ". ShouldTranscode: " + shouldTranscode);
int tid = android.os.Process.myTid();
synchronized (mPendingOpenInfo) {
mPendingOpenInfo.put(tid,
new PendingOpenInfo(uid, mediaCapabilitiesUid, shouldRedact, transcodeReason));
}
try {
return FileUtils.openSafely(getFuseFile(new File(filePath)), modeBits);
} finally {
synchronized (mPendingOpenInfo) {
mPendingOpenInfo.remove(tid);
}
}
}
private @NonNull FuseDaemon getFuseDaemonForFile(@NonNull File file)
throws FileNotFoundException {
final FuseDaemon daemon = ExternalStorageServiceImpl.getFuseDaemon(getVolumeId(file));
if (daemon == null) {
throw new FileNotFoundException("Missing FUSE daemon for " + file);
} else {
return daemon;
}
}
private void invalidateFuseDentry(@NonNull File file) {
invalidateFuseDentry(file.getAbsolutePath());
}
private void invalidateFuseDentry(@NonNull String path) {
try {
final FuseDaemon daemon = getFuseDaemonForFile(new File(path));
if (isFuseThread()) {
// If we are on a FUSE thread, we don't need to invalidate,
// (and *must* not, otherwise we'd crash) because the invalidation
// is already reflected in the lower filesystem
return;
} else {
daemon.invalidateFuseDentryCache(path);
}
} catch (FileNotFoundException e) {
Log.w(TAG, "Failed to invalidate FUSE dentry", e);
}
}
/**
* Replacement for {@link #openFileHelper(Uri, String)} which enforces any
* permissions applicable to the path before returning.
*
* <p>This function should never be called from the fuse thread since it tries to open
* a "/mnt/user" path.
*/
private ParcelFileDescriptor openFileAndEnforcePathPermissionsHelper(Uri uri, int match,
String mode, CancellationSignal signal, @NonNull Bundle opts)
throws FileNotFoundException {
int modeBits = ParcelFileDescriptor.parseMode(mode);
boolean forWrite = (modeBits & ParcelFileDescriptor.MODE_WRITE_ONLY) != 0;
final Uri redactedUri = opts.getParcelable(QUERY_ARG_REDACTED_URI);
if (forWrite) {
if (redactedUri != null) {
throw new UnsupportedOperationException(
"Write is not supported on " + redactedUri.toString());
}
// Upgrade 'w' only to 'rw'. This allows us acquire a WR_LOCK when calling
// #shouldOpenWithFuse
modeBits |= ParcelFileDescriptor.MODE_READ_WRITE;
}
final boolean hasOwnerPackageName = hasOwnerPackageName(uri);
final String[] projection = new String[] {
MediaColumns.DATA,
hasOwnerPackageName ? MediaColumns.OWNER_PACKAGE_NAME : "NULL",
hasOwnerPackageName ? MediaColumns.IS_PENDING : "0",
};
final File file;
final String ownerPackageName;
final boolean isPending;
final LocalCallingIdentity token = clearLocalCallingIdentity();
try (Cursor c = queryForSingleItem(uri, projection, null, null, signal)) {
final String data = c.getString(0);
if (TextUtils.isEmpty(data)) {
throw new FileNotFoundException("Missing path for " + uri);
} else {
file = new File(data).getCanonicalFile();
}
ownerPackageName = c.getString(1);
isPending = c.getInt(2) != 0;
} catch (IOException e) {
throw new FileNotFoundException(e.toString());
} finally {
restoreLocalCallingIdentity(token);
}
if (redactedUri == null) {
checkAccess(uri, Bundle.EMPTY, file, forWrite);
} else {
checkAccess(redactedUri, Bundle.EMPTY, file, false);
}
// We don't check ownership for files with IS_PENDING set by FUSE
if (isPending && !isPendingFromFuse(file)) {
requireOwnershipForItem(ownerPackageName, uri);
}
final boolean callerIsOwner = Objects.equals(getCallingPackageOrSelf(), ownerPackageName);
// Figure out if we need to redact contents
final boolean redactionNeeded =
(redactedUri != null) || (!callerIsOwner && isRedactionNeeded(uri));
final RedactionInfo redactionInfo;
try {
redactionInfo = redactionNeeded ? getRedactionRanges(file)
: new RedactionInfo(new long[0], new long[0]);
} catch (IOException e) {
throw new IllegalStateException(e);
}
// Yell if caller requires original, since we can't give it to them
// unless they have access granted above
if (redactionNeeded && MediaStore.getRequireOriginal(uri)) {
throw new UnsupportedOperationException(
"Caller must hold ACCESS_MEDIA_LOCATION permission to access original");
}
// Kick off metadata update when writing is finished
final OnCloseListener listener = (e) -> {
// We always update metadata to reflect the state on disk, even when
// the remote writer tried claiming an exception
invalidateThumbnails(uri);
// Invalidate so subsequent stat(2) on the upper fs is eventually consistent
invalidateFuseDentry(file);
try {
switch (match) {
case IMAGES_THUMBNAILS_ID:
case VIDEO_THUMBNAILS_ID:
final ContentValues values = new ContentValues();
updateImageMetadata(values, file);
update(uri, values, null, null);
break;
default:
scanFileAsMediaProvider(file, REASON_DEMAND);
break;
}
} catch (Exception e2) {
Log.w(TAG, "Failed to update metadata for " + uri, e2);
}
};
try {
// First, handle any redaction that is needed for caller
final ParcelFileDescriptor pfd;
final String filePath = file.getPath();
final int uid = Binder.getCallingUid();
final int transcodeReason = mTranscodeHelper.shouldTranscode(filePath, uid, opts);
final boolean shouldTranscode = transcodeReason > 0;
int mediaCapabilitiesUid = opts.getInt(MediaStore.EXTRA_MEDIA_CAPABILITIES_UID);
if (!shouldTranscode || mediaCapabilitiesUid < Process.FIRST_APPLICATION_UID) {
// Although 0 is a valid UID, it's not a valid app uid.
// So, we use it to signify that mediaCapabilitiesUid is not set.
mediaCapabilitiesUid = 0;
}
if (redactionInfo.redactionRanges.length > 0) {
// If fuse is enabled, we can provide an fd that points to the fuse
// file system and handle redaction in the fuse handler when the caller reads.
pfd = openWithFuse(filePath, uid, mediaCapabilitiesUid, modeBits,
true /* shouldRedact */, shouldTranscode, transcodeReason);
} else if (shouldTranscode) {
pfd = openWithFuse(filePath, uid, mediaCapabilitiesUid, modeBits,
false /* shouldRedact */, shouldTranscode, transcodeReason);
} else {
FuseDaemon daemon = null;
try {
daemon = getFuseDaemonForFile(file);
} catch (FileNotFoundException ignored) {
}
ParcelFileDescriptor lowerFsFd = FileUtils.openSafely(file, modeBits);
// Always acquire a readLock. This allows us make multiple opens via lower
// filesystem
boolean shouldOpenWithFuse = daemon != null
&& daemon.shouldOpenWithFuse(filePath, true /* forRead */,
lowerFsFd.getFd());
if (shouldOpenWithFuse) {
// If the file is already opened on the FUSE mount with VFS caching enabled
// we return an upper filesystem fd (via FUSE) to avoid file corruption
// resulting from cache inconsistencies between the upper and lower
// filesystem caches
pfd = openWithFuse(filePath, uid, mediaCapabilitiesUid, modeBits,
false /* shouldRedact */, shouldTranscode, transcodeReason);
try {
lowerFsFd.close();
} catch (IOException e) {
Log.w(TAG, "Failed to close lower filesystem fd " + file.getPath(), e);
}
} else {
Log.i(TAG, "Open with lower FS for " + filePath + ". Uid: " + uid);
if (forWrite) {
// When opening for write on the lower filesystem, invalidate the VFS dentry
// so subsequent open/getattr calls will return correctly.
//
// A 'dirty' dentry with write back cache enabled can cause the kernel to
// ignore file attributes or even see stale page cache data when the lower
// filesystem has been modified outside of the FUSE driver
invalidateFuseDentry(file);
}
pfd = lowerFsFd;
}
}
// Second, wrap in any listener that we've requested
if (!isPending && forWrite && listener != null) {
return ParcelFileDescriptor.wrap(pfd, BackgroundThread.getHandler(), listener);
} else {
return pfd;
}
} catch (IOException e) {
if (e instanceof FileNotFoundException) {
throw (FileNotFoundException) e;
} else {
throw new IllegalStateException(e);
}
}
}
private void deleteAndInvalidate(@NonNull Path path) {
deleteAndInvalidate(path.toFile());
}
private void deleteAndInvalidate(@NonNull File file) {
file.delete();
invalidateFuseDentry(file);
}
private void deleteIfAllowed(Uri uri, Bundle extras, String path) {
try {
final File file = new File(path);
checkAccess(uri, extras, file, true);
deleteAndInvalidate(file);
} catch (Exception e) {
Log.e(TAG, "Couldn't delete " + path, e);
}
}
@Deprecated
private boolean isPending(Uri uri) {
final int match = matchUri(uri, true);
switch (match) {
case AUDIO_MEDIA_ID:
case VIDEO_MEDIA_ID:
case IMAGES_MEDIA_ID:
try (Cursor c = queryForSingleItem(uri,
new String[] { MediaColumns.IS_PENDING }, null, null, null)) {
return (c.getInt(0) != 0);
} catch (FileNotFoundException e) {
throw new IllegalStateException(e);
}
default:
return false;
}
}
@Deprecated
private boolean isRedactionNeeded(Uri uri) {
return mCallingIdentity.get().hasPermission(PERMISSION_IS_REDACTION_NEEDED);
}
private boolean isRedactionNeeded() {
return mCallingIdentity.get().hasPermission(PERMISSION_IS_REDACTION_NEEDED);
}
private boolean isCallingPackageRequestingLegacy() {
return mCallingIdentity.get().hasPermission(PERMISSION_IS_LEGACY_GRANTED);
}
private boolean shouldBypassDatabase(int uid) {
if (uid != android.os.Process.SHELL_UID && isCallingPackageManager()) {
return mCallingIdentity.get().shouldBypassDatabase(false /*isSystemGallery*/);
} else if (isCallingPackageSystemGallery()) {
if (isCallingPackageLegacyWrite()) {
// We bypass db operations for legacy system galleries with W_E_S (see b/167307393).
// Tracking a longer term solution in b/168784136.
return true;
} else if (isCallingPackageRequestingLegacy()) {
// If requesting legacy, app should have W_E_S along with SystemGallery appops.
return false;
} else if (!SdkLevel.isAtLeastS()) {
// We don't parse manifest flags for SdkLevel<=R yet. Hence, we don't bypass
// database updates for SystemGallery targeting R or above on R OS.
return false;
}
return mCallingIdentity.get().shouldBypassDatabase(true /*isSystemGallery*/);
}
return false;
}
private static int getFileMediaType(String path) {
final File file = new File(path);
final String mimeType = MimeUtils.resolveMimeType(file);
return MimeUtils.resolveMediaType(mimeType);
}
private boolean canAccessMediaFile(String filePath, boolean allowLegacy) {
if (!allowLegacy && isCallingPackageRequestingLegacy()) {
return false;
}
switch (getFileMediaType(filePath)) {
case FileColumns.MEDIA_TYPE_IMAGE:
return mCallingIdentity.get().hasPermission(PERMISSION_WRITE_IMAGES);
case FileColumns.MEDIA_TYPE_VIDEO:
return mCallingIdentity.get().hasPermission(PERMISSION_WRITE_VIDEO);
default:
return false;
}
}
/**
* Returns true if:
* <ul>
* <li>the calling identity is an app targeting Q or older versions AND is requesting legacy
* storage
* <li>the calling identity holds {@code MANAGE_EXTERNAL_STORAGE}
* <li>the calling identity owns or has access to the filePath (eg /Android/data/com.foo)
* <li>the calling identity has permission to write images and the given file is an image file
* <li>the calling identity has permission to write video and the given file is an video file
* </ul>
*/
private boolean shouldBypassFuseRestrictions(boolean forWrite, String filePath) {
boolean isRequestingLegacyStorage = forWrite ? isCallingPackageLegacyWrite()
: isCallingPackageLegacyRead();
if (isRequestingLegacyStorage) {
return true;
}
if (isCallingPackageManager()) {
return true;
}
// Check if the caller has access to private app directories.
if (isUidAllowedAccessToDataOrObbPathForFuse(mCallingIdentity.get().uid, filePath)) {
return true;
}
// Apps with write access to images and/or videos can bypass our restrictions if all of the
// the files they're accessing are of the compatible media type.
if (canAccessMediaFile(filePath, /*allowLegacy*/ true)) {
return true;
}
return false;
}
/**
* Returns true if the passed in path is an application-private data directory
* (such as Android/data/com.foo or Android/obb/com.foo) that does not belong to the caller and
* the caller does not have special access.
*/
private boolean isPrivatePackagePathNotAccessibleByCaller(String path) {
// Files under the apps own private directory
final String appSpecificDir = extractPathOwnerPackageName(path);
if (appSpecificDir == null) {
return false;
}
// Android/media is not considered private, because it contains media that is explicitly
// scanned and shared by other apps
if (isExternalMediaDirectory(path)) {
return false;
}
return !isUidAllowedAccessToDataOrObbPathForFuse(mCallingIdentity.get().uid, path);
}
private boolean shouldBypassDatabaseAndSetDirtyForFuse(int uid, String path) {
if (shouldBypassDatabase(uid)) {
synchronized (mNonHiddenPaths) {
File file = new File(path);
String key = file.getParent();
boolean maybeHidden = !mNonHiddenPaths.containsKey(key);
if (maybeHidden) {
File topNoMediaDir = FileUtils.getTopLevelNoMedia(new File(path));
if (topNoMediaDir == null) {
mNonHiddenPaths.put(key, 0);
} else {
mMediaScanner.onDirectoryDirty(topNoMediaDir);
}
}
}
return true;
}
return false;
}
/**
* Set of Exif tags that should be considered for redaction.
*/
private static final String[] REDACTED_EXIF_TAGS = new String[] {
ExifInterface.TAG_GPS_ALTITUDE,
ExifInterface.TAG_GPS_ALTITUDE_REF,
ExifInterface.TAG_GPS_AREA_INFORMATION,
ExifInterface.TAG_GPS_DOP,
ExifInterface.TAG_GPS_DATESTAMP,
ExifInterface.TAG_GPS_DEST_BEARING,
ExifInterface.TAG_GPS_DEST_BEARING_REF,
ExifInterface.TAG_GPS_DEST_DISTANCE,
ExifInterface.TAG_GPS_DEST_DISTANCE_REF,
ExifInterface.TAG_GPS_DEST_LATITUDE,
ExifInterface.TAG_GPS_DEST_LATITUDE_REF,
ExifInterface.TAG_GPS_DEST_LONGITUDE,
ExifInterface.TAG_GPS_DEST_LONGITUDE_REF,
ExifInterface.TAG_GPS_DIFFERENTIAL,
ExifInterface.TAG_GPS_IMG_DIRECTION,
ExifInterface.TAG_GPS_IMG_DIRECTION_REF,
ExifInterface.TAG_GPS_LATITUDE,
ExifInterface.TAG_GPS_LATITUDE_REF,
ExifInterface.TAG_GPS_LONGITUDE,
ExifInterface.TAG_GPS_LONGITUDE_REF,
ExifInterface.TAG_GPS_MAP_DATUM,
ExifInterface.TAG_GPS_MEASURE_MODE,
ExifInterface.TAG_GPS_PROCESSING_METHOD,
ExifInterface.TAG_GPS_SATELLITES,
ExifInterface.TAG_GPS_SPEED,
ExifInterface.TAG_GPS_SPEED_REF,
ExifInterface.TAG_GPS_STATUS,
ExifInterface.TAG_GPS_TIMESTAMP,
ExifInterface.TAG_GPS_TRACK,
ExifInterface.TAG_GPS_TRACK_REF,
ExifInterface.TAG_GPS_VERSION_ID,
};
/**
* Set of ISO boxes that should be considered for redaction.
*/
private static final int[] REDACTED_ISO_BOXES = new int[] {
IsoInterface.BOX_LOCI,
IsoInterface.BOX_XYZ,
IsoInterface.BOX_GPS,
IsoInterface.BOX_GPS0,
};
public static final Set<String> sRedactedExifTags = new ArraySet<>(
Arrays.asList(REDACTED_EXIF_TAGS));
private static final class RedactionInfo {
public final long[] redactionRanges;
public final long[] freeOffsets;
public RedactionInfo() {
this.redactionRanges = new long[0];
this.freeOffsets = new long[0];
}
public RedactionInfo(long[] redactionRanges, long[] freeOffsets) {
this.redactionRanges = redactionRanges;
this.freeOffsets = freeOffsets;
}
}
private static class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int mMaxSize;
public LRUCache(int maxSize) {
this.mMaxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > mMaxSize;
}
}
private static final class PendingOpenInfo {
public final int uid;
public final int mediaCapabilitiesUid;
public final boolean shouldRedact;
public final int transcodeReason;
public PendingOpenInfo(int uid, int mediaCapabilitiesUid, boolean shouldRedact,
int transcodeReason) {
this.uid = uid;
this.mediaCapabilitiesUid = mediaCapabilitiesUid;
this.shouldRedact = shouldRedact;
this.transcodeReason = transcodeReason;
}
}
/**
* Calculates the ranges that need to be redacted for the given file and user that wants to
* access the file.
* Note: This method assumes that the caller of this function has already done permission checks
* for the uid to access this path.
*
* @param uid UID of the package wanting to access the file
* @param path File path
* @param tid thread id making IO on the FUSE filesystem
* @return Ranges that should be redacted.
*
* @throws IOException if an error occurs while calculating the redaction ranges
*/
@NonNull
private long[] getRedactionRangesForFuse(String path, String ioPath, int original_uid, int uid,
int tid, boolean forceRedaction) throws IOException {
// |ioPath| might refer to a transcoded file path (which is not indexed in the db)
// |path| will always refer to a valid _data column
// We use |ioPath| for the filesystem access because in the case of transcoding,
// we want to get redaction ranges from the transcoded file and *not* the original file
final File file = new File(ioPath);
if (forceRedaction) {
return getRedactionRanges(file).redactionRanges;
}
// When calculating redaction ranges initiated from MediaProvider, the redaction policy
// is slightly different from the FUSE initiated opens redaction policy. targetSdk=29 from
// MediaProvider requires redaction, but targetSdk=29 apps from FUSE don't require redaction
// Hence, we check the mPendingOpenInfo object (populated when opens are initiated from
// MediaProvider) if there's a pending open from MediaProvider with matching tid and uid and
// use the shouldRedact decision there if there's one.
synchronized (mPendingOpenInfo) {
PendingOpenInfo info = mPendingOpenInfo.get(tid);
if (info != null && info.uid == original_uid) {
boolean shouldRedact = info.shouldRedact;
if (shouldRedact) {
return getRedactionRanges(file).redactionRanges;
} else {
return new long[0];
}
}
}
final LocalCallingIdentity token =
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
try {
if (!isRedactionNeeded()
|| shouldBypassFuseRestrictions(/*forWrite*/ false, path)) {
return new long[0];
}
final Uri contentUri = FileUtils.getContentUriForPath(path);
final String[] projection = new String[]{
MediaColumns.OWNER_PACKAGE_NAME, MediaColumns._ID , FileColumns.MEDIA_TYPE};
final String selection = MediaColumns.DATA + "=?";
final String[] selectionArgs = new String[]{path};
final String ownerPackageName;
final int id;
final int mediaType;
// Query as MediaProvider as non-RES apps will result in FileNotFoundException.
// Note: The caller uid already has passed permission checks to access this file.
try (final Cursor c = queryForSingleItemAsMediaProvider(contentUri, projection,
selection, selectionArgs, null)) {
c.moveToFirst();
ownerPackageName = c.getString(0);
id = c.getInt(1);
mediaType = c.getInt(2);
} catch (FileNotFoundException e) {
// Ideally, this shouldn't happen unless the file was deleted after we checked its
// existence and before we get to the redaction logic here. In this case we throw
// and fail the operation and FuseDaemon should handle this and fail the whole open
// operation gracefully.
throw new FileNotFoundException(
path + " not found while calculating redaction ranges: " + e.getMessage());
}
final boolean callerIsOwner = Objects.equals(getCallingPackageOrSelf(),
ownerPackageName);
// Do not redact if the caller is the owner
if (callerIsOwner) {
return new long[0];
}
// Do not redact if the caller has write uri permission granted on the file.
final Uri fileUri = ContentUris.withAppendedId(contentUri, id);
boolean callerHasWriteUriPermission = getContext().checkUriPermission(
fileUri, mCallingIdentity.get().pid, mCallingIdentity.get().uid,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION) == PERMISSION_GRANTED;
if (callerHasWriteUriPermission) {
return new long[0];
}
// Check if the caller has write access to other uri formats for the same file.
callerHasWriteUriPermission = getOtherUriGrantsForPath(path, mediaType,
Long.toString(id), /* forWrite */ true) != null;
if (callerHasWriteUriPermission) {
return new long[0];
}
return getRedactionRanges(file).redactionRanges;
} finally {
restoreLocalCallingIdentity(token);
}
}
/**
* Calculates the ranges containing sensitive metadata that should be redacted if the caller
* doesn't have the required permissions.
*
* @param file file to be redacted
* @return the ranges to be redacted in a RedactionInfo object, could be empty redaction ranges
* if there's sensitive metadata
* @throws IOException if an IOException happens while calculating the redaction ranges
*/
@VisibleForTesting
public static RedactionInfo getRedactionRanges(File file) throws IOException {
try (FileInputStream is = new FileInputStream(file)) {
return getRedactionRanges(is, MimeUtils.resolveMimeType(file));
} catch (FileNotFoundException ignored) {
// If file not found, then there's nothing to redact
return new RedactionInfo();
} catch (IOException e) {
throw new IOException("Failed to redact " + file, e);
}
}
/**
* Calculates the ranges containing sensitive metadata that should be redacted if the caller
* doesn't have the required permissions.
*
* @param fis {@link FileInputStream} to be redacted
* @return the ranges to be redacted in a RedactionInfo object, could be empty redaction ranges
* if there's sensitive metadata
* @throws IOException if an IOException happens while calculating the redaction ranges
*/
@VisibleForTesting
public static RedactionInfo getRedactionRanges(FileInputStream fis, String mimeType)
throws IOException {
final LongArray res = new LongArray();
final LongArray freeOffsets = new LongArray();
Trace.beginSection("getRedactionRanges");
try {
if (ExifInterface.isSupportedMimeType(mimeType)) {
final ExifInterface exif = new ExifInterface(fis.getFD());
for (String tag : REDACTED_EXIF_TAGS) {
final long[] range = exif.getAttributeRange(tag);
if (range != null) {
res.add(range[0]);
res.add(range[0] + range[1]);
}
}
// Redact xmp where present
final XmpInterface exifXmp = XmpInterface.fromContainer(exif);
res.addAll(exifXmp.getRedactionRanges());
}
if (IsoInterface.isSupportedMimeType(mimeType)) {
final IsoInterface iso = IsoInterface.fromFileDescriptor(fis.getFD());
for (int box : REDACTED_ISO_BOXES) {
final long[] ranges = iso.getBoxRanges(box);
for (int i = 0; i < ranges.length; i += 2) {
long boxTypeOffset = ranges[i] - 4;
freeOffsets.add(boxTypeOffset);
res.add(boxTypeOffset);
res.add(ranges[i + 1]);
}
}
// Redact xmp where present
final XmpInterface isoXmp = XmpInterface.fromContainer(iso);
res.addAll(isoXmp.getRedactionRanges());
}
return new RedactionInfo(res.toArray(), freeOffsets.toArray());
} finally {
Trace.endSection();
}
}
/**
* @return {@code true} if {@code file} is pending from FUSE, {@code false} otherwise.
* Files pending from FUSE will not have pending file pattern.
*/
private static boolean isPendingFromFuse(@NonNull File file) {
final Matcher matcher =
FileUtils.PATTERN_EXPIRES_FILE.matcher(extractDisplayName(file.getName()));
return !matcher.matches();
}
/**
* Checks if the app identified by the given UID is allowed to open the given file for the given
* access mode.
*
* @param path the path of the file to be opened
* @param uid UID of the app requesting to open the file
* @param forWrite specifies if the file is to be opened for write
* @return {@link FileOpenResult} with {@code status} {@code 0} upon success and
* {@link FileOpenResult} with {@code status} {@link OsConstants#EACCES} if the operation is
* illegal or not permitted for the given {@code uid} or if the calling package is a legacy app
* that doesn't have right storage permission.
*
* Called from JNI in jni/MediaProviderWrapper.cpp
*/
@Keep
public FileOpenResult onFileOpenForFuse(String path, String ioPath, int uid, int tid,
int transformsReason, boolean forWrite, boolean redact, boolean logTransformsMetrics) {
final LocalCallingIdentity token =
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path);
boolean isSuccess = false;
final int originalUid = getBinderUidForFuse(uid, tid);
final int callingUserId = uidToUserId(uid);
int mediaCapabilitiesUid = 0;
final PendingOpenInfo pendingOpenInfo;
synchronized (mPendingOpenInfo) {
pendingOpenInfo = mPendingOpenInfo.get(tid);
}
if (pendingOpenInfo != null && pendingOpenInfo.uid == originalUid) {
mediaCapabilitiesUid = pendingOpenInfo.mediaCapabilitiesUid;
}
try {
boolean forceRedaction = false;
String redactedUriId = null;
if (isSyntheticPath(path, callingUserId)) {
if (forWrite) {
// Synthetic URIs are not allowed to update EXIF headers.
return new FileOpenResult(OsConstants.EACCES /* status */, originalUid,
mediaCapabilitiesUid, new long[0]);
}
if (isRedactedPath(path, callingUserId)) {
redactedUriId = extractFileName(path);
// If path is redacted Uris' path, ioPath must be the real path, ioPath must
// haven been updated to the real path during onFileLookupForFuse.
path = ioPath;
// Irrespective of the permissions we want to redact in this case.
redact = true;
forceRedaction = true;
} else if (isPickerPath(path, callingUserId)) {
return handlePickerFileOpen(path, originalUid);
} else {
// we don't support any other transformations under .transforms/synthetic dir
return new FileOpenResult(OsConstants.ENOENT /* status */, originalUid,
mediaCapabilitiesUid, new long[0]);
}
}
if (isPrivatePackagePathNotAccessibleByCaller(path)) {
Log.e(TAG, "Can't open a file in another app's external directory!");
return new FileOpenResult(OsConstants.ENOENT, originalUid, mediaCapabilitiesUid,
new long[0]);
}
if (shouldBypassFuseRestrictions(forWrite, path)) {
isSuccess = true;
return new FileOpenResult(0 /* status */, originalUid, mediaCapabilitiesUid,
redact ? getRedactionRangesForFuse(path, ioPath, originalUid, uid, tid,
forceRedaction) : new long[0]);
}
// Legacy apps that made is this far don't have the right storage permission and hence
// are not allowed to access anything other than their external app directory
if (isCallingPackageRequestingLegacy()) {
return new FileOpenResult(OsConstants.EACCES /* status */, originalUid,
mediaCapabilitiesUid, new long[0]);
}
final Uri contentUri = FileUtils.getContentUriForPath(path);
final String[] projection = new String[]{
MediaColumns._ID,
MediaColumns.OWNER_PACKAGE_NAME,
MediaColumns.IS_PENDING,
FileColumns.MEDIA_TYPE,
MediaColumns.IS_TRASHED
};
final String selection = MediaColumns.DATA + "=?";
final String[] selectionArgs = new String[]{path};
final long id;
final int mediaType;
final boolean isPending;
final boolean isTrashed;
String ownerPackageName = null;
try (final Cursor c = queryForSingleItemAsMediaProvider(contentUri, projection,
selection,
selectionArgs, null)) {
id = c.getLong(0);
ownerPackageName = c.getString(1);
isPending = c.getInt(2) != 0;
mediaType = c.getInt(3);
isTrashed = c.getInt(4) != 0;
}
final File file = new File(path);
Uri fileUri = MediaStore.Files.getContentUri(extractVolumeName(path), id);
// We don't check ownership for files with IS_PENDING set by FUSE
if (isTrashed || (isPending && !isPendingFromFuse(new File(path)))) {
requireOwnershipForItem(ownerPackageName, fileUri);
}
// Check that path looks consistent before uri checks
if (!FileUtils.contains(Environment.getStorageDirectory(), file)) {
checkWorldReadAccess(file.getAbsolutePath());
}
try {
// checkAccess throws FileNotFoundException only from checkWorldReadAccess(),
// which we already check above. Hence, handling only SecurityException.
if (redactedUriId != null) {
fileUri = ContentUris.removeId(fileUri).buildUpon().appendPath(
redactedUriId).build();
}
checkAccess(fileUri, Bundle.EMPTY, file, forWrite);
} catch (SecurityException e) {
// Check for other Uri formats only when the single uri check flow fails.
// Throw the previous exception if the multi-uri checks failed.
final String uriId = redactedUriId == null ? Long.toString(id) : redactedUriId;
if (getOtherUriGrantsForPath(path, mediaType, uriId, forWrite) == null) {
throw e;
}
}
isSuccess = true;
return new FileOpenResult(0 /* status */, originalUid, mediaCapabilitiesUid,
redact ? getRedactionRangesForFuse(path, ioPath, originalUid, uid, tid,
forceRedaction) : new long[0]);
} catch (IOException e) {
// We are here because
// * There is no db row corresponding to the requested path, which is more unlikely.
// * getRedactionRangesForFuse couldn't fetch the redaction info correctly
// In all of these cases, it means that app doesn't have access permission to the file.
Log.e(TAG, "Couldn't find file: " + path, e);
return new FileOpenResult(OsConstants.EACCES /* status */, originalUid,
mediaCapabilitiesUid, new long[0]);
} catch (IllegalStateException | SecurityException e) {
Log.e(TAG, "Permission to access file: " + path + " is denied");
return new FileOpenResult(OsConstants.EACCES /* status */, originalUid,
mediaCapabilitiesUid, new long[0]);
} finally {
if (isSuccess && logTransformsMetrics) {
notifyTranscodeHelperOnFileOpen(path, ioPath, originalUid, transformsReason);
}
restoreLocalCallingIdentity(token);
}
}
private @Nullable Uri getOtherUriGrantsForPath(String path, boolean forWrite) {
final Uri contentUri = FileUtils.getContentUriForPath(path);
final String[] projection = new String[]{
MediaColumns._ID,
FileColumns.MEDIA_TYPE};
final String selection = MediaColumns.DATA + "=?";
final String[] selectionArgs = new String[]{path};
final String id;
final int mediaType;
try (final Cursor c = queryForSingleItemAsMediaProvider(contentUri, projection, selection,
selectionArgs, null)) {
id = c.getString(0);
mediaType = c.getInt(1);
return getOtherUriGrantsForPath(path, mediaType, id, forWrite);
} catch (FileNotFoundException ignored) {
}
return null;
}
@Nullable
private Uri getOtherUriGrantsForPath(String path, int mediaType, String id, boolean forWrite) {
List<Uri> otherUris = new ArrayList<Uri>();
final Uri mediaUri = getMediaUriForFuse(extractVolumeName(path), mediaType, id);
otherUris.add(mediaUri);
final Uri externalMediaUri = getMediaUriForFuse(MediaStore.VOLUME_EXTERNAL, mediaType, id);
otherUris.add(externalMediaUri);
return getPermissionGrantedUri(otherUris, forWrite);
}
@NonNull
private Uri getMediaUriForFuse(@NonNull String volumeName, int mediaType, String id) {
Uri uri = MediaStore.Files.getContentUri(volumeName);
switch (mediaType) {
case FileColumns.MEDIA_TYPE_IMAGE:
uri = MediaStore.Images.Media.getContentUri(volumeName);
break;
case FileColumns.MEDIA_TYPE_VIDEO:
uri = MediaStore.Video.Media.getContentUri(volumeName);
break;
case FileColumns.MEDIA_TYPE_AUDIO:
uri = MediaStore.Audio.Media.getContentUri(volumeName);
break;
case FileColumns.MEDIA_TYPE_PLAYLIST:
uri = MediaStore.Audio.Playlists.getContentUri(volumeName);
break;
}
return uri.buildUpon().appendPath(id).build();
}
/**
* Returns {@code true} if {@link #mCallingIdentity#getSharedPackages(String)} contains the
* given package name, {@code false} otherwise.
* <p> Assumes that {@code mCallingIdentity} has been properly set to reflect the calling
* package.
*/
private boolean isCallingIdentitySharedPackageName(@NonNull String packageName) {
for (String sharedPkgName : mCallingIdentity.get().getSharedPackageNames()) {
if (packageName.toLowerCase(Locale.ROOT)
.equals(sharedPkgName.toLowerCase(Locale.ROOT))) {
return true;
}
}
return false;
}
/**
* @throws IllegalStateException if path is invalid or doesn't match a volume.
*/
@NonNull
private Uri getContentUriForFile(@NonNull String filePath, @NonNull String mimeType) {
final String volName;
try {
volName = FileUtils.getVolumeName(getContext(), new File(filePath));
} catch (FileNotFoundException e) {
throw new IllegalStateException("Couldn't get volume name for " + filePath);
}
Uri uri = Files.getContentUri(volName);
String topLevelDir = extractTopLevelDir(filePath);
if (topLevelDir == null) {
// If the file path doesn't match the external storage directory, we use the files URI
// as default and let #insert enforce the restrictions
return uri;
}
topLevelDir = topLevelDir.toLowerCase(Locale.ROOT);
switch (topLevelDir) {
case DIRECTORY_PODCASTS_LOWER_CASE:
case DIRECTORY_RINGTONES_LOWER_CASE:
case DIRECTORY_ALARMS_LOWER_CASE:
case DIRECTORY_NOTIFICATIONS_LOWER_CASE:
case DIRECTORY_AUDIOBOOKS_LOWER_CASE:
case DIRECTORY_RECORDINGS_LOWER_CASE:
uri = Audio.Media.getContentUri(volName);
break;
case DIRECTORY_MUSIC_LOWER_CASE:
if (MimeUtils.isPlaylistMimeType(mimeType)) {
uri = Audio.Playlists.getContentUri(volName);
} else if (!MimeUtils.isSubtitleMimeType(mimeType)) {
// Send Files uri for media type subtitle
uri = Audio.Media.getContentUri(volName);
}
break;
case DIRECTORY_MOVIES_LOWER_CASE:
if (MimeUtils.isPlaylistMimeType(mimeType)) {
uri = Audio.Playlists.getContentUri(volName);
} else if (!MimeUtils.isSubtitleMimeType(mimeType)) {
// Send Files uri for media type subtitle
uri = Video.Media.getContentUri(volName);
}
break;
case DIRECTORY_DCIM_LOWER_CASE:
case DIRECTORY_PICTURES_LOWER_CASE:
if (MimeUtils.isImageMimeType(mimeType)) {
uri = Images.Media.getContentUri(volName);
} else {
uri = Video.Media.getContentUri(volName);
}
break;
case DIRECTORY_DOWNLOADS_LOWER_CASE:
case DIRECTORY_DOCUMENTS_LOWER_CASE:
break;
default:
Log.w(TAG, "Forgot to handle a top level directory in getContentUriForFile?");
}
return uri;
}
private boolean containsIgnoreCase(@Nullable List<String> stringsList, @Nullable String item) {
if (item == null || stringsList == null) return false;
for (String current : stringsList) {
if (item.equalsIgnoreCase(current)) return true;
}
return false;
}
private boolean fileExists(@NonNull String absolutePath) {
// We don't care about specific columns in the match,
// we just want to check IF there's a match
final String[] projection = {};
final String selection = FileColumns.DATA + " = ?";
final String[] selectionArgs = {absolutePath};
final Uri uri = FileUtils.getContentUriForPath(absolutePath);
final LocalCallingIdentity token = clearLocalCallingIdentity();
try {
try (final Cursor c = query(uri, projection, selection, selectionArgs, null)) {
// Shouldn't return null
return c.getCount() > 0;
}
} finally {
clearLocalCallingIdentity(token);
}
}
private Uri insertFileForFuse(@NonNull String path, @NonNull Uri uri, @NonNull String mimeType,
boolean useData) {
ContentValues values = new ContentValues();
values.put(FileColumns.OWNER_PACKAGE_NAME, getCallingPackageOrSelf());
values.put(MediaColumns.MIME_TYPE, mimeType);
values.put(FileColumns.IS_PENDING, 1);
if (useData) {
values.put(FileColumns.DATA, path);
} else {
values.put(FileColumns.VOLUME_NAME, extractVolumeName(path));
values.put(FileColumns.RELATIVE_PATH, extractRelativePath(path));
values.put(FileColumns.DISPLAY_NAME, extractDisplayName(path));
}
return insert(uri, values, Bundle.EMPTY);
}
/**
* Enforces file creation restrictions (see return values) for the given file on behalf of the
* app with the given {@code uid}. If the file is added to the shared storage, creates a
* database entry for it.
* <p> Does NOT create file.
*
* @param path the path of the file
* @param uid UID of the app requesting to create the file
* @return In case of success, 0. If the operation is illegal or not permitted, returns the
* appropriate {@code errno} value:
* <ul>
* <li>{@link OsConstants#ENOENT} if the app tries to create file in other app's external dir
* <li>{@link OsConstants#EEXIST} if the file already exists
* <li>{@link OsConstants#EPERM} if the file type doesn't match the relative path, or if the
* calling package is a legacy app that doesn't have WRITE_EXTERNAL_STORAGE permission.
* <li>{@link OsConstants#EIO} in case of any other I/O exception
* </ul>
*
* @throws IllegalStateException if given path is invalid.
*
* Called from JNI in jni/MediaProviderWrapper.cpp
*/
@Keep
public int insertFileIfNecessaryForFuse(@NonNull String path, int uid) {
final LocalCallingIdentity token =
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path);
try {
if (isPrivatePackagePathNotAccessibleByCaller(path)) {
Log.e(TAG, "Can't create a file in another app's external directory");
return OsConstants.ENOENT;
}
if (!path.equals(getAbsoluteSanitizedPath(path))) {
Log.e(TAG, "File name contains invalid characters");
return OsConstants.EPERM;
}
if (shouldBypassDatabaseAndSetDirtyForFuse(uid, path)) {
if (path.endsWith("/.nomedia")) {
File parent = new File(path).getParentFile();
synchronized (mNonHiddenPaths) {
mNonHiddenPaths.keySet().removeIf(
k -> FileUtils.contains(parent, new File(k)));
}
}
return 0;
}
final String mimeType = MimeUtils.resolveMimeType(new File(path));
if (shouldBypassFuseRestrictions(/*forWrite*/ true, path)) {
final boolean callerRequestingLegacy = isCallingPackageRequestingLegacy();
if (!fileExists(path)) {
// If app has already inserted the db row, inserting the row again might set
// IS_PENDING=1. We shouldn't overwrite existing entry as part of FUSE
// operation, hence, insert the db row only when it doesn't exist.
try {
insertFileForFuse(path, FileUtils.getContentUriForPath(path),
mimeType, /*useData*/ callerRequestingLegacy);
} catch (Exception ignored) {
}
} else {
// Upon creating a file via FUSE, if a row matching the path already exists
// but a file doesn't exist on the filesystem, we transfer ownership to the
// app attempting to create the file. If we don't update ownership, then the
// app that inserted the original row may be able to observe the contents of
// written file even though they don't hold the right permissions to do so.
if (callerRequestingLegacy) {
final String owner = getCallingPackageOrSelf();
if (owner != null && !updateOwnerForPath(path, owner)) {
return OsConstants.EPERM;
}
}
}
return 0;
}
// Legacy apps that made is this far don't have the right storage permission and hence
// are not allowed to access anything other than their external app directory
if (isCallingPackageRequestingLegacy()) {
return OsConstants.EPERM;
}
if (fileExists(path)) {
// If the file already exists in the db, we shouldn't allow the file creation.
return OsConstants.EEXIST;
}
final Uri contentUri = getContentUriForFile(path, mimeType);
final Uri item = insertFileForFuse(path, contentUri, mimeType, /*useData*/ false);
if (item == null) {
return OsConstants.EPERM;
}
return 0;
} catch (IllegalArgumentException e) {
Log.e(TAG, "insertFileIfNecessary failed", e);
return OsConstants.EPERM;
} finally {
restoreLocalCallingIdentity(token);
}
}
private boolean updateOwnerForPath(@NonNull String path, @NonNull String newOwner) {
final DatabaseHelper helper;
try {
helper = getDatabaseForUri(FileUtils.getContentUriForPath(path));
} catch (VolumeNotFoundException e) {
// Cannot happen, as this is a path that we already resolved.
throw new AssertionError("Path must already be resolved", e);
}
ContentValues values = new ContentValues(1);
values.put(FileColumns.OWNER_PACKAGE_NAME, newOwner);
return helper.runWithoutTransaction((db) -> {
return db.update("files", values, "_data=?", new String[] { path });
}) == 1;
}
private static int deleteFileUnchecked(@NonNull String path) {
final File toDelete = new File(path);
if (toDelete.delete()) {
return 0;
} else {
return OsConstants.ENOENT;
}
}
/**
* Deletes file with the given {@code path} on behalf of the app with the given {@code uid}.
* <p>Before deleting, checks if app has permissions to delete this file.
*
* @param path the path of the file
* @param uid UID of the app requesting to delete the file
* @return 0 upon success.
* In case of error, return the appropriate negated {@code errno} value:
* <ul>
* <li>{@link OsConstants#ENOENT} if the file does not exist or if the app tries to delete file
* in another app's external dir
* <li>{@link OsConstants#EPERM} a security exception was thrown by {@link #delete}, or if the
* calling package is a legacy app that doesn't have WRITE_EXTERNAL_STORAGE permission.
* </ul>
*
* Called from JNI in jni/MediaProviderWrapper.cpp
*/
@Keep
public int deleteFileForFuse(@NonNull String path, int uid) throws IOException {
final LocalCallingIdentity token =
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path);
try {
if (isPrivatePackagePathNotAccessibleByCaller(path)) {
Log.e(TAG, "Can't delete a file in another app's external directory!");
return OsConstants.ENOENT;
}
if (shouldBypassDatabaseAndSetDirtyForFuse(uid, path)) {
return deleteFileUnchecked(path);
}
final boolean shouldBypass = shouldBypassFuseRestrictions(/*forWrite*/ true, path);
// Legacy apps that made is this far don't have the right storage permission and hence
// are not allowed to access anything other than their external app directory
if (!shouldBypass && isCallingPackageRequestingLegacy()) {
return OsConstants.EPERM;
}
final Uri contentUri = FileUtils.getContentUriForPath(path);
final String where = FileColumns.DATA + " = ?";
final String[] whereArgs = {path};
if (delete(contentUri, where, whereArgs) == 0) {
if (shouldBypass) {
return deleteFileUnchecked(path);
}
return OsConstants.ENOENT;
} else {
// success - 1 file was deleted
return 0;
}
} catch (SecurityException e) {
Log.e(TAG, "File deletion not allowed", e);
return OsConstants.EPERM;
} finally {
restoreLocalCallingIdentity(token);
}
}
// These need to stay in sync with MediaProviderWrapper.cpp's DirectoryAccessRequestType enum
@IntDef(flag = true, prefix = { "DIRECTORY_ACCESS_FOR_" }, value = {
DIRECTORY_ACCESS_FOR_READ,
DIRECTORY_ACCESS_FOR_WRITE,
DIRECTORY_ACCESS_FOR_CREATE,
DIRECTORY_ACCESS_FOR_DELETE,
})
@Retention(RetentionPolicy.SOURCE)
@VisibleForTesting
@interface DirectoryAccessType {}
@VisibleForTesting
static final int DIRECTORY_ACCESS_FOR_READ = 1;
@VisibleForTesting
static final int DIRECTORY_ACCESS_FOR_WRITE = 2;
@VisibleForTesting
static final int DIRECTORY_ACCESS_FOR_CREATE = 3;
@VisibleForTesting
static final int DIRECTORY_ACCESS_FOR_DELETE = 4;
/**
* Checks whether the app with the given UID is allowed to access the directory denoted by the
* given path.
*
* @param path directory's path
* @param uid UID of the requesting app
* @param accessType type of access being requested - eg {@link
* MediaProvider#DIRECTORY_ACCESS_FOR_READ}
* @return 0 if it's allowed to access the directory, {@link OsConstants#ENOENT} for attempts
* to access a private package path in Android/data or Android/obb the caller doesn't have
* access to, and otherwise {@link OsConstants#EACCES} if the calling package is a legacy app
* that doesn't have READ_EXTERNAL_STORAGE permission or for other invalid attempts to access
* Android/data or Android/obb dirs.
*
* Called from JNI in jni/MediaProviderWrapper.cpp
*/
@Keep
public int isDirAccessAllowedForFuse(@NonNull String path, int uid,
@DirectoryAccessType int accessType) {
Preconditions.checkArgumentInRange(accessType, 1, DIRECTORY_ACCESS_FOR_DELETE,
"accessType");
final boolean forRead = accessType == DIRECTORY_ACCESS_FOR_READ;
final LocalCallingIdentity token =
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path);
try {
if ("/storage/emulated".equals(path)) {
return OsConstants.EPERM;
}
if (isPrivatePackagePathNotAccessibleByCaller(path)) {
Log.e(TAG, "Can't access another app's external directory!");
return OsConstants.ENOENT;
}
if (shouldBypassFuseRestrictions(/* forWrite= */ !forRead, path)) {
return 0;
}
// Do not allow apps that reach this point to access Android/data or Android/obb dirs.
// Creation should be via getContext().getExternalFilesDir() etc methods.
// Reads and writes on primary volumes should be via mount views of lowerfs for apps
// that get special access to these directories.
// Reads and writes on secondary volumes would be provided via an early return from
// shouldBypassFuseRestrictions above (again just for apps with special access).
if (isDataOrObbPath(path)) {
return OsConstants.EACCES;
}
// Legacy apps that made is this far don't have the right storage permission and hence
// are not allowed to access anything other than their external app directory
if (isCallingPackageRequestingLegacy()) {
return OsConstants.EACCES;
}
// This is a non-legacy app. Rest of the directories are generally writable
// except for non-default top-level directories.
if (!forRead) {
final String[] relativePath = sanitizePath(extractRelativePath(path));
if (relativePath.length == 0) {
Log.e(TAG,
"Directory update not allowed on invalid relative path for " + path);
return OsConstants.EPERM;
}
final boolean isTopLevelDir =
relativePath.length == 1 && TextUtils.isEmpty(relativePath[0]);
if (isTopLevelDir) {
// We don't allow deletion of any top-level folders
if (accessType == DIRECTORY_ACCESS_FOR_DELETE) {
Log.e(TAG, "Deleting top level directories are not allowed!");
return OsConstants.EACCES;
}
// We allow creating or writing to default top-level folders, but we don't
// allow creation or writing to non-default top-level folders.
if ((accessType == DIRECTORY_ACCESS_FOR_CREATE
|| accessType == DIRECTORY_ACCESS_FOR_WRITE)
&& FileUtils.isDefaultDirectoryName(extractDisplayName(path))) {
return 0;
}
Log.e(TAG,
"Creating or writing to a non-default top level directory is not "
+ "allowed!");
return OsConstants.EACCES;
}
}
return 0;
} finally {
restoreLocalCallingIdentity(token);
}
}
@Keep
public boolean isUidAllowedAccessToDataOrObbPathForFuse(int uid, String path) {
final LocalCallingIdentity token =
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
try {
// Files under the apps own private directory
final String appSpecificDir = extractPathOwnerPackageName(path);
if (appSpecificDir != null && isCallingIdentitySharedPackageName(appSpecificDir)) {
return true;
}
// This is a private-package path; return true if accessible by the caller
return isUidAllowedSpecialPrivatePathAccess(uid, path);
} finally {
restoreLocalCallingIdentity(token);
}
}
/**
* @return true iff the caller has installer privileges which gives write access to obb dirs.
* <p> Assumes that {@code mCallingIdentity} has been properly set to reflect the calling
* package.
*/
private boolean isCallingIdentityAllowedInstallerAccess(int uid) {
final boolean hasWrite = mCallingIdentity.get().
hasPermission(PERMISSION_WRITE_EXTERNAL_STORAGE);
if (!hasWrite) {
return false;
}
// We're only willing to give out installer access if they also hold
// runtime permission; this is a firm CDD requirement
final boolean hasInstall = mCallingIdentity.get().
hasPermission(PERMISSION_INSTALL_PACKAGES);
if (hasInstall) {
return true;
}
// OPSTR_REQUEST_INSTALL_PACKAGES is granted/denied per package but vold can't
// update mountpoints of a specific package. So, check the appop for all packages
// sharing the uid and allow same level of storage access for all packages even if
// one of the packages has the appop granted.
// To maintain consistency of access in primary volume and secondary volumes use the same
// logic as we do for Zygote.MOUNT_EXTERNAL_INSTALLER view.
return mCallingIdentity.get().hasPermission(APPOP_REQUEST_INSTALL_PACKAGES_FOR_SHARED_UID);
}
private String getExternalStorageProviderAuthority() {
if (SdkLevel.isAtLeastS()) {
return getExternalStorageProviderAuthorityFromDocumentsContract();
}
return MediaStore.EXTERNAL_STORAGE_PROVIDER_AUTHORITY;
}
@RequiresApi(Build.VERSION_CODES.S)
private String getExternalStorageProviderAuthorityFromDocumentsContract() {
return DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY;
}
private String getDownloadsProviderAuthority() {
if (SdkLevel.isAtLeastS()) {
return getDownloadsProviderAuthorityFromDocumentsContract();
}
return DOWNLOADS_PROVIDER_AUTHORITY;
}
@RequiresApi(Build.VERSION_CODES.S)
private String getDownloadsProviderAuthorityFromDocumentsContract() {
return DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY;
}
private boolean isCallingIdentityDownloadProvider(int uid) {
return uid == mDownloadsAuthorityAppId;
}
private boolean isCallingIdentityExternalStorageProvider(int uid) {
return uid == mExternalStorageAuthorityAppId;
}
private boolean isCallingIdentityMtp(int uid) {
return mCallingIdentity.get().hasPermission(PERMISSION_ACCESS_MTP);
}
/**
* The following apps have access to all private-app directories on secondary volumes:
* * ExternalStorageProvider
* * DownloadProvider
* * Signature apps with ACCESS_MTP permission granted
* (Note: For Android R we also allow privileged apps with ACCESS_MTP to access all
* private-app directories, this additional access is removed for Android S+).
*
* Installer apps can only access private-app directories on Android/obb.
*
* @param uid UID of the calling package
* @param path the path of the file to access
*/
private boolean isUidAllowedSpecialPrivatePathAccess(int uid, String path) {
final LocalCallingIdentity token =
clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid));
try {
if (SdkLevel.isAtLeastS()) {
return isMountModeAllowedPrivatePathAccess(uid, getCallingPackage(), path);
} else {
if (isCallingIdentityDownloadProvider(uid) ||
isCallingIdentityExternalStorageProvider(uid) || isCallingIdentityMtp(
uid)) {
return true;
}
return (isObbOrChildPath(path) && isCallingIdentityAllowedInstallerAccess(uid));
}
} finally {
restoreLocalCallingIdentity(token);
}
}
@RequiresApi(Build.VERSION_CODES.S)
private boolean isMountModeAllowedPrivatePathAccess(int uid, String packageName, String path) {
// This is required as only MediaProvider (package with WRITE_MEDIA_STORAGE) can access
// mount modes.
final CallingIdentity token = clearCallingIdentity();
try {
final int mountMode = mStorageManager.getExternalStorageMountMode(uid, packageName);
switch (mountMode) {
case StorageManager.MOUNT_MODE_EXTERNAL_ANDROID_WRITABLE:
case StorageManager.MOUNT_MODE_EXTERNAL_PASS_THROUGH:
return true;
case StorageManager.MOUNT_MODE_EXTERNAL_INSTALLER:
return isObbOrChildPath(path);
}
} catch (Exception e) {
Log.w(TAG, "Caller does not have the permissions to access mount modes: ", e);
} finally {
restoreCallingIdentity(token);
}
return false;
}
private boolean checkCallingPermissionGlobal(Uri uri, boolean forWrite) {
// System internals can work with all media
if (isCallingPackageSelf() || isCallingPackageShell()) {
return true;
}
// Apps that have permission to manage external storage can work with all files
if (isCallingPackageManager()) {
return true;
}
// Check if caller is known to be owner of this item, to speed up
// performance of our permission checks
final int table = matchUri(uri, true);
switch (table) {
case AUDIO_MEDIA_ID:
case VIDEO_MEDIA_ID:
case IMAGES_MEDIA_ID:
case FILES_ID:
case DOWNLOADS_ID:
final long id = ContentUris.parseId(uri);
if (mCallingIdentity.get().isOwned(id)) {
return true;
}
break;
default:
// continue below
}
// Check whether the uri is a specific table or not. Don't allow the global access to these
// table uris
switch (table) {
case AUDIO_MEDIA:
case IMAGES_MEDIA:
case VIDEO_MEDIA:
case DOWNLOADS:
case FILES:
case AUDIO_ALBUMS:
case AUDIO_ARTISTS:
case AUDIO_GENRES:
case AUDIO_PLAYLISTS:
return false;
default:
// continue below
}
// Outstanding grant means they get access
return isUriPermissionGranted(uri, forWrite);
}
/**
* Returns any uri that is granted from the set of Uris passed.
*/
private @Nullable Uri getPermissionGrantedUri(@NonNull List<Uri> uris, boolean forWrite) {
for (Uri uri : uris) {
if (isUriPermissionGranted(uri, forWrite)) {
return uri;
}
}
return null;
}
private boolean isUriPermissionGranted(Uri uri, boolean forWrite) {
final int modeFlags = forWrite
? Intent.FLAG_GRANT_WRITE_URI_PERMISSION
: Intent.FLAG_GRANT_READ_URI_PERMISSION;
int uriPermission = getContext().checkUriPermission(uri, mCallingIdentity.get().pid,
mCallingIdentity.get().uid, modeFlags);
return uriPermission == PERMISSION_GRANTED;
}
@VisibleForTesting
public boolean isFuseThread() {
return FuseDaemon.native_is_fuse_thread();
}
@VisibleForTesting
public boolean getBooleanDeviceConfig(String key, boolean defaultValue) {
if (!canReadDeviceConfig(key, defaultValue)) {
return defaultValue;
}
final long token = Binder.clearCallingIdentity();
try {
return DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT, key,
defaultValue);
} finally {
Binder.restoreCallingIdentity(token);
}
}
@VisibleForTesting
public int getIntDeviceConfig(String key, int defaultValue) {
if (!canReadDeviceConfig(key, defaultValue)) {
return defaultValue;
}
final long token = Binder.clearCallingIdentity();
try {
return DeviceConfig.getInt(DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT, key,
defaultValue);
} finally {
Binder.restoreCallingIdentity(token);
}
}
@VisibleForTesting
public String getStringDeviceConfig(String key, String defaultValue) {
if (!canReadDeviceConfig(key, defaultValue)) {
return defaultValue;
}
final long token = Binder.clearCallingIdentity();
try {
return DeviceConfig.getString(DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT, key,
defaultValue);
} finally {
Binder.restoreCallingIdentity(token);
}
}
private static <T> boolean canReadDeviceConfig(String key, T defaultValue) {
if (SdkLevel.isAtLeastS()) {
return true;
}
Log.w(TAG, "Cannot read device config before Android S. Returning defaultValue: "
+ defaultValue + " for key: " + key);
return false;
}
@VisibleForTesting
public void addOnPropertiesChangedListener(OnPropertiesChangedListener listener) {
if (!SdkLevel.isAtLeastS()) {
Log.w(TAG, "Cannot add device config changed listener before Android S");
return;
}
DeviceConfig.addOnPropertiesChangedListener(DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT,
BackgroundThread.getExecutor(), listener);
}
@Deprecated
private boolean checkCallingPermissionAudio(boolean forWrite, String callingPackage) {
if (forWrite) {
return mCallingIdentity.get().hasPermission(PERMISSION_WRITE_AUDIO);
} else {
// write permission should be enough for reading as well
return mCallingIdentity.get().hasPermission(PERMISSION_READ_AUDIO)
|| mCallingIdentity.get().hasPermission(PERMISSION_WRITE_AUDIO);
}
}
@Deprecated
private boolean checkCallingPermissionVideo(boolean forWrite, String callingPackage) {
if (forWrite) {
return mCallingIdentity.get().hasPermission(PERMISSION_WRITE_VIDEO);
} else {
// write permission should be enough for reading as well
return mCallingIdentity.get().hasPermission(PERMISSION_READ_VIDEO)
|| mCallingIdentity.get().hasPermission(PERMISSION_WRITE_VIDEO);
}
}
@Deprecated
private boolean checkCallingPermissionImages(boolean forWrite, String callingPackage) {
if (forWrite) {
return mCallingIdentity.get().hasPermission(PERMISSION_WRITE_IMAGES);
} else {
// write permission should be enough for reading as well
return mCallingIdentity.get().hasPermission(PERMISSION_READ_IMAGES)
|| mCallingIdentity.get().hasPermission(PERMISSION_WRITE_IMAGES);
}
}
/**
* Enforce that caller has access to the given {@link Uri}.
*
* @throws SecurityException if access isn't allowed.
*/
private void enforceCallingPermission(@NonNull Uri uri, @NonNull Bundle extras,
boolean forWrite) {
Trace.beginSection("enforceCallingPermission");
try {
enforceCallingPermissionInternal(uri, extras, forWrite);
} finally {
Trace.endSection();
}
}
private void enforceCallingPermission(@NonNull Collection<Uri> uris, boolean forWrite) {
for (Uri uri : uris) {
enforceCallingPermission(uri, Bundle.EMPTY, forWrite);
}
}
private void enforceCallingPermissionInternal(@NonNull Uri uri, @NonNull Bundle extras,
boolean forWrite) {
Objects.requireNonNull(uri);
Objects.requireNonNull(extras);
// Try a simple global check first before falling back to performing a
// simple query to probe for access.
if (checkCallingPermissionGlobal(uri, forWrite)) {
// Access allowed, yay!
return;
}
// For redacted URI proceed with its corresponding URI as query builder doesn't support
// redacted URIs for fetching a database row
// NOTE: The grants (if any) must have been on redacted URI hence global check requires
// redacted URI
Uri redactedUri = null;
if (isRedactedUri(uri)) {
redactedUri = uri;
uri = getUriForRedactedUri(uri);
}
final DatabaseHelper helper;
try {
helper = getDatabaseForUri(uri);
} catch (VolumeNotFoundException e) {
throw e.rethrowAsIllegalArgumentException();
}
final boolean allowHidden = isCallingPackageAllowedHidden();
final int table = matchUri(uri, allowHidden);
// First, check to see if caller has direct write access
if (forWrite) {
final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE, table, uri, extras, null);
qb.allowColumn(SQLiteQueryBuilder.ROWID_COLUMN);
try (Cursor c = qb.query(helper, new String[] { SQLiteQueryBuilder.ROWID_COLUMN },
null, null, null, null, null, null, null)) {
if (c.moveToFirst()) {
// Direct write access granted, yay!
return;
}
}
}
// We only allow the user to grant access to specific media items in
// strongly typed collections; never to broad collections
boolean allowUserGrant = false;
final int matchUri = matchUri(uri, true);
switch (matchUri) {
case IMAGES_MEDIA_ID:
case AUDIO_MEDIA_ID:
case VIDEO_MEDIA_ID:
allowUserGrant = true;
break;
}
// Second, check to see if caller has direct read access
final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_QUERY, table, uri, extras, null);
qb.allowColumn(SQLiteQueryBuilder.ROWID_COLUMN);
try (Cursor c = qb.query(helper, new String[] { SQLiteQueryBuilder.ROWID_COLUMN },
null, null, null, null, null, null, null)) {
if (c.moveToFirst()) {
if (!forWrite) {
// Direct read access granted, yay!
return;
} else if (allowUserGrant) {
// Caller has read access, but they wanted to write, and
// they'll need to get the user to grant that access
final Context context = getContext();
final Collection<Uri> uris = Arrays.asList(uri);
final PendingIntent intent = MediaStore
.createWriteRequest(ContentResolver.wrap(this), uris);
final Icon icon = getCollectionIcon(uri);
final RemoteAction action = new RemoteAction(icon,
context.getText(R.string.permission_required_action),
context.getText(R.string.permission_required_action),
intent);
throw new RecoverableSecurityException(new SecurityException(
getCallingPackageOrSelf() + " has no access to " + uri),
context.getText(R.string.permission_required), action);
}
}
}
if (redactedUri != null) uri = redactedUri;
throw new SecurityException(getCallingPackageOrSelf() + " has no access to " + uri);
}
private Icon getCollectionIcon(Uri uri) {
final PackageManager pm = getContext().getPackageManager();
final String type = uri.getPathSegments().get(1);
final String groupName;
switch (type) {
default: groupName = android.Manifest.permission_group.STORAGE; break;
}
try {
final PermissionGroupInfo perm = pm.getPermissionGroupInfo(groupName, 0);
return Icon.createWithResource(perm.packageName, perm.icon);
} catch (NameNotFoundException e) {
throw new RuntimeException(e);
}
}
private void checkAccess(@NonNull Uri uri, @NonNull Bundle extras, @NonNull File file,
boolean isWrite) throws FileNotFoundException {
// First, does caller have the needed row-level access?
enforceCallingPermission(uri, extras, isWrite);
// Second, does the path look consistent?
if (!FileUtils.contains(Environment.getStorageDirectory(), file)) {
checkWorldReadAccess(file.getAbsolutePath());
}
}
/**
* Check whether the path is a world-readable file
*/
@VisibleForTesting
public static void checkWorldReadAccess(String path) throws FileNotFoundException {
// Path has already been canonicalized, and we relax the check to look
// at groups to support runtime storage permissions.
final int accessBits = path.startsWith("/storage/") ? OsConstants.S_IRGRP
: OsConstants.S_IROTH;
try {
StructStat stat = Os.stat(path);
if (OsConstants.S_ISREG(stat.st_mode) &&
((stat.st_mode & accessBits) == accessBits)) {
checkLeadingPathComponentsWorldExecutable(path);
return;
}
} catch (ErrnoException e) {
// couldn't stat the file, either it doesn't exist or isn't
// accessible to us
}
throw new FileNotFoundException("Can't access " + path);
}
private static void checkLeadingPathComponentsWorldExecutable(String filePath)
throws FileNotFoundException {
File parent = new File(filePath).getParentFile();
// Path has already been canonicalized, and we relax the check to look
// at groups to support runtime storage permissions.
final int accessBits = filePath.startsWith("/storage/") ? OsConstants.S_IXGRP
: OsConstants.S_IXOTH;
while (parent != null) {
if (! parent.exists()) {
// parent dir doesn't exist, give up
throw new FileNotFoundException("access denied");
}
try {
StructStat stat = Os.stat(parent.getPath());
if ((stat.st_mode & accessBits) != accessBits) {
// the parent dir doesn't have the appropriate access
throw new FileNotFoundException("Can't access " + filePath);
}
} catch (ErrnoException e1) {
// couldn't stat() parent
throw new FileNotFoundException("Can't access " + filePath);
}
parent = parent.getParentFile();
}
}
@VisibleForTesting
static class FallbackException extends Exception {
private final int mThrowSdkVersion;
public FallbackException(String message, int throwSdkVersion) {
super(message);
mThrowSdkVersion = throwSdkVersion;
}
public FallbackException(String message, Throwable cause, int throwSdkVersion) {
super(message, cause);
mThrowSdkVersion = throwSdkVersion;
}
@Override
public String getMessage() {
if (getCause() != null) {
return super.getMessage() + ": " + getCause().getMessage();
} else {
return super.getMessage();
}
}
public IllegalArgumentException rethrowAsIllegalArgumentException() {
throw new IllegalArgumentException(getMessage());
}
public Cursor translateForQuery(int targetSdkVersion) {
if (targetSdkVersion >= mThrowSdkVersion) {
throw new IllegalArgumentException(getMessage());
} else {
Log.w(TAG, getMessage());
return null;
}
}
public Uri translateForInsert(int targetSdkVersion) {
if (targetSdkVersion >= mThrowSdkVersion) {
throw new IllegalArgumentException(getMessage());
} else {
Log.w(TAG, getMessage());
return null;
}
}
public int translateForBulkInsert(int targetSdkVersion) {
if (targetSdkVersion >= mThrowSdkVersion) {
throw new IllegalArgumentException(getMessage());
} else {
Log.w(TAG, getMessage());
return 0;
}
}
public int translateForUpdateDelete(int targetSdkVersion) {
if (targetSdkVersion >= mThrowSdkVersion) {
throw new IllegalArgumentException(getMessage());
} else {
Log.w(TAG, getMessage());
return 0;
}
}
}
@VisibleForTesting
static class VolumeNotFoundException extends FallbackException {
public VolumeNotFoundException(String volumeName) {
super("Volume " + volumeName + " not found", Build.VERSION_CODES.Q);
}
}
@VisibleForTesting
static class VolumeArgumentException extends FallbackException {
public VolumeArgumentException(File actual, Collection<File> allowed) {
super("Requested path " + actual + " doesn't appear under " + allowed,
Build.VERSION_CODES.Q);
}
}
public List<String> getSupportedTranscodingRelativePaths() {
return mTranscodeHelper.getSupportedRelativePaths();
}
/**
* Creating a new method for Transcoding to avoid any merge conflicts.
* TODO(b/170465810): Remove this when the code is refactored.
*/
@NonNull DatabaseHelper getDatabaseForUriForTranscoding(Uri uri)
throws VolumeNotFoundException {
return getDatabaseForUri(uri);
}
private @NonNull DatabaseHelper getDatabaseForUri(Uri uri) throws VolumeNotFoundException {
final String volumeName = resolveVolumeName(uri);
synchronized (mAttachedVolumes) {
boolean volumeAttached = false;
UserHandle user = mCallingIdentity.get().getUser();
for (MediaVolume vol : mAttachedVolumes) {
if (vol.getName().equals(volumeName) && vol.isVisibleToUser(user)) {
volumeAttached = true;
break;
}
}
if (!volumeAttached) {
// Dump some more debug info
Log.e(TAG, "Volume " + volumeName + " not found, calling identity: "
+ user + ", attached volumes: " + mAttachedVolumes);
throw new VolumeNotFoundException(volumeName);
}
}
if (MediaStore.VOLUME_INTERNAL.equals(volumeName)) {
return mInternalDatabase;
} else {
return mExternalDatabase;
}
}
static boolean isMediaDatabaseName(String name) {
if (INTERNAL_DATABASE_NAME.equals(name)) {
return true;
}
if (EXTERNAL_DATABASE_NAME.equals(name)) {
return true;
}
if (name.startsWith("external-") && name.endsWith(".db")) {
return true;
}
return false;
}
private @NonNull Uri getBaseContentUri(@NonNull String volumeName) {
return MediaStore.AUTHORITY_URI.buildUpon().appendPath(volumeName).build();
}
public Uri attachVolume(MediaVolume volume, boolean validate) {
if (mCallingIdentity.get().pid != android.os.Process.myPid()) {
throw new SecurityException(
"Opening and closing databases not allowed.");
}
final String volumeName = volume.getName();
// Quick check for shady volume names
MediaStore.checkArgumentVolumeName(volumeName);
// Quick check that volume actually exists
if (!MediaStore.VOLUME_INTERNAL.equals(volumeName) && validate) {
try {
getVolumePath(volumeName);
} catch (IOException e) {
throw new IllegalArgumentException(
"Volume " + volume + " currently unavailable", e);
}
}
synchronized (mAttachedVolumes) {
mAttachedVolumes.add(volume);
}
final ContentResolver resolver = getContext().getContentResolver();
final Uri uri = getBaseContentUri(volumeName);
// TODO(b/182396009) we probably also want to notify clone profile (and vice versa)
resolver.notifyChange(getBaseContentUri(volumeName), null);
if (LOGV) Log.v(TAG, "Attached volume: " + volume);
if (!MediaStore.VOLUME_INTERNAL.equals(volumeName)) {
// Also notify on synthetic view of all devices
resolver.notifyChange(getBaseContentUri(MediaStore.VOLUME_EXTERNAL), null);
ForegroundThread.getExecutor().execute(() -> {
mExternalDatabase.runWithTransaction((db) -> {
ensureDefaultFolders(volume, db);
ensureThumbnailsValid(volume, db);
return null;
});
// We just finished the database operation above, we know that
// it's ready to answer queries, so notify our DocumentProvider
// so it can answer queries without risking ANR
MediaDocumentsProvider.onMediaStoreReady(getContext());
});
}
return uri;
}
private void detachVolume(Uri uri) {
final String volumeName = MediaStore.getVolumeName(uri);
try {
detachVolume(getVolume(volumeName));
} catch (FileNotFoundException e) {
Log.e(TAG, "Couldn't find volume for URI " + uri, e) ;
}
}
public boolean isVolumeAttached(MediaVolume volume) {
synchronized (mAttachedVolumes) {
return mAttachedVolumes.contains(volume);
}
}
public void detachVolume(MediaVolume volume) {
if (mCallingIdentity.get().pid != android.os.Process.myPid()) {
throw new SecurityException(
"Opening and closing databases not allowed.");
}
final String volumeName = volume.getName();
// Quick check for shady volume names
MediaStore.checkArgumentVolumeName(volumeName);
if (MediaStore.VOLUME_INTERNAL.equals(volumeName)) {
throw new UnsupportedOperationException(
"Deleting the internal volume is not allowed");
}
// Signal any scanning to shut down
mMediaScanner.onDetachVolume(volume);
synchronized (mAttachedVolumes) {
mAttachedVolumes.remove(volume);
}
final ContentResolver resolver = getContext().getContentResolver();
final Uri uri = getBaseContentUri(volumeName);
resolver.notifyChange(getBaseContentUri(volumeName), null);
if (!MediaStore.VOLUME_INTERNAL.equals(volumeName)) {
// Also notify on synthetic view of all devices
resolver.notifyChange(getBaseContentUri(MediaStore.VOLUME_EXTERNAL), null);
}
if (LOGV) Log.v(TAG, "Detached volume: " + volumeName);
}
@GuardedBy("mAttachedVolumes")
private final ArraySet<MediaVolume> mAttachedVolumes = new ArraySet<>();
@GuardedBy("mCustomCollators")
private final ArraySet<String> mCustomCollators = new ArraySet<>();
private MediaScanner mMediaScanner;
private DatabaseHelper mInternalDatabase;
private DatabaseHelper mExternalDatabase;
private PickerDbFacade mPickerDbFacade;
private ExternalDbFacade mExternalDbFacade;
private PickerDataLayer mPickerDataLayer;
private PickerSyncController mPickerSyncController;
private TranscodeHelper mTranscodeHelper;
// name of the volume currently being scanned by the media scanner (or null)
private String mMediaScannerVolume;
// current FAT volume ID
private int mVolumeId = -1;
// WARNING: the values of IMAGES_MEDIA, AUDIO_MEDIA, and VIDEO_MEDIA and AUDIO_PLAYLISTS
// are stored in the "files" table, so do not renumber them unless you also add
// a corresponding database upgrade step for it.
static final int IMAGES_MEDIA = 1;
static final int IMAGES_MEDIA_ID = 2;
static final int IMAGES_MEDIA_ID_THUMBNAIL = 3;
static final int IMAGES_THUMBNAILS = 4;
static final int IMAGES_THUMBNAILS_ID = 5;
static final int AUDIO_MEDIA = 100;
static final int AUDIO_MEDIA_ID = 101;
static final int AUDIO_MEDIA_ID_GENRES = 102;
static final int AUDIO_MEDIA_ID_GENRES_ID = 103;
static final int AUDIO_GENRES = 106;
static final int AUDIO_GENRES_ID = 107;
static final int AUDIO_GENRES_ID_MEMBERS = 108;
static final int AUDIO_GENRES_ALL_MEMBERS = 109;
static final int AUDIO_PLAYLISTS = 110;
static final int AUDIO_PLAYLISTS_ID = 111;
static final int AUDIO_PLAYLISTS_ID_MEMBERS = 112;
static final int AUDIO_PLAYLISTS_ID_MEMBERS_ID = 113;
static final int AUDIO_ARTISTS = 114;
static final int AUDIO_ARTISTS_ID = 115;
static final int AUDIO_ALBUMS = 116;
static final int AUDIO_ALBUMS_ID = 117;
static final int AUDIO_ARTISTS_ID_ALBUMS = 118;
static final int AUDIO_ALBUMART = 119;
static final int AUDIO_ALBUMART_ID = 120;
static final int AUDIO_ALBUMART_FILE_ID = 121;
static final int VIDEO_MEDIA = 200;
static final int VIDEO_MEDIA_ID = 201;
static final int VIDEO_MEDIA_ID_THUMBNAIL = 202;
static final int VIDEO_THUMBNAILS = 203;
static final int VIDEO_THUMBNAILS_ID = 204;
static final int VOLUMES = 300;
static final int VOLUMES_ID = 301;
static final int MEDIA_SCANNER = 500;
static final int FS_ID = 600;
static final int VERSION = 601;
static final int FILES = 700;
static final int FILES_ID = 701;
static final int DOWNLOADS = 800;
static final int DOWNLOADS_ID = 801;
static final int PICKER = 900;
static final int PICKER_ID = 901;
static final int PICKER_INTERNAL_MEDIA = 902;
static final int PICKER_INTERNAL_ALBUMS = 903;
static final int PICKER_UNRELIABLE_VOLUME = 904;
private static final HashSet<Integer> REDACTED_URI_SUPPORTED_TYPES = new HashSet<>(
Arrays.asList(AUDIO_MEDIA_ID, IMAGES_MEDIA_ID, VIDEO_MEDIA_ID, FILES_ID, DOWNLOADS_ID));
private LocalUriMatcher mUriMatcher;
private static final String[] PATH_PROJECTION = new String[] {
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DATA,
};
private int matchUri(Uri uri, boolean allowHidden) {
return mUriMatcher.matchUri(uri, allowHidden);
}
static class LocalUriMatcher {
private final UriMatcher mPublic = new UriMatcher(UriMatcher.NO_MATCH);
private final UriMatcher mHidden = new UriMatcher(UriMatcher.NO_MATCH);
public int matchUri(Uri uri, boolean allowHidden) {
final int publicMatch = mPublic.match(uri);
if (publicMatch != UriMatcher.NO_MATCH) {
return publicMatch;
}
final int hiddenMatch = mHidden.match(uri);
if (hiddenMatch != UriMatcher.NO_MATCH) {
// Detect callers asking about hidden behavior by looking closer when
// the matchers diverge; we only care about apps that are explicitly
// targeting a specific public API level.
if (!allowHidden) {
throw new IllegalStateException("Unknown URL: " + uri + " is hidden API");
}
return hiddenMatch;
}
return UriMatcher.NO_MATCH;
}
public LocalUriMatcher(String auth) {
// Warning: Do not move these exact string matches below "*/.." matches.
// If "*/.." match is added to mPublic children before "picker/#/#", then while matching
// "picker/0/10", UriMatcher matches "*" node with "picker" and tries to match "0/10"
// with children of "*".
// UriMatcher does not look for exact "picker" string match if it finds * node before
// it. It finds the first best child match and proceeds the match from there without
// looking at other siblings.
mPublic.addURI(auth, "picker", PICKER);
// TODO(b/195009139): Remove after switching picker URI to new format
// content://media/picker/<user-id>/<media-id>
mPublic.addURI(auth, "picker/#/#", PICKER_ID);
// content://media/picker/<user-id>/<authority>/media/<media-id>
mPublic.addURI(auth, "picker/#/*/media/*", PICKER_ID);
// content://media/picker/unreliable/<media_id>
mPublic.addURI(auth, "picker/unreliable/#", PICKER_UNRELIABLE_VOLUME);
mPublic.addURI(auth, "*/images/media", IMAGES_MEDIA);
mPublic.addURI(auth, "*/images/media/#", IMAGES_MEDIA_ID);
mPublic.addURI(auth, "*/images/media/#/thumbnail", IMAGES_MEDIA_ID_THUMBNAIL);
mPublic.addURI(auth, "*/images/thumbnails", IMAGES_THUMBNAILS);
mPublic.addURI(auth, "*/images/thumbnails/#", IMAGES_THUMBNAILS_ID);
mPublic.addURI(auth, "*/audio/media", AUDIO_MEDIA);
mPublic.addURI(auth, "*/audio/media/#", AUDIO_MEDIA_ID);
mPublic.addURI(auth, "*/audio/media/#/genres", AUDIO_MEDIA_ID_GENRES);
mPublic.addURI(auth, "*/audio/media/#/genres/#", AUDIO_MEDIA_ID_GENRES_ID);
mPublic.addURI(auth, "*/audio/genres", AUDIO_GENRES);
mPublic.addURI(auth, "*/audio/genres/#", AUDIO_GENRES_ID);
mPublic.addURI(auth, "*/audio/genres/#/members", AUDIO_GENRES_ID_MEMBERS);
// TODO: not actually defined in API, but CTS tested
mPublic.addURI(auth, "*/audio/genres/all/members", AUDIO_GENRES_ALL_MEMBERS);
mPublic.addURI(auth, "*/audio/playlists", AUDIO_PLAYLISTS);
mPublic.addURI(auth, "*/audio/playlists/#", AUDIO_PLAYLISTS_ID);
mPublic.addURI(auth, "*/audio/playlists/#/members", AUDIO_PLAYLISTS_ID_MEMBERS);
mPublic.addURI(auth, "*/audio/playlists/#/members/#", AUDIO_PLAYLISTS_ID_MEMBERS_ID);
mPublic.addURI(auth, "*/audio/artists", AUDIO_ARTISTS);
mPublic.addURI(auth, "*/audio/artists/#", AUDIO_ARTISTS_ID);
mPublic.addURI(auth, "*/audio/artists/#/albums", AUDIO_ARTISTS_ID_ALBUMS);
mPublic.addURI(auth, "*/audio/albums", AUDIO_ALBUMS);
mPublic.addURI(auth, "*/audio/albums/#", AUDIO_ALBUMS_ID);
// TODO: not actually defined in API, but CTS tested
mPublic.addURI(auth, "*/audio/albumart", AUDIO_ALBUMART);
// TODO: not actually defined in API, but CTS tested
mPublic.addURI(auth, "*/audio/albumart/#", AUDIO_ALBUMART_ID);
// TODO: not actually defined in API, but CTS tested
mPublic.addURI(auth, "*/audio/media/#/albumart", AUDIO_ALBUMART_FILE_ID);
mPublic.addURI(auth, "*/video/media", VIDEO_MEDIA);
mPublic.addURI(auth, "*/video/media/#", VIDEO_MEDIA_ID);
mPublic.addURI(auth, "*/video/media/#/thumbnail", VIDEO_MEDIA_ID_THUMBNAIL);
mPublic.addURI(auth, "*/video/thumbnails", VIDEO_THUMBNAILS);
mPublic.addURI(auth, "*/video/thumbnails/#", VIDEO_THUMBNAILS_ID);
mPublic.addURI(auth, "*/media_scanner", MEDIA_SCANNER);
// NOTE: technically hidden, since Uri is never exposed
mPublic.addURI(auth, "*/fs_id", FS_ID);
// NOTE: technically hidden, since Uri is never exposed
mPublic.addURI(auth, "*/version", VERSION);
mHidden.addURI(auth, "picker_internal/media", PICKER_INTERNAL_MEDIA);
mHidden.addURI(auth, "picker_internal/albums", PICKER_INTERNAL_ALBUMS);
mHidden.addURI(auth, "*", VOLUMES_ID);
mHidden.addURI(auth, null, VOLUMES);
mPublic.addURI(auth, "*/file", FILES);
mPublic.addURI(auth, "*/file/#", FILES_ID);
mPublic.addURI(auth, "*/downloads", DOWNLOADS);
mPublic.addURI(auth, "*/downloads/#", DOWNLOADS_ID);
}
}
/**
* Set of columns that can be safely mutated by external callers; all other
* columns are treated as read-only, since they reflect what the media
* scanner found on disk, and any mutations would be overwritten the next
* time the media was scanned.
*/
private static final ArraySet<String> sMutableColumns = new ArraySet<>();
static {
sMutableColumns.add(MediaStore.MediaColumns.DATA);
sMutableColumns.add(MediaStore.MediaColumns.RELATIVE_PATH);
sMutableColumns.add(MediaStore.MediaColumns.DISPLAY_NAME);
sMutableColumns.add(MediaStore.MediaColumns.IS_PENDING);
sMutableColumns.add(MediaStore.MediaColumns.IS_TRASHED);
sMutableColumns.add(MediaStore.MediaColumns.IS_FAVORITE);
sMutableColumns.add(MediaStore.MediaColumns.OWNER_PACKAGE_NAME);
sMutableColumns.add(MediaStore.Audio.AudioColumns.BOOKMARK);
sMutableColumns.add(MediaStore.Video.VideoColumns.TAGS);
sMutableColumns.add(MediaStore.Video.VideoColumns.CATEGORY);
sMutableColumns.add(MediaStore.Video.VideoColumns.BOOKMARK);
sMutableColumns.add(MediaStore.Audio.Playlists.NAME);
sMutableColumns.add(MediaStore.Audio.Playlists.Members.AUDIO_ID);
sMutableColumns.add(MediaStore.Audio.Playlists.Members.PLAY_ORDER);
sMutableColumns.add(MediaStore.DownloadColumns.DOWNLOAD_URI);
sMutableColumns.add(MediaStore.DownloadColumns.REFERER_URI);
sMutableColumns.add(MediaStore.Files.FileColumns.MIME_TYPE);
sMutableColumns.add(MediaStore.Files.FileColumns.MEDIA_TYPE);
}
/**
* Set of columns that affect placement of files on disk.
*/
private static final ArraySet<String> sPlacementColumns = new ArraySet<>();
static {
sPlacementColumns.add(MediaStore.MediaColumns.DATA);
sPlacementColumns.add(MediaStore.MediaColumns.RELATIVE_PATH);
sPlacementColumns.add(MediaStore.MediaColumns.DISPLAY_NAME);
sPlacementColumns.add(MediaStore.MediaColumns.MIME_TYPE);
sPlacementColumns.add(MediaStore.MediaColumns.IS_PENDING);
sPlacementColumns.add(MediaStore.MediaColumns.IS_TRASHED);
sPlacementColumns.add(MediaStore.MediaColumns.DATE_EXPIRES);
}
/**
* List of abusive custom columns that we're willing to allow via
* {@link SQLiteQueryBuilder#setProjectionGreylist(List)}.
*/
static final ArrayList<Pattern> sGreylist = new ArrayList<>();
private static void addGreylistPattern(String pattern) {
sGreylist.add(Pattern.compile(" *" + pattern + " *"));
}
static {
final String maybeAs = "( (as )?[_a-z0-9]+)?";
addGreylistPattern("(?i)[_a-z0-9]+" + maybeAs);
addGreylistPattern("audio\\._id AS _id");
addGreylistPattern("(?i)(min|max|sum|avg|total|count|cast)\\(([_a-z0-9]+" + maybeAs + "|\\*)\\)" + maybeAs);
addGreylistPattern("case when case when \\(date_added >= \\d+ and date_added < \\d+\\) then date_added \\* \\d+ when \\(date_added >= \\d+ and date_added < \\d+\\) then date_added when \\(date_added >= \\d+ and date_added < \\d+\\) then date_added / \\d+ else \\d+ end > case when \\(date_modified >= \\d+ and date_modified < \\d+\\) then date_modified \\* \\d+ when \\(date_modified >= \\d+ and date_modified < \\d+\\) then date_modified when \\(date_modified >= \\d+ and date_modified < \\d+\\) then date_modified / \\d+ else \\d+ end then case when \\(date_added >= \\d+ and date_added < \\d+\\) then date_added \\* \\d+ when \\(date_added >= \\d+ and date_added < \\d+\\) then date_added when \\(date_added >= \\d+ and date_added < \\d+\\) then date_added / \\d+ else \\d+ end else case when \\(date_modified >= \\d+ and date_modified < \\d+\\) then date_modified \\* \\d+ when \\(date_modified >= \\d+ and date_modified < \\d+\\) then date_modified when \\(date_modified >= \\d+ and date_modified < \\d+\\) then date_modified / \\d+ else \\d+ end end as corrected_added_modified");
addGreylistPattern("MAX\\(case when \\(datetaken >= \\d+ and datetaken < \\d+\\) then datetaken \\* \\d+ when \\(datetaken >= \\d+ and datetaken < \\d+\\) then datetaken when \\(datetaken >= \\d+ and datetaken < \\d+\\) then datetaken / \\d+ else \\d+ end\\)");
addGreylistPattern("MAX\\(case when \\(date_added >= \\d+ and date_added < \\d+\\) then date_added \\* \\d+ when \\(date_added >= \\d+ and date_added < \\d+\\) then date_added when \\(date_added >= \\d+ and date_added < \\d+\\) then date_added / \\d+ else \\d+ end\\)");
addGreylistPattern("MAX\\(case when \\(date_modified >= \\d+ and date_modified < \\d+\\) then date_modified \\* \\d+ when \\(date_modified >= \\d+ and date_modified < \\d+\\) then date_modified when \\(date_modified >= \\d+ and date_modified < \\d+\\) then date_modified / \\d+ else \\d+ end\\)");
addGreylistPattern("\"content://media/[a-z]+/audio/media\"");
addGreylistPattern("substr\\(_data, length\\(_data\\)-length\\(_display_name\\), 1\\) as filename_prevchar");
addGreylistPattern("\\*" + maybeAs);
addGreylistPattern("case when \\(datetaken >= \\d+ and datetaken < \\d+\\) then datetaken \\* \\d+ when \\(datetaken >= \\d+ and datetaken < \\d+\\) then datetaken when \\(datetaken >= \\d+ and datetaken < \\d+\\) then datetaken / \\d+ else \\d+ end");
}
public ArrayMap<String, String> getProjectionMap(Class<?>... clazzes) {
return mExternalDatabase.getProjectionMap(clazzes);
}
static <T> boolean containsAny(Set<T> a, Set<T> b) {
for (T i : b) {
if (a.contains(i)) {
return true;
}
}
return false;
}
@VisibleForTesting
static @Nullable Uri computeCommonPrefix(@NonNull List<Uri> uris) {
if (uris.isEmpty()) return null;
final Uri base = uris.get(0);
final List<String> basePath = new ArrayList<>(base.getPathSegments());
for (int i = 1; i < uris.size(); i++) {
final List<String> probePath = uris.get(i).getPathSegments();
for (int j = 0; j < basePath.size() && j < probePath.size(); j++) {
if (!Objects.equals(basePath.get(j), probePath.get(j))) {
// Trim away all remaining common elements
while (basePath.size() > j) {
basePath.remove(j);
}
}
}
final int probeSize = probePath.size();
while (basePath.size() > probeSize) {
basePath.remove(probeSize);
}
}
final Uri.Builder builder = base.buildUpon().path(null);
for (int i = 0; i < basePath.size(); i++) {
builder.appendPath(basePath.get(i));
}
return builder.build();
}
public ExternalDbFacade getExternalDbFacade() {
return mExternalDbFacade;
}
public PickerSyncController getPickerSyncController() {
return mPickerSyncController;
}
private boolean isCallingPackageSystemGallery() {
return mCallingIdentity.get().hasPermission(PERMISSION_IS_SYSTEM_GALLERY);
}
private int getCallingUidOrSelf() {
return mCallingIdentity.get().uid;
}
@Deprecated
private String getCallingPackageOrSelf() {
return mCallingIdentity.get().getPackageName();
}
@Deprecated
@VisibleForTesting
public int getCallingPackageTargetSdkVersion() {
return mCallingIdentity.get().getTargetSdkVersion();
}
@Deprecated
private boolean isCallingPackageAllowedHidden() {
return isCallingPackageSelf();
}
@Deprecated
private boolean isCallingPackageSelf() {
return mCallingIdentity.get().hasPermission(PERMISSION_IS_SELF);
}
@Deprecated
private boolean isCallingPackageShell() {
return mCallingIdentity.get().hasPermission(PERMISSION_IS_SHELL);
}
@Deprecated
private boolean isCallingPackageManager() {
return mCallingIdentity.get().hasPermission(PERMISSION_IS_MANAGER);
}
@Deprecated
private boolean isCallingPackageDelegator() {
return mCallingIdentity.get().hasPermission(PERMISSION_IS_DELEGATOR);
}
@Deprecated
private boolean isCallingPackageLegacyRead() {
return mCallingIdentity.get().hasPermission(PERMISSION_IS_LEGACY_READ);
}
@Deprecated
private boolean isCallingPackageLegacyWrite() {
return mCallingIdentity.get().hasPermission(PERMISSION_IS_LEGACY_WRITE);
}
@Override
public void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
writer.println("mThumbSize=" + mThumbSize);
synchronized (mAttachedVolumes) {
writer.println("mAttachedVolumes=" + mAttachedVolumes);
}
writer.println();
mVolumeCache.dump(writer);
writer.println();
mUserCache.dump(writer);
writer.println();
mTranscodeHelper.dump(writer);
writer.println();
Logging.dumpPersistent(writer);
}
}