/* * 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.app.PendingIntent.FLAG_CANCEL_CURRENT; import static android.app.PendingIntent.FLAG_IMMUTABLE; import static android.app.PendingIntent.FLAG_ONE_SHOT; import static android.content.pm.PackageManager.PERMISSION_GRANTED; import static android.os.Environment.buildPath; import static android.os.Trace.TRACE_TAG_DATABASE; import static android.provider.MediaStore.AUTHORITY; import static android.provider.MediaStore.Downloads.PATTERN_DOWNLOADS_FILE; import static android.provider.MediaStore.Downloads.isDownload; import static android.provider.MediaStore.getVolumeName; import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_LEGACY; import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_REDACTION_NEEDED; import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_SYSTEM; 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_IMAGES; import static com.android.providers.media.LocalCallingIdentity.PERMISSION_WRITE_VIDEO; import android.annotation.BytesLong; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.AppGlobals; import android.app.AppOpsManager; import android.app.AppOpsManager.OnOpActiveChangedListener; import android.app.PendingIntent; import android.app.RecoverableSecurityException; import android.app.RemoteAction; import android.content.BroadcastReceiver; 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.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.PermissionGroupInfo; import android.content.res.AssetFileDescriptor; import android.content.res.Configuration; import android.content.res.Resources; import android.database.AbstractCursor; import android.database.Cursor; import android.database.DatabaseUtils; import android.database.MatrixCursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.database.sqlite.SQLiteQueryBuilder; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.drawable.Icon; import android.media.ExifInterface; import android.media.MediaFile; import android.media.ThumbnailUtils; import android.mtp.MtpConstants; import android.net.Uri; import android.os.Binder; import android.os.Build; import android.os.Bundle; import android.os.CancellationSignal; import android.os.Environment; import android.os.FileUtils; import android.os.IBinder; import android.os.ParcelFileDescriptor; import android.os.ParcelFileDescriptor.OnCloseListener; import android.os.RedactingFileDescriptor; import android.os.RemoteException; import android.os.SystemClock; import android.os.SystemProperties; import android.os.Trace; import android.os.UserHandle; import android.os.UserManager; import android.os.storage.StorageEventListener; import android.os.storage.StorageManager; import android.os.storage.StorageVolume; import android.os.storage.VolumeInfo; import android.os.storage.VolumeRecord; import android.preference.PreferenceManager; import android.provider.BaseColumns; import android.provider.Column; import android.provider.DocumentsContract; 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.LongArray; import android.util.LongSparseArray; import android.util.Pair; import android.util.Size; import android.util.SparseArray; import com.android.internal.annotations.GuardedBy; import com.android.internal.annotations.VisibleForTesting; import com.android.internal.os.BackgroundThread; import com.android.internal.util.ArrayUtils; import com.android.internal.util.IndentingPrintWriter; 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.IsoInterface; import com.android.providers.media.util.XmpInterface; import libcore.io.IoUtils; import libcore.util.EmptyArray; import java.io.File; import java.io.FileDescriptor; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.FilenameFilter; import java.io.IOException; import java.io.OutputStream; import java.io.PrintWriter; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Media content provider. See {@link android.provider.MediaStore} for details. * Separate databases are kept for each external storage card we see (using the * card's ID as an index). The content visible at content://media/external/... * changes with the card. */ public class MediaProvider extends ContentProvider { public static final boolean ENABLE_MODERN_SCANNER = SystemProperties .getBoolean("persist.sys.modern_scanner", true); /** * Regex that matches paths in all well-known package-specific directories, * and which captures the package name as the first group. */ private static final Pattern PATTERN_OWNED_PATH = Pattern.compile( "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(?:data|media|obb|sandbox)/([^/]+)/.*"); /** * Regex that matches paths under well-known storage paths. */ private static final Pattern PATTERN_STORAGE_PATH = Pattern.compile( "(?i)^/storage/[^/]+/(?:[0-9]+/)?"); /** * Regex that matches paths for {@link MediaColumns#RELATIVE_PATH}; it * captures both top-level paths and sandboxed paths. */ private static final Pattern PATTERN_RELATIVE_PATH = Pattern.compile( "(?i)^/storage/[^/]+/(?:[0-9]+/)?(Android/sandbox/([^/]+)/)?"); /** * Regex that matches paths under well-known storage paths. */ private static final Pattern PATTERN_VOLUME_NAME = Pattern.compile( "(?i)^/storage/([^/]+)"); /** * Regex of a selection string that matches a specific ID. */ private static final Pattern PATTERN_SELECTION_ID = Pattern.compile( "(?:image_id|video_id)\\s*=\\s*(\\d+)"); /** * Set of {@link Cursor} columns that refer to raw filesystem paths. */ private static final ArrayMap sDataColumns = new ArrayMap<>(); { 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 Object sCacheLock = new Object(); @GuardedBy("sCacheLock") private static final List sCachedVolumes = new ArrayList<>(); @GuardedBy("sCacheLock") private static final Set sCachedExternalVolumeNames = new ArraySet<>(); @GuardedBy("sCacheLock") private static final Map> sCachedVolumeScanPaths = new ArrayMap<>(); private void updateVolumes() { synchronized (sCacheLock) { sCachedVolumes.clear(); sCachedVolumes.addAll(mStorageManager.getVolumes()); sCachedExternalVolumeNames.clear(); sCachedExternalVolumeNames.addAll(MediaStore.getExternalVolumeNames(getContext())); sCachedVolumeScanPaths.clear(); try { sCachedVolumeScanPaths.put(MediaStore.VOLUME_INTERNAL, MediaStore.getVolumeScanPaths(MediaStore.VOLUME_INTERNAL)); for (String volumeName : sCachedExternalVolumeNames) { sCachedVolumeScanPaths.put(volumeName, MediaStore.getVolumeScanPaths(volumeName)); } } catch (FileNotFoundException e) { throw new IllegalStateException(e.getMessage()); } } } public static File getVolumePath(String volumeName) throws FileNotFoundException { synchronized (sCacheLock) { return MediaStore.getVolumePath(sCachedVolumes, volumeName); } } public static Set getExternalVolumeNames() { synchronized (sCacheLock) { return new ArraySet<>(sCachedExternalVolumeNames); } } public static Collection getVolumeScanPaths(String volumeName) { synchronized (sCacheLock) { return new ArrayList<>(sCachedVolumeScanPaths.get(volumeName)); } } private StorageManager mStorageManager; private AppOpsManager mAppOpsManager; private PackageManager mPackageManager; 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 mCachedCallingIdentity = new SparseArray<>(); private static volatile long sBackgroundDelay = 0; private final OnOpActiveChangedListener mActiveListener = (code, uid, packageName, active) -> { synchronized (mCachedCallingIdentity) { if (active) { mCachedCallingIdentity.put(uid, LocalCallingIdentity.fromExternal(uid, packageName)); } else { mCachedCallingIdentity.remove(uid); } if (mCachedCallingIdentity.size() > 0) { sBackgroundDelay = 10 * DateUtils.SECOND_IN_MILLIS; } else { sBackgroundDelay = 0; } } }; /** * 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 mCallingIdentity = ThreadLocal .withInitial(() -> { synchronized (mCachedCallingIdentity) { final LocalCallingIdentity cached = mCachedCallingIdentity .get(Binder.getCallingUid()); return (cached != null) ? cached : LocalCallingIdentity.fromBinder(this); } }); // In memory cache of path<->id mappings, to speed up inserts during media scan @GuardedBy("mDirectoryCache") private final ArrayMap mDirectoryCache = new ArrayMap<>(); private static final String[] sMediaTableColumns = new String[] { FileColumns._ID, FileColumns.MEDIA_TYPE, }; private static final String[] sIdOnlyColumn = new String[] { FileColumns._ID }; private static final String[] sDataOnlyColumn = new String[] { FileColumns.DATA }; private static final String[] sPlaylistIdPlayOrder = new String[] { Playlists.Members.PLAYLIST_ID, Playlists.Members.PLAY_ORDER }; private static final String ID_NOT_PARENT_CLAUSE = "_id NOT IN (SELECT parent FROM files)"; private static final String CANONICAL = "canonical"; private BroadcastReceiver mMediaReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { final StorageVolume sv = intent.getParcelableExtra(StorageVolume.EXTRA_STORAGE_VOLUME); if (sv != null) { final String volumeName; if (sv.isPrimary()) { volumeName = MediaStore.VOLUME_EXTERNAL_PRIMARY; } else { try { volumeName = MediaStore.checkArgumentVolumeName(sv.getNormalizedUuid()); } catch (IllegalArgumentException ignored) { return; } } switch (intent.getAction()) { case Intent.ACTION_MEDIA_MOUNTED: attachVolume(volumeName); break; case Intent.ACTION_MEDIA_UNMOUNTED: case Intent.ACTION_MEDIA_EJECT: case Intent.ACTION_MEDIA_REMOVED: case Intent.ACTION_MEDIA_BAD_REMOVAL: detachVolume(volumeName); break; } } } }; private final SQLiteDatabase.CustomFunction mObjectRemovedCallback = new SQLiteDatabase.CustomFunction() { @Override public void callback(String[] args) { // We could remove only the deleted entry from the cache, but that // requires the path, which we don't have here, so instead we just // clear the entire cache. // TODO: include the path in the callback and only remove the affected // entry from the cache synchronized (mDirectoryCache) { mDirectoryCache.clear(); } } }; /** * Wrapper class for a specific database (associated with one particular * external card, or with internal storage). Can open the actual database * on demand, create and upgrade the schema, etc. */ static class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { final Context mContext; final String mName; final int mVersion; final boolean mInternal; // True if this is the internal database final boolean mEarlyUpgrade; final SQLiteDatabase.CustomFunction mObjectRemovedCallback; long mScanStartTime; long mScanStopTime; // In memory caches of artist and album data. ArrayMap mArtistCache = new ArrayMap(); ArrayMap mAlbumCache = new ArrayMap(); public DatabaseHelper(Context context, String name, boolean internal, boolean earlyUpgrade, SQLiteDatabase.CustomFunction objectRemovedCallback) { this(context, name, getDatabaseVersion(context), internal, earlyUpgrade, objectRemovedCallback); } public DatabaseHelper(Context context, String name, int version, boolean internal, boolean earlyUpgrade, SQLiteDatabase.CustomFunction objectRemovedCallback) { super(context, name, null, version); mContext = context; mName = name; mVersion = version; mInternal = internal; mEarlyUpgrade = earlyUpgrade; mObjectRemovedCallback = objectRemovedCallback; setWriteAheadLoggingEnabled(true); setIdleConnectionTimeout(IDLE_CONNECTION_TIMEOUT_MS); } @Override public void onCreate(final SQLiteDatabase db) { Log.v(TAG, "onCreate() for " + mName); updateDatabase(mContext, db, mInternal, 0, mVersion); } @Override public void onUpgrade(final SQLiteDatabase db, final int oldV, final int newV) { Log.v(TAG, "onUpgrade() for " + mName + " from " + oldV + " to " + newV); updateDatabase(mContext, db, mInternal, oldV, newV); } @Override public void onDowngrade(final SQLiteDatabase db, final int oldV, final int newV) { Log.v(TAG, "onDowngrade() for " + mName + " from " + oldV + " to " + newV); downgradeDatabase(mContext, db, mInternal, oldV, newV); } /** * For devices that have removable storage, we support keeping multiple databases * to allow users to switch between a number of cards. * On such devices, touch this particular database and garbage collect old databases. * An LRU cache system is used to clean up databases for old external * storage volumes. */ @Override public void onOpen(SQLiteDatabase db) { if (mEarlyUpgrade) return; // Doing early upgrade. if (mObjectRemovedCallback != null) { db.addCustomFunction("_OBJECT_REMOVED", 1, mObjectRemovedCallback); } if (mInternal) return; // The internal database is kept separately. // the code below is only needed on devices with removable storage if (!Environment.isExternalStorageRemovable()) return; // touch the database file to show it is most recently used File file = new File(db.getPath()); long now = System.currentTimeMillis(); file.setLastModified(now); // delete least recently used databases if we are over the limit String[] databases = mContext.databaseList(); // Don't delete wal auxiliary files(db-shm and db-wal) directly because db file may // not be deleted, and it will cause Disk I/O error when accessing this database. List dbList = new ArrayList(); for (String database : databases) { if (database != null && database.endsWith(".db")) { dbList.add(database); } } databases = dbList.toArray(new String[0]); int count = databases.length; int limit = MAX_EXTERNAL_DATABASES; // delete external databases that have not been used in the past two months long twoMonthsAgo = now - OBSOLETE_DATABASE_DB; for (int i = 0; i < databases.length; i++) { File other = mContext.getDatabasePath(databases[i]); if (INTERNAL_DATABASE_NAME.equals(databases[i]) || file.equals(other)) { databases[i] = null; count--; if (file.equals(other)) { // reduce limit to account for the existence of the database we // are about to open, which we removed from the list. limit--; } } else { long time = other.lastModified(); if (time < twoMonthsAgo) { if (LOCAL_LOGV) Log.v(TAG, "Deleting old database " + databases[i]); mContext.deleteDatabase(databases[i]); databases[i] = null; count--; } } } // delete least recently used databases until // we are no longer over the limit while (count > limit) { int lruIndex = -1; long lruTime = 0; for (int i = 0; i < databases.length; i++) { if (databases[i] != null) { long time = mContext.getDatabasePath(databases[i]).lastModified(); if (lruTime == 0 || time < lruTime) { lruIndex = i; lruTime = time; } } } // delete least recently used database if (lruIndex != -1) { if (LOCAL_LOGV) Log.v(TAG, "Deleting old database " + databases[lruIndex]); mContext.deleteDatabase(databases[lruIndex]); databases[lruIndex] = null; count--; } } } /** * List of {@link Uri} that would have been sent directly via * {@link ContentResolver#notifyChange}, but are instead being collected * due to an ongoing transaction. */ private final ThreadLocal> mNotifyChanges = new ThreadLocal<>(); public void beginTransaction() { getWritableDatabase().beginTransaction(); mNotifyChanges.set(new ArrayList<>()); } public void setTransactionSuccessful() { getWritableDatabase().setTransactionSuccessful(); final List uris = mNotifyChanges.get(); if (uris != null) { BackgroundThread.getHandler().postDelayed(() -> { for (Uri uri : uris) { notifyChangeInternal(uri); } }, sBackgroundDelay); } mNotifyChanges.remove(); } public void endTransaction() { getWritableDatabase().endTransaction(); } /** * Notify that the given {@link Uri} has changed. This enqueues the * notification if currently inside a transaction, and they'll be * clustered and sent when the transaction completes. */ public void notifyChange(Uri uri) { if (LOCAL_LOGV) Log.v(TAG, "Notifying " + uri); final List uris = mNotifyChanges.get(); if (uris != null) { uris.add(uri); } else { BackgroundThread.getHandler().postDelayed(() -> { notifyChangeInternal(uri); }, sBackgroundDelay); } } private void notifyChangeInternal(Uri uri) { Trace.traceBegin(TRACE_TAG_DATABASE, "notifyChange"); try { mContext.getContentResolver().notifyChange(uri, null); } finally { Trace.traceEnd(TRACE_TAG_DATABASE); } } } /** * Apply {@link Consumer#accept} to the given {@link Uri}. *

* 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. */ public static void acceptWithExpansion(Consumer consumer, Uri uri) { final int match = matchUri(uri, true); acceptWithExpansionInternal(consumer, uri, match); try { // When targeting a specific volume, we need to expand to also // notify the top-level view final String volumeName = getVolumeName(uri); switch (volumeName) { case MediaStore.VOLUME_INTERNAL: case MediaStore.VOLUME_EXTERNAL: // Already a top-level view, no need to expand break; default: final List segments = new ArrayList<>(uri.getPathSegments()); segments.set(0, MediaStore.VOLUME_EXTERNAL); final Uri.Builder builder = uri.buildUpon().path(null); for (String segment : segments) { builder.appendPath(segment); } acceptWithExpansionInternal(consumer, builder.build(), match); break; } } catch (IllegalArgumentException ignored) { } } private static void acceptWithExpansionInternal(Consumer consumer, Uri uri, int match) { // Start by always notifying the base item consumer.accept(uri); // Some items can be exposed through multiple collections, // so we need to notify all possible views of those items switch (match) { case AUDIO_MEDIA_ID: case VIDEO_MEDIA_ID: case IMAGES_MEDIA_ID: { final String volumeName = getVolumeName(uri); final long id = ContentUris.parseId(uri); consumer.accept(Files.getContentUri(volumeName, id)); consumer.accept(Downloads.getContentUri(volumeName, id)); break; } case AUDIO_MEDIA: case VIDEO_MEDIA: case IMAGES_MEDIA: { final String volumeName = getVolumeName(uri); consumer.accept(Files.getContentUri(volumeName)); consumer.accept(Downloads.getContentUri(volumeName)); break; } case FILES_ID: case DOWNLOADS_ID: { final String volumeName = getVolumeName(uri); final long id = ContentUris.parseId(uri); consumer.accept(Audio.Media.getContentUri(volumeName, id)); consumer.accept(Video.Media.getContentUri(volumeName, id)); consumer.accept(Images.Media.getContentUri(volumeName, id)); break; } case FILES: case DOWNLOADS: { final String volumeName = getVolumeName(uri); consumer.accept(Audio.Media.getContentUri(volumeName)); consumer.accept(Video.Media.getContentUri(volumeName)); consumer.accept(Images.Media.getContentUri(volumeName)); break; } } // Any changing audio items mean we probably need to invalidate all // indexed views built from that media switch (match) { case AUDIO_MEDIA: case AUDIO_MEDIA_ID: { final String volumeName = getVolumeName(uri); 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; } } } private static final String[] sDefaultFolderNames = { Environment.DIRECTORY_MUSIC, Environment.DIRECTORY_PODCASTS, Environment.DIRECTORY_RINGTONES, Environment.DIRECTORY_ALARMS, Environment.DIRECTORY_NOTIFICATIONS, Environment.DIRECTORY_PICTURES, Environment.DIRECTORY_MOVIES, Environment.DIRECTORY_DOWNLOADS, Environment.DIRECTORY_DCIM, }; /** * This method cleans up any files created by android.media.MiniThumbFile, removed after P. * It's triggered during database update only, in order to run only once. */ private static void deleteLegacyThumbnailData() { File directory = new File(Environment.getExternalStorageDirectory(), "/DCIM/.thumbnails"); FilenameFilter filter = (dir, filename) -> filename.startsWith(".thumbdata"); for (File f : ArrayUtils.defeatNullable(directory.listFiles(filter))) { if (!f.delete()) { Log.e(TAG, "Failed to delete legacy thumbnail data " + f.getAbsolutePath()); } } } /** * Ensure that default folders are created on mounted primary storage * devices. We only do this once per volume so we don't annoy the user if * deleted manually. */ private void ensureDefaultFolders(String volumeName, DatabaseHelper helper, SQLiteDatabase db) { try { final File path = getVolumePath(volumeName); final StorageVolume vol = mStorageManager.getStorageVolume(path); final String key; if (VolumeInfo.ID_EMULATED_INTERNAL.equals(vol.getId())) { key = "created_default_folders"; } else { key = "created_default_folders_" + vol.getNormalizedUuid(); } final SharedPreferences prefs = PreferenceManager .getDefaultSharedPreferences(getContext()); if (prefs.getInt(key, 0) == 0) { for (String folderName : sDefaultFolderNames) { final File folder = new File(vol.getPathFile(), folderName); if (!folder.exists()) { folder.mkdirs(); insertDirectory(helper, db, folder.getAbsolutePath()); } } SharedPreferences.Editor editor = prefs.edit(); editor.putInt(key, 1); editor.commit(); } } catch (IOException e) { Log.w(TAG, "Failed to ensure default folders for " + volumeName, e); } } public static int getDatabaseVersion(Context context) { try { return context.getPackageManager().getPackageInfo( context.getPackageName(), 0).versionCode; } catch (NameNotFoundException e) { throw new RuntimeException("couldn't get version code for " + context); } } @Override public boolean onCreate() { final Context context = getContext(); // Enable verbose transport logging when requested setTransportLoggingEnabled(LOCAL_LOGV); // Shift call statistics back to the original caller Binder.setProxyTransactListener( new Binder.PropagateWorkSourceTransactListener()); mStorageManager = context.getSystemService(StorageManager.class); mAppOpsManager = context.getSystemService(AppOpsManager.class); mPackageManager = context.getPackageManager(); // 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); mInternalDatabase = new DatabaseHelper(context, INTERNAL_DATABASE_NAME, true, false, mObjectRemovedCallback); mExternalDatabase = new DatabaseHelper(context, EXTERNAL_DATABASE_NAME, false, false, mObjectRemovedCallback); final IntentFilter filter = new IntentFilter(); filter.setPriority(10); filter.addDataScheme("file"); filter.addAction(Intent.ACTION_MEDIA_UNMOUNTED); filter.addAction(Intent.ACTION_MEDIA_MOUNTED); filter.addAction(Intent.ACTION_MEDIA_EJECT); filter.addAction(Intent.ACTION_MEDIA_REMOVED); filter.addAction(Intent.ACTION_MEDIA_BAD_REMOVAL); context.registerReceiver(mMediaReceiver, filter); // Watch for invalidation of cached volumes mStorageManager.registerListener(new StorageEventListener() { @Override public void onVolumeStateChanged(VolumeInfo vol, int oldState, int newState) { updateVolumes(); } }); updateVolumes(); attachVolume(MediaStore.VOLUME_INTERNAL); // Attach all currently mounted external volumes for (String volumeName : getExternalVolumeNames()) { attachVolume(volumeName); } // Watch for performance-sensitive activity mAppOpsManager.startWatchingActive(new int[] { AppOpsManager.OP_CAMERA }, mActiveListener); return true; } @Override public void onCallingPackageChanged() { // Identity of the current thread has changed, so invalidate caches mCallingIdentity.remove(); } public LocalCallingIdentity clearLocalCallingIdentity() { final LocalCallingIdentity token = mCallingIdentity.get(); mCallingIdentity.set(LocalCallingIdentity.fromSelf()); return token; } public void restoreLocalCallingIdentity(LocalCallingIdentity token) { mCallingIdentity.set(token); } public void onIdleMaintenance(@NonNull CancellationSignal signal) { final DatabaseHelper helper = mExternalDatabase; final SQLiteDatabase db = helper.getReadableDatabase(); // Scan all volumes to resolve any staleness for (String volumeName : getExternalVolumeNames()) { // Possibly bail before digging into each volume signal.throwIfCanceled(); try { final File file = getVolumePath(volumeName); MediaService.onScanVolume(getContext(), Uri.fromFile(file)); } catch (IOException e) { Log.w(TAG, e); } } // Delete any stale thumbnails pruneThumbnails(signal); // Finished orphaning any content whose package no longer exists final ArraySet unknownPackages = new ArraySet<>(); try (Cursor c = db.query(true, "files", new String[] { "owner_package_name" }, null, null, null, null, null, null, signal)) { while (c.moveToNext()) { final String packageName = c.getString(0); if (TextUtils.isEmpty(packageName)) continue; try { getContext().getPackageManager().getPackageInfo(packageName, PackageManager.MATCH_UNINSTALLED_PACKAGES); } catch (NameNotFoundException e) { unknownPackages.add(packageName); } } } Log.d(TAG, "Found " + unknownPackages.size() + " unknown packages"); for (String packageName : unknownPackages) { onPackageOrphaned(packageName); } // Delete any expired content; we're paranoid about wildly changing // clocks, so only delete items within the last week final long from = ((System.currentTimeMillis() - DateUtils.WEEK_IN_MILLIS) / 1000); final long to = (System.currentTimeMillis() / 1000); try (Cursor c = db.query(true, "files", new String[] { "volume_name", "_id" }, FileColumns.DATE_EXPIRES + " BETWEEN " + from + " AND " + to, null, null, null, null, null, signal)) { while (c.moveToNext()) { final String volumeName = c.getString(0); final long id = c.getLong(1); delete(Files.getContentUri(volumeName, id), null, null); } Log.d(TAG, "Deleted " + c.getCount() + " expired items on " + helper.mName); } // Forget any stale volumes final long lastWeek = System.currentTimeMillis() - DateUtils.WEEK_IN_MILLIS; for (VolumeRecord rec : mStorageManager.getVolumeRecords()) { // Skip volumes without valid UUIDs if (TextUtils.isEmpty(rec.fsUuid)) continue; // Skip volumes that are currently mounted final VolumeInfo vol = mStorageManager.findVolumeByUuid(rec.fsUuid); if (vol != null && vol.isMountedReadable()) continue; if (rec.lastSeenMillis > 0 && rec.lastSeenMillis < lastWeek) { final int num = db.delete("files", FileColumns.VOLUME_NAME + "=?", new String[] { rec.getNormalizedFsUuid() }); Log.d(TAG, "Forgot " + num + " stale items from " + rec.fsUuid); } } } public void onPackageOrphaned(String packageName) { final DatabaseHelper helper = mExternalDatabase; final SQLiteDatabase db = helper.getWritableDatabase(); final ContentValues values = new ContentValues(); values.putNull(FileColumns.OWNER_PACKAGE_NAME); final int count = db.update("files", values, "owner_package_name=?", new String[] { packageName }); if (count > 0) { Log.d(TAG, "Orphaned " + count + " items belonging to " + packageName + " on " + helper.mName); } } private void enforceShellRestrictions() { if (UserHandle.getCallingAppId() == android.os.Process.SHELL_UID && getContext().getSystemService(UserManager.class) .hasUserRestriction(UserManager.DISALLOW_USB_FILE_TRANSFER)) { throw new SecurityException( "Shell user cannot access files for user " + UserHandle.myUserId()); } } @Override protected int enforceReadPermissionInner(Uri uri, String callingPkg, IBinder callerToken) throws SecurityException { enforceShellRestrictions(); return super.enforceReadPermissionInner(uri, callingPkg, callerToken); } @Override protected int enforceWritePermissionInner(Uri uri, String callingPkg, IBinder callerToken) throws SecurityException { enforceShellRestrictions(); return super.enforceWritePermissionInner(uri, callingPkg, callerToken); } @VisibleForTesting static void makePristineSchema(SQLiteDatabase db) { // drop all triggers Cursor c = db.query("sqlite_master", new String[] {"name"}, "type is 'trigger'", null, null, null, null); while (c.moveToNext()) { if (c.getString(0).startsWith("sqlite_")) continue; db.execSQL("DROP TRIGGER IF EXISTS " + c.getString(0)); } c.close(); // drop all views c = db.query("sqlite_master", new String[] {"name"}, "type is 'view'", null, null, null, null); while (c.moveToNext()) { if (c.getString(0).startsWith("sqlite_")) continue; db.execSQL("DROP VIEW IF EXISTS " + c.getString(0)); } c.close(); // drop all indexes c = db.query("sqlite_master", new String[] {"name"}, "type is 'index'", null, null, null, null); while (c.moveToNext()) { if (c.getString(0).startsWith("sqlite_")) continue; db.execSQL("DROP INDEX IF EXISTS " + c.getString(0)); } c.close(); // drop all tables c = db.query("sqlite_master", new String[] {"name"}, "type is 'table'", null, null, null, null); while (c.moveToNext()) { if (c.getString(0).startsWith("sqlite_")) continue; db.execSQL("DROP TABLE IF EXISTS " + c.getString(0)); } c.close(); } private static void createLatestSchema(SQLiteDatabase db, boolean internal) { // We're about to start all ID numbering from scratch, so revoke any // outstanding permission grants to ensure we don't leak data AppGlobals.getInitialApplication().revokeUriPermission(MediaStore.AUTHORITY_URI, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); MediaDocumentsProvider.revokeAllUriGrants(AppGlobals.getInitialApplication()); BackgroundThread.getHandler().post(() -> { try (ContentProviderClient client = AppGlobals.getInitialApplication() .getContentResolver().acquireContentProviderClient( android.provider.Downloads.Impl.AUTHORITY)) { client.call(android.provider.Downloads.CALL_REVOKE_MEDIASTORE_URI_PERMS, null, null); } catch (RemoteException e) { // Should not happen } }); makePristineSchema(db); db.execSQL("CREATE TABLE android_metadata (locale TEXT)"); db.execSQL("CREATE TABLE thumbnails (_id INTEGER PRIMARY KEY,_data TEXT,image_id INTEGER," + "kind INTEGER,width INTEGER,height INTEGER)"); db.execSQL("CREATE TABLE artists (artist_id INTEGER PRIMARY KEY," + "artist_key TEXT NOT NULL UNIQUE,artist TEXT NOT NULL)"); db.execSQL("CREATE TABLE albums (album_id INTEGER PRIMARY KEY," + "album_key TEXT NOT NULL UNIQUE,album TEXT NOT NULL)"); db.execSQL("CREATE TABLE album_art (album_id INTEGER PRIMARY KEY,_data TEXT)"); db.execSQL("CREATE TABLE videothumbnails (_id INTEGER PRIMARY KEY,_data TEXT," + "video_id INTEGER,kind INTEGER,width INTEGER,height INTEGER)"); db.execSQL("CREATE TABLE files (_id INTEGER PRIMARY KEY AUTOINCREMENT," + "_data TEXT UNIQUE COLLATE NOCASE,_size INTEGER,format INTEGER,parent INTEGER," + "date_added INTEGER,date_modified INTEGER,mime_type TEXT,title TEXT," + "description TEXT,_display_name TEXT,picasa_id TEXT,orientation INTEGER," + "latitude DOUBLE,longitude DOUBLE,datetaken INTEGER,mini_thumb_magic INTEGER," + "bucket_id TEXT,bucket_display_name TEXT,isprivate INTEGER,title_key TEXT," + "artist_id INTEGER,album_id INTEGER,composer TEXT,track INTEGER," + "year INTEGER CHECK(year!=0),is_ringtone INTEGER,is_music INTEGER," + "is_alarm INTEGER,is_notification INTEGER,is_podcast INTEGER,album_artist TEXT," + "duration INTEGER,bookmark INTEGER,artist TEXT,album TEXT,resolution TEXT," + "tags TEXT,category TEXT,language TEXT,mini_thumb_data TEXT,name TEXT," + "media_type INTEGER,old_id INTEGER,is_drm INTEGER," + "width INTEGER, height INTEGER, title_resource_uri TEXT," + "owner_package_name TEXT DEFAULT NULL," + "color_standard INTEGER, color_transfer INTEGER, color_range INTEGER," + "_hash BLOB DEFAULT NULL, is_pending INTEGER DEFAULT 0," + "is_download INTEGER DEFAULT 0, download_uri TEXT DEFAULT NULL," + "referer_uri TEXT DEFAULT NULL, is_audiobook INTEGER DEFAULT 0," + "date_expires INTEGER DEFAULT NULL,is_trashed INTEGER DEFAULT 0," + "group_id INTEGER DEFAULT NULL,primary_directory TEXT DEFAULT NULL," + "secondary_directory TEXT DEFAULT NULL,document_id TEXT DEFAULT NULL," + "instance_id TEXT DEFAULT NULL,original_document_id TEXT DEFAULT NULL," + "relative_path TEXT DEFAULT NULL,volume_name TEXT DEFAULT NULL)"); db.execSQL("CREATE TABLE log (time DATETIME, message TEXT)"); if (!internal) { db.execSQL("CREATE TABLE audio_genres (_id INTEGER PRIMARY KEY,name TEXT NOT NULL)"); db.execSQL("CREATE TABLE audio_genres_map (_id INTEGER PRIMARY KEY," + "audio_id INTEGER NOT NULL,genre_id INTEGER NOT NULL," + "UNIQUE (audio_id,genre_id) ON CONFLICT IGNORE)"); db.execSQL("CREATE TABLE audio_playlists_map (_id INTEGER PRIMARY KEY," + "audio_id INTEGER NOT NULL,playlist_id INTEGER NOT NULL," + "play_order INTEGER NOT NULL)"); db.execSQL("CREATE TRIGGER audio_genres_cleanup DELETE ON audio_genres BEGIN DELETE" + " FROM audio_genres_map WHERE genre_id = old._id;END"); db.execSQL("CREATE TRIGGER audio_playlists_cleanup DELETE ON files" + " WHEN old.media_type=4" + " BEGIN DELETE FROM audio_playlists_map WHERE playlist_id = old._id;" + "SELECT _DELETE_FILE(old._data);END"); db.execSQL("CREATE TRIGGER files_cleanup DELETE ON files" + " BEGIN SELECT _OBJECT_REMOVED(old._id);END"); } db.execSQL("CREATE INDEX image_id_index on thumbnails(image_id)"); db.execSQL("CREATE INDEX album_idx on albums(album)"); db.execSQL("CREATE INDEX albumkey_index on albums(album_key)"); db.execSQL("CREATE INDEX artist_idx on artists(artist)"); db.execSQL("CREATE INDEX artistkey_index on artists(artist_key)"); db.execSQL("CREATE INDEX video_id_index on videothumbnails(video_id)"); db.execSQL("CREATE INDEX album_id_idx ON files(album_id)"); db.execSQL("CREATE INDEX artist_id_idx ON files(artist_id)"); db.execSQL("CREATE INDEX bucket_index on files(bucket_id,media_type,datetaken, _id)"); db.execSQL("CREATE INDEX bucket_name on files(bucket_id,media_type,bucket_display_name)"); db.execSQL("CREATE INDEX format_index ON files(format)"); db.execSQL("CREATE INDEX media_type_index ON files(media_type)"); db.execSQL("CREATE INDEX parent_index ON files(parent)"); db.execSQL("CREATE INDEX path_index ON files(_data)"); db.execSQL("CREATE INDEX sort_index ON files(datetaken ASC, _id ASC)"); db.execSQL("CREATE INDEX title_idx ON files(title)"); db.execSQL("CREATE INDEX titlekey_index ON files(title_key)"); db.execSQL("CREATE TRIGGER albumart_cleanup1 DELETE ON albums BEGIN DELETE FROM album_art" + " WHERE album_id = old.album_id;END"); db.execSQL("CREATE TRIGGER albumart_cleanup2 DELETE ON album_art" + " BEGIN SELECT _DELETE_FILE(old._data);END"); createLatestViews(db, internal); } private static void makePristineViews(SQLiteDatabase db) { // drop all views Cursor c = db.query("sqlite_master", new String[] {"name"}, "type is 'view'", null, null, null, null); while (c.moveToNext()) { db.execSQL("DROP VIEW IF EXISTS " + c.getString(0)); } c.close(); } private static void createLatestViews(SQLiteDatabase db, boolean internal) { makePristineViews(db); if (!internal) { db.execSQL("CREATE VIEW audio_playlists AS SELECT _id,_data,name,date_added," + "date_modified,owner_package_name,_hash,is_pending,date_expires,is_trashed," + "volume_name FROM files WHERE media_type=4"); } db.execSQL("CREATE VIEW audio_meta AS SELECT _id,_data,_display_name,_size,mime_type," + "date_added,is_drm,date_modified,title,title_key,duration,artist_id,composer," + "album_id,track,year,is_ringtone,is_music,is_alarm,is_notification,is_podcast," + "bookmark,album_artist,owner_package_name,_hash,is_pending,is_audiobook," + "date_expires,is_trashed,group_id,primary_directory,secondary_directory," + "document_id,instance_id,original_document_id,title_resource_uri,relative_path," + "volume_name,datetaken,bucket_id,bucket_display_name,group_id,orientation" + " FROM files WHERE media_type=2"); db.execSQL("CREATE VIEW artists_albums_map AS SELECT DISTINCT artist_id, album_id" + " FROM audio_meta"); db.execSQL("CREATE VIEW audio as SELECT *, NULL AS width, NULL as height" + " FROM audio_meta LEFT OUTER JOIN artists" + " ON audio_meta.artist_id=artists.artist_id LEFT OUTER JOIN albums" + " ON audio_meta.album_id=albums.album_id"); db.execSQL("CREATE VIEW album_info AS SELECT audio.album_id AS _id, album, album_key," + " MIN(year) AS minyear, MAX(year) AS maxyear, artist, artist_id, artist_key," + " count(*) AS numsongs,album_art._data AS album_art FROM audio" + " LEFT OUTER JOIN album_art ON audio.album_id=album_art.album_id WHERE is_music=1" + " GROUP BY audio.album_id"); db.execSQL("CREATE VIEW searchhelpertitle AS SELECT * FROM audio ORDER BY title_key"); db.execSQL("CREATE VIEW artist_info AS SELECT artist_id AS _id, artist, artist_key," + " COUNT(DISTINCT album_key) AS number_of_albums, COUNT(*) AS number_of_tracks" + " FROM audio" + " WHERE is_music=1 GROUP BY artist_key"); db.execSQL("CREATE VIEW search AS SELECT _id,'artist' AS mime_type,artist,NULL AS album," + "NULL AS title,artist AS text1,NULL AS text2,number_of_albums AS data1," + "number_of_tracks AS data2,artist_key AS match," + "'content://media/external/audio/artists/'||_id AS suggest_intent_data," + "1 AS grouporder FROM artist_info WHERE (artist!='')" + " UNION ALL SELECT _id,'album' AS mime_type,artist,album," + "NULL AS title,album AS text1,artist AS text2,NULL AS data1," + "NULL AS data2,artist_key||' '||album_key AS match," + "'content://media/external/audio/albums/'||_id AS suggest_intent_data," + "2 AS grouporder FROM album_info" + " WHERE (album!='')" + " UNION ALL SELECT searchhelpertitle._id AS _id,mime_type,artist,album,title," + "title AS text1,artist AS text2,NULL AS data1," + "NULL AS data2,artist_key||' '||album_key||' '||title_key AS match," + "'content://media/external/audio/media/'||searchhelpertitle._id" + " AS suggest_intent_data," + "3 AS grouporder FROM searchhelpertitle WHERE (title != '')"); db.execSQL("CREATE VIEW audio_genres_map_noid AS SELECT audio_id,genre_id" + " FROM audio_genres_map"); db.execSQL("CREATE VIEW video AS SELECT " + String.join(",", getProjectionMap(Video.Media.class).keySet()) + " FROM files WHERE media_type=3"); db.execSQL("CREATE VIEW images AS SELECT " + String.join(",", getProjectionMap(Images.Media.class).keySet()) + " FROM files WHERE media_type=1"); db.execSQL("CREATE VIEW downloads AS SELECT " + String.join(",", getProjectionMap(Downloads.class).keySet()) + " FROM files WHERE is_download=1"); } private static void updateCollationKeys(SQLiteDatabase db) { // Delete albums and artists, then clear the modification time on songs, which // will cause the media scanner to rescan everything, rebuilding the artist and // album tables along the way, while preserving playlists. // We need this rescan because ICU also changed, and now generates different // collation keys db.execSQL("DELETE from albums"); db.execSQL("DELETE from artists"); db.execSQL("UPDATE files SET date_modified=0;"); } private static void updateAddTitleResource(SQLiteDatabase db) { // Add the column used for title localization, and force a rescan of any // ringtones, alarms and notifications that may be using it. db.execSQL("ALTER TABLE files ADD COLUMN title_resource_uri TEXT"); db.execSQL("UPDATE files SET date_modified=0" + " WHERE (is_alarm IS 1) OR (is_ringtone IS 1) OR (is_notification IS 1)"); } private static void updateAddOwnerPackageName(SQLiteDatabase db, boolean internal) { db.execSQL("ALTER TABLE files ADD COLUMN owner_package_name TEXT DEFAULT NULL"); // Derive new column value based on well-known paths try (Cursor c = db.query("files", new String[] { FileColumns._ID, FileColumns.DATA }, FileColumns.DATA + " REGEXP '" + PATTERN_OWNED_PATH.pattern() + "'", null, null, null, null, null)) { Log.d(TAG, "Updating " + c.getCount() + " entries with well-known owners"); final Matcher m = PATTERN_OWNED_PATH.matcher(""); final ContentValues values = new ContentValues(); while (c.moveToNext()) { final long id = c.getLong(0); final String data = c.getString(1); m.reset(data); if (m.matches()) { final String packageName = m.group(1); values.clear(); values.put(FileColumns.OWNER_PACKAGE_NAME, packageName); db.update("files", values, "_id=" + id, null); } } } } private static void updateAddColorSpaces(SQLiteDatabase db) { // Add the color aspects related column used for HDR detection etc. db.execSQL("ALTER TABLE files ADD COLUMN color_standard INTEGER;"); db.execSQL("ALTER TABLE files ADD COLUMN color_transfer INTEGER;"); db.execSQL("ALTER TABLE files ADD COLUMN color_range INTEGER;"); } private static void updateAddHashAndPending(SQLiteDatabase db, boolean internal) { db.execSQL("ALTER TABLE files ADD COLUMN _hash BLOB DEFAULT NULL;"); db.execSQL("ALTER TABLE files ADD COLUMN is_pending INTEGER DEFAULT 0;"); } private static void updateAddDownloadInfo(SQLiteDatabase db, boolean internal) { db.execSQL("ALTER TABLE files ADD COLUMN is_download INTEGER DEFAULT 0;"); db.execSQL("ALTER TABLE files ADD COLUMN download_uri TEXT DEFAULT NULL;"); db.execSQL("ALTER TABLE files ADD COLUMN referer_uri TEXT DEFAULT NULL;"); } private static void updateAddAudiobook(SQLiteDatabase db, boolean internal) { db.execSQL("ALTER TABLE files ADD COLUMN is_audiobook INTEGER DEFAULT 0;"); } private static void updateClearLocation(SQLiteDatabase db, boolean internal) { db.execSQL("UPDATE files SET latitude=NULL, longitude=NULL;"); } private static void updateSetIsDownload(SQLiteDatabase db, boolean internal) { db.execSQL("UPDATE files SET is_download=1 WHERE _data REGEXP '" + PATTERN_DOWNLOADS_FILE + "'"); } private static void updateAddExpiresAndTrashed(SQLiteDatabase db, boolean internal) { db.execSQL("ALTER TABLE files ADD COLUMN date_expires INTEGER DEFAULT NULL;"); db.execSQL("ALTER TABLE files ADD COLUMN is_trashed INTEGER DEFAULT 0;"); } private static void updateAddGroupId(SQLiteDatabase db, boolean internal) { db.execSQL("ALTER TABLE files ADD COLUMN group_id INTEGER DEFAULT NULL;"); } private static void updateAddDirectories(SQLiteDatabase db, boolean internal) { db.execSQL("ALTER TABLE files ADD COLUMN primary_directory TEXT DEFAULT NULL;"); db.execSQL("ALTER TABLE files ADD COLUMN secondary_directory TEXT DEFAULT NULL;"); } private static void updateAddXmp(SQLiteDatabase db, boolean internal) { db.execSQL("ALTER TABLE files ADD COLUMN document_id TEXT DEFAULT NULL;"); db.execSQL("ALTER TABLE files ADD COLUMN instance_id TEXT DEFAULT NULL;"); db.execSQL("ALTER TABLE files ADD COLUMN original_document_id TEXT DEFAULT NULL;"); } private static void updateAddPath(SQLiteDatabase db, boolean internal) { db.execSQL("ALTER TABLE files ADD COLUMN relative_path TEXT DEFAULT NULL;"); } private static void updateAddVolumeName(SQLiteDatabase db, boolean internal) { db.execSQL("ALTER TABLE files ADD COLUMN volume_name TEXT DEFAULT NULL;"); } private static void updateDirsMimeType(SQLiteDatabase db, boolean internal) { db.execSQL("UPDATE files SET mime_type=NULL WHERE format=" + MtpConstants.FORMAT_ASSOCIATION); } private static void updateRelativePath(SQLiteDatabase db, boolean internal) { db.execSQL("UPDATE files" + " SET " + MediaColumns.RELATIVE_PATH + "=" + MediaColumns.RELATIVE_PATH + "||'/'" + " WHERE " + MediaColumns.RELATIVE_PATH + " IS NOT NULL" + " AND " + MediaColumns.RELATIVE_PATH + " NOT LIKE '%/';"); } private static void recomputeDataValues(SQLiteDatabase db, boolean internal) { try (Cursor c = db.query("files", new String[] { FileColumns._ID, FileColumns.DATA }, null, null, null, null, null, null)) { Log.d(TAG, "Recomputing " + c.getCount() + " data values"); final ContentValues values = new ContentValues(); while (c.moveToNext()) { values.clear(); final long id = c.getLong(0); final String data = c.getString(1); values.put(FileColumns.DATA, data); computeDataValues(values); values.remove(FileColumns.DATA); if (!values.isEmpty()) { db.update("files", values, "_id=" + id, null); } } } } static final int VERSION_J = 509; static final int VERSION_K = 700; static final int VERSION_L = 700; static final int VERSION_M = 800; static final int VERSION_N = 800; static final int VERSION_O = 800; static final int VERSION_P = 900; static final int VERSION_Q = 1023; /** * This method takes care of updating all the tables in the database to the * current version, creating them if necessary. * This method can only update databases at schema 700 or higher, which was * used by the KitKat release. Older database will be cleared and recreated. * @param db Database * @param internal True if this is the internal media database */ private static void updateDatabase(Context context, SQLiteDatabase db, boolean internal, int fromVersion, int toVersion) { final long startTime = SystemClock.elapsedRealtime(); if (fromVersion < 700) { // Anything older than KK is recreated from scratch createLatestSchema(db, internal); } else { boolean recomputeDataValues = false; if (fromVersion < 800) { updateCollationKeys(db); } if (fromVersion < 900) { updateAddTitleResource(db); } if (fromVersion < 1000) { updateAddOwnerPackageName(db, internal); } if (fromVersion < 1003) { updateAddColorSpaces(db); } if (fromVersion < 1004) { updateAddHashAndPending(db, internal); } if (fromVersion < 1005) { updateAddDownloadInfo(db, internal); } if (fromVersion < 1006) { updateAddAudiobook(db, internal); } if (fromVersion < 1007) { updateClearLocation(db, internal); } if (fromVersion < 1008) { updateSetIsDownload(db, internal); } if (fromVersion < 1009) { // This database version added "secondary_bucket_id", but that // column name was refactored in version 1013 below, so this // update step is no longer needed. } if (fromVersion < 1010) { updateAddExpiresAndTrashed(db, internal); } if (fromVersion < 1012) { recomputeDataValues = true; } if (fromVersion < 1013) { updateAddGroupId(db, internal); updateAddDirectories(db, internal); recomputeDataValues = true; } if (fromVersion < 1014) { updateAddXmp(db, internal); } if (fromVersion < 1015) { // Empty version bump to ensure views are recreated } if (fromVersion < 1016) { // Empty version bump to ensure views are recreated } if (fromVersion < 1017) { updateSetIsDownload(db, internal); recomputeDataValues = true; } if (fromVersion < 1018) { updateAddPath(db, internal); recomputeDataValues = true; } if (fromVersion < 1019) { // Only trigger during "external", so that it runs only once. if (!internal) { deleteLegacyThumbnailData(); } } if (fromVersion < 1020) { updateAddVolumeName(db, internal); recomputeDataValues = true; } if (fromVersion < 1021) { // Empty version bump to ensure views are recreated } if (fromVersion < 1022) { updateDirsMimeType(db, internal); } if (fromVersion < 1023) { updateRelativePath(db, internal); } if (recomputeDataValues) { recomputeDataValues(db, internal); } } // Always recreate latest views during upgrade; they're cheap and it's // an easy way to ensure they're defined consistently createLatestViews(db, internal); sanityCheck(db, fromVersion); getOrCreateUuid(db); final long elapsedSeconds = (SystemClock.elapsedRealtime() - startTime) / DateUtils.SECOND_IN_MILLIS; logToDb(db, "Database upgraded from version " + fromVersion + " to " + toVersion + " in " + elapsedSeconds + " seconds"); } private static void downgradeDatabase(Context context, SQLiteDatabase db, boolean internal, int fromVersion, int toVersion) { final long startTime = SystemClock.elapsedRealtime(); // The best we can do is wipe and start over createLatestSchema(db, internal); final long elapsedSeconds = (SystemClock.elapsedRealtime() - startTime) / DateUtils.SECOND_IN_MILLIS; logToDb(db, "Database downgraded from version " + fromVersion + " to " + toVersion + " in " + elapsedSeconds + " seconds"); } /** * Write a persistent diagnostic message to the log table. */ static void logToDb(SQLiteDatabase db, String message) { db.execSQL("INSERT OR REPLACE" + " INTO log (time,message) VALUES (strftime('%Y-%m-%d %H:%M:%f','now'),?);", new String[] { message }); // delete all but the last 500 rows db.execSQL("DELETE FROM log WHERE rowid IN" + " (SELECT rowid FROM log ORDER BY rowid DESC LIMIT 500,-1);"); } /** * Perform a simple sanity check on the database. Currently this tests * whether all the _data entries in audio_meta are unique */ private static void sanityCheck(SQLiteDatabase db, int fromVersion) { Cursor c1 = null; Cursor c2 = null; try { c1 = db.query("audio_meta", new String[] {"count(*)"}, null, null, null, null, null); c2 = db.query("audio_meta", new String[] {"count(distinct _data)"}, null, null, null, null, null); c1.moveToFirst(); c2.moveToFirst(); int num1 = c1.getInt(0); int num2 = c2.getInt(0); if (num1 != num2) { Log.e(TAG, "audio_meta._data column is not unique while upgrading" + " from schema " +fromVersion + " : " + num1 +"/" + num2); // Delete all audio_meta rows so they will be rebuilt by the media scanner db.execSQL("DELETE FROM audio_meta;"); } } finally { IoUtils.closeQuietly(c1); IoUtils.closeQuietly(c2); } } private static final String XATTR_UUID = "user.uuid"; /** * Return a UUID for the given database. If the database is deleted or * otherwise corrupted, then a new UUID will automatically be generated. */ private static @NonNull String getOrCreateUuid(@NonNull SQLiteDatabase db) { try { return new String(Os.getxattr(db.getPath(), XATTR_UUID)); } catch (ErrnoException e) { if (e.errno == OsConstants.ENODATA) { // Doesn't exist yet, so generate and persist a UUID final String uuid = UUID.randomUUID().toString(); try { Os.setxattr(db.getPath(), XATTR_UUID, uuid.getBytes(), 0); } catch (ErrnoException e2) { throw new RuntimeException(e); } return uuid; } else { throw new RuntimeException(e); } } } @VisibleForTesting static void computeDataValues(ContentValues values) { // Worst case we have to assume no bucket details values.remove(ImageColumns.BUCKET_ID); values.remove(ImageColumns.BUCKET_DISPLAY_NAME); values.remove(ImageColumns.GROUP_ID); values.remove(ImageColumns.VOLUME_NAME); values.remove(ImageColumns.RELATIVE_PATH); values.remove(ImageColumns.PRIMARY_DIRECTORY); values.remove(ImageColumns.SECONDARY_DIRECTORY); final String data = values.getAsString(MediaColumns.DATA); if (TextUtils.isEmpty(data)) return; final File file = new File(data); final File fileLower = new File(data.toLowerCase()); values.put(ImageColumns.VOLUME_NAME, extractVolumeName(data)); values.put(ImageColumns.RELATIVE_PATH, extractRelativePath(data)); values.put(ImageColumns.DISPLAY_NAME, extractDisplayName(data)); // Buckets are the parent directory final String parent = fileLower.getParent(); if (parent != null) { values.put(ImageColumns.BUCKET_ID, parent.hashCode()); // The relative path for files in the top directory is "/" if (!"/".equals(values.getAsString(ImageColumns.RELATIVE_PATH))) { values.put(ImageColumns.BUCKET_DISPLAY_NAME, file.getParentFile().getName()); } } // Groups are the first part of name final String name = fileLower.getName(); final int firstDot = name.indexOf('.'); if (firstDot > 0) { values.put(ImageColumns.GROUP_ID, name.substring(0, firstDot).hashCode()); } // Directories are first two levels of storage paths final String relativePath = values.getAsString(ImageColumns.RELATIVE_PATH); if (TextUtils.isEmpty(relativePath)) return; final String[] segments = relativePath.split("/"); if (segments.length > 0) { values.put(ImageColumns.PRIMARY_DIRECTORY, segments[0]); } if (segments.length > 1) { values.put(ImageColumns.SECONDARY_DIRECTORY, segments[1]); } } @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(); } } 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(); } } } } 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; } @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { return query(uri, projection, ContentResolver.createSqlQueryBundle(selection, selectionArgs, sortOrder), null); } @Override public Cursor query(Uri uri, String[] projection, Bundle queryArgs, CancellationSignal signal) { Trace.traceBegin(TRACE_TAG_DATABASE, "query"); try { return queryInternal(uri, projection, queryArgs, signal); } finally { Trace.traceEnd(TRACE_TAG_DATABASE); } } private Cursor queryInternal(Uri uri, String[] projection, Bundle queryArgs, CancellationSignal signal) { String selection = null; String[] selectionArgs = null; String sortOrder = null; if (queryArgs != null) { selection = queryArgs.getString(ContentResolver.QUERY_ARG_SQL_SELECTION); selectionArgs = queryArgs.getStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS); sortOrder = queryArgs.getString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER); if (sortOrder == null && queryArgs.containsKey(ContentResolver.QUERY_ARG_SORT_COLUMNS)) { sortOrder = ContentResolver.createSqlSortClause(queryArgs); } } uri = safeUncanonicalize(uri); final String volumeName = getVolumeName(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[] {getDatabaseVersion(getContext())}); return c; } final DatabaseHelper helper; final SQLiteDatabase db; try { helper = getDatabaseForUri(uri); db = helper.getReadableDatabase(); } catch (VolumeNotFoundException e) { return e.translateForQuery(targetSdkVersion); } if (table == MTP_OBJECT_REFERENCES) { final int handle = Integer.parseInt(uri.getPathSegments().get(2)); return getObjectReferences(helper, db, handle); } SQLiteQueryBuilder qb = getQueryBuilder(TYPE_QUERY, uri, table, queryArgs); String limit = uri.getQueryParameter(MediaStore.PARAM_LIMIT); String filter = uri.getQueryParameter("filter"); String [] keywords = null; if (filter != null) { filter = Uri.decode(filter).trim(); if (!TextUtils.isEmpty(filter)) { String [] searchWords = filter.split(" "); keywords = new String[searchWords.length]; for (int i = 0; i < searchWords.length; i++) { String key = MediaStore.Audio.keyFor(searchWords[i]); key = key.replace("\\", "\\\\"); key = key.replace("%", "\\%"); key = key.replace("_", "\\_"); keywords[i] = key; } } } String keywordColumn = null; switch (table) { case AUDIO_MEDIA: case AUDIO_GENRES_ALL_MEMBERS: case AUDIO_GENRES_ID_MEMBERS: case AUDIO_PLAYLISTS_ID_MEMBERS_ID: case AUDIO_PLAYLISTS_ID_MEMBERS: keywordColumn = MediaStore.Audio.Media.ARTIST_KEY + "||" + MediaStore.Audio.Media.ALBUM_KEY + "||" + MediaStore.Audio.Media.TITLE_KEY; break; case AUDIO_ARTISTS_ID_ALBUMS: case AUDIO_ALBUMS: keywordColumn = MediaStore.Audio.Media.ARTIST_KEY + "||" + MediaStore.Audio.Media.ALBUM_KEY; break; case AUDIO_ARTISTS: keywordColumn = MediaStore.Audio.Media.ARTIST_KEY; break; } if (keywordColumn != null) { for (int i = 0; keywords != null && i < keywords.length; i++) { appendWhereStandalone(qb, keywordColumn + " LIKE ? ESCAPE '\\'", "%" + keywords[i] + "%"); } } String groupBy = null; if (table == AUDIO_ARTISTS_ID_ALBUMS) { groupBy = "audio.album_id"; } if (getCallingPackageTargetSdkVersion() < Build.VERSION_CODES.Q) { // Some apps are abusing the "WHERE" clause by injecting "GROUP BY" // clauses; gracefully lift them out. final Pair selectionAndGroupBy = recoverAbusiveGroupBy( Pair.create(selection, groupBy)); selection = selectionAndGroupBy.first; groupBy = selectionAndGroupBy.second; // Some apps are abusing the first column to inject "DISTINCT"; // gracefully lift them out. if (!ArrayUtils.isEmpty(projection) && 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. 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); try { String data = null; if (ContentResolver.DEPRECATE_DATA_COLUMNS) { // Go through provider to escape sandbox data = ContentResolver.translateDeprecatedDataPath( fullUri.buildUpon().appendPath("thumbnail").build()); } else { // Go directly to thumbnail file on disk data = ensureThumbnail(fullUri, signal).getAbsolutePath(); } cursor.newRow().add(MediaColumns._ID, null) .add(Images.Thumbnails.IMAGE_ID, id) .add(Video.Thumbnails.VIDEO_ID, id) .add(MediaColumns.DATA, data); } catch (FileNotFoundException ignored) { // Return empty cursor if we had thumbnail trouble } return cursor; } } } final String having = null; final Cursor c = qb.query(db, projection, selection, selectionArgs, groupBy, having, sortOrder, limit, signal); if (c != null) { ((AbstractCursor) c).setNotificationUris(getContext().getContentResolver(), Arrays.asList(uri), UserHandle.myUserId(), false); } return c; } @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_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: case AUDIO_MEDIA_ID_PLAYLISTS: return Audio.Playlists.CONTENT_TYPE; case AUDIO_PLAYLISTS_ID: case AUDIO_MEDIA_ID_PLAYLISTS_ID: return Audio.Playlists.ENTRY_CONTENT_TYPE; case VIDEO_MEDIA: return Video.Media.CONTENT_TYPE; case DOWNLOADS: return Downloads.CONTENT_TYPE; } throw new IllegalStateException("Unknown URL : " + url); } @VisibleForTesting static void ensureFileColumns(Uri uri, ContentValues values) throws VolumeArgumentException { ensureNonUniqueFileColumns(matchUri(uri, true), uri, values, null /* currentPath */); } private static void ensureUniqueFileColumns(int match, Uri uri, ContentValues values) throws VolumeArgumentException { ensureFileColumns(match, uri, values, true, null /* currentPath */); } private static void ensureNonUniqueFileColumns(int match, Uri uri, ContentValues values, @Nullable String currentPath) throws VolumeArgumentException { ensureFileColumns(match, uri, values, false, currentPath); } /** * Get the various file-related {@link MediaColumns} in the given * {@link ContentValues} into sane 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 static void ensureFileColumns(int match, Uri uri, ContentValues values, boolean makeUnique, @Nullable String currentPath) throws VolumeArgumentException { Trace.traceBegin(TRACE_TAG_DATABASE, "ensureFileColumns"); // Figure out defaults based on Uri being modified String defaultMimeType = ContentResolver.MIME_TYPE_DEFAULT; String defaultPrimary = Environment.DIRECTORY_DOWNLOADS; String defaultSecondary = null; List allowedPrimary = Arrays.asList( Environment.DIRECTORY_DOWNLOADS, Environment.DIRECTORY_DOCUMENTS); switch (match) { case AUDIO_MEDIA: case AUDIO_MEDIA_ID: defaultMimeType = "audio/mpeg"; defaultPrimary = Environment.DIRECTORY_MUSIC; allowedPrimary = Arrays.asList( Environment.DIRECTORY_ALARMS, Environment.DIRECTORY_MUSIC, Environment.DIRECTORY_NOTIFICATIONS, Environment.DIRECTORY_PODCASTS, Environment.DIRECTORY_RINGTONES); break; case VIDEO_MEDIA: case VIDEO_MEDIA_ID: defaultMimeType = "video/mp4"; defaultPrimary = Environment.DIRECTORY_MOVIES; allowedPrimary = Arrays.asList( Environment.DIRECTORY_DCIM, Environment.DIRECTORY_MOVIES); break; case IMAGES_MEDIA: case IMAGES_MEDIA_ID: defaultMimeType = "image/jpeg"; defaultPrimary = Environment.DIRECTORY_PICTURES; allowedPrimary = Arrays.asList( Environment.DIRECTORY_DCIM, Environment.DIRECTORY_PICTURES); break; case AUDIO_ALBUMART: case AUDIO_ALBUMART_ID: defaultMimeType = "image/jpeg"; defaultPrimary = Environment.DIRECTORY_MUSIC; allowedPrimary = Arrays.asList(defaultPrimary); defaultSecondary = ".thumbnails"; break; case VIDEO_THUMBNAILS: case VIDEO_THUMBNAILS_ID: defaultMimeType = "image/jpeg"; defaultPrimary = Environment.DIRECTORY_MOVIES; allowedPrimary = Arrays.asList(defaultPrimary); defaultSecondary = ".thumbnails"; break; case IMAGES_THUMBNAILS: case IMAGES_THUMBNAILS_ID: defaultMimeType = "image/jpeg"; defaultPrimary = Environment.DIRECTORY_PICTURES; allowedPrimary = Arrays.asList(defaultPrimary); defaultSecondary = ".thumbnails"; break; case AUDIO_PLAYLISTS: case AUDIO_PLAYLISTS_ID: defaultPrimary = Environment.DIRECTORY_MUSIC; allowedPrimary = Arrays.asList(defaultPrimary); 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))) { final String data = values.getAsString(MediaColumns.DATA); if (TextUtils.isEmpty(values.getAsString(MediaColumns.DISPLAY_NAME))) { values.put(MediaColumns.DISPLAY_NAME, extractDisplayName(data)); } if (TextUtils.isEmpty(values.getAsString(MediaColumns.MIME_TYPE))) { values.put(MediaColumns.MIME_TYPE, MediaFile.getMimeTypeForFile(data)); } } // Give ourselves sane 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); } else if (TextUtils.isEmpty(values.getAsString(MediaColumns.MIME_TYPE))) { values.put(MediaColumns.MIME_TYPE, defaultMimeType); } // Sanity check MIME type against table final String mimeType = values.getAsString(MediaColumns.MIME_TYPE); if (mimeType != null && !defaultMimeType.equals(ContentResolver.MIME_TYPE_DEFAULT)) { final String[] split = defaultMimeType.split("/"); if (!mimeType.startsWith(split[0])) { throw new IllegalArgumentException( "MIME type " + mimeType + " cannot be inserted into " + uri + "; expected MIME type under " + split[0] + "/*"); } } // Generate path when undefined if (TextUtils.isEmpty(values.getAsString(MediaColumns.DATA))) { // Combine together deprecated columns when path undefined if (TextUtils.isEmpty(values.getAsString(MediaColumns.RELATIVE_PATH))) { String primary = values.getAsString(MediaColumns.PRIMARY_DIRECTORY); String secondary = values.getAsString(MediaColumns.SECONDARY_DIRECTORY); // Fall back to defaults when caller left undefined if (TextUtils.isEmpty(primary)) primary = defaultPrimary; if (TextUtils.isEmpty(secondary)) secondary = defaultSecondary; if (primary != null) { if (secondary != null) { values.put(MediaColumns.RELATIVE_PATH, primary + '/' + secondary + '/'); } else { values.put(MediaColumns.RELATIVE_PATH, primary + '/'); } } } final String[] relativePath = sanitizePath( values.getAsString(MediaColumns.RELATIVE_PATH)); final String displayName = sanitizeDisplayName( values.getAsString(MediaColumns.DISPLAY_NAME)); // Create result file File res; try { res = getVolumePath(resolvedVolumeName); } catch (FileNotFoundException e) { throw new IllegalArgumentException(e); } res = Environment.buildPath(res, relativePath); try { if (makeUnique) { res = FileUtils.buildUniqueFile(res, mimeType, displayName); } else { res = FileUtils.buildNonUniqueFile(res, mimeType, displayName); } } catch (FileNotFoundException e) { throw new IllegalStateException( "Failed to build unique file: " + res + " " + displayName + " " + mimeType); } // Check for shady looking paths // Require content live under specific directories, but allow in-place updates of // existing content that lives in the invalid directory. final String primary = relativePath[0]; if (!res.getAbsolutePath().equals(currentPath) && !allowedPrimary.contains(primary)) { throw new IllegalArgumentException( "Primary directory " + primary + " not allowed for " + uri + "; allowed directories are " + allowedPrimary); } // Ensure all parent folders of result file exist res.getParentFile().mkdirs(); if (!res.getParentFile().exists()) { throw new IllegalStateException("Failed to create directory: " + res); } values.put(MediaColumns.DATA, res.getAbsolutePath()); } else { assertFileColumnsSane(match, uri, values); } // Drop columns that aren't relevant for special tables switch (match) { case AUDIO_ALBUMART: case VIDEO_THUMBNAILS: case IMAGES_THUMBNAILS: case AUDIO_PLAYLISTS: values.remove(MediaColumns.DISPLAY_NAME); values.remove(MediaColumns.MIME_TYPE); break; } Trace.traceEnd(TRACE_TAG_DATABASE); } private static @NonNull String[] sanitizePath(@Nullable String path) { if (path == null) { return EmptyArray.STRING; } else { final String[] segments = path.split("/"); for (int i = 0; i < segments.length; i++) { segments[i] = sanitizeDisplayName(segments[i]); } return segments; } } private static @Nullable String sanitizeDisplayName(@Nullable String name) { if (name == null) { return null; } else if (name.startsWith(".")) { // The resulting file must not be hidden. return FileUtils.buildValidFatFilename("_" + name); } else { return FileUtils.buildValidFatFilename(name); } } /** * Sanity check that any requested {@link MediaColumns#DATA} paths actually * live on the storage volume being targeted. */ private static void assertFileColumnsSane(int match, Uri uri, ContentValues values) throws VolumeArgumentException { if (!values.containsKey(MediaColumns.DATA)) return; try { // Sanity check that the requested path actually lives on volume final String volumeName = resolveVolumeName(uri); final Collection allowed = getVolumeScanPaths(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 IllegalArgumentException(e); } } @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); } final DatabaseHelper helper; final SQLiteDatabase db; try { helper = getDatabaseForUri(uri); db = helper.getWritableDatabase(); } catch (VolumeNotFoundException e) { return e.translateForUpdateDelete(targetSdkVersion); } if (match == MTP_OBJECT_REFERENCES) { int handle = Integer.parseInt(uri.getPathSegments().get(2)); return setObjectReferences(helper, db, handle, values); } helper.beginTransaction(); try { final int result = super.bulkInsert(uri, values); helper.setTransactionSuccessful(); return result; } finally { helper.endTransaction(); } } private int playlistBulkInsert(SQLiteDatabase db, Uri uri, ContentValues values[]) { DatabaseUtils.InsertHelper helper = new DatabaseUtils.InsertHelper(db, "audio_playlists_map"); int audioidcolidx = helper.getColumnIndex(MediaStore.Audio.Playlists.Members.AUDIO_ID); int playlistididx = helper.getColumnIndex(Audio.Playlists.Members.PLAYLIST_ID); int playorderidx = helper.getColumnIndex(MediaStore.Audio.Playlists.Members.PLAY_ORDER); long playlistId = Long.parseLong(uri.getPathSegments().get(3)); db.beginTransaction(); int numInserted = 0; try { int len = values.length; for (int i = 0; i < len; i++) { helper.prepareForInsert(); // getting the raw Object and converting it long ourselves saves // an allocation (the alternative is ContentValues.getAsLong, which // returns a Long object) long audioid = ((Number) values[i].get( MediaStore.Audio.Playlists.Members.AUDIO_ID)).longValue(); helper.bind(audioidcolidx, audioid); helper.bind(playlistididx, playlistId); // convert to int ourselves to save an allocation. int playorder = ((Number) values[i].get( MediaStore.Audio.Playlists.Members.PLAY_ORDER)).intValue(); helper.bind(playorderidx, playorder); helper.execute(); } numInserted = len; db.setTransactionSuccessful(); } finally { db.endTransaction(); helper.close(); } getContext().getContentResolver().notifyChange(uri, null); return numInserted; } private long insertDirectory(DatabaseHelper helper, SQLiteDatabase db, String path) { if (LOCAL_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(helper, 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)); File file = new File(path); if (file.exists()) { values.put(FileColumns.DATE_MODIFIED, file.lastModified() / 1000); } long rowId = db.insert("files", FileColumns.DATE_MODIFIED, values); return rowId; } private static @Nullable String extractVolumeName(@Nullable String data) { if (data == null) return null; final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data); if (matcher.find()) { final String volumeName = matcher.group(1); if (volumeName.equals("emulated")) { return MediaStore.VOLUME_EXTERNAL_PRIMARY; } else { return StorageVolume.normalizeUuid(volumeName); } } else { return MediaStore.VOLUME_INTERNAL; } } private static @Nullable String extractRelativePath(@Nullable String data) { if (data == null) return null; final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data); if (matcher.find()) { final int lastSlash = data.lastIndexOf('/'); if (lastSlash == -1 || lastSlash < matcher.end()) { // This is a file in the top-level directory, so relative path is "/" // which is different than null, which means unknown path return "/"; } else { return data.substring(matcher.end(), lastSlash + 1); } } else { return null; } } private static @Nullable String extractDisplayName(@Nullable String data) { if (data == null) return null; if (data.endsWith("/")) { data = data.substring(0, data.length() - 1); } return data.substring(data.lastIndexOf('/') + 1); } private long getParent(DatabaseHelper helper, SQLiteDatabase db, 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(helper, 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 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() { localizeTitles(); } private void localizeTitles() { final DatabaseHelper helper = mInternalDatabase; final SQLiteDatabase db = helper.getWritableDatabase(); 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 { final String localizedTitle = getLocalizedTitle(titleResourceUri); values.put("title_key", MediaStore.Audio.keyFor(localizedTitle)); // do a final trim of the title, in case it started with the special // "sort first" character (ascii \001) values.put("title", localizedTitle.trim()); db.update("files", values, "_id=?", new String[]{id}); } catch (Exception e) { Log.e(TAG, "Error updating localized title for " + titleResourceUri + ", keeping old localization"); } } } } private long insertFile(DatabaseHelper helper, int match, Uri uri, ContentValues values, int mediaType, boolean notify) { final SQLiteDatabase db = helper.getWritableDatabase(); boolean wasPathEmpty = !values.containsKey(MediaStore.MediaColumns.DATA) || TextUtils.isEmpty(values.getAsString(MediaStore.MediaColumns.DATA)); // Make sure all file-related columns are defined try { ensureUniqueFileColumns(match, uri, values); } catch (VolumeArgumentException e) { if (getCallingPackageTargetSdkVersion() >= Build.VERSION_CODES.Q) { throw new IllegalArgumentException(e.getMessage()); } else { Log.w(TAG, e.getMessage()); return 0; } } switch (mediaType) { case FileColumns.MEDIA_TYPE_IMAGE: { values.put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis() / 1000); break; } case FileColumns.MEDIA_TYPE_AUDIO: { // SQLite Views are read-only, so we need to deconstruct this // insert and do inserts into the underlying tables. // If doing this here turns out to be a performance bottleneck, // consider moving this to native code and using triggers on // the view. String albumartist = values.getAsString(MediaStore.Audio.Media.ALBUM_ARTIST); String compilation = values.getAsString(MediaStore.Audio.Media.COMPILATION); values.remove(MediaStore.Audio.Media.COMPILATION); // Insert the artist into the artist table and remove it from // the input values Object so = values.get("artist"); String s = (so == null ? "" : so.toString()); values.remove("artist"); long artistRowId; ArrayMap artistCache = helper.mArtistCache; String path = values.getAsString(MediaStore.MediaColumns.DATA); synchronized(artistCache) { Long temp = artistCache.get(s); if (temp == null) { artistRowId = getKeyIdForName(helper, db, "artists", "artist_key", "artist", s, s, path, 0, null, artistCache, uri); } else { artistRowId = temp.longValue(); } } String artist = s; // Do the same for the album field so = values.get("album"); s = (so == null ? "" : so.toString()); values.remove("album"); long albumRowId; ArrayMap albumCache = helper.mAlbumCache; synchronized(albumCache) { int albumhash = 0; if (albumartist != null) { albumhash = albumartist.hashCode(); } else if (compilation != null && compilation.equals("1")) { // nothing to do, hash already set } else { albumhash = path.substring(0, path.lastIndexOf('/')).hashCode(); } String cacheName = s + albumhash; Long temp = albumCache.get(cacheName); if (temp == null) { albumRowId = getKeyIdForName(helper, db, "albums", "album_key", "album", s, cacheName, path, albumhash, artist, albumCache, uri); } else { albumRowId = temp; } } values.put("artist_id", Integer.toString((int)artistRowId)); values.put("album_id", Integer.toString((int)albumRowId)); so = values.getAsString("title"); s = (so == null ? "" : so.toString()); try { final String localizedTitle = getLocalizedTitle(s); if (localizedTitle != null) { values.put("title_resource_uri", s); s = localizedTitle; } else { values.putNull("title_resource_uri"); } } catch (Exception e) { values.put("title_resource_uri", s); } values.put("title_key", MediaStore.Audio.keyFor(s)); // do a final trim of the title, in case it started with the special // "sort first" character (ascii \001) values.put("title", s.trim()); break; } case FileColumns.MEDIA_TYPE_VIDEO: { break; } } // compute bucket_id and bucket_display_name for all files String path = values.getAsString(MediaStore.MediaColumns.DATA); computeDataValues(values); values.put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis() / 1000); long rowId = 0; Integer i = values.getAsInteger( MediaStore.MediaColumns.MEDIA_SCANNER_NEW_OBJECT_ID); if (i != null) { rowId = i.intValue(); values = new ContentValues(values); values.remove(MediaStore.MediaColumns.MEDIA_SCANNER_NEW_OBJECT_ID); } String title = values.getAsString(MediaStore.MediaColumns.TITLE); if (title == null && path != null) { title = MediaFile.getFileTitle(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) { if (TextUtils.isEmpty(path) || wasPathEmpty) { // special case device created playlists if (mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) { values.put(FileColumns.FORMAT, MtpConstants.FORMAT_ABSTRACT_AV_PLAYLIST); // create a file path for the benefit of MTP path = Environment.getExternalStorageDirectory() + "/Playlists/" + values.getAsString(Audio.Playlists.NAME); values.put(MediaStore.MediaColumns.DATA, path); values.put(FileColumns.PARENT, 0); } } else { format = MediaFile.getFormatCode(path, mimeType); } } if (path != null && path.endsWith("/")) { Log.e(TAG, "directory has trailing slash: " + path); return 0; } if (format != 0) { values.put(FileColumns.FORMAT, format); if (mimeType == null && format != MtpConstants.FORMAT_ASSOCIATION) { mimeType = MediaFile.getMimeTypeForFormatCode(format); } } if (mimeType == null && path != null && format != MtpConstants.FORMAT_ASSOCIATION) { mimeType = MediaFile.getMimeTypeForFile(path); } if (mimeType != null) { values.put(FileColumns.MIME_TYPE, mimeType); // If 'values' contained the media type, then the caller wants us // to use that exact type, so don't override it based on mimetype if (!values.containsKey(FileColumns.MEDIA_TYPE) && mediaType == FileColumns.MEDIA_TYPE_NONE && !android.media.MediaScanner.isNoMediaPath(path)) { if (MediaFile.isAudioMimeType(mimeType)) { mediaType = FileColumns.MEDIA_TYPE_AUDIO; } else if (MediaFile.isVideoMimeType(mimeType)) { mediaType = FileColumns.MEDIA_TYPE_VIDEO; } else if (MediaFile.isImageMimeType(mimeType)) { mediaType = FileColumns.MEDIA_TYPE_IMAGE; } else if (MediaFile.isPlayListMimeType(mimeType)) { mediaType = FileColumns.MEDIA_TYPE_PLAYLIST; } } } values.put(FileColumns.MEDIA_TYPE, mediaType); if (rowId == 0) { 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()); } } } Long parent = values.getAsLong(FileColumns.PARENT); if (parent == null) { if (path != null) { long parentId = getParent(helper, db, path); values.put(FileColumns.PARENT, parentId); } } rowId = db.insert("files", FileColumns.DATE_MODIFIED, values); } else { db.update("files", values, FileColumns._ID + "=?", new String[] { Long.toString(rowId) }); } if (format == MtpConstants.FORMAT_ASSOCIATION) { synchronized (mDirectoryCache) { mDirectoryCache.put(path, rowId); } } return rowId; } private Cursor getObjectReferences(DatabaseHelper helper, SQLiteDatabase db, int handle) { Cursor c = db.query("files", sMediaTableColumns, "_id=?", new String[] { Integer.toString(handle) }, null, null, null); try { if (c != null && c.moveToNext()) { long playlistId = c.getLong(0); int mediaType = c.getInt(1); if (mediaType != FileColumns.MEDIA_TYPE_PLAYLIST) { // we only support object references for playlist objects return null; } return db.rawQuery(OBJECT_REFERENCES_QUERY, new String[] { Long.toString(playlistId) } ); } } finally { IoUtils.closeQuietly(c); } return null; } private int setObjectReferences(DatabaseHelper helper, SQLiteDatabase db, int handle, ContentValues values[]) { // first look up the media table and media ID for the object long playlistId = 0; Cursor c = db.query("files", sMediaTableColumns, "_id=?", new String[] { Integer.toString(handle) }, null, null, null); try { if (c != null && c.moveToNext()) { int mediaType = c.getInt(1); if (mediaType != FileColumns.MEDIA_TYPE_PLAYLIST) { // we only support object references for playlist objects return 0; } playlistId = c.getLong(0); } } finally { IoUtils.closeQuietly(c); } if (playlistId == 0) { return 0; } // next delete any existing entries db.delete("audio_playlists_map", "playlist_id=?", new String[] { Long.toString(playlistId) }); // finally add the new entries int count = values.length; int added = 0; ContentValues[] valuesList = new ContentValues[count]; for (int i = 0; i < count; i++) { // convert object ID to audio ID long audioId = 0; long objectId = values[i].getAsLong(MediaStore.MediaColumns._ID); c = db.query("files", sMediaTableColumns, "_id=?", new String[] { Long.toString(objectId) }, null, null, null); try { if (c != null && c.moveToNext()) { int mediaType = c.getInt(1); if (mediaType != FileColumns.MEDIA_TYPE_AUDIO) { // we only allow audio files in playlists, so skip continue; } audioId = c.getLong(0); } } finally { IoUtils.closeQuietly(c); } if (audioId != 0) { ContentValues v = new ContentValues(); v.put(MediaStore.Audio.Playlists.Members.PLAYLIST_ID, playlistId); v.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, audioId); v.put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, added); valuesList[added++] = v; } } if (added < count) { // we weren't able to find everything on the list, so lets resize the array // and pass what we have. ContentValues[] newValues = new ContentValues[added]; System.arraycopy(valuesList, 0, newValues, 0, added); valuesList = newValues; } int rowsChanged = playlistBulkInsert(db, Audio.Playlists.Members.getContentUri(MediaStore.VOLUME_EXTERNAL, playlistId), valuesList); if (rowsChanged > 0) { updatePlaylistDateModifiedToNow(db, playlistId); } return rowsChanged; } private static final String[] GENRE_LOOKUP_PROJECTION = new String[] { Audio.Genres._ID, // 0 Audio.Genres.NAME, // 1 }; private void updateGenre(long rowId, String genre, String volumeName) { Uri uri = null; Cursor cursor = null; Uri genresUri = MediaStore.Audio.Genres.getContentUri(volumeName); try { // see if the genre already exists cursor = query(genresUri, GENRE_LOOKUP_PROJECTION, MediaStore.Audio.Genres.NAME + "=?", new String[] { genre }, null); if (cursor == null || cursor.getCount() == 0) { // genre does not exist, so create the genre in the genre table ContentValues values = new ContentValues(); values.put(MediaStore.Audio.Genres.NAME, genre); uri = insert(genresUri, values); } else { // genre already exists, so compute its Uri cursor.moveToNext(); uri = ContentUris.withAppendedId(genresUri, cursor.getLong(0)); } if (uri != null) { uri = Uri.withAppendedPath(uri, MediaStore.Audio.Genres.Members.CONTENT_DIRECTORY); } } finally { IoUtils.closeQuietly(cursor); } if (uri != null) { // add entry to audio_genre_map ContentValues values = new ContentValues(); values.put(MediaStore.Audio.Genres.Members.AUDIO_ID, Long.valueOf(rowId)); insert(uri, values); } } @VisibleForTesting static @Nullable String extractPathOwnerPackageName(@Nullable String path) { if (path == null) return null; final Matcher m = PATTERN_OWNED_PATH.matcher(path); if (m.matches()) { return m.group(1); } else { return null; } } 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, true); 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; } } @Override public Uri insert(Uri uri, ContentValues initialValues) { Trace.traceBegin(TRACE_TAG_DATABASE, "insert"); try { return insertInternal(uri, initialValues); } finally { Trace.traceEnd(TRACE_TAG_DATABASE); } } private Uri insertInternal(Uri uri, ContentValues initialValues) { final boolean allowHidden = isCallingPackageAllowedHidden(); final int match = matchUri(uri, allowHidden); final int targetSdkVersion = getCallingPackageTargetSdkVersion(); final String originalVolumeName = getVolumeName(uri); 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; try { helper = getDatabaseForUri(MediaStore.Files.getContentUri(mMediaScannerVolume)); } catch (VolumeNotFoundException e) { return e.translateForInsert(targetSdkVersion); } helper.mScanStartTime = SystemClock.currentTimeMicro(); return MediaStore.getMediaScannerUri(); } if (match == VOLUMES) { String name = initialValues.getAsString("name"); Uri attachedVolume = attachVolume(name); if (mMediaScannerVolume != null && mMediaScannerVolume.equals(name)) { final DatabaseHelper helper; try { helper = getDatabaseForUri( MediaStore.Files.getContentUri(mMediaScannerVolume)); } catch (VolumeNotFoundException e) { return e.translateForInsert(targetSdkVersion); } helper.mScanStartTime = SystemClock.currentTimeMicro(); } return attachedVolume; } String genre = null; String path = null; String ownerPackageName = null; if (initialValues != null) { // Ignore or augment incoming raw filesystem paths for (String column : sDataColumns.keySet()) { if (!initialValues.containsKey(column)) continue; if (isCallingPackageSystem() || isCallingPackageLegacy()) { // Mutation allowed } else { Log.w(TAG, "Ignoring mutation of " + column + " from " + getCallingPackageOrSelf()); initialValues.remove(column); } } genre = initialValues.getAsString(Audio.AudioColumns.GENRE); initialValues.remove(Audio.AudioColumns.GENRE); path = initialValues.getAsString(MediaStore.MediaColumns.DATA); if (!isCallingPackageSystem()) { 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 (isCallingPackageSystem()) { // When media inserted by ourselves, the best we can do is guess // ownership based on path. ownerPackageName = initialValues.getAsString(FileColumns.OWNER_PACKAGE_NAME); if (TextUtils.isEmpty(ownerPackageName)) { ownerPackageName = extractPathOwnerPackageName(path); } } 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 DatabaseHelper helper; final SQLiteDatabase db; try { helper = getDatabaseForUri(uri); db = helper.getWritableDatabase(); } catch (VolumeNotFoundException e) { return e.translateForInsert(targetSdkVersion); } switch (match) { case IMAGES_MEDIA: { maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName); final boolean isDownload = maybeMarkAsDownload(initialValues); rowId = insertFile(helper, match, uri, initialValues, FileColumns.MEDIA_TYPE_IMAGE, true); if (rowId > 0) { MediaDocumentsProvider.onMediaStoreInsert( getContext(), resolvedVolumeName, FileColumns.MEDIA_TYPE_IMAGE, rowId); newUri = ContentUris.withAppendedId( Images.Media.getContentUri(originalVolumeName), rowId); } break; } case IMAGES_THUMBNAILS: { if (helper.mInternal) { 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), true); try { ensureUniqueFileColumns(match, uri, initialValues); } catch (VolumeArgumentException e) { return e.translateForInsert(targetSdkVersion); } rowId = db.insert("thumbnails", "name", initialValues); if (rowId > 0) { newUri = ContentUris.withAppendedId(Images.Thumbnails. getContentUri(originalVolumeName), rowId); } break; } case VIDEO_THUMBNAILS: { if (helper.mInternal) { 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), true); try { ensureUniqueFileColumns(match, uri, initialValues); } catch (VolumeArgumentException e) { return e.translateForInsert(targetSdkVersion); } rowId = db.insert("videothumbnails", "name", 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); rowId = insertFile(helper, match, uri, initialValues, FileColumns.MEDIA_TYPE_AUDIO, true); if (rowId > 0) { MediaDocumentsProvider.onMediaStoreInsert( getContext(), resolvedVolumeName, FileColumns.MEDIA_TYPE_AUDIO, rowId); newUri = ContentUris.withAppendedId( Audio.Media.getContentUri(originalVolumeName), rowId); if (genre != null) { updateGenre(rowId, genre, resolvedVolumeName); } } break; } case AUDIO_MEDIA_ID_GENRES: { // Require that caller has write access to underlying media final long audioId = Long.parseLong(uri.getPathSegments().get(2)); enforceCallingPermission(ContentUris.withAppendedId( MediaStore.Audio.Media.getContentUri(resolvedVolumeName), audioId), true); ContentValues values = new ContentValues(initialValues); values.put(Audio.Genres.Members.AUDIO_ID, audioId); rowId = db.insert("audio_genres_map", "genre_id", values); if (rowId > 0) { newUri = ContentUris.withAppendedId(uri, rowId); } break; } case AUDIO_MEDIA_ID_PLAYLISTS: { // Require that caller has write access to underlying media final long audioId = Long.parseLong(uri.getPathSegments().get(2)); enforceCallingPermission(ContentUris.withAppendedId( MediaStore.Audio.Media.getContentUri(resolvedVolumeName), audioId), true); final long playlistId = initialValues .getAsLong(MediaStore.Audio.Playlists.Members.PLAYLIST_ID); enforceCallingPermission(ContentUris.withAppendedId( MediaStore.Audio.Playlists.getContentUri(resolvedVolumeName), playlistId), true); ContentValues values = new ContentValues(initialValues); values.put(Audio.Playlists.Members.AUDIO_ID, audioId); rowId = db.insert("audio_playlists_map", "playlist_id", values); if (rowId > 0) { newUri = ContentUris.withAppendedId(uri, rowId); updatePlaylistDateModifiedToNow(db, playlistId); } break; } case AUDIO_GENRES: { // NOTE: No permission enforcement on genres rowId = db.insert("audio_genres", "audio_id", initialValues); if (rowId > 0) { newUri = ContentUris.withAppendedId( Audio.Genres.getContentUri(originalVolumeName), rowId); } break; } case AUDIO_GENRES_ID_MEMBERS: { // Require that caller has write access to underlying media final long audioId = initialValues .getAsLong(MediaStore.Audio.Genres.Members.AUDIO_ID); enforceCallingPermission(ContentUris.withAppendedId( MediaStore.Audio.Media.getContentUri(resolvedVolumeName), audioId), true); Long genreId = Long.parseLong(uri.getPathSegments().get(3)); ContentValues values = new ContentValues(initialValues); values.put(Audio.Genres.Members.GENRE_ID, genreId); rowId = db.insert("audio_genres_map", "genre_id", values); if (rowId > 0) { newUri = ContentUris.withAppendedId(uri, rowId); } break; } 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); rowId = insertFile(helper, match, uri, values, FileColumns.MEDIA_TYPE_PLAYLIST, true); if (rowId > 0) { newUri = ContentUris.withAppendedId( Audio.Playlists.getContentUri(originalVolumeName), rowId); } break; } case AUDIO_PLAYLISTS_ID: case AUDIO_PLAYLISTS_ID_MEMBERS: { // Require that caller has write access to underlying media final long audioId = initialValues .getAsLong(MediaStore.Audio.Playlists.Members.AUDIO_ID); enforceCallingPermission(ContentUris.withAppendedId( MediaStore.Audio.Media.getContentUri(resolvedVolumeName), audioId), true); final long playlistId = Long.parseLong(uri.getPathSegments().get(3)); enforceCallingPermission(ContentUris.withAppendedId( MediaStore.Audio.Playlists.getContentUri(resolvedVolumeName), playlistId), true); ContentValues values = new ContentValues(initialValues); values.put(Audio.Playlists.Members.PLAYLIST_ID, playlistId); rowId = db.insert("audio_playlists_map", "playlist_id", values); if (rowId > 0) { newUri = ContentUris.withAppendedId(uri, rowId); updatePlaylistDateModifiedToNow(db, playlistId); } break; } case VIDEO_MEDIA: { maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName); final boolean isDownload = maybeMarkAsDownload(initialValues); rowId = insertFile(helper, match, uri, initialValues, FileColumns.MEDIA_TYPE_VIDEO, true); if (rowId > 0) { MediaDocumentsProvider.onMediaStoreInsert( getContext(), resolvedVolumeName, FileColumns.MEDIA_TYPE_VIDEO, rowId); newUri = ContentUris.withAppendedId( Video.Media.getContentUri(originalVolumeName), rowId); } break; } case AUDIO_ALBUMART: { if (helper.mInternal) { throw new UnsupportedOperationException("no internal album art allowed"); } try { ensureUniqueFileColumns(match, uri, initialValues); } catch (VolumeArgumentException e) { return e.translateForInsert(targetSdkVersion); } rowId = db.insert("album_art", MediaStore.MediaColumns.DATA, initialValues); if (rowId > 0) { newUri = ContentUris.withAppendedId(uri, rowId); } break; } case FILES: { maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName); final boolean isDownload = maybeMarkAsDownload(initialValues); rowId = insertFile(helper, match, uri, initialValues, FileColumns.MEDIA_TYPE_NONE, true); if (rowId > 0) { MediaDocumentsProvider.onMediaStoreInsert( getContext(), resolvedVolumeName, FileColumns.MEDIA_TYPE_NONE, rowId); newUri = Files.getContentUri(originalVolumeName, rowId); } break; } case MTP_OBJECTS: // We don't send a notification if the insert originated from MTP final boolean isDownload = maybeMarkAsDownload(initialValues); rowId = insertFile(helper, match, uri, initialValues, FileColumns.MEDIA_TYPE_NONE, false); if (rowId > 0) { newUri = Files.getMtpObjectsUri(originalVolumeName, rowId); } break; case FILES_DIRECTORY: rowId = insertDirectory(helper, helper.getWritableDatabase(), initialValues.getAsString(FileColumns.DATA)); if (rowId > 0) { newUri = Files.getContentUri(originalVolumeName, rowId); } break; case DOWNLOADS: maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName); initialValues.put(FileColumns.IS_DOWNLOAD, true); rowId = insertFile(helper, match, uri, initialValues, FileColumns.MEDIA_TYPE_NONE, false); if (rowId > 0) { final int mediaType = initialValues.getAsInteger(FileColumns.MEDIA_TYPE); MediaDocumentsProvider.onMediaStoreInsert( getContext(), resolvedVolumeName, mediaType, rowId); newUri = ContentUris.withAppendedId( MediaStore.Downloads.getContentUri(originalVolumeName), rowId); } 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.US).endsWith("/.nomedia")) { MediaScanner.instance(getContext()).scanFile(new File(path).getParentFile()); } if (newUri != null) { acceptWithExpansion(helper::notifyChange, newUri); } return newUri; } @Override public ContentProviderResult[] applyBatch(ArrayList operations) throws OperationApplicationException { // Open transactions on databases for requested volumes final ArrayMap transactions = new ArrayMap<>(); try { for (ContentProviderOperation op : operations) { final String volumeName = MediaStore.getVolumeName(op.getUri()); if (!transactions.containsKey(volumeName)) { try { final DatabaseHelper helper = getDatabaseForUri(op.getUri()); helper.beginTransaction(); transactions.put(volumeName, helper); } catch (VolumeNotFoundException e) { Log.w(TAG, e.getMessage()); } } } final ContentProviderResult[] result = super.applyBatch(operations); for (DatabaseHelper helper : transactions.values()) { helper.setTransactionSuccessful(); } return result; } finally { for (DatabaseHelper helper : transactions.values()) { helper.endTransaction(); } } } private static void appendWhereStandalone(@NonNull SQLiteQueryBuilder qb, @Nullable String selection, @Nullable Object... selectionArgs) { qb.appendWhereStandalone(DatabaseUtils.bindSelection(selection, selectionArgs)); } static @NonNull String bindList(@NonNull Object... args) { final StringBuilder sb = new StringBuilder(); sb.append('('); for (int i = 0; i < args.length; i++) { sb.append('?'); if (i < args.length - 1) { sb.append(','); } } sb.append(')'); return DatabaseUtils.bindSelection(sb.toString(), args); } private static boolean parseBoolean(String value) { if (value == null) return false; if ("1".equals(value)) return true; if ("true".equalsIgnoreCase(value)) return true; return false; } @Deprecated private String getSharedPackages(String callingPackage) { final String[] sharedPackageNames = mCallingIdentity.get().getSharedPackageNames(); return bindList((Object[]) sharedPackageNames); } private static final int TYPE_QUERY = 0; private static final int TYPE_UPDATE = 1; private static final int TYPE_DELETE = 2; /** * Generate a {@link SQLiteQueryBuilder} that is filtered based on the * runtime permissions and/or {@link Uri} grants held by the caller. *

    *
  • If caller holds a {@link Uri} grant, access is allowed according to * that grant. *
  • If caller holds the write permission for a collection, they can * read/write all contents of that collection. *
  • 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. *
  • If caller holds no permissions for a collection, all reads/write are * limited to content they own. *
*/ private SQLiteQueryBuilder getQueryBuilder(int type, Uri uri, int match, Bundle queryArgs) { Trace.traceBegin(TRACE_TAG_DATABASE, "getQueryBuilder"); try { return getQueryBuilderInternal(type, uri, match, queryArgs); } finally { Trace.traceEnd(TRACE_TAG_DATABASE); } } private SQLiteQueryBuilder getQueryBuilderInternal(int type, Uri uri, int match, Bundle queryArgs) { final boolean forWrite; switch (type) { case TYPE_QUERY: forWrite = false; break; case TYPE_UPDATE: forWrite = true; break; case TYPE_DELETE: forWrite = true; break; default: throw new IllegalStateException(); } final SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); if (parseBoolean(uri.getQueryParameter("distinct"))) { qb.setDistinct(true); } qb.setProjectionAggregationAllowed(true); qb.setStrict(true); final String callingPackage = getCallingPackageOrSelf(); // 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(getExternalVolumeNames().toArray()); } else { includeVolumes = bindList(volumeName); } final String sharedPackages = getSharedPackages(callingPackage); final boolean allowGlobal = checkCallingPermissionGlobal(uri, forWrite); final boolean allowLegacy = checkCallingPermissionLegacy(uri, forWrite, callingPackage); final boolean allowLegacyRead = allowLegacy && !forWrite; boolean includePending = parseBoolean( uri.getQueryParameter(MediaStore.PARAM_INCLUDE_PENDING)); boolean includeTrashed = parseBoolean( uri.getQueryParameter(MediaStore.PARAM_INCLUDE_TRASHED)); boolean includeAllVolumes = false; switch (match) { case IMAGES_MEDIA_ID: appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3)); includePending = true; includeTrashed = true; // fall-through case IMAGES_MEDIA: if (type == TYPE_QUERY) { qb.setTables("images"); qb.setProjectionMap(getProjectionMap(Images.Media.class)); } else { qb.setTables("files"); appendWhereStandalone(qb, FileColumns.MEDIA_TYPE + "=?", FileColumns.MEDIA_TYPE_IMAGE); } if (!allowGlobal && !checkCallingPermissionImages(forWrite, callingPackage)) { appendWhereStandalone(qb, FileColumns.OWNER_PACKAGE_NAME + " IN " + sharedPackages); } if (!includePending) { appendWhereStandalone(qb, FileColumns.IS_PENDING + "=?", 0); } if (!includeTrashed) { appendWhereStandalone(qb, FileColumns.IS_TRASHED + "=?", 0); } 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 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 owner_package_name IN " + sharedPackages + ")"); } break; } case AUDIO_MEDIA_ID: appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3)); includePending = true; includeTrashed = true; // fall-through case AUDIO_MEDIA: if (type == TYPE_QUERY) { qb.setTables("audio"); qb.setProjectionMap(getProjectionMap(Audio.Media.class)); } else { qb.setTables("files"); 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(FileColumns.OWNER_PACKAGE_NAME + " IN " + sharedPackages + " OR is_ringtone=1 OR is_alarm=1 OR is_notification=1")); } if (!includePending) { appendWhereStandalone(qb, FileColumns.IS_PENDING + "=?", 0); } if (!includeTrashed) { appendWhereStandalone(qb, FileColumns.IS_TRASHED + "=?", 0); } 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: qb.setTables("audio_genres"); qb.setProjectionMap(getProjectionMap(Audio.Genres.class)); appendWhereStandalone(qb, "_id IN (SELECT genre_id FROM " + "audio_genres_map WHERE audio_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_MEDIA_ID_PLAYLISTS_ID: appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(5)); // fall-through case AUDIO_MEDIA_ID_PLAYLISTS: qb.setTables("audio_playlists"); qb.setProjectionMap(getProjectionMap(Audio.Playlists.class)); appendWhereStandalone(qb, "_id IN (SELECT playlist_id FROM " + "audio_playlists_map WHERE audio_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_genres_map_noid, audio"); qb.setProjectionMap(getProjectionMap(Audio.Genres.Members.class)); appendWhereStandalone(qb, "audio._id = audio_id"); } else { qb.setTables("audio_genres_map"); } if (!allowGlobal && !checkCallingPermissionAudio(false, callingPackage)) { // We don't have a great way to filter parsed metadata by // owner, so callers need to hold READ_MEDIA_AUDIO appendWhereStandalone(qb, "0"); } break; case AUDIO_PLAYLISTS_ID: appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3)); includePending = true; includeTrashed = true; // fall-through case AUDIO_PLAYLISTS: if (type == TYPE_QUERY) { qb.setTables("audio_playlists"); qb.setProjectionMap(getProjectionMap(Audio.Playlists.class)); } else { qb.setTables("files"); appendWhereStandalone(qb, FileColumns.MEDIA_TYPE + "=?", FileColumns.MEDIA_TYPE_PLAYLIST); } if (!allowGlobal && !checkCallingPermissionAudio(forWrite, callingPackage)) { appendWhereStandalone(qb, FileColumns.OWNER_PACKAGE_NAME + " IN " + sharedPackages); } if (!includePending) { appendWhereStandalone(qb, FileColumns.IS_PENDING + "=?", 0); } if (!includeTrashed) { appendWhereStandalone(qb, FileColumns.IS_TRASHED + "=?", 0); } 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 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"); } else { qb.setTables("audio_playlists_map"); } 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 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) { final String artistId = uri.getPathSegments().get(3); qb.setTables("audio LEFT OUTER JOIN album_art ON" + " audio.album_id=album_art.album_id"); appendWhereStandalone(qb, "is_music=1 AND audio.album_id IN (SELECT album_id FROM " + "artists_albums_map WHERE artist_id=?)", artistId); final ArrayMap projectionMap = new ArrayMap<>( getProjectionMap(Audio.Artists.Albums.class)); projectionMap.put(Audio.Artists.Albums.ALBUM_ART, "album_art._data AS " + Audio.Artists.Albums.ALBUM_ART); projectionMap.put(Audio.Artists.Albums.NUMBER_OF_SONGS, "count(*) AS " + Audio.Artists.Albums.NUMBER_OF_SONGS); projectionMap.put(Audio.Artists.Albums.NUMBER_OF_SONGS_FOR_ARTIST, "count(CASE WHEN artist_id==" + artistId + " THEN 'foo' ELSE NULL END) AS " + Audio.Artists.Albums.NUMBER_OF_SONGS_FOR_ARTIST); projectionMap.put(Audio.Artists.Albums.FIRST_YEAR, "MIN(year) AS " + Audio.Artists.Albums.FIRST_YEAR); projectionMap.put(Audio.Artists.Albums.LAST_YEAR, "MAX(year) AS " + Audio.Artists.Albums.LAST_YEAR); projectionMap.put(Audio.Artists.Albums.ALBUM_ID, "audio.album_id AS " + Audio.Artists.Albums.ALBUM_ID); qb.setProjectionMap(projectionMap); } else { throw new UnsupportedOperationException("Albums cannot be directly modified"); } 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("artist_info"); qb.setProjectionMap(getProjectionMap(Audio.Artists.class)); } else { throw new UnsupportedOperationException("Artists cannot be directly modified"); } 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("album_info"); final ArrayMap projectionMap = new ArrayMap<>( getProjectionMap(Audio.Albums.class)); projectionMap.put(Audio.Artists.Albums.NUMBER_OF_SONGS_FOR_ARTIST, "NULL AS " + Audio.Artists.Albums.NUMBER_OF_SONGS_FOR_ARTIST); projectionMap.put(Audio.Artists.Albums.ALBUM_ID, BaseColumns._ID + " AS " + Audio.Artists.Albums.ALBUM_ID); qb.setProjectionMap(projectionMap); } else { throw new UnsupportedOperationException("Albums cannot be directly modified"); } 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)); includePending = true; includeTrashed = true; // fall-through case VIDEO_MEDIA: if (type == TYPE_QUERY) { qb.setTables("video"); qb.setProjectionMap(getProjectionMap(Video.Media.class)); } else { qb.setTables("files"); appendWhereStandalone(qb, FileColumns.MEDIA_TYPE + "=?", FileColumns.MEDIA_TYPE_VIDEO); } if (!allowGlobal && !checkCallingPermissionVideo(forWrite, callingPackage)) { appendWhereStandalone(qb, FileColumns.OWNER_PACKAGE_NAME + " IN " + sharedPackages); } if (!includePending) { appendWhereStandalone(qb, FileColumns.IS_PENDING + "=?", 0); } if (!includeTrashed) { appendWhereStandalone(qb, FileColumns.IS_TRASHED + "=?", 0); } 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 owner_package_name IN " + sharedPackages + ")"); } break; case FILES_ID: case MTP_OBJECTS_ID: appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(2)); includePending = true; includeTrashed = true; // fall-through case FILES: case FILES_DIRECTORY: case MTP_OBJECTS: { qb.setTables("files"); qb.setProjectionMap(getProjectionMap(Files.FileColumns.class)); final ArrayList options = new ArrayList<>(); if (!allowGlobal && !allowLegacyRead) { options.add(DatabaseUtils.bindSelection("owner_package_name IN " + sharedPackages)); 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("media_type=0 AND mime_type LIKE 'audio/%'"); } if (checkCallingPermissionVideo(forWrite, callingPackage)) { options.add(DatabaseUtils.bindSelection("media_type=?", FileColumns.MEDIA_TYPE_VIDEO)); options.add("media_type=0 AND mime_type LIKE 'video/%'"); } if (checkCallingPermissionImages(forWrite, callingPackage)) { options.add(DatabaseUtils.bindSelection("media_type=?", FileColumns.MEDIA_TYPE_IMAGE)); options.add("media_type=0 AND mime_type LIKE 'image/%'"); } } if (options.size() > 0) { appendWhereStandalone(qb, TextUtils.join(" OR ", options)); } if (!includePending) { appendWhereStandalone(qb, FileColumns.IS_PENDING + "=?", 0); } if (!includeTrashed) { appendWhereStandalone(qb, FileColumns.IS_TRASHED + "=?", 0); } if (!includeAllVolumes) { appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes); } break; } case DOWNLOADS_ID: appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(2)); includePending = true; includeTrashed = true; // fall-through case DOWNLOADS: { if (type == TYPE_QUERY) { qb.setTables("downloads"); qb.setProjectionMap(getProjectionMap(Downloads.class)); } else { qb.setTables("files"); appendWhereStandalone(qb, FileColumns.IS_DOWNLOAD + "=1"); } final ArrayList options = new ArrayList<>(); if (!allowGlobal && !allowLegacyRead) { options.add(DatabaseUtils.bindSelection("owner_package_name IN " + sharedPackages)); if (allowLegacy) { options.add(DatabaseUtils.bindSelection("volume_name=?", MediaStore.VOLUME_EXTERNAL_PRIMARY)); } } if (options.size() > 0) { appendWhereStandalone(qb, TextUtils.join(" OR ", options)); } if (!includePending) { appendWhereStandalone(qb, FileColumns.IS_PENDING + "=?", 0); } if (!includeTrashed) { appendWhereStandalone(qb, FileColumns.IS_TRASHED + "=?", 0); } if (!includeAllVolumes) { appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes); } break; } default: throw new UnsupportedOperationException( "Unknown or unsupported URL: " + uri.toString()); } if (type == TYPE_QUERY) { // To ensure we're enforcing our security model, all queries 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; } /** * Determine if given {@link Uri} has a * {@link MediaColumns#OWNER_PACKAGE_NAME} column. */ private static 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; } } @Override public int delete(Uri uri, String userWhere, String[] userWhereArgs) { Trace.traceBegin(TRACE_TAG_DATABASE, "insert"); try { return deleteInternal(uri, userWhere, userWhereArgs); } finally { Trace.traceEnd(TRACE_TAG_DATABASE); } } private int deleteInternal(Uri uri, String userWhere, String[] userWhereArgs) { uri = safeUncanonicalize(uri); int count; final String volumeName = getVolumeName(uri); final int targetSdkVersion = getCallingPackageTargetSdkVersion(); final boolean allowHidden = isCallingPackageAllowedHidden(); final int match = matchUri(uri, allowHidden); // handle MEDIA_SCANNER before calling getDatabaseForUri() if (match == MEDIA_SCANNER) { if (mMediaScannerVolume == null) { return 0; } final DatabaseHelper helper; try { helper = getDatabaseForUri(MediaStore.Files.getContentUri(mMediaScannerVolume)); } catch (VolumeNotFoundException e) { return e.translateForUpdateDelete(targetSdkVersion); } helper.mScanStopTime = SystemClock.currentTimeMicro(); String msg = dump(helper, false); logToDb(helper.getWritableDatabase(), msg); if (MediaStore.VOLUME_INTERNAL.equals(mMediaScannerVolume)) { // persist current build fingerprint as fingerprint for system (internal) sound scan final SharedPreferences scanSettings = getContext().getSharedPreferences( android.media.MediaScanner.SCANNED_BUILD_PREFS_NAME, Context.MODE_PRIVATE); final SharedPreferences.Editor editor = scanSettings.edit(); editor.putString(android.media.MediaScanner.LAST_INTERNAL_SCAN_FINGERPRINT, Build.FINGERPRINT); editor.apply(); } mMediaScannerVolume = null; return 1; } if (match == VOLUMES_ID) { detachVolume(uri); count = 1; } final DatabaseHelper helper; final SQLiteDatabase db; try { helper = getDatabaseForUri(uri); db = helper.getWritableDatabase(); } catch (VolumeNotFoundException e) { return e.translateForUpdateDelete(targetSdkVersion); } { final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_DELETE, uri, match, 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, true); } final String[] projection = new String[] { FileColumns.MEDIA_TYPE, FileColumns.DATA, FileColumns._ID, FileColumns.IS_DOWNLOAD, FileColumns.MIME_TYPE, }; final LongSparseArray deletedDownloadIds = new LongSparseArray<>(); if (qb.getTables().equals("files")) { String deleteparam = uri.getQueryParameter(MediaStore.PARAM_DELETE_DATA); if (deleteparam == null || ! deleteparam.equals("false")) { Cursor c = qb.query(db, projection, userWhere, userWhereArgs, null, null, null, null); String [] idvalue = new String[] { "" }; String [] playlistvalues = new String[] { "", "" }; 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); // Forget that caller is owner of this item mCallingIdentity.get().setOwned(id, false); // Invalidate thumbnails and revoke all outstanding grants final Uri deletedUri = Files.getContentUri(volumeName, id); invalidateThumbnails(deletedUri); acceptWithExpansion((expandedUri) -> { getContext().revokeUriPermission(expandedUri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); }, deletedUri); // Only need to inform DownloadProvider about the downloads deleted on // external volume. if (isDownload == 1) { deletedDownloadIds.put(id, mimeType); } if (mediaType == FileColumns.MEDIA_TYPE_IMAGE) { deleteIfAllowed(uri, data); MediaDocumentsProvider.onMediaStoreDelete(getContext(), volumeName, FileColumns.MEDIA_TYPE_IMAGE, id); } else if (mediaType == FileColumns.MEDIA_TYPE_VIDEO) { deleteIfAllowed(uri, data); MediaDocumentsProvider.onMediaStoreDelete(getContext(), volumeName, FileColumns.MEDIA_TYPE_VIDEO, id); } else if (mediaType == FileColumns.MEDIA_TYPE_AUDIO) { if (!helper.mInternal) { deleteIfAllowed(uri, data); MediaDocumentsProvider.onMediaStoreDelete(getContext(), volumeName, FileColumns.MEDIA_TYPE_AUDIO, id); idvalue[0] = String.valueOf(id); db.delete("audio_genres_map", "audio_id=?", idvalue); // for each playlist that the item appears in, move // all the items behind it forward by one Cursor cc = db.query("audio_playlists_map", sPlaylistIdPlayOrder, "audio_id=?", idvalue, null, null, null); try { while (cc.moveToNext()) { long playlistId = cc.getLong(0); playlistvalues[0] = String.valueOf(playlistId); playlistvalues[1] = String.valueOf(cc.getInt(1)); int rowsChanged = db.executeSql("UPDATE audio_playlists_map" + " SET play_order=play_order-1" + " WHERE playlist_id=? AND play_order>?", playlistvalues); if (rowsChanged > 0) { updatePlaylistDateModifiedToNow(db, playlistId); } } db.delete("audio_playlists_map", "audio_id=?", idvalue); } finally { IoUtils.closeQuietly(cc); } } } else if (isDownload == 1) { deleteIfAllowed(uri, data); MediaDocumentsProvider.onMediaStoreDelete(getContext(), volumeName, mediaType, id); } else if (mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) { // TODO, maybe: remove the audio_playlists_cleanup trigger and // implement functionality here (clean up the playlist map) } } } finally { IoUtils.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 MTP_OBJECTS: case MTP_OBJECTS_ID: count = deleteRecursive(qb, db, userWhere, userWhereArgs); break; case AUDIO_GENRES_ID_MEMBERS: count = deleteRecursive(qb, db, userWhere, userWhereArgs); break; case IMAGES_THUMBNAILS_ID: case IMAGES_THUMBNAILS: case VIDEO_THUMBNAILS_ID: case VIDEO_THUMBNAILS: // Delete the referenced files first. Cursor c = qb.query(db, sDataOnlyColumn, userWhere, userWhereArgs, null, null, null, null); if (c != null) { try { while (c.moveToNext()) { deleteIfAllowed(uri, c.getString(0)); } } finally { IoUtils.closeQuietly(c); } } count = deleteRecursive(qb, db, userWhere, userWhereArgs); break; case AUDIO_PLAYLISTS_ID_MEMBERS_ID: long playlistId = Long.parseLong(uri.getPathSegments().get(3)); count = deleteRecursive(qb, db, userWhere, userWhereArgs); if (count > 0) { updatePlaylistDateModifiedToNow(db, playlistId); } break; default: count = deleteRecursive(qb, db, userWhere, userWhereArgs); break; } if (deletedDownloadIds.size() > 0) { final long token = Binder.clearCallingIdentity(); try (ContentProviderClient client = getContext().getContentResolver() .acquireUnstableContentProviderClient( android.provider.Downloads.Impl.AUTHORITY)) { final Bundle extras = new Bundle(); final long[] ids = new long[deletedDownloadIds.size()]; final String[] mimeTypes = new String[deletedDownloadIds.size()]; for (int i = deletedDownloadIds.size() - 1; i >= 0; --i) { ids[i] = deletedDownloadIds.keyAt(i); mimeTypes[i] = deletedDownloadIds.valueAt(i); } extras.putLongArray(android.provider.Downloads.EXTRA_IDS, ids); extras.putStringArray(android.provider.Downloads.EXTRA_MIME_TYPES, mimeTypes); client.call(android.provider.Downloads.CALL_MEDIASTORE_DOWNLOADS_DELETED, null, extras); } catch (RemoteException e) { // Should not happen } finally { Binder.restoreCallingIdentity(token); } } } if (count > 0) { acceptWithExpansion(helper::notifyChange, uri); } return count; } /** * 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, SQLiteDatabase db, String userWhere, String[] userWhereArgs) { db.beginTransaction(); try { int n = 0; int total = 0; do { n = qb.delete(db, userWhere, userWhereArgs); total += n; } while (n > 0); db.setTransactionSuccessful(); return total; } finally { db.endTransaction(); } } @Override public Bundle call(String method, String arg, Bundle extras) { switch (method) { case MediaStore.WAIT_FOR_IDLE_CALL: { 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 LocalCallingIdentity token = clearLocalCallingIdentity(); final CallingIdentity providerToken = clearCallingIdentity(); try { final Uri uri = extras.getParcelable(Intent.EXTRA_STREAM); final File file = new File(uri.getPath()); final Bundle res = new Bundle(); switch (method) { case MediaStore.SCAN_FILE_CALL: res.putParcelable(Intent.EXTRA_STREAM, MediaScanner.instance(getContext()).scanFile(file)); break; case MediaStore.SCAN_VOLUME_CALL: MediaService.onScanVolume(getContext(), Uri.fromFile(file)); break; } return res; } catch (IOException e) { throw new RuntimeException(e); } finally { restoreCallingIdentity(providerToken); restoreLocalCallingIdentity(token); } } case MediaStore.UNHIDE_CALL: { throw new UnsupportedOperationException(); } case MediaStore.RETRANSLATE_CALL: { localizeTitles(); return null; } case MediaStore.GET_VERSION_CALL: { final String volumeName = extras.getString(Intent.EXTRA_TEXT); final SQLiteDatabase db; try { db = getDatabaseForUri(MediaStore.Files.getContentUri(volumeName)) .getReadableDatabase(); } catch (VolumeNotFoundException e) { throw e.rethrowAsIllegalArgumentException(); } final String version = db.getVersion() + ":" + getOrCreateUuid(db); final Bundle res = new Bundle(); res.putString(Intent.EXTRA_TEXT, version); return res; } case MediaStore.GET_DOCUMENT_URI_CALL: { final Uri mediaUri = extras.getParcelable(DocumentsContract.EXTRA_URI); enforceCallingPermission(mediaUri, 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( DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY)) { extras.putParcelable(DocumentsContract.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(DocumentsContract.EXTRA_URI); getContext().enforceCallingUriPermission(documentUri, Intent.FLAG_GRANT_READ_URI_PERMISSION, TAG); final Uri fileUri; try (ContentProviderClient client = getContext().getContentResolver() .acquireUnstableContentProviderClient( DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY)) { final Bundle res = client.call(method, null, extras); fileUri = res.getParcelable(DocumentsContract.EXTRA_URI); } catch (RemoteException e) { throw new IllegalStateException(e); } final LocalCallingIdentity token = clearLocalCallingIdentity(); try { final Bundle res = new Bundle(); res.putParcelable(DocumentsContract.EXTRA_URI, queryForMediaUri(new File(fileUri.getPath()), null)); return res; } catch (FileNotFoundException e) { throw new IllegalArgumentException(e); } finally { restoreLocalCallingIdentity(token); } } case MediaStore.GET_CONTRIBUTED_MEDIA_CALL: { getContext().enforceCallingOrSelfPermission( android.Manifest.permission.CLEAR_APP_USER_DATA, TAG); final String packageName = extras.getString(Intent.EXTRA_PACKAGE_NAME); final long totalSize = forEachContributedMedia(packageName, null); final Bundle res = new Bundle(); res.putLong(Intent.EXTRA_INDEX, totalSize); return res; } case MediaStore.DELETE_CONTRIBUTED_MEDIA_CALL: { getContext().enforceCallingOrSelfPermission( android.Manifest.permission.CLEAR_APP_USER_DATA, TAG); final String packageName = extras.getString(Intent.EXTRA_PACKAGE_NAME); forEachContributedMedia(packageName, (uri) -> { delete(uri, null, null); }); return null; } default: throw new UnsupportedOperationException("Unsupported call: " + method); } } /** * Execute the given operation for each media item contributed by given * package. The meaning of "contributed" means it won't automatically be * deleted when the app is uninstalled. */ private @BytesLong long forEachContributedMedia(String packageName, Consumer consumer) { final DatabaseHelper helper = mExternalDatabase; final SQLiteDatabase db = helper.getReadableDatabase(); final SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); qb.setTables("files"); qb.appendWhere( DatabaseUtils.bindSelection(FileColumns.OWNER_PACKAGE_NAME + "=?", packageName) + " AND NOT " + FileColumns.DATA + " REGEXP '" + PATTERN_OWNED_PATH.pattern() + "'"); long totalSize = 0; final LocalCallingIdentity token = clearLocalCallingIdentity(); try { try (Cursor c = qb.query(db, new String[] { FileColumns.VOLUME_NAME, FileColumns._ID, FileColumns.SIZE, FileColumns.DATA }, null, null, null, null, null, null)) { while (c.moveToNext()) { final String volumeName = c.getString(0); final long id = c.getLong(1); final long size = c.getLong(2); final String data = c.getString(3); Log.d(TAG, "Found " + data + " from " + packageName + " in " + helper.mName + " with size " + size); if (consumer != null) { consumer.accept(Files.getContentUri(volumeName, id)); } totalSize += size; } } } finally { restoreLocalCallingIdentity(token); } return totalSize; } private void pruneThumbnails(@NonNull CancellationSignal signal) { final DatabaseHelper helper = mExternalDatabase; final SQLiteDatabase db = helper.getReadableDatabase(); // 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 (String volumeName : getExternalVolumeNames()) { final File volumePath; try { volumePath = getVolumePath(volumeName); } catch (FileNotFoundException e) { Log.w(TAG, "Failed to resolve volume " + volumeName, e); continue; } // Reconcile all thumbnails, deleting stale items for (File thumbDir : new File[] { buildPath(volumePath, Environment.DIRECTORY_MUSIC, ".thumbnails"), buildPath(volumePath, Environment.DIRECTORY_MOVIES, ".thumbnails"), buildPath(volumePath, Environment.DIRECTORY_PICTURES, ".thumbnails"), }) { // Possibly bail before digging into each directory signal.throwIfCanceled(); for (File thumbFile : FileUtils.listFilesOrEmpty(thumbDir)) { final String name = ModernMediaScanner.extractName(thumbFile); 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); thumbFile.delete(); } } } // 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)"); } static 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 Environment.buildPath(volumePath, directoryName, ".thumbnails", ContentUris.parseId(uri) + ".jpg"); } public abstract Bitmap getThumbnailBitmap(Uri uri, CancellationSignal signal) throws IOException; public File ensureThumbnail(Uri uri, CancellationSignal signal) throws IOException { final File thumbFile = getThumbnailFile(uri); thumbFile.getParentFile().mkdirs(); if (!thumbFile.exists()) { final Bitmap thumbnail = getThumbnailBitmap(uri, signal); try (OutputStream out = new FileOutputStream(thumbFile)) { thumbnail.compress(Bitmap.CompressFormat.JPEG, 75, out); } } return thumbFile; } public void invalidateThumbnail(Uri uri) throws IOException { getThumbnailFile(uri).delete(); } } 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 void invalidateThumbnails(Uri uri) { Trace.traceBegin(TRACE_TAG_DATABASE, "invalidateThumbnails"); try { invalidateThumbnailsInternal(uri); } finally { Trace.traceEnd(TRACE_TAG_DATABASE); } } 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; final SQLiteDatabase db; try { helper = getDatabaseForUri(uri); db = helper.getWritableDatabase(); } catch (VolumeNotFoundException e) { Log.w(TAG, e); return; } 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, path); } } db.execSQL("delete from thumbnails where image_id=?", new String[] { idString }); db.execSQL("delete from videothumbnails where video_id=?", new String[] { idString }); } @Override public int update(Uri uri, ContentValues initialValues, String userWhere, String[] userWhereArgs) { Trace.traceBegin(TRACE_TAG_DATABASE, "update"); try { return updateInternal(uri, initialValues, userWhere, userWhereArgs); } finally { Trace.traceEnd(TRACE_TAG_DATABASE); } } private int updateInternal(Uri uri, ContentValues initialValues, String userWhere, String[] userWhereArgs) { // 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 String volumeName = getVolumeName(uri); final int targetSdkVersion = getCallingPackageTargetSdkVersion(); final boolean allowHidden = isCallingPackageAllowedHidden(); final int match = matchUri(uri, allowHidden); final DatabaseHelper helper; final SQLiteDatabase db; try { helper = getDatabaseForUri(uri); db = helper.getWritableDatabase(); } catch (VolumeNotFoundException e) { return e.translateForUpdateDelete(targetSdkVersion); } final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE, uri, match, 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, true); } boolean triggerInvalidate = false; boolean triggerScan = false; String genre = null; if (initialValues != null) { // IDs are forever; nobody should be editing them initialValues.remove(MediaColumns._ID); // Ignore or augment incoming raw filesystem paths for (String column : sDataColumns.keySet()) { if (!initialValues.containsKey(column)) continue; if (isCallingPackageSystem() || isCallingPackageLegacy()) { // Mutation allowed } else { Log.w(TAG, "Ignoring mutation of " + column + " from " + getCallingPackageOrSelf()); initialValues.remove(column); } } if (!isCallingPackageSystem()) { Trace.traceBegin(TRACE_TAG_DATABASE, "filter"); // Remote callers have no direct control over owner column; we // force it be whoever is creating the content. initialValues.remove(MediaColumns.OWNER_PACKAGE_NAME); // 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 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); switch (match) { default: triggerScan = true; break; // If entry is a playlist, do not re-scan to match previous behavior // and allow persistence of database-only edits until real re-scan case AUDIO_MEDIA_ID_PLAYLISTS_ID: case AUDIO_PLAYLISTS_ID: break; } } // If we're publishing this item, perform a blocking scan to // make sure metadata is updated if (MediaColumns.IS_PENDING.equals(column)) { triggerScan = true; } } Trace.traceEnd(TRACE_TAG_DATABASE); } genre = initialValues.getAsString(Audio.AudioColumns.GENRE); initialValues.remove(Audio.AudioColumns.GENRE); 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 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; } // If we're touching columns that would change placement of a file, // blend in current values and recalculate path if (containsAny(initialValues.keySet(), sPlacementColumns) && !initialValues.containsKey(MediaColumns.DATA) && !isCallingPackageSystem() && !isThumbnail) { Trace.traceBegin(TRACE_TAG_DATABASE, "movement"); // We only support movement under well-defined collections switch (match) { case AUDIO_MEDIA_ID: case VIDEO_MEDIA_ID: case IMAGES_MEDIA_ID: case DOWNLOADS_ID: break; default: throw new IllegalArgumentException("Movement of " + uri + " which isn't part of well-defined collection not allowed"); } final LocalCallingIdentity token = clearLocalCallingIdentity(); try (Cursor c = queryForSingleItem(uri, sPlacementColumns.toArray(EmptyArray.STRING), 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); try { ensureNonUniqueFileColumns(match, uri, initialValues, beforePath); } catch (VolumeArgumentException e) { return e.translateForUpdateDelete(targetSdkVersion); } 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 (!Objects.equals(beforeOwner, probeOwner)) { 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); try { ensureUniqueFileColumns(match, uri, initialValues); } catch (VolumeArgumentException e) { return e.translateForUpdateDelete(targetSdkVersion); } final String afterPath = initialValues.getAsString(MediaColumns.DATA); Log.d(TAG, "Moving " + beforePath + " to " + afterPath); try { Os.rename(beforePath, afterPath); } catch (ErrnoException e) { throw new IllegalStateException(e); } initialValues.put(MediaColumns.DATA, afterPath); } Trace.traceEnd(TRACE_TAG_DATABASE); } // Make sure any updated paths look sane try { assertFileColumnsSane(match, uri, initialValues); } catch (VolumeArgumentException e) { return e.translateForUpdateDelete(targetSdkVersion); } // if the media type is being changed, check if it's being changed from image or video // to something else if (initialValues.containsKey(FileColumns.MEDIA_TYPE)) { final int newMediaType = initialValues.getAsInteger(FileColumns.MEDIA_TYPE); // If we're changing media types, invalidate any cached "empty" // answers for the new collection type. MediaDocumentsProvider.onMediaStoreInsert( getContext(), volumeName, newMediaType, -1); // If we're changing media types, invalidate any thumbnails triggerInvalidate = true; } if (initialValues.containsKey(FileColumns.DATA)) { // If we're changing paths, invalidate any thumbnails triggerInvalidate = true; } // 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.traceBegin(TRACE_TAG_DATABASE, "snapshot"); final LocalCallingIdentity token = clearLocalCallingIdentity(); try (Cursor c = qb.query(db, new String[] { FileColumns._ID }, userWhere, userWhereArgs, null, null, null)) { while (c.moveToNext()) { updatedIds.add(c.getLong(0)); } } finally { restoreLocalCallingIdentity(token); Trace.traceEnd(TRACE_TAG_DATABASE); } } // special case renaming directories via MTP. // in this case we must update all paths in the database with // the directory name as a prefix if ((match == MTP_OBJECTS || match == MTP_OBJECTS_ID || match == FILES_DIRECTORY) && initialValues != null // Is a rename operation && ((initialValues.size() == 1 && initialValues.containsKey(FileColumns.DATA)) // Is a move operation || (initialValues.size() == 2 && initialValues.containsKey(FileColumns.DATA) && initialValues.containsKey(FileColumns.PARENT)))) { String oldPath = null; String newPath = initialValues.getAsString(MediaStore.MediaColumns.DATA); synchronized (mDirectoryCache) { mDirectoryCache.remove(newPath); } // MtpDatabase will rename the directory first, so we test the new file name File f = new File(newPath); if (newPath != null && f.isDirectory()) { Cursor cursor = qb.query(db, PATH_PROJECTION, userWhere, userWhereArgs, null, null, null, null); try { if (cursor != null && cursor.moveToNext()) { oldPath = cursor.getString(1); } } finally { IoUtils.closeQuietly(cursor); } final boolean isDownload = isDownload(newPath); if (oldPath != null) { synchronized (mDirectoryCache) { mDirectoryCache.remove(oldPath); } final boolean wasDownload = isDownload(oldPath); // first rename the row for the directory count = qb.update(db, initialValues, userWhere, userWhereArgs); if (count > 0) { // update the paths of any files and folders contained in the directory Object[] bindArgs = new Object[] { newPath, oldPath.length() + 1, oldPath + "/", oldPath + "0", // update bucket_display_name and bucket_id based on new path f.getName(), f.toString().toLowerCase().hashCode(), isDownload }; db.execSQL("UPDATE files SET _data=?1||SUBSTR(_data, ?2)" + // also update bucket_display_name ",bucket_display_name=?5" + ",bucket_id=?6" + ",is_download=?7" + " WHERE _data >= ?3 AND _data < ?4;", bindArgs); } if (count > 0) { acceptWithExpansion(helper::notifyChange, uri); } if (f.getName().startsWith(".")) { MediaScanner.instance(getContext()).scanFile(new File(newPath)); } return count; } } else if (newPath.toLowerCase(Locale.US).endsWith("/.nomedia")) { MediaScanner.instance(getContext()).scanFile(new File(newPath).getParentFile()); } } switch (match) { case AUDIO_MEDIA: case AUDIO_MEDIA_ID: { ContentValues values = new ContentValues(initialValues); String albumartist = values.getAsString(MediaStore.Audio.Media.ALBUM_ARTIST); String compilation = values.getAsString(MediaStore.Audio.Media.COMPILATION); values.remove(MediaStore.Audio.Media.COMPILATION); // Insert the artist into the artist table and remove it from // the input values String artist = values.getAsString("artist"); values.remove("artist"); if (artist != null) { long artistRowId; ArrayMap artistCache = helper.mArtistCache; synchronized(artistCache) { Long temp = artistCache.get(artist); if (temp == null) { artistRowId = getKeyIdForName(helper, db, "artists", "artist_key", "artist", artist, artist, null, 0, null, artistCache, uri); } else { artistRowId = temp.longValue(); } } values.put("artist_id", Integer.toString((int)artistRowId)); } // Do the same for the album field. String so = values.getAsString("album"); values.remove("album"); if (so != null) { String path = values.getAsString(MediaStore.MediaColumns.DATA); int albumHash = 0; if (albumartist != null) { albumHash = albumartist.hashCode(); } else if (compilation != null && compilation.equals("1")) { // nothing to do, hash already set } else { if (path == null) { if (match == AUDIO_MEDIA) { Log.w(TAG, "Possible multi row album name update without" + " path could give wrong album key"); } else { //Log.w(TAG, "Specify path to avoid extra query"); Cursor c = query(uri, new String[] { MediaStore.Audio.Media.DATA}, null, null, null); if (c != null) { try { int numrows = c.getCount(); if (numrows == 1) { c.moveToFirst(); path = c.getString(0); } else { Log.e(TAG, "" + numrows + " rows for " + uri); } } finally { IoUtils.closeQuietly(c); } } } } if (path != null) { albumHash = path.substring(0, path.lastIndexOf('/')).hashCode(); } } String s = so.toString(); long albumRowId; ArrayMap albumCache = helper.mAlbumCache; synchronized(albumCache) { String cacheName = s + albumHash; Long temp = albumCache.get(cacheName); if (temp == null) { albumRowId = getKeyIdForName(helper, db, "albums", "album_key", "album", s, cacheName, path, albumHash, artist, albumCache, uri); } else { albumRowId = temp.longValue(); } } values.put("album_id", Integer.toString((int)albumRowId)); } // don't allow the title_key field to be updated directly values.remove("title_key"); // If the title field is modified, update the title_key so = values.getAsString("title"); if (so != null) { try { final String localizedTitle = getLocalizedTitle(so); if (localizedTitle != null) { values.put("title_resource_uri", so); so = localizedTitle; } else { values.putNull("title_resource_uri"); } } catch (Exception e) { values.put("title_resource_uri", so); } values.put("title_key", MediaStore.Audio.keyFor(so)); // do a final trim of the title, in case it started with the special // "sort first" character (ascii \001) values.put("title", so.trim()); } count = qb.update(db, values, userWhere, userWhereArgs); if (genre != null) { if (count == 1 && match == AUDIO_MEDIA_ID) { long rowId = Long.parseLong(uri.getPathSegments().get(3)); updateGenre(rowId, genre, volumeName); } else { // can't handle genres for bulk update or for non-audio files Log.w(TAG, "ignoring genre in update: count = " + count + " match = " + match); } } } break; case IMAGES_MEDIA: case IMAGES_MEDIA_ID: case VIDEO_MEDIA: case VIDEO_MEDIA_ID: { ContentValues values = new ContentValues(initialValues); // Don't allow bucket id or display name to be updated directly. // The same names are used for both images and table columns, so // we use the ImageColumns constants here. values.remove(ImageColumns.BUCKET_ID); values.remove(ImageColumns.BUCKET_DISPLAY_NAME); // If the data is being modified update the bucket values computeDataValues(values); count = qb.update(db, values, userWhere, userWhereArgs); } break; case AUDIO_MEDIA_ID_PLAYLISTS_ID: case AUDIO_PLAYLISTS_ID: long playlistId = ContentUris.parseId(uri); count = qb.update(db, initialValues, userWhere, userWhereArgs); if (count > 0) { updatePlaylistDateModifiedToNow(db, playlistId); } break; case AUDIO_PLAYLISTS_ID_MEMBERS: long playlistIdMembers = Long.parseLong(uri.getPathSegments().get(3)); count = qb.update(db, initialValues, userWhere, userWhereArgs); if (count > 0) { updatePlaylistDateModifiedToNow(db, playlistIdMembers); } break; case AUDIO_PLAYLISTS_ID_MEMBERS_ID: String moveit = uri.getQueryParameter("move"); if (moveit != null) { String key = MediaStore.Audio.Playlists.Members.PLAY_ORDER; if (initialValues.containsKey(key)) { int newpos = initialValues.getAsInteger(key); List segments = uri.getPathSegments(); long playlist = Long.parseLong(segments.get(3)); int oldpos = Integer.parseInt(segments.get(5)); int rowsChanged = movePlaylistEntry(volumeName, helper, db, playlist, oldpos, newpos); if (rowsChanged > 0) { updatePlaylistDateModifiedToNow(db, playlist); } return rowsChanged; } throw new IllegalArgumentException("Need to specify " + key + " when using 'move' parameter"); } // fall through default: count = qb.update(db, initialValues, userWhere, userWhereArgs); break; } // 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.traceBegin(TRACE_TAG_DATABASE, "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); BackgroundThread.getExecutor().execute(() -> { invalidateThumbnails(updatedUri); }); if (triggerScan) { try (Cursor c = queryForSingleItem(updatedUri, new String[] { FileColumns.DATA }, null, null, null)) { MediaScanner.instance(getContext()).scanFile(new File(c.getString(0))); } catch (Exception e) { Log.w(TAG, "Failed to update metadata for " + updatedUri, e); } } } } finally { restoreLocalCallingIdentity(token); Trace.traceEnd(TRACE_TAG_DATABASE); } } if (count > 0) { acceptWithExpansion(helper::notifyChange, uri); } return count; } private int movePlaylistEntry(String volumeName, DatabaseHelper helper, SQLiteDatabase db, long playlist, int from, int to) { if (from == to) { return 0; } db.beginTransaction(); int numlines = 0; Cursor c = null; try { c = db.query("audio_playlists_map", new String [] {"play_order" }, "playlist_id=?", new String[] {"" + playlist}, null, null, "play_order", from + ",1"); c.moveToFirst(); int from_play_order = c.getInt(0); IoUtils.closeQuietly(c); c = db.query("audio_playlists_map", new String [] {"play_order" }, "playlist_id=?", new String[] {"" + playlist}, null, null, "play_order", to + ",1"); c.moveToFirst(); int to_play_order = c.getInt(0); db.execSQL("UPDATE audio_playlists_map SET play_order=-1" + " WHERE play_order=" + from_play_order + " AND playlist_id=" + playlist); // We could just run both of the next two statements, but only one of // of them will actually do anything, so might as well skip the compile // and execute steps. if (from < to) { db.execSQL("UPDATE audio_playlists_map SET play_order=play_order-1" + " WHERE play_order<=" + to_play_order + " AND play_order>" + from_play_order + " AND playlist_id=" + playlist); numlines = to - from + 1; } else { db.execSQL("UPDATE audio_playlists_map SET play_order=play_order+1" + " WHERE play_order>=" + to_play_order + " AND play_order<" + from_play_order + " AND playlist_id=" + playlist); numlines = from - to + 1; } db.execSQL("UPDATE audio_playlists_map SET play_order=" + to_play_order + " WHERE play_order=-1 AND playlist_id=" + playlist); db.setTransactionSuccessful(); } finally { db.endTransaction(); IoUtils.closeQuietly(c); } Uri uri = ContentUris.withAppendedId( MediaStore.Audio.Playlists.getContentUri(volumeName), playlist); // notifyChange() must be called after the database transaction is ended // or the listeners will read the old data in the callback getContext().getContentResolver().notifyChange(uri, null); return numlines; } private void updatePlaylistDateModifiedToNow(SQLiteDatabase database, long playlistId) { ContentValues values = new ContentValues(); values.put( FileColumns.DATE_MODIFIED, TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) ); database.update( MediaStore.Files.TABLE, values, MediaStore.Files.FileColumns._ID + "=?", new String[]{String.valueOf(playlistId)} ); } @Override public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { return openFileCommon(uri, mode, null); } @Override public ParcelFileDescriptor openFile(Uri uri, String mode, CancellationSignal signal) throws FileNotFoundException { return openFileCommon(uri, mode, signal); } private ParcelFileDescriptor openFileCommon(Uri uri, String mode, CancellationSignal signal) throws FileNotFoundException { uri = safeUncanonicalize(uri); 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 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 ParcelFileDescriptor.open(ensureThumbnail(targetUri, signal), ParcelFileDescriptor.MODE_READ_ONLY); } 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 ParcelFileDescriptor.open(ensureThumbnail(targetUri, signal), ParcelFileDescriptor.MODE_READ_ONLY); } 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 ParcelFileDescriptor.open(ensureThumbnail(targetUri, signal), ParcelFileDescriptor.MODE_READ_ONLY); } 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 ParcelFileDescriptor.open(ensureThumbnail(targetUri, signal), ParcelFileDescriptor.MODE_READ_ONLY); } } return openFileAndEnforcePathPermissionsHelper(uri, match, mode, signal); } @Override public AssetFileDescriptor openTypedAssetFile(Uri uri, String mimeTypeFilter, Bundle opts) throws FileNotFoundException { return openTypedAssetFileCommon(uri, mimeTypeFilter, opts, null); } @Override public AssetFileDescriptor openTypedAssetFile(Uri uri, String mimeTypeFilter, Bundle opts, CancellationSignal signal) throws FileNotFoundException { return openTypedAssetFileCommon(uri, mimeTypeFilter, opts, signal); } private AssetFileDescriptor openTypedAssetFileCommon(Uri uri, String mimeTypeFilter, Bundle opts, CancellationSignal signal) throws FileNotFoundException { uri = safeUncanonicalize(uri); // TODO: enforce that caller has access to this uri // Offer thumbnail of media, when requested final boolean wantsThumb = (opts != null) && opts.containsKey(ContentResolver.EXTRA_SIZE) && (mimeTypeFilter != null) && mimeTypeFilter.startsWith("image/"); if (wantsThumb) { final File thumbFile = ensureThumbnail(uri, signal); return new AssetFileDescriptor( ParcelFileDescriptor.open(thumbFile, ParcelFileDescriptor.MODE_READ_ONLY), 0, AssetFileDescriptor.UNKNOWN_LENGTH); } // Worst case, return the underlying file return new AssetFileDescriptor(openFileCommon(uri, "r", signal), 0, AssetFileDescriptor.UNKNOWN_LENGTH); } private File ensureThumbnail(Uri uri, CancellationSignal signal) throws FileNotFoundException { final boolean allowHidden = isCallingPackageAllowedHidden(); final int match = matchUri(uri, allowHidden); Trace.traceBegin(TRACE_TAG_DATABASE, "ensureThumbnail"); final LocalCallingIdentity token = clearLocalCallingIdentity(); try { final File thumbFile; 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); default: throw new FileNotFoundException(); } } catch (IOException e) { Log.w(TAG, e); throw new FileNotFoundException(e.getMessage()); } finally { restoreLocalCallingIdentity(token); Trace.traceEnd(TRACE_TAG_DATABASE); } } /** * 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); } /** * 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 = MediaStore.getVolumeName(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}, expecting only a single item to be found. * * @throws FileNotFoundException if no items were found, or multiple items * were found, or there was trouble reading the data. */ Cursor queryForSingleItem(Uri uri, String[] projection, String selection, String[] selectionArgs, CancellationSignal signal) throws FileNotFoundException { final Cursor c = query(uri, projection, ContentResolver.createSqlQueryBundle(selection, selectionArgs, null), signal); if (c == null) { throw new FileNotFoundException("Missing cursor for " + uri); } else if (c.getCount() < 1) { IoUtils.closeQuietly(c); throw new FileNotFoundException("No item at " + uri); } else if (c.getCount() > 1) { IoUtils.closeQuietly(c); throw new FileNotFoundException("Multiple items at " + uri); } if (c.moveToFirst()) { return c; } else { IoUtils.closeQuietly(c); throw new FileNotFoundException("Failed to read row from " + uri); } } /** * Replacement for {@link #openFileHelper(Uri, String)} which enforces any * permissions applicable to the path before returning. */ private ParcelFileDescriptor openFileAndEnforcePathPermissionsHelper(Uri uri, int match, String mode, CancellationSignal signal) throws FileNotFoundException { final int modeBits = ParcelFileDescriptor.parseMode(mode); final boolean forWrite = (modeBits != ParcelFileDescriptor.MODE_READ_ONLY); 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); } checkAccess(uri, file, forWrite); // Require ownership if item is still pending final boolean hasOwner = (ownerPackageName != null); final boolean callerIsOwner = Objects.equals(getCallingPackageOrSelf(), ownerPackageName); if (isPending && hasOwner && !callerIsOwner) { throw new IllegalStateException( "Only owner is able to interact with pending media " + uri); } // Figure out if we need to redact contents final boolean redactionNeeded = callerIsOwner ? false : isRedactionNeeded(uri); final RedactionInfo redactionInfo = redactionNeeded ? getRedactionRanges(file) : new RedactionInfo(EmptyArray.LONG, EmptyArray.LONG); // Yell if caller requires original, since we can't give it to them // unless they have access granted above if (redactionNeeded && parseBoolean(uri.getQueryParameter(MediaStore.PARAM_REQUIRE_ORIGINAL))) { 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); 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: MediaScanner.instance(getContext()).scanFile(file); 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; if (redactionInfo.redactionRanges.length > 0) { pfd = RedactingFileDescriptor.open( getContext(), file, modeBits, redactionInfo.redactionRanges, redactionInfo.freeOffsets); } else { pfd = ParcelFileDescriptor.open(file, modeBits); } // Second, wrap in any listener that we've requested if (!isPending && forWrite && listener != null) { return ParcelFileDescriptor.fromPfd(pfd, BackgroundThread.getHandler(), listener); } else { return pfd; } } catch (IOException e) { if (e instanceof FileNotFoundException) { throw (FileNotFoundException) e; } else { throw new IllegalStateException(e); } } } private void deleteIfAllowed(Uri uri, String path) { try { final File file = new File(path); checkAccess(uri, file, true); file.delete(); } 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); } /** * 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, }; private static final class RedactionInfo { public final long[] redactionRanges; public final long[] freeOffsets; public RedactionInfo(long[] redactionRanges, long[] freeOffsets) { this.redactionRanges = redactionRanges; this.freeOffsets = freeOffsets; } } private RedactionInfo getRedactionRanges(File file) { Trace.traceBegin(TRACE_TAG_DATABASE, "getRedactionRanges"); final LongArray res = new LongArray(); final LongArray freeOffsets = new LongArray(); try (FileInputStream is = new FileInputStream(file)) { final ExifInterface exif = new ExifInterface(is.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]); } } final IsoInterface iso = IsoInterface.fromFileDescriptor(is.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 Set redactedXmpTags = new ArraySet<>(Arrays.asList(REDACTED_EXIF_TAGS)); final XmpInterface exifXmp = XmpInterface.fromContainer(exif, redactedXmpTags); res.addAll(exifXmp.getRedactionRanges()); final XmpInterface isoXmp = XmpInterface.fromContainer(iso, redactedXmpTags); res.addAll(isoXmp.getRedactionRanges()); } catch (IOException e) { Log.w(TAG, "Failed to redact " + file + ": " + e); } Trace.traceEnd(TRACE_TAG_DATABASE); return new RedactionInfo(res.toArray(), freeOffsets.toArray()); } private boolean checkCallingPermissionGlobal(Uri uri, boolean forWrite) { // System internals can work with all media if (isCallingPackageSystem()) { 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; } } // Outstanding grant means they get access if (getContext().checkUriPermission(uri, mCallingIdentity.get().pid, mCallingIdentity.get().uid, forWrite ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION : Intent.FLAG_GRANT_READ_URI_PERMISSION) == PERMISSION_GRANTED) { return true; } return false; } private boolean checkCallingPermissionLegacy(Uri uri, boolean forWrite, String callingPackage) { return mCallingIdentity.get().hasPermission(PERMISSION_IS_LEGACY); } @Deprecated private boolean checkCallingPermissionAudio(boolean forWrite, String callingPackage) { if (forWrite) { return mCallingIdentity.get().hasPermission(PERMISSION_WRITE_AUDIO); } else { return mCallingIdentity.get().hasPermission(PERMISSION_READ_AUDIO); } } @Deprecated private boolean checkCallingPermissionVideo(boolean forWrite, String callingPackage) { if (forWrite) { return mCallingIdentity.get().hasPermission(PERMISSION_WRITE_VIDEO); } else { return mCallingIdentity.get().hasPermission(PERMISSION_READ_VIDEO); } } @Deprecated private boolean checkCallingPermissionImages(boolean forWrite, String callingPackage) { if (forWrite) { return mCallingIdentity.get().hasPermission(PERMISSION_WRITE_IMAGES); } else { return mCallingIdentity.get().hasPermission(PERMISSION_READ_IMAGES); } } /** * Enforce that caller has access to the given {@link Uri}. * * @throws SecurityException if access isn't allowed. */ private void enforceCallingPermission(Uri uri, boolean forWrite) { Trace.traceBegin(TRACE_TAG_DATABASE, "enforceCallingPermission"); try { enforceCallingPermissionInternal(uri, forWrite); } finally { Trace.traceEnd(TRACE_TAG_DATABASE); } } private void enforceCallingPermissionInternal(Uri uri, boolean forWrite) { // 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; } final DatabaseHelper helper; final SQLiteDatabase db; try { helper = getDatabaseForUri(uri); db = helper.getReadableDatabase(); } catch (VolumeNotFoundException e) { throw e.rethrowAsIllegalArgumentException(); } final boolean allowHidden = isCallingPackageAllowedHidden(); final int table = matchUri(uri, allowHidden); // First, check to see if caller has direct write access if (forWrite) { final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE, uri, table, null); try (Cursor c = qb.query(db, new String[0], 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, uri, table, null); try (Cursor c = qb.query(db, new String[0], 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 PendingIntent intent = PendingIntent.getActivity(context, 42, new Intent(null, uri, context, PermissionActivity.class), FLAG_ONE_SHOT | FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE); 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); } } } 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(Uri uri, File file, boolean isWrite) throws FileNotFoundException { // First, does caller have the needed row-level access? enforceCallingPermission(uri, isWrite); // Second, does the path look sane? if (!FileUtils.contains(Environment.getStorageDirectory(), file)) { checkWorldReadAccess(file.getAbsolutePath()); } } /** * Check whether the path is a world-readable file */ private 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(); } } /** * Look up the artist or album entry for the given name, creating that entry * if it does not already exists. * @param db The database * @param table The table to store the key/name pair in. * @param keyField The name of the key-column * @param nameField The name of the name-column * @param rawName The name that the calling app was trying to insert into the database * @param cacheName The string that will be inserted in to the cache * @param path The full path to the file being inserted in to the audio table * @param albumHash A hash to distinguish between different albums of the same name * @param artist The name of the artist, if known * @param cache The cache to add this entry to * @param srcuri The Uri that prompted the call to this method, used for determining whether this is * the internal or external database * @return The row ID for this artist/album, or -1 if the provided name was invalid */ private long getKeyIdForName(DatabaseHelper helper, SQLiteDatabase db, String table, String keyField, String nameField, String rawName, String cacheName, String path, int albumHash, String artist, ArrayMap cache, Uri srcuri) { long rowId; if (rawName == null || rawName.length() == 0) { rawName = MediaStore.UNKNOWN_STRING; } String k = MediaStore.Audio.keyFor(rawName); if (k == null) { // shouldn't happen, since we only get null keys for null inputs Log.e(TAG, "null key", new Exception()); return -1; } boolean isAlbum = table.equals("albums"); boolean isUnknown = MediaStore.UNKNOWN_STRING.equals(rawName); // To distinguish same-named albums, we append a hash. The hash is based // on the "album artist" tag if present, otherwise on the "compilation" tag // if present, otherwise on the path. // Ideally we would also take things like CDDB ID in to account, so // we can group files from the same album that aren't in the same // folder, but this is a quick and easy start that works immediately // without requiring support from the mp3, mp4 and Ogg meta data // readers, as long as the albums are in different folders. if (isAlbum) { k = k + albumHash; if (isUnknown) { k = k + artist; } } String [] selargs = { k }; Cursor c = db.query(table, null, keyField + "=?", selargs, null, null, null); try { switch (c.getCount()) { case 0: { // insert new entry into table ContentValues otherValues = new ContentValues(); otherValues.put(keyField, k); otherValues.put(nameField, rawName); rowId = db.insert(table, "duration", otherValues); if (rowId > 0) { String volume = srcuri.toString().substring(16, 24); // extract internal/external Uri uri = Uri.parse("content://media/" + volume + "/audio/" + table + "/" + rowId); getContext().getContentResolver().notifyChange(uri, null); } } break; case 1: { // Use the existing entry c.moveToFirst(); rowId = c.getLong(0); // Determine whether the current rawName is better than what's // currently stored in the table, and update the table if it is. String currentFancyName = c.getString(2); String bestName = makeBestName(rawName, currentFancyName); if (!bestName.equals(currentFancyName)) { // update the table with the new name ContentValues newValues = new ContentValues(); newValues.put(nameField, bestName); db.update(table, newValues, "rowid="+Integer.toString((int)rowId), null); String volume = srcuri.toString().substring(16, 24); // extract internal/external Uri uri = Uri.parse("content://media/" + volume + "/audio/" + table + "/" + rowId); getContext().getContentResolver().notifyChange(uri, null); // We have to remove the previous key from the cache otherwise we will // not be able to change between upper and lower case letters. if (isAlbum) { cache.remove(currentFancyName + albumHash); } else { cache.remove(currentFancyName); } } } break; default: // corrupt database Log.e(TAG, "Multiple entries in table " + table + " for key " + k); rowId = -1; break; } } finally { IoUtils.closeQuietly(c); } if (cache != null && ! isUnknown) { cache.put(cacheName, rowId); } return rowId; } /** * Returns the best string to use for display, given two names. * Note that this function does not necessarily return either one * of the provided names; it may decide to return a better alternative * (for example, specifying the inputs "Police" and "Police, The" will * return "The Police") * * The basic assumptions are: * - longer is better ("The police" is better than "Police") * - prefix is better ("The Police" is better than "Police, The") * - accents are better ("Motörhead" is better than "Motorhead") * * @param one The first of the two names to consider * @param two The last of the two names to consider * @return The actual name to use */ String makeBestName(String one, String two) { String name; // Longer names are usually better. if (one.length() > two.length()) { name = one; } else { // Names with accents are usually better, and conveniently sort later if (one.toLowerCase().compareTo(two.toLowerCase()) >= 0) { name = one; } else { name = two; } } // Prefixes are better than postfixes. if (name.endsWith(", the") || name.endsWith(",the") || name.endsWith(", an") || name.endsWith(",an") || name.endsWith(", a") || name.endsWith(",a")) { String fix = name.substring(1 + name.lastIndexOf(',')); name = fix.trim() + " " + name.substring(0, name.lastIndexOf(',')); } // TODO: word-capitalize the resulting name return name; } private static class FallbackException extends Exception { public FallbackException(String message) { super(message); } public IllegalArgumentException rethrowAsIllegalArgumentException() { throw new IllegalArgumentException(getMessage()); } public Cursor translateForQuery(int targetSdkVersion) { if (targetSdkVersion >= Build.VERSION_CODES.Q) { throw new IllegalArgumentException(getMessage()); } else { Log.w(TAG, getMessage()); return null; } } public Uri translateForInsert(int targetSdkVersion) { if (targetSdkVersion >= Build.VERSION_CODES.Q) { throw new IllegalArgumentException(getMessage()); } else { Log.w(TAG, getMessage()); return null; } } public int translateForUpdateDelete(int targetSdkVersion) { if (targetSdkVersion >= Build.VERSION_CODES.Q) { throw new IllegalArgumentException(getMessage()); } else { Log.w(TAG, getMessage()); return 0; } } } static class VolumeNotFoundException extends FallbackException { public VolumeNotFoundException(String volumeName) { super("Volume " + volumeName + " not found"); } } static class VolumeArgumentException extends FallbackException { public VolumeArgumentException(File actual, Collection allowed) { super("Requested path " + actual + " doesn't appear under " + allowed); } } private @NonNull DatabaseHelper getDatabaseForUri(Uri uri) throws VolumeNotFoundException { final String volumeName = resolveVolumeName(uri); synchronized (mAttachedVolumeNames) { if (!mAttachedVolumeNames.contains(volumeName)) { 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; } static boolean isInternalMediaDatabaseName(String name) { if (INTERNAL_DATABASE_NAME.equals(name)) { return true; } return false; } private void attachVolume(Uri uri) { attachVolume(MediaStore.getVolumeName(uri)); } public Uri attachVolume(String volume) { if (mCallingIdentity.get().pid != android.os.Process.myPid()) { throw new SecurityException( "Opening and closing databases not allowed."); } // Quick sanity check for shady volume names MediaStore.checkArgumentVolumeName(volume); // Quick sanity check that volume actually exists if (!MediaStore.VOLUME_INTERNAL.equals(volume)) { try { getVolumePath(volume); } catch (IOException e) { throw new IllegalArgumentException( "Volume " + volume + " currently unavailable", e); } } synchronized (mAttachedVolumeNames) { mAttachedVolumeNames.add(volume); } final Uri uri = MediaStore.AUTHORITY_URI.buildUpon().appendPath(volume).build(); getContext().getContentResolver().notifyChange(uri, null); if (LOCAL_LOGV) Log.v(TAG, "Attached volume: " + volume); if (!MediaStore.VOLUME_INTERNAL.equals(volume)) { final DatabaseHelper helper = mInternalDatabase; ensureDefaultFolders(volume, helper, helper.getWritableDatabase()); } return uri; } private void detachVolume(Uri uri) { detachVolume(MediaStore.getVolumeName(uri)); } public void detachVolume(String volume) { if (mCallingIdentity.get().pid != android.os.Process.myPid()) { throw new SecurityException( "Opening and closing databases not allowed."); } // Quick sanity check for shady volume names MediaStore.checkArgumentVolumeName(volume); if (MediaStore.VOLUME_INTERNAL.equals(volume)) { throw new UnsupportedOperationException( "Deleting the internal volume is not allowed"); } // Signal any scanning to shut down MediaScanner.instance(getContext()).onDetachVolume(volume); synchronized (mAttachedVolumeNames) { mAttachedVolumeNames.remove(volume); } final Uri uri = MediaStore.AUTHORITY_URI.buildUpon().appendPath(volume).build(); getContext().getContentResolver().notifyChange(uri, null); if (LOCAL_LOGV) Log.v(TAG, "Detached volume: " + volume); } /* * Useful commands to enable debugging: * $ adb shell setprop log.tag.MediaProvider VERBOSE * $ adb shell setprop db.log.slow_query_threshold.`adb shell cat \ * /data/system/packages.list |grep "com.android.providers.media " |cut -b 29-33` 0 * $ adb shell setprop db.log.bindargs 1 */ static final String TAG = "MediaProvider"; static final boolean LOCAL_LOGV = Log.isLoggable(TAG, Log.VERBOSE); private static final String INTERNAL_DATABASE_NAME = "internal.db"; private static final String EXTERNAL_DATABASE_NAME = "external.db"; // maximum number of cached external databases to keep private static final int MAX_EXTERNAL_DATABASES = 3; // Delete databases that have not been used in two months // 60 days in milliseconds (1000 * 60 * 60 * 24 * 60) private static final long OBSOLETE_DATABASE_DB = 5184000000L; // Memory optimization - close idle connections after 30s of inactivity private static final int IDLE_CONNECTION_TIMEOUT_MS = 30000; @GuardedBy("mAttachedVolumeNames") private final ArraySet mAttachedVolumeNames = new ArraySet<>(); private DatabaseHelper mInternalDatabase; private DatabaseHelper mExternalDatabase; // 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. private static final int IMAGES_MEDIA = 1; private static final int IMAGES_MEDIA_ID = 2; private static final int IMAGES_MEDIA_ID_THUMBNAIL = 3; private static final int IMAGES_THUMBNAILS = 4; private static final int IMAGES_THUMBNAILS_ID = 5; private static final int AUDIO_MEDIA = 100; private static final int AUDIO_MEDIA_ID = 101; private static final int AUDIO_MEDIA_ID_GENRES = 102; private static final int AUDIO_MEDIA_ID_GENRES_ID = 103; private static final int AUDIO_MEDIA_ID_PLAYLISTS = 104; private static final int AUDIO_MEDIA_ID_PLAYLISTS_ID = 105; private static final int AUDIO_GENRES = 106; private static final int AUDIO_GENRES_ID = 107; private static final int AUDIO_GENRES_ID_MEMBERS = 108; private static final int AUDIO_GENRES_ALL_MEMBERS = 109; private static final int AUDIO_PLAYLISTS = 110; private static final int AUDIO_PLAYLISTS_ID = 111; private static final int AUDIO_PLAYLISTS_ID_MEMBERS = 112; private static final int AUDIO_PLAYLISTS_ID_MEMBERS_ID = 113; private static final int AUDIO_ARTISTS = 114; private static final int AUDIO_ARTISTS_ID = 115; private static final int AUDIO_ALBUMS = 116; private static final int AUDIO_ALBUMS_ID = 117; private static final int AUDIO_ARTISTS_ID_ALBUMS = 118; private static final int AUDIO_ALBUMART = 119; private static final int AUDIO_ALBUMART_ID = 120; private static final int AUDIO_ALBUMART_FILE_ID = 121; private static final int VIDEO_MEDIA = 200; private static final int VIDEO_MEDIA_ID = 201; private static final int VIDEO_MEDIA_ID_THUMBNAIL = 202; private static final int VIDEO_THUMBNAILS = 203; private static final int VIDEO_THUMBNAILS_ID = 204; private static final int VOLUMES = 300; private static final int VOLUMES_ID = 301; private static final int MEDIA_SCANNER = 500; private static final int FS_ID = 600; private static final int VERSION = 601; private static final int FILES = 700; private static final int FILES_ID = 701; // Used only by the MTP implementation private static final int MTP_OBJECTS = 702; private static final int MTP_OBJECTS_ID = 703; private static final int MTP_OBJECT_REFERENCES = 704; // Used only to invoke special logic for directories private static final int FILES_DIRECTORY = 706; private static final int DOWNLOADS = 800; private static final int DOWNLOADS_ID = 801; private static final UriMatcher HIDDEN_URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); private static final UriMatcher PUBLIC_URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH); private static final String[] PATH_PROJECTION = new String[] { MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATA, }; private static final String OBJECT_REFERENCES_QUERY = "SELECT " + Audio.Playlists.Members.AUDIO_ID + " FROM audio_playlists_map" + " WHERE " + Audio.Playlists.Members.PLAYLIST_ID + "=?" + " ORDER BY " + Audio.Playlists.Members.PLAY_ORDER; private static int matchUri(Uri uri, boolean allowHidden) { final int publicMatch = PUBLIC_URI_MATCHER.match(uri); if (publicMatch != UriMatcher.NO_MATCH) { return publicMatch; } final int hiddenMatch = HIDDEN_URI_MATCHER.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; } static { final UriMatcher publicMatcher = PUBLIC_URI_MATCHER; final UriMatcher hiddenMatcher = HIDDEN_URI_MATCHER; publicMatcher.addURI(AUTHORITY, "*/images/media", IMAGES_MEDIA); publicMatcher.addURI(AUTHORITY, "*/images/media/#", IMAGES_MEDIA_ID); publicMatcher.addURI(AUTHORITY, "*/images/media/#/thumbnail", IMAGES_MEDIA_ID_THUMBNAIL); publicMatcher.addURI(AUTHORITY, "*/images/thumbnails", IMAGES_THUMBNAILS); publicMatcher.addURI(AUTHORITY, "*/images/thumbnails/#", IMAGES_THUMBNAILS_ID); publicMatcher.addURI(AUTHORITY, "*/audio/media", AUDIO_MEDIA); publicMatcher.addURI(AUTHORITY, "*/audio/media/#", AUDIO_MEDIA_ID); publicMatcher.addURI(AUTHORITY, "*/audio/media/#/genres", AUDIO_MEDIA_ID_GENRES); publicMatcher.addURI(AUTHORITY, "*/audio/media/#/genres/#", AUDIO_MEDIA_ID_GENRES_ID); hiddenMatcher.addURI(AUTHORITY, "*/audio/media/#/playlists", AUDIO_MEDIA_ID_PLAYLISTS); hiddenMatcher.addURI(AUTHORITY, "*/audio/media/#/playlists/#", AUDIO_MEDIA_ID_PLAYLISTS_ID); publicMatcher.addURI(AUTHORITY, "*/audio/genres", AUDIO_GENRES); publicMatcher.addURI(AUTHORITY, "*/audio/genres/#", AUDIO_GENRES_ID); publicMatcher.addURI(AUTHORITY, "*/audio/genres/#/members", AUDIO_GENRES_ID_MEMBERS); // TODO: not actually defined in API, but CTS tested publicMatcher.addURI(AUTHORITY, "*/audio/genres/all/members", AUDIO_GENRES_ALL_MEMBERS); publicMatcher.addURI(AUTHORITY, "*/audio/playlists", AUDIO_PLAYLISTS); publicMatcher.addURI(AUTHORITY, "*/audio/playlists/#", AUDIO_PLAYLISTS_ID); publicMatcher.addURI(AUTHORITY, "*/audio/playlists/#/members", AUDIO_PLAYLISTS_ID_MEMBERS); publicMatcher.addURI(AUTHORITY, "*/audio/playlists/#/members/#", AUDIO_PLAYLISTS_ID_MEMBERS_ID); publicMatcher.addURI(AUTHORITY, "*/audio/artists", AUDIO_ARTISTS); publicMatcher.addURI(AUTHORITY, "*/audio/artists/#", AUDIO_ARTISTS_ID); publicMatcher.addURI(AUTHORITY, "*/audio/artists/#/albums", AUDIO_ARTISTS_ID_ALBUMS); publicMatcher.addURI(AUTHORITY, "*/audio/albums", AUDIO_ALBUMS); publicMatcher.addURI(AUTHORITY, "*/audio/albums/#", AUDIO_ALBUMS_ID); // TODO: not actually defined in API, but CTS tested publicMatcher.addURI(AUTHORITY, "*/audio/albumart", AUDIO_ALBUMART); // TODO: not actually defined in API, but CTS tested publicMatcher.addURI(AUTHORITY, "*/audio/albumart/#", AUDIO_ALBUMART_ID); // TODO: not actually defined in API, but CTS tested publicMatcher.addURI(AUTHORITY, "*/audio/media/#/albumart", AUDIO_ALBUMART_FILE_ID); publicMatcher.addURI(AUTHORITY, "*/video/media", VIDEO_MEDIA); publicMatcher.addURI(AUTHORITY, "*/video/media/#", VIDEO_MEDIA_ID); publicMatcher.addURI(AUTHORITY, "*/video/media/#/thumbnail", VIDEO_MEDIA_ID_THUMBNAIL); publicMatcher.addURI(AUTHORITY, "*/video/thumbnails", VIDEO_THUMBNAILS); publicMatcher.addURI(AUTHORITY, "*/video/thumbnails/#", VIDEO_THUMBNAILS_ID); publicMatcher.addURI(AUTHORITY, "*/media_scanner", MEDIA_SCANNER); // NOTE: technically hidden, since Uri is never exposed publicMatcher.addURI(AUTHORITY, "*/fs_id", FS_ID); // NOTE: technically hidden, since Uri is never exposed publicMatcher.addURI(AUTHORITY, "*/version", VERSION); hiddenMatcher.addURI(AUTHORITY, "*", VOLUMES_ID); hiddenMatcher.addURI(AUTHORITY, null, VOLUMES); // Used by MTP implementation publicMatcher.addURI(AUTHORITY, "*/file", FILES); publicMatcher.addURI(AUTHORITY, "*/file/#", FILES_ID); hiddenMatcher.addURI(AUTHORITY, "*/object", MTP_OBJECTS); hiddenMatcher.addURI(AUTHORITY, "*/object/#", MTP_OBJECTS_ID); hiddenMatcher.addURI(AUTHORITY, "*/object/#/references", MTP_OBJECT_REFERENCES); // Used only to trigger special logic for directories hiddenMatcher.addURI(AUTHORITY, "*/dir", FILES_DIRECTORY); publicMatcher.addURI(AUTHORITY, "*/downloads", DOWNLOADS); publicMatcher.addURI(AUTHORITY, "*/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 sMutableColumns = new ArraySet<>(); { 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.DATE_EXPIRES); sMutableColumns.add(MediaStore.MediaColumns.PRIMARY_DIRECTORY); sMutableColumns.add(MediaStore.MediaColumns.SECONDARY_DIRECTORY); 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.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 sPlacementColumns = new ArraySet<>(); { 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.PRIMARY_DIRECTORY); sPlacementColumns.add(MediaStore.MediaColumns.SECONDARY_DIRECTORY); } /** * List of abusive custom columns that we're willing to allow via * {@link SQLiteQueryBuilder#setProjectionGreylist(List)}. */ static final ArrayList 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"); } @GuardedBy("sProjectionMapCache") private static final ArrayMap, ArrayMap> sProjectionMapCache = new ArrayMap<>(); /** * Return a projection map that represents the valid columns that can be * queried the given contract class. The mapping is built automatically * using the {@link Column} annotation, and is designed to ensure that we * always support public API commitments. */ static ArrayMap getProjectionMap(Class clazz) { synchronized (sProjectionMapCache) { ArrayMap map = sProjectionMapCache.get(clazz); if (map == null) { map = new ArrayMap<>(); sProjectionMapCache.put(clazz, map); try { for (Field field : clazz.getFields()) { if (field.isAnnotationPresent(Column.class)) { final String column = (String) field.get(null); map.put(column, column); } } } catch (ReflectiveOperationException e) { throw new RuntimeException(e); } } return map; } } /** * Simple attempt to balance the given SQL expression by adding parenthesis * when needed. *

* Since this is only used for recovering from abusive apps, we're not * interested in trying to build a fully valid SQL parser up in Java. It'll * give up when it encounters complex SQL, such as string literals. */ @VisibleForTesting static @Nullable String maybeBalance(@Nullable String sql) { if (sql == null) return null; int count = 0; char literal = '\0'; for (int i = 0; i < sql.length(); i++) { final char c = sql.charAt(i); if (c == '\'' || c == '"') { if (literal == '\0') { // Start literal literal = c; } else if (literal == c) { // End literal literal = '\0'; } } if (literal == '\0') { if (c == '(') { count++; } else if (c == ')') { count--; } } } while (count > 0) { sql = sql + ")"; count--; } while (count < 0) { sql = "(" + sql; count++; } return sql; } static boolean containsAny(Set a, Set b) { for (T i : b) { if (a.contains(i)) { return true; } } return false; } /** * Gracefully recover from abusive callers that are smashing invalid * {@code GROUP BY} clauses into {@code WHERE} clauses. */ @VisibleForTesting static Pair recoverAbusiveGroupBy(Pair selectionAndGroupBy) { final String origSelection = selectionAndGroupBy.first; final String origGroupBy = selectionAndGroupBy.second; final int index = (origSelection != null) ? origSelection.toUpperCase().indexOf(" GROUP BY ") : -1; if (index != -1) { String selection = origSelection.substring(0, index); String groupBy = origSelection.substring(index + " GROUP BY ".length()); // Try balancing things out selection = maybeBalance(selection); groupBy = maybeBalance(groupBy); // Yell if we already had a group by requested if (!TextUtils.isEmpty(origGroupBy)) { throw new IllegalArgumentException( "Abusive '" + groupBy + "' conflicts with requested '" + origGroupBy + "'"); } Log.w(TAG, "Recovered abusive '" + selection + "' and '" + groupBy + "' from '" + origSelection + "'"); return Pair.create(selection, groupBy); } else { return selectionAndGroupBy; } } @VisibleForTesting static @Nullable Uri computeCommonPrefix(@NonNull List uris) { if (uris.isEmpty()) return null; final Uri base = uris.get(0); final List basePath = new ArrayList<>(base.getPathSegments()); for (int i = 1; i < uris.size(); i++) { final List 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(); } @Deprecated private String getCallingPackageOrSelf() { return mCallingIdentity.get().getPackageName(); } @Deprecated private int getCallingPackageTargetSdkVersion() { return mCallingIdentity.get().getTargetSdkVersion(); } @Deprecated private boolean isCallingPackageAllowedHidden() { return isCallingPackageSystem(); } @Deprecated private boolean isCallingPackageSystem() { return mCallingIdentity.get().hasPermission(PERMISSION_IS_SYSTEM); } @Deprecated private boolean isCallingPackageLegacy() { return mCallingIdentity.get().hasPermission(PERMISSION_IS_LEGACY); } @Override public void dump(FileDescriptor fd, PrintWriter writer, String[] args) { final IndentingPrintWriter pw = new IndentingPrintWriter(writer, " "); pw.printPair("mThumbSize", mThumbSize); pw.println(); pw.printPair("mAttachedVolumeNames", mAttachedVolumeNames); pw.println(); pw.println(dump(mInternalDatabase, true)); pw.println(dump(mExternalDatabase, true)); } private String dump(DatabaseHelper dbh, boolean dumpDbLog) { StringBuilder s = new StringBuilder(); s.append(dbh.mName); s.append(": "); SQLiteDatabase db = dbh.getReadableDatabase(); if (db == null) { s.append("null"); } else { s.append("version " + db.getVersion() + ", "); Cursor c = db.query("files", new String[] {"count(*)"}, null, null, null, null, null); try { if (c != null && c.moveToFirst()) { int num = c.getInt(0); s.append(num + " rows, "); } else { s.append("couldn't get row count, "); } } finally { IoUtils.closeQuietly(c); } if (dbh.mScanStartTime != 0) { s.append("scan started " + DateUtils.formatDateTime(getContext(), dbh.mScanStartTime / 1000, DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_ABBREV_ALL)); long now = dbh.mScanStopTime; if (now < dbh.mScanStartTime) { now = SystemClock.currentTimeMicro(); } s.append(" (" + DateUtils.formatElapsedTime( (now - dbh.mScanStartTime) / 1000000) + ")"); if (dbh.mScanStopTime < dbh.mScanStartTime) { if (mMediaScannerVolume != null && dbh.mName.startsWith(mMediaScannerVolume)) { s.append(" (ongoing)"); } else { s.append(" (scanning " + mMediaScannerVolume + ")"); } } } if (dumpDbLog) { c = db.query("log", new String[] {"time", "message"}, null, null, null, null, "rowid"); try { if (c != null) { while (c.moveToNext()) { String when = c.getString(0); String msg = c.getString(1); s.append("\n" + when + " : " + msg); } } } finally { IoUtils.closeQuietly(c); } } else { s.append(": pid=" + android.os.Process.myPid()); s.append(", fingerprint=" + Build.FINGERPRINT); } } return s.toString(); } }