blob: 9ae9c6f7fe62195c07e27cb29a8da6e594f7ff3e [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.CloudMediaProviderContract.EXTRA_ASYNC_CONTENT_PROVIDER;
import static android.provider.CloudMediaProviderContract.METHOD_GET_ASYNC_CONTENT_PROVIDER;
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.VOLUME_EXTERNAL;
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.extractOwnerPackageNameFromRelativePath;
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.extractRelativePathWithDisplayName;
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.fromFuseFile;
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.isDataOrObbRelativePath;
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.isObbOrChildRelativePath;
import static com.android.providers.media.util.FileUtils.sanitizePath;
import static com.android.providers.media.util.FileUtils.toFuseFile;
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.AsyncContentProvider;
import android.provider.BaseColumns;
import android.provider.Column;
import android.provider.DeviceConfig;
import android.provider.DeviceConfig.OnPropertiesChangedListener;
import android.provider.DocumentsContract;
import android.provider.ExportedSince;
import android.provider.IAsyncContentProvider;
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.Pair;
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.BackgroundThread;
import com.android.modules.utils.build.SdkLevel;
import com.android.providers.media.DatabaseHelper.OnFilesChangeListener;
import com.android.providers.media.DatabaseHelper.OnLegacyMigrationListener;
import com.android.providers.media.dao.FileRow;
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.StringUtils;
import com.android.providers.media.util.UserCache;
import com.android.providers.media.util.XAttrUtils;
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.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
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;
import java.util.stream.Collectors;
/**
* 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";
/**
* 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;
/**
* This is required as idle maintenance maybe stopped anytime; we do not want to query
* and accumulate values to update for a long time, instead we want to batch query and update
* by a limited number.
*/
private static final int IDLE_MAINTENANCE_ROWS_LIMIT = 1000;
/**
* 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());
if (Intent.ACTION_PACKAGE_REMOVED.equals(intent.getAction())) {
mUserCache.invalidateWorkProfileOwnerApps(pkg);
mPickerSyncController.notifyPackageRemoval(pkg);
}
} else {
Log.w(TAG, "Failed to retrieve package from intent: " + intent.getAction());
}
break;
}
}
};
private BroadcastReceiver mUserIntentReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
switch (intent.getAction()) {
case Intent.ACTION_USER_REMOVED:
/**
* Removing media files for user being deleted. This would impact if the deleted
* user have been using same MediaProvider as the current user i.e. when
* isMediaSharedWithParent is true.On removal of such user profile,
* the owner's MediaProvider would need to clean any media files stored
* by the removed user profile.
*/
UserHandle userToBeRemoved = intent.getParcelableExtra(Intent.EXTRA_USER);
if(userToBeRemoved.getIdentifier() != sUserId){
mExternalDatabase.runWithTransaction((db) -> {
db.execSQL("delete from files where _user_id=?",
new String[]{String.valueOf(userToBeRemoved.getIdentifier())});
return null ;
});
}
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 FileRow insertedRow) {
handleInsertedRowForFuse(insertedRow.getId());
acceptWithExpansion(helper::notifyInsert, insertedRow.getVolumeName(),
insertedRow.getId(), insertedRow.getMediaType(), insertedRow.isDownload());
updateNextRowIdXattr(helper, insertedRow.getId());
helper.postBackground(() -> {
if (helper.isExternal()) {
// Update the quota type on the filesystem
Uri fileUri = MediaStore.Files.getContentUri(insertedRow.getVolumeName(),
insertedRow.getId());
updateQuotaTypeForUri(fileUri, insertedRow.getMediaType());
}
// Tell our SAF provider so it knows when views are no longer empty
MediaDocumentsProvider.onMediaStoreInsert(getContext(), insertedRow.getVolumeName(),
insertedRow.getMediaType(), insertedRow.getId());
if (mExternalDbFacade.onFileInserted(insertedRow.getMediaType(),
insertedRow.isPending())) {
mPickerSyncController.notifyMediaEvent();
}
});
}
@Override
public void onUpdate(@NonNull DatabaseHelper helper, @NonNull FileRow oldRow,
@NonNull FileRow newRow) {
final boolean isDownload = oldRow.isDownload() || newRow.isDownload();
final Uri fileUri = MediaStore.Files.getContentUri(oldRow.getVolumeName(),
oldRow.getId());
handleUpdatedRowForFuse(oldRow.getPath(), oldRow.getOwnerPackageName(), oldRow.getId(),
newRow.getId());
handleOwnerPackageNameChange(oldRow.getPath(), oldRow.getOwnerPackageName(),
newRow.getOwnerPackageName());
acceptWithExpansion(helper::notifyUpdate, oldRow.getVolumeName(), oldRow.getId(),
oldRow.getMediaType(), isDownload);
updateNextRowIdXattr(helper, newRow.getId());
helper.postBackground(() -> {
if (helper.isExternal()) {
// Update the quota type on the filesystem
updateQuotaTypeForUri(fileUri, newRow.getMediaType());
}
if (mExternalDbFacade.onFileUpdated(oldRow.getId(),
oldRow.getMediaType(), newRow.getMediaType(),
oldRow.isTrashed(), newRow.isTrashed(),
oldRow.isPending(), newRow.isPending(),
oldRow.isFavorite(), newRow.isFavorite(),
oldRow.getSpecialFormat(), newRow.getSpecialFormat())) {
mPickerSyncController.notifyMediaEvent();
}
});
if (newRow.getMediaType() != oldRow.getMediaType()) {
acceptWithExpansion(helper::notifyUpdate, oldRow.getVolumeName(), oldRow.getId(),
newRow.getMediaType(), isDownload);
helper.postBackground(() -> {
// Invalidate any thumbnails when the media type changes
invalidateThumbnails(fileUri);
});
}
}
@Override
public void onDelete(@NonNull DatabaseHelper helper, @NonNull FileRow deletedRow) {
handleDeletedRowForFuse(deletedRow.getPath(), deletedRow.getOwnerPackageName(),
deletedRow.getId());
acceptWithExpansion(helper::notifyDelete, deletedRow.getVolumeName(),
deletedRow.getId(), deletedRow.getMediaType(), deletedRow.isDownload());
// Remove cached transcoded file if any
mTranscodeHelper.deleteCachedTranscodeFile(deletedRow.getId());
helper.postBackground(() -> {
// Item no longer exists, so revoke all access to it
Trace.beginSection("revokeUriPermission");
try {
acceptWithExpansion((uri) -> {
getContext().revokeUriPermission(uri, ~0);
},
deletedRow.getVolumeName(), deletedRow.getId(),
deletedRow.getMediaType(), deletedRow.isDownload());
} finally {
Trace.endSection();
}
switch (deletedRow.getMediaType()) {
case FileColumns.MEDIA_TYPE_PLAYLIST:
case FileColumns.MEDIA_TYPE_AUDIO:
if (helper.isExternal()) {
removePlaylistMembers(deletedRow.getMediaType(), deletedRow.getId());
}
}
// Invalidate any thumbnails now that media is gone
invalidateThumbnails(MediaStore.Files.getContentUri(deletedRow.getVolumeName(),
deletedRow.getId()));
// Tell our SAF provider so it can revoke too
MediaDocumentsProvider.onMediaStoreDelete(getContext(), deletedRow.getVolumeName(),
deletedRow.getMediaType(), deletedRow.getId());
if (mExternalDbFacade.onFileDeleted(deletedRow.getId(),
deletedRow.getMediaType())) {
mPickerSyncController.notifyMediaEvent();
}
});
}
};
protected void updateNextRowIdXattr(DatabaseHelper helper, long id) {
if (!helper.isNextRowIdBackupEnabled()) {
Log.v(TAG, "Skipping next row id backup.");
return;
}
Optional<Long> nextRowIdBackupOptional = helper.getNextRowId();
if (!nextRowIdBackupOptional.isPresent()) {
throw new RuntimeException(
String.format(Locale.ROOT, "Cannot find next row id xattr for %s.",
helper.getDatabaseName()));
}
if (id >= nextRowIdBackupOptional.get()) {
helper.backupNextRowId(id);
} else {
Log.v(TAG, String.format(Locale.ROOT, "Inserted id:%d less than next row id backup:%d.",
id, nextRowIdBackupOptional.get()));
}
}
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.isExternallyManaged()) {
// Default folders should not be automatically created inside volumes managed from
// outside Android.
return;
}
final String volumeName = volume.getName();
String key;
if (volumeName.equals(MediaStore.VOLUME_EXTERNAL_PRIMARY)) {
// For the primary volume, we use the ID, because we may be handling
// the primary volume for multiple users
key = "created_default_folders_" + volume.getId();
} else {
// For others, like public volumes, just use the name, because the id
// might not change when re-formatted
key = "created_default_folders_" + volumeName;
}
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.isExternallyManaged()) {
// Default folders and thumbnail directories should not be automatically created inside
// volumes managed from outside Android, and there is no need to ensure the validity of
// their 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, true);
mExternalDatabase = new DatabaseHelper(context, EXTERNAL_DATABASE_NAME, false, false,
Column.class, ExportedSince.class, Metrics::logSchemaChange, mFilesListener,
MIGRATION_LISTENER, mIdGenerator, true);
mExternalDbFacade = new ExternalDbFacade(getContext(), mExternalDatabase, mVolumeCache);
mPickerDbFacade = new PickerDbFacade(context);
final String localPickerProvider = PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY;
final String allowedCloudProviders =
getStringDeviceConfig(PickerSyncController.ALLOWED_CLOUD_PROVIDERS_KEY,
/* default */ "");
final int pickerSyncDelayMs = getIntDeviceConfig(PickerSyncController.SYNC_DELAY_MS,
/* default */ 5000);
mPickerSyncController = new PickerSyncController(context, mPickerDbFacade,
localPickerProvider, allowedCloudProviders, pickerSyncDelayMs);
mPickerDataLayer = new PickerDataLayer(context, mPickerDbFacade, mPickerSyncController);
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);
// Creating intent broadcast receiver for user actions like Intent.ACTION_USER_REMOVED,
// where we would need to remove files stored by removed user.
final IntentFilter userIntentFilter = new IntentFilter();
userIntentFilter.addAction(Intent.ACTION_USER_REMOVED);
context.registerReceiver(mUserIntentReceiver, userIntentFilter);
// Watch for invalidation of cached volumes
mStorageManager.registerStorageVolumeCallback(context.getMainExecutor(),
new StorageVolumeCallback() {
@Override
public void onStateChanged(@NonNull StorageVolume volume) {
updateVolumes();
}
});
if (SdkLevel.isAtLeastT()) {
try {
mStorageManager.setCloudMediaProvider(mPickerSyncController.getCloudProvider());
} catch (SecurityException e) {
// This can happen in unit tests
Log.w(TAG, "Failed to update the system_server with the latest cloud provider", e);
}
}
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_READ_MEDIA_AUDIO,
null /* all packages */, mModeListener);
mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_READ_MEDIA_IMAGES,
null /* all packages */, mModeListener);
mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_READ_MEDIA_VIDEO,
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;
}
Optional<DatabaseHelper> getDatabaseHelper(String dbName) {
if (dbName.equalsIgnoreCase(INTERNAL_DATABASE_NAME)) {
return Optional.of(mInternalDatabase);
} else if (dbName.equalsIgnoreCase(EXTERNAL_DATABASE_NAME)) {
return Optional.of(mExternalDatabase);
}
return Optional.empty();
}
@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, int userId) {
final Context context = mUserCache.getContextForUser(UserHandle.of(userId));
final PackageManager pm = context.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);
final long itemCount = mExternalDatabase.runWithTransaction((db) -> {
return DatabaseHelper.getItemCount(db);
});
// Cleaning media files for users that have been removed
cleanMediaFilesForRemovedUser(signal);
// Populate _SPECIAL_FORMAT column for files which have column value as NULL
detectSpecialFormat(signal);
final long durationMillis = (SystemClock.elapsedRealtime() - startTime);
Metrics.logIdleMaintenance(MediaStore.VOLUME_EXTERNAL, itemCount,
durationMillis, staleThumbnails, deletedExpiredMedia);
}
/**
* This function find and clean the files related to user who have been removed
*/
private void cleanMediaFilesForRemovedUser(CancellationSignal signal) {
//Finding userIds that are available in database
final List<String> userIds = mExternalDatabase.runWithTransaction((db) -> {
final List<String> userIdsPresent = new ArrayList<>();
try (Cursor c = db.query(true, "files", new String[] { "_user_id" },
null, null, null, null, null,
null, signal)) {
while (c.moveToNext()) {
final String userId = c.getString(0);
userIdsPresent.add(userId);
}
}
return userIdsPresent;
});
//removing calling userId
userIds.remove(String.valueOf(sUserId));
//removing all the valid/existing user, remaining userIds would be users who would have been
//removed
userIds.removeAll(mUserManager.getEnabledProfiles().stream()
.map(userHandle -> String.valueOf(userHandle.getIdentifier())).collect(
Collectors.toList()));
// Cleaning media files of users who have been removed
mExternalDatabase.runWithTransaction((db) -> {
userIds.stream().forEach(userId ->{
Log.d(TAG, "Removing media files associated with user : " + userId);
db.execSQL("delete from files where _user_id=?",
new String[]{String.valueOf(userId)});
});
return null ;
});
}
private void pruneStalePackages(CancellationSignal signal) {
final int stalePackages = mExternalDatabase.runWithTransaction((db) -> {
final ArraySet<Pair<String, Integer>> unknownPackages = new ArraySet<>();
try (Cursor c = db.query(true, "files",
new String[] { "owner_package_name", "_user_id" },
null, null, null, null, null, null, signal)) {
while (c.moveToNext()) {
final String packageName = c.getString(0);
if (TextUtils.isEmpty(packageName)) continue;
final int userId = c.getInt(1);
if (!isPackageKnown(packageName, userId)) {
unknownPackages.add(Pair.create(packageName, userId));
}
}
}
for (Pair<String, Integer> pair : unknownPackages) {
onPackageOrphaned(db, pair.first, pair.second);
}
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
public void setUriResolver(PickerUriResolver resolver) {
Log.w(TAG, "Changing the PickerUriResolver!!! Should only be called during test");
mPickerUriResolver = resolver;
}
@VisibleForTesting
void detectSpecialFormat(@NonNull CancellationSignal signal) {
mExternalDatabase.runWithTransaction((db) -> {
updateSpecialFormatColumn(db, signal);
return null;
});
}
private void updateSpecialFormatColumn(SQLiteDatabase db, @NonNull CancellationSignal signal) {
// This is to ensure we only do a bounded iteration over the rows as updates can fail, and
// we don't want to keep running the query/update indefinitely.
final int totalRowsToUpdate = getPendingSpecialFormatRowsCount(db,signal);
for (int i = 0 ; i < totalRowsToUpdate ; i += IDLE_MAINTENANCE_ROWS_LIMIT) {
updateSpecialFormatForLimitedRows(db, signal);
}
}
private int getPendingSpecialFormatRowsCount(SQLiteDatabase db,
@NonNull CancellationSignal signal) {
try (Cursor c = queryForPendingSpecialFormatColumns(db, /* limit */ null, signal)) {
if (c == null) {
return 0;
}
return c.getCount();
}
}
private void updateSpecialFormatForLimitedRows(SQLiteDatabase db,
@NonNull CancellationSignal signal) {
final SQLiteQueryBuilder qbForUpdate = getQueryBuilder(TYPE_UPDATE, FILES,
Files.getContentUri(VOLUME_EXTERNAL), Bundle.EMPTY, null);
// Accumulate all the new SPECIAL_FORMAT updates with their ids
ArrayMap<Long, Integer> newSpecialFormatValues = new ArrayMap<>();
final String limit = String.valueOf(IDLE_MAINTENANCE_ROWS_LIMIT);
try (Cursor c = queryForPendingSpecialFormatColumns(db, limit, signal)) {
while (c.moveToNext() && !signal.isCanceled()) {
final long id = c.getLong(0);
final String path = c.getString(1);
newSpecialFormatValues.put(id, getSpecialFormatValue(path));
}
}
// Now, update all the new SPECIAL_FORMAT values.
final ContentValues values = new ContentValues();
int count = 0;
for (long id: newSpecialFormatValues.keySet()) {
if (signal.isCanceled()) {
return;
}
values.clear();
values.put(_SPECIAL_FORMAT, newSpecialFormatValues.get(id));
final String selection = MediaColumns._ID + "=?";
final String[] selectionArgs = new String[]{String.valueOf(id)};
if (qbForUpdate.update(db, values, selection, selectionArgs) == 1) {
count++;
} else {
Log.e(TAG, "Unable to update _SPECIAL_FORMAT for id = " + id);
}
}
Log.d(TAG, "Updated _SPECIAL_FORMAT for " + count + " items");
}
private int getSpecialFormatValue(String path) {
final File file = new File(path);
if (!file.exists()) {
// We always update special format to none if the file is not found or there is an
// error, this is so that we do not repeat over the same column again and again.
return _SPECIAL_FORMAT_NONE;
}
try {
return 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);
return _SPECIAL_FORMAT_NONE;
}
}
private Cursor queryForPendingSpecialFormatColumns(SQLiteDatabase db, String limit,
@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, limit, 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, int uid) {
mExternalDatabase.runWithTransaction((db) -> {
final int userId = uid / PER_USER_RANGE;
onPackageOrphaned(db, packageName, userId);
return null;
});
}
/**
* Orphan any content of the given package from the given database. This will delete
* Android/media files from the database if the underlying file no longe exists.
*/
public void onPackageOrphaned(@NonNull SQLiteDatabase db,
@NonNull String packageName, int userId) {
// Delete Android/media entries.
deleteAndroidMediaEntries(db, packageName, userId);
// Orphan rest of entries.
orphanEntries(db, packageName, userId);
}
private void deleteAndroidMediaEntries(SQLiteDatabase db, String packageName, int userId) {
String relativePath = "Android/media/" + DatabaseUtils.escapeForLike(packageName) + "/%";
try (Cursor cursor = db.query(
"files",
new String[] { MediaColumns._ID, MediaColumns.DATA },
"relative_path LIKE ? ESCAPE '\\' AND owner_package_name=? AND _user_id=?",
new String[] { relativePath, packageName, "" + userId },
/* groupBy= */ null,
/* having= */ null,
/* orderBy= */null,
/* limit= */ null)) {
int countDeleted = 0;
if (cursor != null) {
while (cursor.moveToNext()) {
File file = new File(cursor.getString(1));
// We check for existence to be sure we don't delete files that still exist.
// This can happen even if the pair (package, userid) is unknown,
// since some framework implementations may rely on special userids.
if (!file.exists()) {
countDeleted +=
db.delete("files", "_id=?", new String[]{cursor.getString(0)});
}
}
}
Log.d(TAG, "Deleted " + countDeleted + " Android/media items belonging to "
+ packageName + " on " + db.getPath());
}
}
private void orphanEntries(
@NonNull SQLiteDatabase db, @NonNull String packageName, int userId) {
final ContentValues values = new ContentValues();
values.putNull(FileColumns.OWNER_PACKAGE_NAME);
final int countOrphaned = db.update("files", values,
"owner_package_name=? AND _user_id=?", new String[] { packageName, "" + userId });
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 > 5) {
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/<user-id>
try {
Integer.parseInt(lastSegment);
result = file.exists() || file.mkdir();
} catch (NumberFormatException e) {
Log.w(TAG, "Invalid user id for picker file lookup: " + lastSegment
+ ". File: " + file);
}
break;
case 3:
// .../picker/<user-id>/<authority>
result = preparePickerAuthorityPathSegment(file, lastSegment, uid);
break;
case 4:
// .../picker/<user-id>/<authority>/media
if (lastSegment.equals("media")) {
result = file.exists() || file.mkdir();
}
break;
case 5:
// .../picker/<user-id>/<authority>/media/<media-id.extension>
final String fileUserId = syntheticRelativePathSegments.get(1);
final String authority = syntheticRelativePathSegments.get(2);
result = preparePickerMediaIdPathSegment(file, authority, lastSegment, fileUserId);
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 != 11) {
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', '<user-id>',
// '<host>', 'media', '<fileName>']
final String userId = segments[7];
final String fileName = segments[10];
final String host = segments[8];
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();
IBinder binder = getContext().getContentResolver()
.call(uri, METHOD_GET_ASYNC_CONTENT_PROVIDER, null, null)
.getBinder(EXTRA_ASYNC_CONTENT_PROVIDER);
if (binder == null) {
Log.e(TAG, "Picker file open failed. No cloud media provider found.");
return FileOpenResult.createError(OsConstants.ENOENT, uid);
}
IAsyncContentProvider iAsyncContentProvider = IAsyncContentProvider.Stub.asInterface(
binder);
AsyncContentProvider asyncContentProvider = new AsyncContentProvider(iAsyncContentProvider);
final ParcelFileDescriptor pfd;
try {
pfd = asyncContentProvider.openMedia(uri, "r");
} catch (FileNotFoundException | ExecutionException | InterruptedException
| TimeoutException | RemoteException 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.exists() || file.mkdir();
}
return false;
}
private boolean preparePickerMediaIdPathSegment(File file, String authority, String fileName,
String userId) {
final String mediaId = extractFileName(fileName);
final String[] projection = new String[] { MediaStore.PickerMediaColumns.SIZE };
final Uri uri = Uri.parse("content://media/picker/" + userId + "/" + authority + "/media/"
+ mediaId);
try (Cursor cursor = mPickerUriResolver.query(uri, projection, /* callingUid */0,
android.os.Process.myUid())) {
if (cursor != null && cursor.moveToFirst()) {
final int sizeBytesIdx = cursor.getColumnIndex(MediaStore.PickerMediaColumns.SIZE);
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 = extractRelativePathWithDisplayName(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) ||
StringUtils.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 = extractRelativePathWithDisplayName(oldPath);
final String newRelativePath = extractRelativePathWithDisplayName(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.shouldDirBeHidden(new File(oldPath));
final boolean isHidden = FileUtils.shouldDirBeHidden(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 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 = FileUtils.shouldFileBeHidden(new File(oldPath));
final boolean isHidden = FileUtils.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, oldPath)
&& 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, 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))) {
// Note that just the volume name isn't enough to determine the path,
// since we can manage different volumes with the same name for
// different users. Instead, if we have a current path (which implies
// an already existing file to be renamed), use that to derive the
// user-id of the file, and in turn use that to derive the correct
// volume. Cross-user renames are not supported without a specified
// DATA column.
File volumePath;
UserHandle userHandle = mCallingIdentity.get().getUser();
if (currentPath != null) {
int userId = FileUtils.extractUserId(currentPath);
if (userId != -1) {
userHandle = UserHandle.of(userId);
}
}
try {
volumePath = mVolumeCache.getVolumePath(resolvedVolumeName, userHandle);
} 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(), /*excludeNonSystemGallery*/ true);
}
// 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();
}
/**
* For apps targetSdk >= S: Check that values does not contain any external private path.
* For all apps: Check that values does not contain any other app's external private paths.
*/
private void assertPrivatePathNotInValues(ContentValues values)
throws IllegalArgumentException {
ArrayList<String> relativePaths = new ArrayList<String>();
relativePaths.add(extractRelativePath(values.getAsString(MediaColumns.DATA)));
relativePaths.add(values.getAsString(MediaColumns.RELATIVE_PATH));
for (final String relativePath : relativePaths) {
if (!isDataOrObbRelativePath(relativePath)) {
continue;
}
/**
* 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.
*
* Note: For backwards compatibility we allow apps with targetSdk < S to insert private
* files to MediaProvider
*/
if (CompatChanges.isChangeEnabled(ENABLE_CHECKS_FOR_PRIVATE_FILES,
Binder.getCallingUid())) {
throw new IllegalArgumentException(
"Inserting private file: " + relativePath + " is not allowed.");
}
/**
* Restrict all (legacy and non-legacy) apps from inserting paths in other
* app's private directories.
* Allow legacy apps to insert/update files in app private directories for backward
* compatibility but don't allow them to do so in other app's private directories.
*/
if (!isCallingIdentityAllowedAccessToDataOrObbPath(relativePath)) {
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 = toFuseFile(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
&& FileUtils.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()
&& FileUtils.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");
}
// In order to be consistent with other audio views like audio_artist, audio_albums,
// and audio_genres, exclude pending and trashed item
appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, MATCH_EXCLUDE, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, MATCH_EXCLUDE, uri);
appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri);
if (honored != null) {
honored.accept(QUERY_ARG_MATCH_FAVORITE);
}
if (!includeAllVolumes) {
appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes);
}
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
syncAllMedia();
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, "Test initiated cloud provider switch: " + cloudProvider);
mPickerSyncController.forceSetCloudProvider(cloudProvider);
// fall-through
}
case MediaStore.SYNC_PROVIDERS_CALL: {
syncAllMedia();
return new Bundle();
}
case MediaStore.IS_SUPPORTED_CLOUD_PROVIDER_CALL: {
final boolean isSupported = mPickerSyncController.isProviderSupported(arg,
Binder.getCallingUid());
Bundle bundle = new Bundle();
bundle.putBoolean(MediaStore.EXTRA_CLOUD_PROVIDER_RESULT, isSupported);
return bundle;
}
case MediaStore.IS_CURRENT_CLOUD_PROVIDER_CALL: {
final boolean isEnabled = mPickerSyncController.isProviderEnabled(arg,
Binder.getCallingUid());
Bundle bundle = new Bundle();
bundle.putBoolean(MediaStore.EXTRA_CLOUD_PROVIDER_RESULT, isEnabled);
return bundle;
}
case MediaStore.NOTIFY_CLOUD_MEDIA_CHANGED_EVENT_CALL: {
final boolean notifyCloudEventResult;
if (mPickerSyncController.isProviderEnabled(arg, Binder.getCallingUid())) {
mPickerSyncController.notifyMediaEvent();
notifyCloudEventResult = true;
} else {
notifyCloudEventResult = false;
}
Bundle bundle = new Bundle();
bundle.putBoolean(MediaStore.EXTRA_CLOUD_PROVIDER_RESULT,
notifyCloudEventResult);
return bundle;
}
case MediaStore.USES_FUSE_PASSTHROUGH: {
boolean isEnabled = false;
try {
FuseDaemon daemon = getFuseDaemonForFile(new File(arg));
if (daemon != null) {
isEnabled = daemon.usesFusePassthrough();
}
} catch (FileNotFoundException e) {
}
Bundle bundle = new Bundle();
bundle.putBoolean(MediaStore.USES_FUSE_PASSTHROUGH_RESULT, isEnabled);
return bundle;
}
default:
throw new UnsupportedOperationException("Unsupported call: " + method);
}
}
private void syncAllMedia() {
// Clear the binder calling identity so that we can sync the unexported
// local_provider while running as MediaProvider
final long t = Binder.clearCallingIdentity();
try {
Log.v(TAG, "Test initiated cloud provider sync");
mPickerSyncController.syncAllMedia();
} finally {
Binder.restoreCallingIdentity(t);
}
}
private AssetFileDescriptor getOriginalMediaFormatFileDescriptor(Bundle extras)
throws FileNotFoundException {
try (ParcelFileDescriptor inputPfd =
extras.getParcelable(MediaStore.EXTRA_FILE_DESCRIPTOR)) {
File file = getFileFromFileDescriptor(inputPfd);
// Convert from FUSE file to lower fs file because the supportsTranscode() check below
// expects a lower fs file format
file = fromFuseFile(file);
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);
int uid = Binder.getCallingUid();
FdAccessResult result = fuseDaemon.checkFdAccess(inputPfd, uid);
if (!result.isSuccess()) {
throw new FileNotFoundException("Invalid path for original media format file");
}
String outputPath = result.filePath;
boolean shouldRedact = result.shouldRedact;
int posixMode = Os.fcntlInt(inputPfd.getFileDescriptor(), F_GETFL,
0 /* args */);
int modeBits = FileUtils.translateModePosixToPfd(posixMode);
ParcelFileDescriptor pfd = openWithFuse(outputPath, uid, 0 /* mediaCapabilitiesUid */,
modeBits, shouldRedact, false /* shouldTranscode */,
0 /* transcodeReason */);
return new AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH);
} catch (IOException 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}.
*
* Note that the file may be a FUSE or lower fs file and depending on the purpose might need
* to be converted with {@link FileUtils#toFuseFile} or {@link FileUtils#fromFuseFile}.
*
* 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, /* excludeNonSystemGallery */ true) &&
(canAccessMediaFile(destPath, /* excludeNonSystemGallery */ true)))) {
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 {
final boolean wantsThumb = (opts != null) && opts.containsKey(ContentResolver.EXTRA_SIZE)
&& StringUtils.startsWithIgnoreCase(mimeTypeFilter, "image/");
String mode = "r";
// If request is not for thumbnail and arising from MediaProvider, then check for EXTRA_MODE
if (opts != null && !wantsThumb && isCallingPackageSelf()) {
mode = opts.getString(MediaStore.EXTRA_MODE, "r");
} else if (opts != null) {
opts.remove(MediaStore.EXTRA_MODE);
}
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
try {
return getOriginalMediaFormatFileDescriptor(opts);
} finally {
// Clearing the Bundle closes the underlying Parcel, ensuring that the input fd
// owned by the Parcel is closed immediately and not at the next GC.
// This works around a change in behavior introduced by:
// aosp/Icfe8880cad00c3cd2afcbe4b92400ad4579e680e
opts.clear();
}
}
// 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
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, mode, 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 {
Cursor c = null;
try {
c = query(uri, projection,
DatabaseUtils.createSqlQueryBundle(selection, selectionArgs, null),
signal, true);
} catch (IllegalArgumentException e) {
throw new FileNotFoundException("Volume not found for " + uri);
}
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);
}
}
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(toFuseFile(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).getCanonicalFile();
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 excludeNonSystemGallery) {
if (excludeNonSystemGallery && !isCallingPackageSystemGallery()) {
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, /*excludeNonSystemGallery*/ false)) {
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();
}
private FileAccessAttributes queryForFileAttributes(final String path)
throws FileNotFoundException {
Trace.beginSection("queryFileAttr");
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};
FileAccessAttributes fileAccessAttributes;
try (final Cursor c = queryForSingleItemAsMediaProvider(contentUri, projection,
selection,
selectionArgs, null)) {
fileAccessAttributes = FileAccessAttributes.fromCursor(c);
}
Trace.endSection();
return fileAccessAttributes;
}
private void checkIfFileOpenIsPermitted(String path,
FileAccessAttributes fileAccessAttributes, String redactedUriId,
boolean forWrite) throws FileNotFoundException {
final File file = new File(path);
Uri fileUri = MediaStore.Files.getContentUri(extractVolumeName(path),
fileAccessAttributes.getId());
// We don't check ownership for files with IS_PENDING set by FUSE
// Please note that even if ownerPackageName is null, the check below will throw an
// IllegalStateException
if (fileAccessAttributes.isTrashed() || (fileAccessAttributes.isPending()
&& !isPendingFromFuse(new File(path)))) {
requireOwnershipForItem(fileAccessAttributes.getOwnerPackageName(), 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(fileAccessAttributes.getId()) : redactedUriId;
if (getOtherUriGrantsForPath(path, fileAccessAttributes.getMediaType(),
uriId, forWrite) == null) {
throw e;
}
}
}
/**
* 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]);
}
// TODO: Fetch owner id from Android/media directory and check if caller is owner
FileAccessAttributes fileAttributes = null;
if (XAttrUtils.ENABLE_XATTR_METADATA_FOR_FUSE) {
Optional<FileAccessAttributes> fileAttributesThroughXattr =
XAttrUtils.getFileAttributesFromXAttr(path,
XAttrUtils.FILE_ACCESS_XATTR_KEY);
if (fileAttributesThroughXattr.isPresent()) {
fileAttributes = fileAttributesThroughXattr.get();
}
}
// FileAttributes will be null if the xattr call failed or the flag to enable xattr
// metadata support is not set
if (fileAttributes == null) {
fileAttributes = queryForFileAttributes(path);
}
checkIfFileOpenIsPermitted(path, fileAttributes, redactedUriId, forWrite);
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 {
return isCallingIdentityAllowedAccessToDataOrObbPath(
extractRelativePathWithDisplayName(path));
} finally {
restoreLocalCallingIdentity(token);
}
}
private boolean isCallingIdentityAllowedAccessToDataOrObbPath(String relativePath) {
// Files under the apps own private directory
final String appSpecificDir = extractOwnerPackageNameFromRelativePath(relativePath);
if (appSpecificDir != null && isCallingIdentitySharedPackageName(appSpecificDir)) {
return true;
}
// This is a private-package relativePath; return true if accessible by the caller
return isCallingIdentityAllowedSpecialPrivatePathAccess(relativePath);
}
/**
* @return true iff the caller has installer privileges which gives write access to obb dirs.
*/
private boolean isCallingIdentityAllowedInstallerAccess() {
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() {
return getCallingUidOrSelf() == mDownloadsAuthorityAppId;
}
private boolean isCallingIdentityExternalStorageProvider() {
return getCallingUidOrSelf() == mExternalStorageAuthorityAppId;
}
private boolean isCallingIdentityMtp() {
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 relativePath the relative path of the file to access
*/
private boolean isCallingIdentityAllowedSpecialPrivatePathAccess(String relativePath) {
if (SdkLevel.isAtLeastS()) {
return isMountModeAllowedPrivatePathAccess(getCallingUidOrSelf(), getCallingPackage(),
relativePath);
} else {
if (isCallingIdentityDownloadProvider() ||
isCallingIdentityExternalStorageProvider() || isCallingIdentityMtp()) {
return true;
}
return (isObbOrChildRelativePath(relativePath) &&
isCallingIdentityAllowedInstallerAccess());
}
}
@RequiresApi(Build.VERSION_CODES.S)
private boolean isMountModeAllowedPrivatePathAccess(int uid, String packageName,
String relativePath) {
// 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 isObbOrChildRelativePath(relativePath);
}
} 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 int getIntDeviceConfig(String namespace, String key, int defaultValue) {
if (!canReadDeviceConfig(key, defaultValue)) {
return defaultValue;
}
final long token = Binder.clearCallingIdentity();
try {
return DeviceConfig.getInt(namespace, 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);
final String selection = extras.getString(QUERY_ARG_SQL_SELECTION);
final String[] selectionArgs = extras.getStringArray(QUERY_ARG_SQL_SELECTION_ARGS);
// 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 },
selection, selectionArgs, 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 },
selection, selectionArgs, 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();
}
public List<String> getSupportedUncachedRelativePaths() {
return StringUtils.verifySupportedUncachedRelativePaths(
StringUtils.getStringArrayConfig(getContext(),
R.array.config_supported_uncached_relative_paths));
}
/**
* 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) || vol.isPublicVolume()) ) {
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);
}
}