diff options
| author | Xin Li <delphij@google.com> | 2021-10-07 23:50:48 +0000 |
|---|---|---|
| committer | Gerrit Code Review <noreply-gerritcodereview@google.com> | 2021-10-07 23:50:48 +0000 |
| commit | 65c8bd6f10fa52c110caa4e95eef9193e7b604c8 (patch) | |
| tree | e82cb75a38776840eb3954ddefe2782c4c7f4291 /src/com/android | |
| parent | 2fb199ae6dbce46353833a508d33745bd4cff23d (diff) | |
| parent | 8b4f9d9b74f051da6b377ae3147cc36d599afc5f (diff) | |
| download | platform_packages_providers_MediaProvider-master.tar.gz platform_packages_providers_MediaProvider-master.tar.bz2 platform_packages_providers_MediaProvider-master.zip | |
Diffstat (limited to 'src/com/android')
35 files changed, 7829 insertions, 960 deletions
diff --git a/src/com/android/providers/media/DatabaseHelper.java b/src/com/android/providers/media/DatabaseHelper.java index eb3b923e..00bbc7a7 100644 --- a/src/com/android/providers/media/DatabaseHelper.java +++ b/src/com/android/providers/media/DatabaseHelper.java @@ -22,6 +22,7 @@ import static com.android.providers.media.util.Logging.TAG; import android.content.ContentProviderClient; import android.content.ContentResolver; +import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Intent; @@ -35,8 +36,10 @@ import android.mtp.MtpConstants; import android.net.Uri; import android.os.Bundle; import android.os.Environment; +import android.os.RemoteException; import android.os.SystemClock; import android.os.Trace; +import android.os.UserHandle; import android.provider.MediaStore; import android.provider.MediaStore.Audio; import android.provider.MediaStore.Downloads; @@ -59,19 +62,24 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.android.modules.utils.BackgroundThread; +import com.android.providers.media.playlist.Playlist; import com.android.providers.media.util.DatabaseUtils; import com.android.providers.media.util.FileUtils; import com.android.providers.media.util.ForegroundThread; import com.android.providers.media.util.Logging; import com.android.providers.media.util.MimeUtils; +import com.google.common.collect.Iterables; + import java.io.File; +import java.io.FileNotFoundException; import java.io.FilenameFilter; import java.io.IOException; import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collection; +import java.util.List; import java.util.Objects; import java.util.Set; import java.util.UUID; @@ -96,6 +104,8 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { */ public static final String CURRENT_GENERATION_CLAUSE = "SELECT generation FROM local_metadata"; + private static final int NOTIFY_BATCH_SIZE = 256; + final Context mContext; final String mName; final int mVersion; @@ -109,17 +119,11 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { final @Nullable OnLegacyMigrationListener mMigrationListener; final @Nullable UnaryOperator<String> mIdGenerator; final Set<String> mFilterVolumeNames = new ArraySet<>(); + private final String mMigrationFileName; long mScanStartTime; long mScanStopTime; /** - * Flag indicating that this database should invoke - * {@link #migrateFromLegacy} to migrate from a legacy database, typically - * only set when this database is starting from scratch. - */ - boolean mMigrateFromLegacy; - - /** * Lock used to guard against deadlocks in SQLite; the write lock is used to * guard any schema changes, and the read lock is used for all other * database operations. @@ -132,9 +136,12 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { */ private final ReentrantReadWriteLock mSchemaLock = new ReentrantReadWriteLock(); + private static Object sMigrationLockInternal = new Object(); + private static Object sMigrationLockExternal = new Object(); + public interface OnSchemaChangeListener { public void onSchemaChange(@NonNull String volumeName, int versionFrom, int versionTo, - long itemCount, long durationMillis); + long itemCount, long durationMillis, String databaseUuid); } public interface OnFilesChangeListener { @@ -186,6 +193,7 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { mFilesListener = filesListener; mMigrationListener = migrationListener; mIdGenerator = idGenerator; + mMigrationFileName = "." + mVolumeName; // Configure default filters until we hear differently if (mInternal) { @@ -364,9 +372,19 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { @Override public void onOpen(final SQLiteDatabase db) { Log.v(TAG, "onOpen() for " + mName); - if (mMigrateFromLegacy) { - // Clear flag, since we should only attempt once - mMigrateFromLegacy = false; + + tryMigrateFromLegacy(db, mInternal ? sMigrationLockInternal : sMigrationLockExternal); + } + + private void tryMigrateFromLegacy(SQLiteDatabase db, Object migrationLock) { + final File migration = new File(mContext.getFilesDir(), mMigrationFileName); + // Another thread entering migration block will be blocked until the + // migration is complete from current thread. + synchronized (migrationLock) { + if (!migration.exists()) { + Log.v(TAG, "onOpen() finished for " + mName); + return; + } mSchemaLock.writeLock().lock(); try { @@ -376,9 +394,11 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { createLatestIndexes(db, mInternal); } finally { mSchemaLock.writeLock().unlock(); + // Clear flag, since we should only attempt once + migration.delete(); + Log.v(TAG, "onOpen() finished for " + mName); } } - Log.v(TAG, "onOpen() finished for " + mName); } @GuardedBy("mProjectionMapCache") @@ -409,7 +429,7 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { } catch (ReflectiveOperationException e) { throw new RuntimeException(e); } - mProjectionMapCache.put(clazz, map); + mProjectionMapCache.put(clazz, map); } result.putAll(map); } @@ -520,7 +540,6 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { for (int i = 0; i < state.blockingTasks.size(); i++) { state.blockingTasks.get(i).run(); } - // We carefully "phase" our two sets of work here to ensure that we // completely finish dispatching all change notifications before we // process background tasks, to ensure that the background work @@ -635,7 +654,9 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { private void notifyChangeInternal(@NonNull Collection<Uri> uris, int flags) { Trace.beginSection("notifyChange"); try { - mContext.getContentResolver().notifyChange(uris, null, flags); + for (List<Uri> partition : Iterables.partition(uris, NOTIFY_BATCH_SIZE)) { + mContext.getContentResolver().notifyChange(partition, null, flags); + } } finally { Trace.endSection(); } @@ -694,6 +715,11 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { @VisibleForTesting static void makePristineSchema(SQLiteDatabase db) { + // We are dropping all tables and recreating new schema. This + // is a clear indication of major change in MediaStore version. + // Hence reset the Uuid whenever we change the schema. + resetAndGetUuid(db); + // drop all triggers Cursor c = db.query("sqlite_master", new String[] {"name"}, "type is 'trigger'", null, null, null, null); @@ -791,7 +817,11 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { + "writer TEXT DEFAULT NULL, exposure_time TEXT DEFAULT NULL," + "f_number TEXT DEFAULT NULL, iso INTEGER DEFAULT NULL," + "scene_capture_type INTEGER DEFAULT NULL, generation_added INTEGER DEFAULT 0," - + "generation_modified INTEGER DEFAULT 0, xmp BLOB DEFAULT NULL)"); + + "generation_modified INTEGER DEFAULT 0, xmp BLOB DEFAULT NULL," + + "_transcode_status INTEGER DEFAULT 0, _video_codec_type TEXT DEFAULT NULL," + + "_modifier INTEGER DEFAULT 0, is_recording INTEGER DEFAULT 0," + + "redacted_uri_id TEXT DEFAULT NULL, _user_id INTEGER DEFAULT " + + UserHandle.myUserId() + ")"); db.execSQL("CREATE TABLE log (time DATETIME, message TEXT)"); if (!mInternal) { @@ -807,7 +837,11 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { // Since this code is used by both the legacy and modern providers, we // only want to migrate when we're running as the modern provider if (!mLegacyProvider) { - mMigrateFromLegacy = true; + try { + new File(mContext.getFilesDir(), mMigrationFileName).createNewFile(); + } catch (IOException e) { + Log.e(TAG, "Failed to create a migration file: ." + mVolumeName, e); + } } } @@ -838,7 +872,9 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { db.beginTransaction(); Log.d(TAG, "Starting migration from legacy provider"); - mMigrationListener.onStarted(client, mVolumeName); + if (mMigrationListener != null) { + mMigrationListener.onStarted(client, mVolumeName); + } try (Cursor c = client.query(queryUri, sMigrateColumns.toArray(new String[0]), extras, null)) { final ContentValues values = new ContentValues(); @@ -850,24 +886,65 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { final String data = c.getString(c.getColumnIndex(MediaColumns.DATA)); values.put(MediaColumns.DATA, data); FileUtils.computeValuesFromData(values, /*isForFuse*/ false); + final String volumeNameFromPath = values.getAsString(MediaColumns.VOLUME_NAME); for (String column : sMigrateColumns) { DatabaseUtils.copyFromCursorToContentValues(column, c, values); } + final String volumeNameMigrated = values.getAsString(MediaColumns.VOLUME_NAME); + // While upgrading from P OS or below, VOLUME_NAME can be NULL in legacy + // database. When VOLUME_NAME is NULL, extract VOLUME_NAME from + // MediaColumns.DATA + if (volumeNameMigrated == null || volumeNameMigrated.isEmpty()) { + values.put(MediaColumns.VOLUME_NAME, volumeNameFromPath); + } + + final String volumePath = FileUtils.extractVolumePath(data); + + // Handle playlist files which may need special handling if + // there are no "real" playlist files. + final int mediaType = c.getInt(c.getColumnIndex(FileColumns.MEDIA_TYPE)); + if (!mInternal && volumePath != null && + mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) { + File playlistFile = new File(data); + + if (!playlistFile.exists()) { + if (LOGV) Log.v(TAG, "Migrating playlist file " + playlistFile); + + // Migrate virtual playlists to a "real" playlist file. + // Also change playlist file name and path to adapt to new + // default primary directory. + String playlistFilePath = data; + try { + playlistFilePath = migratePlaylistFiles(client, + c.getLong(c.getColumnIndex(FileColumns._ID))); + // Either migration didn't happen or is not necessary because + // playlist file already exists + if (playlistFilePath == null) playlistFilePath = data; + } catch (Exception e) { + // We only have one shot to migrate data, so log and + // keep marching forward. + Log.w(TAG, "Couldn't migrate playlist file " + data); + } + + values.put(FileColumns.DATA, playlistFilePath); + FileUtils.computeValuesFromData(values, /*isForFuse*/ false); + } + } // When migrating pending or trashed files, we might need to // rename them on disk to match new schema - final String volumePath = FileUtils.extractVolumePath(data); if (volumePath != null) { + final String oldData = values.getAsString(MediaColumns.DATA); FileUtils.computeDataFromValues(values, new File(volumePath), /*isForFuse*/ false); final String recomputedData = values.getAsString(MediaColumns.DATA); - if (!Objects.equals(data, recomputedData)) { + if (!Objects.equals(oldData, recomputedData)) { try { - renameWithRetry(data, recomputedData); + renameWithRetry(oldData, recomputedData); } catch (IOException e) { // We only have one shot to migrate data, so log and // keep marching forward - Log.wtf(TAG, "Failed to rename " + values + "; continuing", e); + Log.w(TAG, "Failed to rename " + values + "; continuing", e); FileUtils.computeValuesFromData(values, /*isForFuse*/ false); } } @@ -890,7 +967,9 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { final int progress = c.getPosition(); final int total = c.getCount(); Log.v(TAG, "Migrated " + progress + " of " + total + "..."); - mMigrationListener.onProgress(client, mVolumeName, progress, total); + if (mMigrationListener != null) { + mMigrationListener.onProgress(client, mVolumeName, progress, total); + } } } @@ -898,18 +977,149 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { } catch (Exception e) { // We have to guard ourselves against any weird behavior of the // legacy provider by trying to catch everything - Log.wtf(TAG, "Failed migration from legacy provider", e); + Log.w(TAG, "Failed migration from legacy provider", e); } // We tried our best above to migrate everything we could, and we // only have one possible shot, so mark everything successful db.setTransactionSuccessful(); db.endTransaction(); - mMigrationListener.onFinished(client, mVolumeName); + if (mMigrationListener != null) { + mMigrationListener.onFinished(client, mVolumeName); + } + } + + } + + @Nullable + private String migratePlaylistFiles(ContentProviderClient client, long playlistId) + throws IllegalStateException { + final String selection = FileColumns.MEDIA_TYPE + "=" + FileColumns.MEDIA_TYPE_PLAYLIST + + " AND " + FileColumns._ID + "=" + playlistId; + final String[] projection = new String[]{ + FileColumns._ID, + FileColumns.DATA, + MediaColumns.MIME_TYPE, + MediaStore.Audio.PlaylistsColumns.NAME, + }; + final Uri queryUri = MediaStore + .rewriteToLegacy(MediaStore.Files.getContentUri(mVolumeName)); + + try (Cursor cursor = client.query(queryUri, projection, selection, null, null)) { + if (!cursor.moveToFirst()) { + throw new IllegalStateException("Couldn't find database row for playlist file" + + playlistId); + } + + final String data = cursor.getString(cursor.getColumnIndex(MediaColumns.DATA)); + File playlistFile = new File(data); + if (playlistFile.exists()) { + throw new IllegalStateException("Playlist file exists " + data); + } + + String mimeType = cursor.getString(cursor.getColumnIndex(MediaColumns.MIME_TYPE)); + // Sometimes, playlists in Q may have mimeType as + // "application/octet-stream". Ensure that playlist rows have the + // right playlist mimeType. These rows will be committed to a file + // and hence they should have correct playlist mimeType for + // Playlist#write to identify the right child playlist class. + if (!MimeUtils.isPlaylistMimeType(mimeType)) { + // Playlist files should always have right mimeType, default to + // audio/mpegurl when mimeType doesn't match playlist media_type. + mimeType = "audio/mpegurl"; + } + + // If the directory is Playlists/ change the directory to Music/ + // since defaultPrimary for playlists is Music/. This helps + // resolve any future app-compat issues around renaming playlist + // files. + File parentFile = playlistFile.getParentFile(); + if (parentFile.getName().equalsIgnoreCase("Playlists")) { + parentFile = new File(parentFile.getParentFile(), Environment.DIRECTORY_MUSIC); + } + final String playlistName = cursor.getString( + cursor.getColumnIndex(MediaStore.Audio.PlaylistsColumns.NAME)); + + try { + // Build playlist file path with a file extension that matches + // playlist mimeType. + playlistFile = FileUtils.buildUniqueFile(parentFile, mimeType, playlistName); + } catch(FileNotFoundException e) { + Log.e(TAG, "Couldn't create unique file for " + playlistFile + + ", using actual playlist file name", e); + } + + final long rowId = cursor.getLong(cursor.getColumnIndex(FileColumns._ID)); + final Uri playlistMemberUri = MediaStore.rewriteToLegacy( + MediaStore.Audio.Playlists.Members.getContentUri(mVolumeName, rowId)); + createPlaylistFile(client, playlistMemberUri, playlistFile); + return playlistFile.getAbsolutePath(); + } catch (RemoteException e) { + throw new IllegalStateException(e); + } + } + + /** + * Creates "real" playlist files on disk from the playlist data from the database. + */ + private void createPlaylistFile(ContentProviderClient client, @NonNull Uri playlistMemberUri, + @NonNull File playlistFile) throws IllegalStateException { + final String[] projection = new String[] { + MediaStore.Audio.Playlists.Members.AUDIO_ID, + MediaStore.Audio.Playlists.Members.PLAY_ORDER, + }; + + final Playlist playlist = new Playlist(); + // Migrating music->playlist association. + try (Cursor c = client.query(playlistMemberUri, projection, null, null, + Audio.Playlists.Members.DEFAULT_SORT_ORDER)) { + while (c.moveToNext()) { + // Write these values to the playlist file + final long audioId = c.getLong(0); + final int playOrder = c.getInt(1); + + final Uri audioFileUri = MediaStore.rewriteToLegacy(ContentUris.withAppendedId( + MediaStore.Files.getContentUri(mVolumeName), audioId)); + final String audioFilePath = queryForData(client, audioFileUri); + if (audioFilePath == null) { + // This shouldn't happen, we should always find audio file + // unless audio file is removed, and database has stale db + // row. However this shouldn't block creating playlist + // files; + Log.e(TAG, "Couldn't find audio file for " + audioId + ", continuing.."); + continue; + } + playlist.add(playOrder, playlistFile.toPath().getParent(). + relativize(new File(audioFilePath).toPath())); + } + + try { + writeToPlaylistFileWithRetry(playlistFile, playlist); + } catch (IOException e) { + // We only have one shot to migrate data, so log and + // keep marching forward. + Log.w(TAG, "Couldn't migrate playlist file " + playlistFile); + } + } catch (RemoteException e) { + throw new IllegalStateException(e); } } /** + * Return the {@link MediaColumns#DATA} field for the given {@code uri}. + */ + private String queryForData(ContentProviderClient client, @NonNull Uri uri) { + try (Cursor c = client.query(uri, new String[] {FileColumns.DATA}, Bundle.EMPTY, null)) { + if (c.moveToFirst()) { + return c.getString(0); + } + } catch (Exception e) { + Log.w(TAG, "Exception occurred while querying for data file for " + uri, e); + } + return null; + } + + /** * Set of columns that should be migrated from the legacy provider, * including core information to identify each media item, followed by * columns that can be edited by users. (We omit columns here that are @@ -931,12 +1141,19 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { sMigrateColumns.add(MediaStore.MediaColumns.IS_FAVORITE); sMigrateColumns.add(MediaStore.MediaColumns.OWNER_PACKAGE_NAME); + sMigrateColumns.add(MediaStore.MediaColumns.ORIENTATION); + sMigrateColumns.add(MediaStore.Files.FileColumns.PARENT); + sMigrateColumns.add(MediaStore.Audio.AudioColumns.BOOKMARK); sMigrateColumns.add(MediaStore.Video.VideoColumns.TAGS); sMigrateColumns.add(MediaStore.Video.VideoColumns.CATEGORY); sMigrateColumns.add(MediaStore.Video.VideoColumns.BOOKMARK); + // This also migrates MediaStore.Images.ImageColumns.IS_PRIVATE + // as they both have the same value "isprivate". + sMigrateColumns.add(MediaStore.Video.VideoColumns.IS_PRIVATE); + sMigrateColumns.add(MediaStore.DownloadColumns.DOWNLOAD_URI); sMigrateColumns.add(MediaStore.DownloadColumns.REFERER_URI); } @@ -966,7 +1183,7 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { if (!internal) { db.execSQL("CREATE VIEW audio_playlists AS SELECT " - + String.join(",", getProjectionMap(Audio.Playlists.class).keySet()) + + getColumnsForCollection(Audio.Playlists.class) + " FROM files WHERE media_type=4"); } @@ -990,16 +1207,16 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { + "3 AS grouporder FROM searchhelpertitle WHERE (title != '')"); db.execSQL("CREATE VIEW audio AS SELECT " - + String.join(",", getProjectionMap(Audio.Media.class).keySet()) + + getColumnsForCollection(Audio.Media.class) + " FROM files WHERE media_type=2"); db.execSQL("CREATE VIEW video AS SELECT " - + String.join(",", getProjectionMap(Video.Media.class).keySet()) + + getColumnsForCollection(Video.Media.class) + " FROM files WHERE media_type=3"); db.execSQL("CREATE VIEW images AS SELECT " - + String.join(",", getProjectionMap(Images.Media.class).keySet()) + + getColumnsForCollection(Images.Media.class) + " FROM files WHERE media_type=1"); db.execSQL("CREATE VIEW downloads AS SELECT " - + String.join(",", getProjectionMap(Downloads.class).keySet()) + + getColumnsForCollection(Downloads.class) + " FROM files WHERE is_download=1"); db.execSQL("CREATE VIEW audio_artists AS SELECT " @@ -1009,9 +1226,29 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { + ", COUNT(DISTINCT album_id) AS " + Audio.Artists.NUMBER_OF_ALBUMS + ", COUNT(DISTINCT _id) AS " + Audio.Artists.NUMBER_OF_TRACKS + " FROM audio" - + " WHERE is_music=1 AND volume_name IN " + filterVolumeNames + + " WHERE is_music=1 AND is_pending=0 AND is_trashed=0" + + " AND volume_name IN " + filterVolumeNames + " GROUP BY artist_id"); + db.execSQL("CREATE VIEW audio_artists_albums AS SELECT " + + " album_id AS " + Audio.Albums._ID + + ", album_id AS " + Audio.Albums.ALBUM_ID + + ", MIN(album) AS " + Audio.Albums.ALBUM + + ", album_key AS " + Audio.Albums.ALBUM_KEY + + ", artist_id AS " + Audio.Albums.ARTIST_ID + + ", artist AS " + Audio.Albums.ARTIST + + ", artist_key AS " + Audio.Albums.ARTIST_KEY + + ", (SELECT COUNT(*) FROM audio WHERE " + Audio.Albums.ALBUM_ID + + " = TEMP.album_id) AS " + Audio.Albums.NUMBER_OF_SONGS + + ", COUNT(DISTINCT _id) AS " + Audio.Albums.NUMBER_OF_SONGS_FOR_ARTIST + + ", MIN(year) AS " + Audio.Albums.FIRST_YEAR + + ", MAX(year) AS " + Audio.Albums.LAST_YEAR + + ", NULL AS " + Audio.Albums.ALBUM_ART + + " FROM audio TEMP" + + " WHERE is_music=1 AND is_pending=0 AND is_trashed=0" + + " AND volume_name IN " + filterVolumeNames + + " GROUP BY album_id, artist_id"); + db.execSQL("CREATE VIEW audio_albums AS SELECT " + " album_id AS " + Audio.Albums._ID + ", album_id AS " + Audio.Albums.ALBUM_ID @@ -1026,17 +1263,22 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { + ", MAX(year) AS " + Audio.Albums.LAST_YEAR + ", NULL AS " + Audio.Albums.ALBUM_ART + " FROM audio" - + " WHERE is_music=1 AND volume_name IN " + filterVolumeNames + + " WHERE is_music=1 AND is_pending=0 AND is_trashed=0" + + " AND volume_name IN " + filterVolumeNames + " GROUP BY album_id"); db.execSQL("CREATE VIEW audio_genres AS SELECT " + " genre_id AS " + Audio.Genres._ID + ", MIN(genre) AS " + Audio.Genres.NAME + " FROM audio" - + " WHERE volume_name IN " + filterVolumeNames + + " WHERE is_pending=0 AND is_trashed=0 AND volume_name IN " + filterVolumeNames + " GROUP BY genre_id"); } + private String getColumnsForCollection(Class<?> collection) { + return String.join(",", getProjectionMap(collection).keySet()) + ",_modifier"; + } + private static void makePristineTriggers(SQLiteDatabase db) { // drop all triggers Cursor c = db.query("sqlite_master", new String[] {"name"}, "type is 'trigger'", @@ -1167,6 +1409,16 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { db.execSQL("ALTER TABLE files ADD COLUMN is_audiobook INTEGER DEFAULT 0;"); } + private static void updateAddRecording(SQLiteDatabase db, boolean internal) { + db.execSQL("ALTER TABLE files ADD COLUMN is_recording INTEGER DEFAULT 0;"); + // We add the column is_recording, rescan all music files + db.execSQL("UPDATE files SET date_modified=0 WHERE is_music=1;"); + } + + private static void updateAddRedactedUriId(SQLiteDatabase db) { + db.execSQL("ALTER TABLE files ADD COLUMN redacted_uri_id TEXT DEFAULT NULL;"); + } + private static void updateClearLocation(SQLiteDatabase db, boolean internal) { db.execSQL("UPDATE files SET latitude=NULL, longitude=NULL;"); } @@ -1216,6 +1468,15 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { + " AND " + MediaColumns.RELATIVE_PATH + " NOT LIKE '%/';"); } + private static void updateAddTranscodeSatus(SQLiteDatabase db, boolean internal) { + db.execSQL("ALTER TABLE files ADD COLUMN _transcode_status INTEGER DEFAULT 0;"); + } + + + private static void updateAddVideoCodecType(SQLiteDatabase db, boolean internal) { + db.execSQL("ALTER TABLE files ADD COLUMN _video_codec_type TEXT DEFAULT NULL;"); + } + private static void updateClearDirectories(SQLiteDatabase db, boolean internal) { db.execSQL("UPDATE files SET primary_directory=NULL, secondary_directory=NULL;"); } @@ -1295,6 +1556,17 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { db.execSQL("UPDATE files SET date_modified=0 WHERE media_type=2;"); } + private static void updateAddModifier(SQLiteDatabase db, boolean internal) { + db.execSQL("ALTER TABLE files ADD COLUMN _modifier INTEGER DEFAULT 0;"); + // For existing files, set default value as _MODIFIER_MEDIA_SCAN + db.execSQL("UPDATE files SET _modifier=3;"); + } + + private void updateUserId(SQLiteDatabase db) { + db.execSQL(String.format("ALTER TABLE files ADD COLUMN _user_id INTEGER DEFAULT %d;", + UserHandle.myUserId())); + } + 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)) { @@ -1320,26 +1592,30 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { final String selection = FileColumns.MEDIA_TYPE + "=?"; final String[] selectionArgs = new String[]{String.valueOf(FileColumns.MEDIA_TYPE_NONE)}; + ArrayMap<Long, Integer> newMediaTypes = new ArrayMap<>(); try (Cursor c = db.query("files", new String[] { FileColumns._ID, FileColumns.MIME_TYPE }, selection, selectionArgs, null, null, null, null)) { Log.d(TAG, "Recomputing " + c.getCount() + " MediaType values"); - final ContentValues values = new ContentValues(); + // Accumulate all the new MEDIA_TYPE updates. while (c.moveToNext()) { - values.clear(); final long id = c.getLong(0); final String mimeType = c.getString(1); // Only update Document and Subtitle media type - if (MimeUtils.isDocumentMimeType(mimeType)) { - values.put(FileColumns.MEDIA_TYPE, FileColumns.MEDIA_TYPE_DOCUMENT); - } else if (MimeUtils.isSubtitleMimeType(mimeType)) { - values.put(FileColumns.MEDIA_TYPE, FileColumns.MEDIA_TYPE_SUBTITLE); - } - if (!values.isEmpty()) { - db.update("files", values, "_id=" + id, null); + if (MimeUtils.isSubtitleMimeType(mimeType)) { + newMediaTypes.put(id, FileColumns.MEDIA_TYPE_SUBTITLE); + } else if (MimeUtils.isDocumentMimeType(mimeType)) { + newMediaTypes.put(id, FileColumns.MEDIA_TYPE_DOCUMENT); } } } + // Now, update all the new MEDIA_TYPE values. + final ContentValues values = new ContentValues(); + for (long id: newMediaTypes.keySet()) { + values.clear(); + values.put(FileColumns.MEDIA_TYPE, newMediaTypes.get(id)); + db.update("files", values, "_id=" + id, null); + } } static final int VERSION_J = 509; @@ -1351,7 +1627,10 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { static final int VERSION_P = 900; static final int VERSION_Q = 1023; static final int VERSION_R = 1115; - static final int VERSION_LATEST = VERSION_R; + // Leave some gaps in database version tagging to allow R schema changes + // to go independent of S schema changes. + static final int VERSION_S = 1209; + static final int VERSION_LATEST = VERSION_S; /** * This method takes care of updating all the tables in the database to the @@ -1496,6 +1775,36 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { if (fromVersion < 1115) { updateAudioAlbumId(db, internal); } + if (fromVersion < 1200) { + updateAddTranscodeSatus(db, internal); + } + if (fromVersion < 1201) { + updateAddVideoCodecType(db, internal); + } + if (fromVersion < 1202) { + updateAddModifier(db, internal); + } + if (fromVersion < 1203) { + // Empty version bump to ensure views are recreated + } + if (fromVersion < 1204) { + // Empty version bump to ensure views are recreated + } + if (fromVersion < 1205) { + updateAddRecording(db, internal); + } + if (fromVersion < 1206) { + // Empty version bump to ensure views are recreated + } + if (fromVersion < 1207) { + updateAddRedactedUriId(db); + } + if (fromVersion < 1208) { + updateUserId(db); + } + if (fromVersion < 1209) { + // Empty version bump to ensure views are recreated + } // If this is the legacy database, it's not worth recomputing data // values locally, since they'll be recomputed after the migration @@ -1518,7 +1827,7 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { final long elapsedMillis = (SystemClock.elapsedRealtime() - startTime); if (mSchemaListener != null) { mSchemaListener.onSchemaChange(mVolumeName, fromVersion, toVersion, - getItemCount(db), elapsedMillis); + getItemCount(db), elapsedMillis, getOrCreateUuid(db)); } } @@ -1531,7 +1840,7 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { final long elapsedMillis = (SystemClock.elapsedRealtime() - startTime); if (mSchemaListener != null) { mSchemaListener.onSchemaChange(mVolumeName, fromVersion, toVersion, - getItemCount(db), elapsedMillis); + getItemCount(db), elapsedMillis, getOrCreateUuid(db)); } } @@ -1547,20 +1856,51 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { } 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; + return resetAndGetUuid(db); } else { throw new RuntimeException(e); } } } - private static final long RENAME_TIMEOUT = 10 * DateUtils.SECOND_IN_MILLIS; + private static @NonNull String resetAndGetUuid(SQLiteDatabase db) { + final String uuid = UUID.randomUUID().toString(); + try { + Os.setxattr(db.getPath(), XATTR_UUID, uuid.getBytes(), 0); + } catch (ErrnoException e) { + throw new RuntimeException(e); + } + return uuid; + } + + private static final long PASSTHROUGH_WAIT_TIMEOUT = 10 * DateUtils.SECOND_IN_MILLIS; + + /** + * When writing to playlist files during migration, the underlying + * pass-through view of storage may not be mounted yet, so we're willing + * to retry several times before giving up. + * The retry logic is mainly added to avoid test flakiness. + */ + private static void writeToPlaylistFileWithRetry(@NonNull File playlistFile, + @NonNull Playlist playlist) throws IOException { + final long start = SystemClock.elapsedRealtime(); + while (true) { + if (SystemClock.elapsedRealtime() - start > PASSTHROUGH_WAIT_TIMEOUT) { + throw new IOException("Passthrough failed to mount"); + } + + try { + playlistFile.getParentFile().mkdirs(); + playlistFile.createNewFile(); + playlist.write(playlistFile); + return; + } catch (IOException e) { + Log.i(TAG, "Failed to migrate playlist file, retrying " + e); + } + Log.i(TAG, "Waiting for passthrough to be mounted..."); + SystemClock.sleep(100); + } + } /** * When renaming files during migration, the underlying pass-through view of @@ -1571,7 +1911,7 @@ public class DatabaseHelper extends SQLiteOpenHelper implements AutoCloseable { throws IOException { final long start = SystemClock.elapsedRealtime(); while (true) { - if (SystemClock.elapsedRealtime() - start > RENAME_TIMEOUT) { + if (SystemClock.elapsedRealtime() - start > PASSTHROUGH_WAIT_TIMEOUT) { throw new IOException("Passthrough failed to mount"); } diff --git a/src/com/android/providers/media/FileLookupResult.java b/src/com/android/providers/media/FileLookupResult.java new file mode 100644 index 00000000..f0e6bfbe --- /dev/null +++ b/src/com/android/providers/media/FileLookupResult.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2021 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; + +/** + * Wrapper class which contains transforms, transforms completion status and ioPath for transform + * lookup query for a file and uid pair. + */ +public final class FileLookupResult { + public final int transforms; + public final int transformsReason; + public final int uid; + public final boolean transformsComplete; + public final boolean transformsSupported; + public final String ioPath; + + public FileLookupResult(int transforms, int transformsReason, int uid, + boolean transformsComplete, boolean transformsSupported, String ioPath) { + this.transforms = transforms; + this.transformsReason = transformsReason; + this.uid = uid; + this.transformsComplete = transformsComplete; + this.transformsSupported = transformsSupported; + this.ioPath = ioPath; + } +} diff --git a/src/com/android/providers/media/FileOpenResult.java b/src/com/android/providers/media/FileOpenResult.java new file mode 100644 index 00000000..1052f98a --- /dev/null +++ b/src/com/android/providers/media/FileOpenResult.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2021 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; + +/** + * Wrapper class which contains the result of an open. + */ +public final class FileOpenResult { + public final int status; + public final int uid; + public final int transformsUid; + public final long[] redactionRanges; + + public FileOpenResult(int status, int uid, int transformsUid, long[] redactionRanges) { + this.status = status; + this.uid = uid; + this.transformsUid = transformsUid; + this.redactionRanges = redactionRanges; + } +} diff --git a/src/com/android/providers/media/IdleService.java b/src/com/android/providers/media/IdleService.java index 11fe7a0c..213d2c18 100644 --- a/src/com/android/providers/media/IdleService.java +++ b/src/com/android/providers/media/IdleService.java @@ -51,6 +51,11 @@ public class IdleService extends JobService { @Override public boolean onStopJob(JobParameters params) { mSignal.cancel(); + try (ContentProviderClient cpc = getContentResolver() + .acquireContentProviderClient(MediaStore.AUTHORITY)) { + ((MediaProvider) cpc.getLocalContentProvider()).onIdleMaintenanceStopped(); + } catch (OperationCanceledException ignored) { + } return false; } diff --git a/src/com/android/providers/media/LocalCallingIdentity.java b/src/com/android/providers/media/LocalCallingIdentity.java index 4d3700b2..61914574 100644 --- a/src/com/android/providers/media/LocalCallingIdentity.java +++ b/src/com/android/providers/media/LocalCallingIdentity.java @@ -17,12 +17,16 @@ package com.android.providers.media; import static android.Manifest.permission.ACCESS_MEDIA_LOCATION; +import static android.Manifest.permission.MANAGE_EXTERNAL_STORAGE; import static android.app.AppOpsManager.MODE_ALLOWED; import static android.app.AppOpsManager.permissionToOp; import static android.content.pm.PackageManager.PERMISSION_DENIED; +import static com.android.providers.media.util.PermissionUtils.checkAppOpRequestInstallPackagesForSharedUid; import static com.android.providers.media.util.PermissionUtils.checkIsLegacyStorageGranted; +import static com.android.providers.media.util.PermissionUtils.checkPermissionAccessMtp; import static com.android.providers.media.util.PermissionUtils.checkPermissionDelegator; +import static com.android.providers.media.util.PermissionUtils.checkPermissionInstallPackages; import static com.android.providers.media.util.PermissionUtils.checkPermissionManager; import static com.android.providers.media.util.PermissionUtils.checkPermissionReadAudio; import static com.android.providers.media.util.PermissionUtils.checkPermissionReadImages; @@ -39,6 +43,9 @@ import static com.android.providers.media.util.PermissionUtils.checkWriteImagesO import android.annotation.Nullable; import android.app.AppOpsManager; import android.app.compat.CompatChanges; +import android.compat.annotation.ChangeId; +import android.compat.annotation.EnabledAfter; +import android.compat.annotation.EnabledSince; import android.content.ContentProvider; import android.content.Context; import android.content.pm.ApplicationInfo; @@ -51,23 +58,31 @@ import android.os.UserHandle; import android.os.UserManager; import android.util.ArrayMap; +import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; +import com.android.modules.utils.build.SdkLevel; import com.android.providers.media.util.LongArray; +import com.android.providers.media.util.UserCache; + +import java.util.Locale; public class LocalCallingIdentity { - public final Context context; public final int pid; public final int uid; - public final String packageNameUnchecked; + private final UserHandle user; + private final Context context; + private final String packageNameUnchecked; // Info used for logging permission checks - public @Nullable String attributionTag; + private final @Nullable String attributionTag; + private final Object lock = new Object(); - private LocalCallingIdentity(Context context, int pid, int uid, String packageNameUnchecked, - @Nullable String attributionTag) { + private LocalCallingIdentity(Context context, int pid, int uid, UserHandle user, + String packageNameUnchecked, @Nullable String attributionTag) { this.context = context; this.pid = pid; this.uid = uid; + this.user = user; this.packageNameUnchecked = packageNameUnchecked; this.attributionTag = attributionTag; } @@ -84,25 +99,54 @@ public class LocalCallingIdentity { private static final long UNKNOWN_ROW_ID = -1; - public static LocalCallingIdentity fromBinder(Context context, ContentProvider provider) { + public static LocalCallingIdentity fromBinder(Context context, ContentProvider provider, + UserCache userCache) { String callingPackage = provider.getCallingPackageUnchecked(); + int binderUid = Binder.getCallingUid(); if (callingPackage == null) { + if (binderUid == Process.SYSTEM_UID) { + // If UID is system assume we are running as ourself and not handling IPC + // Otherwise, we'd crash when we attempt AppOpsManager#checkPackage + // in LocalCallingIdentity#getPackageName + return fromSelf(context); + } callingPackage = context.getOpPackageName(); } String callingAttributionTag = provider.getCallingAttributionTag(); if (callingAttributionTag == null) { callingAttributionTag = context.getAttributionTag(); } - return new LocalCallingIdentity(context, Binder.getCallingPid(), Binder.getCallingUid(), - callingPackage, callingAttributionTag); + UserHandle user; + if (binderUid == Process.SHELL_UID || binderUid == Process.ROOT_UID) { + // For requests coming from the shell (eg `content query`), assume they are + // for the user we are running as. + user = Process.myUserHandle(); + } else { + user = UserHandle.getUserHandleForUid(binderUid); + } + // We need to use the cached variant here, because the uncached version may + // make a binder transaction, which would cause infinite recursion here. + // Using the cached variant is fine, because we shouldn't be getting any binder + // requests for this volume before it has been mounted anyway, at which point + // we must already know about the new user. + if (!userCache.userSharesMediaWithParentCached(user)) { + // It's possible that we got a cross-profile intent from a regular work profile; in + // that case, the request was explicitly targeted at the media database of the owner + // user; reflect that here. + user = Process.myUserHandle(); + } + return new LocalCallingIdentity(context, Binder.getCallingPid(), binderUid, + user, callingPackage, callingAttributionTag); } - public static LocalCallingIdentity fromExternal(Context context, int uid) { + public static LocalCallingIdentity fromExternal(Context context, @Nullable UserCache userCache, + int uid) { final String[] sharedPackageNames = context.getPackageManager().getPackagesForUid(uid); if (sharedPackageNames == null || sharedPackageNames.length == 0) { throw new IllegalArgumentException("UID " + uid + " has no associated package"); } - LocalCallingIdentity ident = fromExternal(context, uid, sharedPackageNames[0], null); + LocalCallingIdentity ident = fromExternal(context, userCache, uid, sharedPackageNames[0], + null); ident.sharedPackageNames = sharedPackageNames; ident.sharedPackageNamesResolved = true; if (uid == Process.SHELL_UID) { @@ -112,19 +156,34 @@ public class LocalCallingIdentity { ident.hasPermissionResolved = PERMISSION_IS_REDACTION_NEEDED; } } + return ident; } - public static LocalCallingIdentity fromExternal(Context context, int uid, String packageName, - @Nullable String attributionTag) { - return new LocalCallingIdentity(context, -1, uid, packageName, attributionTag); + public static LocalCallingIdentity fromExternal(Context context, @Nullable UserCache userCache, + int uid, String packageName, @Nullable String attributionTag) { + UserHandle user = UserHandle.getUserHandleForUid(uid); + if (userCache != null && !userCache.userSharesMediaWithParentCached(user)) { + // This can happen on some proprietary app clone solutions, where the owner + // and clone user each have their own MediaProvider instance, but refer to + // each other for cross-user file access through the use of bind mounts. + // In this case, assume the access is for the owner user, since that is + // the only user for which we manage volumes anyway. + user = Process.myUserHandle(); + } + return new LocalCallingIdentity(context, -1, uid, user, packageName, attributionTag); } public static LocalCallingIdentity fromSelf(Context context) { + return fromSelfAsUser(context, Process.myUserHandle()); + } + + public static LocalCallingIdentity fromSelfAsUser(Context context, UserHandle user) { final LocalCallingIdentity ident = new LocalCallingIdentity( context, android.os.Process.myPid(), android.os.Process.myUid(), + user, context.getOpPackageName(), context.getAttributionTag()); @@ -133,6 +192,8 @@ public class LocalCallingIdentity { // Use ident.attributionTag from context, hence no change ident.targetSdkVersion = Build.VERSION_CODES.CUR_DEVELOPMENT; ident.targetSdkVersionResolved = true; + ident.shouldBypass = false; + ident.shouldBypassResolved = true; ident.hasPermission = ~(PERMISSION_IS_LEGACY_GRANTED | PERMISSION_IS_LEGACY_WRITE | PERMISSION_IS_LEGACY_READ | PERMISSION_IS_REDACTION_NEEDED | PERMISSION_IS_SHELL | PERMISSION_IS_DELEGATOR); @@ -140,8 +201,8 @@ public class LocalCallingIdentity { return ident; } - private String packageName; - private boolean packageNameResolved; + private volatile String packageName; + private volatile boolean packageNameResolved; public String getPackageName() { if (!packageNameResolved) { @@ -158,8 +219,8 @@ public class LocalCallingIdentity { return packageNameUnchecked; } - private String[] sharedPackageNames; - private boolean sharedPackageNamesResolved; + private volatile String[] sharedPackageNames; + private volatile boolean sharedPackageNamesResolved; public String[] getSharedPackageNames() { if (!sharedPackageNamesResolved) { @@ -174,8 +235,8 @@ public class LocalCallingIdentity { return (packageNames != null) ? packageNames : new String[0]; } - private int targetSdkVersion; - private boolean targetSdkVersionResolved; + private volatile int targetSdkVersion; + private volatile boolean targetSdkVersionResolved; public int getTargetSdkVersion() { if (!targetSdkVersionResolved) { @@ -197,6 +258,10 @@ public class LocalCallingIdentity { return Build.VERSION_CODES.CUR_DEVELOPMENT; } + public UserHandle getUser() { + return user; + } + public static final int PERMISSION_IS_SELF = 1 << 0; public static final int PERMISSION_IS_SHELL = 1 << 1; public static final int PERMISSION_IS_MANAGER = 1 << 2; @@ -214,10 +279,21 @@ public class LocalCallingIdentity { public static final int PERMISSION_WRITE_VIDEO = 1 << 20; public static final int PERMISSION_WRITE_IMAGES = 1 << 21; - public static final int PERMISSION_IS_SYSTEM_GALLERY = 1 <<22; + public static final int PERMISSION_IS_SYSTEM_GALLERY = 1 << 22; + /** + * Explicitly checks **only** for INSTALL_PACKAGES runtime permission. + */ + public static final int PERMISSION_INSTALL_PACKAGES = 1 << 23; + public static final int PERMISSION_WRITE_EXTERNAL_STORAGE = 1 << 24; + + /** + * Checks if REQUEST_INSTALL_PACKAGES app-op is allowed for any package sharing this UID. + */ + public static final int APPOP_REQUEST_INSTALL_PACKAGES_FOR_SHARED_UID = 1 << 25; + public static final int PERMISSION_ACCESS_MTP = 1 << 26; - private int hasPermission; - private int hasPermissionResolved; + private volatile int hasPermission; + private volatile int hasPermissionResolved; public boolean hasPermission(int permission) { if ((hasPermissionResolved & permission) == 0) { @@ -256,6 +332,10 @@ public class LocalCallingIdentity { case PERMISSION_IS_LEGACY_WRITE: return isLegacyWriteInternal(); + case PERMISSION_WRITE_EXTERNAL_STORAGE: + return checkPermissionWriteStorage( + context, pid, uid, getPackageName(), attributionTag); + case PERMISSION_READ_AUDIO: return checkPermissionReadAudio( context, pid, uid, getPackageName(), attributionTag); @@ -277,6 +357,15 @@ public class LocalCallingIdentity { case PERMISSION_IS_SYSTEM_GALLERY: return checkWriteImagesOrVideoAppOps( context, uid, getPackageName(), attributionTag); + case PERMISSION_INSTALL_PACKAGES: + return checkPermissionInstallPackages( + context, pid, uid, getPackageName(), attributionTag); + case APPOP_REQUEST_INSTALL_PACKAGES_FOR_SHARED_UID: + return checkAppOpRequestInstallPackagesForSharedUid( + context, uid, getSharedPackageNames(), attributionTag); + case PERMISSION_ACCESS_MTP: + return checkPermissionAccessMtp( + context, pid, uid, getPackageName(), attributionTag); default: return false; } @@ -297,7 +386,84 @@ public class LocalCallingIdentity { return true; } - return checkIsLegacyStorageGranted(context, uid, getPackageName()); + return checkIsLegacyStorageGranted(context, uid, getPackageName(), attributionTag); + } + + private volatile boolean shouldBypass; + private volatile boolean shouldBypassResolved; + + /** + * Allow apps holding {@link android.Manifest.permission#MANAGE_EXTERNAL_STORAGE} + * permission to request raw external storage access. + */ + @ChangeId + @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.R) + static final long ENABLE_RAW_MANAGE_EXTERNAL_STORAGE_ACCESS = 178209446L; + + /** + * Allow apps holding {@link android.app.role}#SYSTEM_GALLERY role to request raw external + * storage access. + */ + @ChangeId + @EnabledSince(targetSdkVersion = Build.VERSION_CODES.R) + static final long ENABLE_RAW_SYSTEM_GALLERY_ACCESS = 183372781L; + + /** + * Checks if app chooses to bypass database operations. + * + * <p> + * Note that this method doesn't check if app qualifies to bypass database operations. + * + * @return {@code true} if AndroidManifest.xml of this app has + * android:requestRawExternalStorageAccess=true + * {@code false} otherwise. + */ + public boolean shouldBypassDatabase(boolean isSystemGallery) { + if (!shouldBypassResolved) { + shouldBypass = shouldBypassDatabaseInternal(isSystemGallery); + shouldBypassResolved = true; + } + return shouldBypass; + } + + private boolean shouldBypassDatabaseInternal(boolean isSystemGallery) { + if (!SdkLevel.isAtLeastS()) { + // We need to parse the manifest flag ourselves here. + // TODO(b/178209446): Parse app manifest to get new flag values + return true; + } + + final ApplicationInfo ai; + try { + ai = context.getPackageManager() + .getApplicationInfo(getPackageName(), 0); + if (ai != null) { + final int requestRawExternalStorageValue + = ai.getRequestRawExternalStorageAccess(); + if (requestRawExternalStorageValue + != ApplicationInfo.RAW_EXTERNAL_STORAGE_ACCESS_DEFAULT) { + return requestRawExternalStorageValue + == ApplicationInfo.RAW_EXTERNAL_STORAGE_ACCESS_REQUESTED; + } + // Manifest flag is not set, hence return default value based on the category of the + // app and targetSDK. + if (isSystemGallery) { + if (CompatChanges.isChangeEnabled( + ENABLE_RAW_SYSTEM_GALLERY_ACCESS, uid)) { + // If systemGallery, then the flag will default to false when they are + // targeting targetSDK>=30. + return false; + } + } else if (CompatChanges.isChangeEnabled( + ENABLE_RAW_MANAGE_EXTERNAL_STORAGE_ACCESS, uid)) { + // If app has MANAGE_EXTERNAL_STORAGE, the flag will default to false when they + // are targeting targetSDK>=31. + return false; + } + } + } catch (NameNotFoundException e) { + } + return true; } private boolean isScopedStorageEnforced(boolean defaultScopedStorage, @@ -336,42 +502,70 @@ public class LocalCallingIdentity { return false; } - private LongArray ownedIds = new LongArray(); + @GuardedBy("lock") + private final LongArray ownedIds = new LongArray(); public boolean isOwned(long id) { - return ownedIds.indexOf(id) != -1; + synchronized (lock) { + return ownedIds.indexOf(id) != -1; + } } public void setOwned(long id, boolean owned) { - final int index = ownedIds.indexOf(id); - if (owned) { - if (index == -1) { - ownedIds.add(id); - } - } else { - if (index != -1) { - ownedIds.remove(index); + synchronized (lock) { + final int index = ownedIds.indexOf(id); + if (owned) { + if (index == -1) { + ownedIds.add(id); + } + } else { + if (index != -1) { + ownedIds.remove(index); + } } } } - private ArrayMap<String, Long> rowIdOfDeletedPaths = new ArrayMap<>(); + @GuardedBy("lock") + private final ArrayMap<String, Long> rowIdOfDeletedPaths = new ArrayMap<>(); public void addDeletedRowId(@NonNull String path, long id) { - rowIdOfDeletedPaths.put(path, id); + synchronized (lock) { + rowIdOfDeletedPaths.put(path.toLowerCase(Locale.ROOT), id); + } } public boolean removeDeletedRowId(long id) { - int index = rowIdOfDeletedPaths.indexOfValue(id); - final boolean isDeleted = index > -1; - while (index > -1) { - rowIdOfDeletedPaths.removeAt(index); - index = rowIdOfDeletedPaths.indexOfValue(id); + synchronized (lock) { + int index = rowIdOfDeletedPaths.indexOfValue(id); + final boolean isDeleted = index > -1; + while (index > -1) { + rowIdOfDeletedPaths.removeAt(index); + index = rowIdOfDeletedPaths.indexOfValue(id); + } + return isDeleted; } - return isDeleted; } public long getDeletedRowId(@NonNull String path) { - return rowIdOfDeletedPaths.getOrDefault(path, UNKNOWN_ROW_ID); + synchronized (lock) { + return rowIdOfDeletedPaths.getOrDefault(path.toLowerCase(Locale.ROOT), UNKNOWN_ROW_ID); + } + } + + private volatile int applicationMediaCapabilitiesSupportedFlags = -1; + private volatile int applicationMediaCapabilitiesUnsupportedFlags = -1; + + public int getApplicationMediaCapabilitiesSupportedFlags() { + return applicationMediaCapabilitiesSupportedFlags; + } + + public int getApplicationMediaCapabilitiesUnsupportedFlags() { + return applicationMediaCapabilitiesUnsupportedFlags; + } + + public void setApplicationMediaCapabilitiesFlags(int supportedFlags, int unsupportedFlags) { + applicationMediaCapabilitiesSupportedFlags = supportedFlags; + applicationMediaCapabilitiesUnsupportedFlags = unsupportedFlags; } } diff --git a/src/com/android/providers/media/MediaDocumentsProvider.java b/src/com/android/providers/media/MediaDocumentsProvider.java index 5121a45a..c958721f 100644 --- a/src/com/android/providers/media/MediaDocumentsProvider.java +++ b/src/com/android/providers/media/MediaDocumentsProvider.java @@ -17,11 +17,13 @@ package com.android.providers.media; import static android.content.ContentResolver.EXTRA_SIZE; +import static android.content.ContentResolver.QUERY_ARG_SQL_LIMIT; import static android.provider.DocumentsContract.QUERY_ARG_DISPLAY_NAME; import static android.provider.DocumentsContract.QUERY_ARG_EXCLUDE_MEDIA; import static android.provider.DocumentsContract.QUERY_ARG_FILE_SIZE_OVER; import static android.provider.DocumentsContract.QUERY_ARG_LAST_MODIFIED_AFTER; import static android.provider.DocumentsContract.QUERY_ARG_MIME_TYPES; +import static android.provider.MediaStore.GET_MEDIA_URI_CALL; import android.content.ContentResolver; import android.content.ContentUris; @@ -70,6 +72,7 @@ import androidx.core.content.MimeTypeFilter; import com.android.providers.media.util.FileUtils; import java.io.FileNotFoundException; +import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -429,6 +432,28 @@ public class MediaDocumentsProvider extends DocumentsProvider { } @Override + public Bundle call(String method, String arg, Bundle extras) { + Bundle bundle = super.call(method, arg, extras); + if (bundle == null && !TextUtils.isEmpty(method)) { + switch (method) { + case GET_MEDIA_URI_CALL: { + getContext().enforceCallingOrSelfPermission( + android.Manifest.permission.WRITE_MEDIA_STORAGE, TAG); + final Uri documentUri = extras.getParcelable(MediaStore.EXTRA_URI); + final String docId = DocumentsContract.getDocumentId(documentUri); + final Bundle out = new Bundle(); + final Uri uri = getUriForDocumentId(docId); + out.putParcelable(MediaStore.EXTRA_URI, uri); + return out; + } + default: + Log.w(TAG, "unknown method passed to call(): " + method); + } + } + return bundle; + } + + @Override public void deleteDocument(String docId) throws FileNotFoundException { enforceShellRestrictions(); final Uri target = getUriForDocumentId(docId); @@ -1017,6 +1042,7 @@ public class MediaDocumentsProvider extends DocumentsProvider { throws FileNotFoundException { enforceShellRestrictions(); final Uri target = getUriForDocumentId(docId); + final int callingUid = Binder.getCallingUid(); if (!"r".equals(mode)) { throw new IllegalArgumentException("Media is read-only"); @@ -1025,12 +1051,27 @@ public class MediaDocumentsProvider extends DocumentsProvider { // Delegate to real provider final long token = Binder.clearCallingIdentity(); try { - return getContext().getContentResolver().openFileDescriptor(target, mode); + return openFileForRead(target, callingUid); } finally { Binder.restoreCallingIdentity(token); } } + public ParcelFileDescriptor openFileForRead(final Uri target, final int callingUid) + throws FileNotFoundException { + final Bundle opts = new Bundle(); + opts.putInt(MediaStore.EXTRA_MEDIA_CAPABILITIES_UID, callingUid); + + AssetFileDescriptor afd = + getContext().getContentResolver().openTypedAssetFileDescriptor(target, "*/*", + opts); + if (afd == null) { + return null; + } + + return afd.getParcelFileDescriptor(); + } + @Override public AssetFileDescriptor openDocumentThumbnail( String docId, Point sizeHint, CancellationSignal signal) throws FileNotFoundException { @@ -1060,8 +1101,9 @@ public class MediaDocumentsProvider extends DocumentsProvider { private boolean isEmpty(Uri uri) { final ContentResolver resolver = getContext().getContentResolver(); final long token = Binder.clearCallingIdentity(); - try (Cursor cursor = resolver.query(uri, - new String[] { "COUNT(_id)" }, null, null, null)) { + Bundle extras = new Bundle(); + extras.putString(QUERY_ARG_SQL_LIMIT, "1"); + try (Cursor cursor = resolver.query(uri, new String[]{FileColumns._ID}, extras, null)) { if (cursor.moveToFirst()) { return cursor.getInt(0) == 0; } else { diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java index 9a38d7ee..96e53427 100644 --- a/src/com/android/providers/media/MediaProvider.java +++ b/src/com/android/providers/media/MediaProvider.java @@ -23,19 +23,28 @@ import static android.app.PendingIntent.FLAG_IMMUTABLE; import static android.app.PendingIntent.FLAG_ONE_SHOT; import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION; import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS; +import static android.content.pm.PackageManager.MATCH_ANY_USER; +import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_AWARE; +import static android.content.pm.PackageManager.MATCH_DIRECT_BOOT_UNAWARE; import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static android.database.Cursor.FIELD_TYPE_BLOB; import static android.provider.MediaStore.MATCH_DEFAULT; import static android.provider.MediaStore.MATCH_EXCLUDE; import static android.provider.MediaStore.MATCH_INCLUDE; import static android.provider.MediaStore.MATCH_ONLY; +import static android.provider.MediaStore.QUERY_ARG_DEFER_SCAN; import static android.provider.MediaStore.QUERY_ARG_MATCH_FAVORITE; import static android.provider.MediaStore.QUERY_ARG_MATCH_PENDING; import static android.provider.MediaStore.QUERY_ARG_MATCH_TRASHED; import static android.provider.MediaStore.QUERY_ARG_RELATED_URI; import static android.provider.MediaStore.getVolumeName; +import static android.system.OsConstants.F_GETFL; import static com.android.providers.media.DatabaseHelper.EXTERNAL_DATABASE_NAME; import static com.android.providers.media.DatabaseHelper.INTERNAL_DATABASE_NAME; +import static com.android.providers.media.LocalCallingIdentity.APPOP_REQUEST_INSTALL_PACKAGES_FOR_SHARED_UID; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_ACCESS_MTP; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_INSTALL_PACKAGES; import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_DELEGATOR; import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_LEGACY_GRANTED; import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_LEGACY_READ; @@ -49,6 +58,7 @@ import static com.android.providers.media.LocalCallingIdentity.PERMISSION_READ_A import static com.android.providers.media.LocalCallingIdentity.PERMISSION_READ_IMAGES; import static com.android.providers.media.LocalCallingIdentity.PERMISSION_READ_VIDEO; import static com.android.providers.media.LocalCallingIdentity.PERMISSION_WRITE_AUDIO; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_WRITE_EXTERNAL_STORAGE; import static com.android.providers.media.LocalCallingIdentity.PERMISSION_WRITE_IMAGES; import static com.android.providers.media.LocalCallingIdentity.PERMISSION_WRITE_VIDEO; import static com.android.providers.media.scan.MediaScanner.REASON_DEMAND; @@ -57,15 +67,20 @@ import static com.android.providers.media.util.DatabaseUtils.bindList; import static com.android.providers.media.util.FileUtils.DEFAULT_FOLDER_NAMES; import static com.android.providers.media.util.FileUtils.PATTERN_PENDING_FILEPATH_FOR_SQL; import static com.android.providers.media.util.FileUtils.extractDisplayName; +import static com.android.providers.media.util.FileUtils.extractFileExtension; import static com.android.providers.media.util.FileUtils.extractFileName; import static com.android.providers.media.util.FileUtils.extractPathOwnerPackageName; import static com.android.providers.media.util.FileUtils.extractRelativePath; import static com.android.providers.media.util.FileUtils.extractRelativePathForDirectory; import static com.android.providers.media.util.FileUtils.extractTopLevelDir; import static com.android.providers.media.util.FileUtils.extractVolumeName; +import static com.android.providers.media.util.FileUtils.extractVolumePath; import static com.android.providers.media.util.FileUtils.getAbsoluteSanitizedPath; +import static com.android.providers.media.util.FileUtils.isCrossUserEnabled; import static com.android.providers.media.util.FileUtils.isDataOrObbPath; import static com.android.providers.media.util.FileUtils.isDownload; +import static com.android.providers.media.util.FileUtils.isExternalMediaDirectory; +import static com.android.providers.media.util.FileUtils.isObbOrChildPath; import static com.android.providers.media.util.FileUtils.sanitizePath; import static com.android.providers.media.util.Logging.LOGV; import static com.android.providers.media.util.Logging.TAG; @@ -77,6 +92,10 @@ import android.app.DownloadManager; import android.app.PendingIntent; import android.app.RecoverableSecurityException; import android.app.RemoteAction; +import android.app.admin.DevicePolicyManager; +import android.app.compat.CompatChanges; +import android.compat.annotation.ChangeId; +import android.compat.annotation.EnabledAfter; import android.content.BroadcastReceiver; import android.content.ClipData; import android.content.ClipDescription; @@ -123,17 +142,23 @@ import android.os.Environment; import android.os.IBinder; import android.os.ParcelFileDescriptor; import android.os.ParcelFileDescriptor.OnCloseListener; +import android.os.Parcelable; +import android.os.Process; import android.os.RemoteException; import android.os.SystemClock; import android.os.SystemProperties; import android.os.Trace; import android.os.UserHandle; +import android.os.UserManager; import android.os.storage.StorageManager; import android.os.storage.StorageManager.StorageVolumeCallback; import android.os.storage.StorageVolume; import android.preference.PreferenceManager; import android.provider.BaseColumns; import android.provider.Column; +import android.provider.DeviceConfig; +import android.provider.DeviceConfig.OnPropertiesChangedListener; +import android.provider.DocumentsContract; import android.provider.MediaStore; import android.provider.MediaStore.Audio; import android.provider.MediaStore.Audio.AudioColumns; @@ -164,13 +189,16 @@ import androidx.annotation.GuardedBy; import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; +import com.android.modules.utils.build.SdkLevel; import com.android.modules.utils.BackgroundThread; import com.android.providers.media.DatabaseHelper.OnFilesChangeListener; import com.android.providers.media.DatabaseHelper.OnLegacyMigrationListener; import com.android.providers.media.fuse.ExternalStorageServiceImpl; import com.android.providers.media.fuse.FuseDaemon; +import com.android.providers.media.metrics.PulledMetrics; import com.android.providers.media.playlist.Playlist; import com.android.providers.media.scan.MediaScanner; import com.android.providers.media.scan.ModernMediaScanner; @@ -184,8 +212,8 @@ import com.android.providers.media.util.LongArray; import com.android.providers.media.util.Metrics; import com.android.providers.media.util.MimeUtils; import com.android.providers.media.util.PermissionUtils; -import com.android.providers.media.util.RedactingFileDescriptor; import com.android.providers.media.util.SQLiteQueryBuilder; +import com.android.providers.media.util.UserCache; import com.android.providers.media.util.XmpInterface; import com.google.common.hash.Hashing; @@ -198,17 +226,22 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.PrintWriter; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @@ -225,36 +258,53 @@ import java.util.regex.Pattern; */ public class MediaProvider extends ContentProvider { /** + * Enables checks to stop apps from inserting and updating to private files via media provider. + */ + @ChangeId + @EnabledAfter(targetSdkVersion = android.os.Build.VERSION_CODES.R) + static final long ENABLE_CHECKS_FOR_PRIVATE_FILES = 172100307L; + + /** * Regex of a selection string that matches a specific ID. */ static final Pattern PATTERN_SELECTION_ID = Pattern.compile( "(?:image_id|video_id)\\s*=\\s*(\\d+)"); - /** - * Property that indicates whether fuse is enabled. - */ - private static final String PROP_FUSE = "persist.sys.fuse"; + /** File access by uid requires the transcoding transform */ + private static final int FLAG_TRANSFORM_TRANSCODING = 1 << 0; + + /** File access by uid is a synthetic path corresponding to a redacted URI */ + private static final int FLAG_TRANSFORM_REDACTION = 1 << 1; /** * These directory names aren't declared in Environment as final variables, and so we need to * have the same values in separate final variables in order to have them considered constant * expressions. + * These directory names are intentionally in lower case to ease the case insensitive path + * comparison. */ - private static final String DIRECTORY_MUSIC = "Music"; - private static final String DIRECTORY_PODCASTS = "Podcasts"; - private static final String DIRECTORY_RINGTONES = "Ringtones"; - private static final String DIRECTORY_ALARMS = "Alarms"; - private static final String DIRECTORY_NOTIFICATIONS = "Notifications"; - private static final String DIRECTORY_PICTURES = "Pictures"; - private static final String DIRECTORY_MOVIES = "Movies"; - private static final String DIRECTORY_DOWNLOADS = "Download"; - private static final String DIRECTORY_DCIM = "DCIM"; - private static final String DIRECTORY_DOCUMENTS = "Documents"; - private static final String DIRECTORY_AUDIOBOOKS = "Audiobooks"; - private static final String DIRECTORY_ANDROID = "Android"; + private static final String DIRECTORY_MUSIC_LOWER_CASE = "music"; + private static final String DIRECTORY_PODCASTS_LOWER_CASE = "podcasts"; + private static final String DIRECTORY_RINGTONES_LOWER_CASE = "ringtones"; + private static final String DIRECTORY_ALARMS_LOWER_CASE = "alarms"; + private static final String DIRECTORY_NOTIFICATIONS_LOWER_CASE = "notifications"; + private static final String DIRECTORY_PICTURES_LOWER_CASE = "pictures"; + private static final String DIRECTORY_MOVIES_LOWER_CASE = "movies"; + private static final String DIRECTORY_DOWNLOADS_LOWER_CASE = "download"; + private static final String DIRECTORY_DCIM_LOWER_CASE = "dcim"; + private static final String DIRECTORY_DOCUMENTS_LOWER_CASE = "documents"; + private static final String DIRECTORY_AUDIOBOOKS_LOWER_CASE = "audiobooks"; + private static final String DIRECTORY_RECORDINGS_LOWER_CASE = "recordings"; + private static final String DIRECTORY_ANDROID_LOWER_CASE = "android"; private static final String DIRECTORY_MEDIA = "media"; private static final String DIRECTORY_THUMBNAILS = ".thumbnails"; + private static final List<String> PRIVATE_SUBDIRECTORIES_ANDROID = Arrays.asList("data", "obb"); + private static final String REDACTED_URI_ID_PREFIX = "RUID"; + private static final String TRANSFORMS_SYNTHETIC_DIR = ".transforms/synthetic"; + private static final String REDACTED_URI_DIR = TRANSFORMS_SYNTHETIC_DIR + "/redacted"; + public static final int REDACTED_URI_ID_SIZE = 36; + private static final String QUERY_ARG_REDACTED_URI = "android:query-arg-redacted-uri"; /** * Hard-coded filename where the current value of @@ -282,6 +332,8 @@ public class MediaProvider extends ContentProvider { */ private static final int MATCH_VISIBLE_FOR_FILEPATH = 32; + private static final int NON_HIDDEN_CACHE_SIZE = 50; + /** * Where clause to match pending files from FUSE. Pending files from FUSE will not have * PATTERN_PENDING_FILEPATH_FOR_SQL pattern. @@ -290,11 +342,35 @@ public class MediaProvider extends ContentProvider { MediaColumns.DATA, PATTERN_PENDING_FILEPATH_FOR_SQL); /** + * This flag is replaced with {@link MediaStore#QUERY_ARG_DEFER_SCAN} from S onwards and only + * kept around for app compatibility in R. + */ + private static final String QUERY_ARG_DO_ASYNC_SCAN = "android:query-arg-do-async-scan"; + /** + * Enable option to defer the scan triggered as part of MediaProvider#update() + */ + @ChangeId + @EnabledAfter(targetSdkVersion = android.os.Build.VERSION_CODES.R) + static final long ENABLE_DEFERRED_SCAN = 180326732L; + + /** + * Enable option to include database rows of files from recently unmounted + * volume in MediaProvider#query + */ + @ChangeId + @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.R) + static final long ENABLE_INCLUDE_ALL_VOLUMES = 182734110L; + + // Stolen from: UserHandle#getUserId + private static final int PER_USER_RANGE = 100000; + private static final int MY_UID = android.os.Process.myUid(); + + /** * Set of {@link Cursor} columns that refer to raw filesystem paths. */ private static final ArrayMap<String, Object> sDataColumns = new ArrayMap<>(); - { + static { sDataColumns.put(MediaStore.MediaColumns.DATA, null); sDataColumns.put(MediaStore.Images.Thumbnails.DATA, null); sDataColumns.put(MediaStore.Video.Thumbnails.DATA, null); @@ -302,57 +378,32 @@ public class MediaProvider extends ContentProvider { sDataColumns.put(MediaStore.Audio.AlbumColumns.ALBUM_ART, null); } - private static final Object sCacheLock = new Object(); - - @GuardedBy("sCacheLock") - private static final Set<String> sCachedExternalVolumeNames = new ArraySet<>(); - @GuardedBy("sCacheLock") - private static final Map<String, File> sCachedVolumePaths = new ArrayMap<>(); - @GuardedBy("sCacheLock") - private static final Map<String, Collection<File>> sCachedVolumeScanPaths = new ArrayMap<>(); - @GuardedBy("sCacheLock") - private static final ArrayMap<File, String> sCachedVolumePathToId = new ArrayMap<>(); + private static final int sUserId = UserHandle.myUserId(); - @GuardedBy("mShouldRedactThreadIds") - private final LongArray mShouldRedactThreadIds = new LongArray(); + /** + * Please use {@link getDownloadsProviderAuthority()} instead of using this directly. + */ + private static final String DOWNLOADS_PROVIDER_AUTHORITY = "downloads"; - public void updateVolumes() { - synchronized (sCacheLock) { - sCachedExternalVolumeNames.clear(); - sCachedExternalVolumeNames.addAll(MediaStore.getExternalVolumeNames(getContext())); - Log.v(TAG, "Updated external volumes to: " + sCachedExternalVolumeNames.toString()); - - sCachedVolumePaths.clear(); - sCachedVolumeScanPaths.clear(); - sCachedVolumePathToId.clear(); - try { - sCachedVolumeScanPaths.put(MediaStore.VOLUME_INTERNAL, - FileUtils.getVolumeScanPaths(getContext(), MediaStore.VOLUME_INTERNAL)); - } catch (FileNotFoundException e) { - Log.wtf(TAG, "Failed to update volume " + MediaStore.VOLUME_INTERNAL, e); - } + @GuardedBy("mPendingOpenInfo") + private final Map<Integer, PendingOpenInfo> mPendingOpenInfo = new ArrayMap<>(); - for (String volumeName : sCachedExternalVolumeNames) { - try { - final Uri uri = MediaStore.Files.getContentUri(volumeName); - final StorageVolume volume = mStorageManager.getStorageVolume(uri); - sCachedVolumePaths.put(volumeName, volume.getDirectory()); - sCachedVolumeScanPaths.put(volumeName, - FileUtils.getVolumeScanPaths(getContext(), volumeName)); - sCachedVolumePathToId.put(volume.getDirectory(), volume.getId()); - } catch (IllegalStateException | FileNotFoundException e) { - Log.wtf(TAG, "Failed to update volume " + volumeName, e); - } - } - } + @GuardedBy("mNonHiddenPaths") + private final LRUCache<String, Integer> mNonHiddenPaths = new LRUCache<>(NON_HIDDEN_CACHE_SIZE); + public void updateVolumes() { + mVolumeCache.update(); // Update filters to reflect mounted volumes so users don't get // confused by metadata from ejected volumes ForegroundThread.getExecutor().execute(() -> { - mExternalDatabase.setFilterVolumeNames(getExternalVolumeNames()); + mExternalDatabase.setFilterVolumeNames(mVolumeCache.getExternalVolumeNames()); }); } + public @NonNull MediaVolume getVolume(@NonNull String volumeName) throws FileNotFoundException { + return mVolumeCache.findVolume(volumeName, mCallingIdentity.get().getUser()); + } + public @NonNull File getVolumePath(@NonNull String volumeName) throws FileNotFoundException { // Ugly hack to keep unit tests passing, where we don't always have a // Context to discover volumes with @@ -360,60 +411,56 @@ public class MediaProvider extends ContentProvider { return Environment.getExternalStorageDirectory(); } - synchronized (sCacheLock) { - if (sCachedVolumePaths.containsKey(volumeName)) { - return sCachedVolumePaths.get(volumeName); - } - - // Nothing found above; let's ask directly and cache the answer - final File res = FileUtils.getVolumePath(getContext(), volumeName); - sCachedVolumePaths.put(volumeName, res); - return res; - } + return mVolumeCache.getVolumePath(volumeName, mCallingIdentity.get().getUser()); } public @NonNull String getVolumeId(@NonNull File file) throws FileNotFoundException { - synchronized (sCacheLock) { - for (int i = 0; i < sCachedVolumePathToId.size(); i++) { - if (FileUtils.contains(sCachedVolumePathToId.keyAt(i), file)) { - return sCachedVolumePathToId.valueAt(i); - } - } - - // Nothing found above; let's ask directly and cache the answer - final StorageVolume volume = mStorageManager.getStorageVolume(file); - if (volume == null) { - throw new FileNotFoundException("Missing volume for " + file); - } - sCachedVolumePathToId.put(volume.getDirectory(), volume.getId()); - return volume.getId(); - } + return mVolumeCache.getVolumeId(file); } - public @NonNull Set<String> getExternalVolumeNames() { - synchronized (sCacheLock) { - return new ArraySet<>(sCachedExternalVolumeNames); + private @NonNull Collection<File> getAllowedVolumePaths(String volumeName) + throws FileNotFoundException { + // This method is used to verify whether a path belongs to a certain volume name; + // we can't always use the calling user's identity here to determine exactly which + // volume is meant, because the MediaScanner may scan paths belonging to another user, + // eg a clone user. + // So, for volumes like external_primary, just return allowed paths for all users. + List<UserHandle> users = mUserCache.getUsersCached(); + ArrayList<File> allowedPaths = new ArrayList<>(); + for (UserHandle user : users) { + Collection<File> volumeScanPaths = mVolumeCache.getVolumeScanPaths(volumeName, user); + allowedPaths.addAll(volumeScanPaths); } + + return allowedPaths; } - public @NonNull Collection<File> getVolumeScanPaths(String volumeName) - throws FileNotFoundException { - synchronized (sCacheLock) { - if (sCachedVolumeScanPaths.containsKey(volumeName)) { - return new ArrayList<>(sCachedVolumeScanPaths.get(volumeName)); - } + /** + * Frees any cache held by MediaProvider. + * + * @param bytes number of bytes which need to be freed + */ + public void freeCache(long bytes) { + mTranscodeHelper.freeCache(bytes); + } - // Nothing found above; let's ask directly and cache the answer - final Collection<File> res = FileUtils.getVolumeScanPaths(getContext(), volumeName); - sCachedVolumeScanPaths.put(volumeName, res); - return res; - } + public void onAnrDelayStarted(@NonNull String packageName, int uid, int tid, int reason) { + mTranscodeHelper.onAnrDelayStarted(packageName, uid, tid, reason); } + private volatile Locale mLastLocale = Locale.getDefault(); + private StorageManager mStorageManager; private AppOpsManager mAppOpsManager; private PackageManager mPackageManager; + private DevicePolicyManager mDevicePolicyManager; + private UserManager mUserManager; + + private UserCache mUserCache; + private VolumeCache mVolumeCache; + private int mExternalStorageAuthorityAppId; + private int mDownloadsAuthorityAppId; private Size mThumbSize; /** @@ -429,8 +476,8 @@ public class MediaProvider extends ContentProvider { if (active) { // TODO moltmann: Set correct featureId mCachedCallingIdentity.put(uid, - LocalCallingIdentity.fromExternal(getContext(), uid, packageName, - null)); + LocalCallingIdentity.fromExternal(getContext(), mUserCache, uid, + packageName, null)); } else { mCachedCallingIdentity.remove(uid); } @@ -449,6 +496,9 @@ public class MediaProvider extends ContentProvider { private OnOpChangedListener mModeListener = (op, packageName) -> invalidateLocalCallingIdentityCache(packageName, "op " + op); + @GuardedBy("mNonWorkProfileUsers") + private final List<Integer> mNonWorkProfileUsers = new ArrayList<>(); + /** * Retrieves a cached calling identity or creates a new one. Also, always sets the app-op * description for the calling identity. @@ -456,12 +506,19 @@ public class MediaProvider extends ContentProvider { private LocalCallingIdentity getCachedCallingIdentityForFuse(int uid) { synchronized (mCachedCallingIdentityForFuse) { PermissionUtils.setOpDescription("via FUSE"); - LocalCallingIdentity ident = mCachedCallingIdentityForFuse.get(uid); - if (ident == null) { - ident = LocalCallingIdentity.fromExternal(getContext(), uid); - mCachedCallingIdentityForFuse.put(uid, ident); + LocalCallingIdentity identity = mCachedCallingIdentityForFuse.get(uid); + if (identity == null) { + identity = LocalCallingIdentity.fromExternal(getContext(), mUserCache, uid); + if (uid / PER_USER_RANGE == sUserId) { + mCachedCallingIdentityForFuse.put(uid, identity); + } else { + // In some app cloning designs, MediaProvider user 0 may + // serve requests for apps running as a "clone" user; in + // those cases, don't keep a cache for the clone user, since + // we don't get any invalidation events for these users. + } } - return ident; + return identity; } } @@ -477,7 +534,7 @@ public class MediaProvider extends ContentProvider { final LocalCallingIdentity cached = mCachedCallingIdentity .get(Binder.getCallingUid()); return (cached != null) ? cached - : LocalCallingIdentity.fromBinder(getContext(), this); + : LocalCallingIdentity.fromBinder(getContext(), this, mUserCache); } }); @@ -514,6 +571,8 @@ public class MediaProvider extends ContentProvider { private static final String CANONICAL = "canonical"; + private static final String ALL_VOLUMES = "all_volumes"; + private BroadcastReceiver mPackageReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { @@ -562,8 +621,9 @@ public class MediaProvider extends ContentProvider { } updateQuotaTypeForFileInternal(file, mediaType); - } catch (FileNotFoundException e) { + } catch (FileNotFoundException | IllegalArgumentException e) { // Ignore + Log.w(TAG, "Failed to update quota for uri: " + uri, e); return; } finally { Trace.endSection(); @@ -654,6 +714,9 @@ public class MediaProvider extends ContentProvider { int mediaType, boolean isDownload, String ownerPackageName, String path) { handleDeletedRowForFuse(path, ownerPackageName, id); acceptWithExpansion(helper::notifyDelete, volumeName, id, mediaType, isDownload); + // Remove cached transcoded file if any + mTranscodeHelper.deleteCachedTranscodeFile(id); + helper.postBackground(() -> { // Item no longer exists, so revoke all access to it @@ -666,6 +729,14 @@ public class MediaProvider extends ContentProvider { Trace.endSection(); } + switch (mediaType) { + case FileColumns.MEDIA_TYPE_PLAYLIST: + case FileColumns.MEDIA_TYPE_AUDIO: + if (helper.isExternal()) { + removePlaylistMembers(mediaType, id); + } + } + // Invalidate any thumbnails now that media is gone invalidateThumbnails(MediaStore.Files.getContentUri(volumeName, id)); @@ -731,6 +802,11 @@ public class MediaProvider extends ContentProvider { case FileColumns.MEDIA_TYPE_IMAGE: consumer.accept(MediaStore.Images.Media.getContentUri(volumeName, id)); break; + + case FileColumns.MEDIA_TYPE_PLAYLIST: + consumer.accept(ContentUris.withAppendedId( + MediaStore.Audio.Playlists.getContentUri(volumeName), id)); + break; } // Also notify through any generic views @@ -757,39 +833,22 @@ public class MediaProvider extends ContentProvider { * devices. We only do this once per volume so we don't annoy the user if * deleted manually. */ - private void ensureDefaultFolders(@NonNull String volumeName, @NonNull SQLiteDatabase db) { - try { - final File path = getVolumePath(volumeName); - final StorageVolume vol = mStorageManager.getStorageVolume(path); - final String key; - if (vol == null) { - Log.w(TAG, "Failed to ensure default folders for " + volumeName); - return; - } - - if (vol.isPrimary()) { - key = "created_default_folders"; - } else { - key = "created_default_folders_" + vol.getMediaStoreVolumeName(); - } + private void ensureDefaultFolders(@NonNull MediaVolume volume, @NonNull SQLiteDatabase db) { + final String key = "created_default_folders_" + volume.getId(); - final SharedPreferences prefs = PreferenceManager - .getDefaultSharedPreferences(getContext()); - if (prefs.getInt(key, 0) == 0) { - for (String folderName : DEFAULT_FOLDER_NAMES) { - final File folder = new File(vol.getDirectory(), folderName); - if (!folder.exists()) { - folder.mkdirs(); - insertDirectory(db, folder.getAbsolutePath()); - } + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + if (prefs.getInt(key, 0) == 0) { + for (String folderName : DEFAULT_FOLDER_NAMES) { + final File folder = new File(volume.getPath(), folderName); + if (!folder.exists()) { + folder.mkdirs(); + insertDirectory(db, folder.getAbsolutePath()); } - - SharedPreferences.Editor editor = prefs.edit(); - editor.putInt(key, 1); - editor.commit(); } - } catch (IOException e) { - Log.w(TAG, "Failed to ensure default folders for " + volumeName, e); + + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(key, 1); + editor.commit(); } } @@ -799,10 +858,10 @@ public class MediaProvider extends ContentProvider { * {@link DatabaseHelper#getOrCreateUuid} doesn't match the UUID found on * disk, then all thumbnails will be considered stable and will be deleted. */ - private void ensureThumbnailsValid(@NonNull String volumeName, @NonNull SQLiteDatabase db) { + private void ensureThumbnailsValid(@NonNull MediaVolume volume, @NonNull SQLiteDatabase db) { final String uuidFromDatabase = DatabaseHelper.getOrCreateUuid(db); try { - for (File dir : getThumbnailDirectories(volumeName)) { + for (File dir : getThumbnailDirectories(volume)) { if (!dir.exists()) { dir.mkdirs(); } @@ -830,7 +889,7 @@ public class MediaProvider extends ContentProvider { } } } catch (IOException e) { - Log.w(TAG, "Failed to ensure thumbnails valid for " + volumeName, e); + Log.w(TAG, "Failed to ensure thumbnails valid for " + volume.getName(), e); } } @@ -847,12 +906,17 @@ public class MediaProvider extends ContentProvider { public boolean onCreate() { final Context context = getContext(); + mUserCache = new UserCache(context); + // Shift call statistics back to the original caller Binder.setProxyTransactListener(mTransactListener); mStorageManager = context.getSystemService(StorageManager.class); mAppOpsManager = context.getSystemService(AppOpsManager.class); mPackageManager = context.getPackageManager(); + mDevicePolicyManager = context.getSystemService(DevicePolicyManager.class); + mUserManager = context.getSystemService(UserManager.class); + mVolumeCache = new VolumeCache(context, mUserCache); // Reasonable thumbnail size is half of the smallest screen edge width final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); @@ -868,6 +932,15 @@ public class MediaProvider extends ContentProvider { false, false, false, Column.class, Metrics::logSchemaChange, mFilesListener, MIGRATION_LISTENER, mIdGenerator); + if (SdkLevel.isAtLeastS()) { + mTranscodeHelper = new TranscodeHelperImpl(context, this); + } else { + mTranscodeHelper = new TranscodeHelperNoOp(); + } + + // Create dir for redacted URI's path. + new File("/storage/emulated/" + UserHandle.myUserId(), REDACTED_URI_DIR).mkdirs(); + final IntentFilter packageFilter = new IntentFilter(); packageFilter.setPriority(10); packageFilter.addDataScheme("package"); @@ -885,9 +958,9 @@ public class MediaProvider extends ContentProvider { }); updateVolumes(); - attachVolume(MediaStore.VOLUME_INTERNAL, /* validate */ false); - for (String volumeName : getExternalVolumeNames()) { - attachVolume(volumeName, /* validate */ false); + attachVolume(MediaVolume.fromInternal(), /* validate */ false); + for (MediaVolume volume : mVolumeCache.getExternalVolumes()) { + attachVolume(volume, /* validate */ false); } // Watch for performance-sensitive activity @@ -918,11 +991,26 @@ public class MediaProvider extends ContentProvider { // throw an IllegalArgumentException during MediaProvider startup. In combination with // MediaProvider's CTS tests it should give us guarantees that OPSTR_NO_ISOLATED_STORAGE // is defined. - mAppOpsManager.startWatchingMode(PermissionUtils.OPSTR_NO_ISOLATED_STORAGE, + mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_NO_ISOLATED_STORAGE, null /* all packages */, mModeListener); } catch (IllegalArgumentException e) { - Log.w(TAG, "Failed to start watching " + PermissionUtils.OPSTR_NO_ISOLATED_STORAGE, e); + Log.w(TAG, "Failed to start watching " + AppOpsManager.OPSTR_NO_ISOLATED_STORAGE, e); + } + + ProviderInfo provider = mPackageManager.resolveContentProvider( + getDownloadsProviderAuthority(), PackageManager.MATCH_DIRECT_BOOT_AWARE + | PackageManager.MATCH_DIRECT_BOOT_UNAWARE); + if (provider != null) { + mDownloadsAuthorityAppId = UserHandle.getAppId(provider.applicationInfo.uid); + } + + provider = mPackageManager.resolveContentProvider(getExternalStorageProviderAuthority(), + PackageManager.MATCH_DIRECT_BOOT_AWARE | PackageManager.MATCH_DIRECT_BOOT_UNAWARE); + if (provider != null) { + mExternalStorageAuthorityAppId = UserHandle.getAppId(provider.applicationInfo.uid); } + + PulledMetrics.initialize(context); return true; } @@ -933,7 +1021,11 @@ public class MediaProvider extends ContentProvider { } public LocalCallingIdentity clearLocalCallingIdentity() { - return clearLocalCallingIdentity(LocalCallingIdentity.fromSelf(getContext())); + // We retain the user part of the calling identity, since we are executing + // the call on behalf of that user, and we need to maintain the user context + // to correctly resolve things like volumes + UserHandle user = mCallingIdentity.get().getUser(); + return clearLocalCallingIdentity(LocalCallingIdentity.fromSelfAsUser(getContext(), user)); } public LocalCallingIdentity clearLocalCallingIdentity(LocalCallingIdentity replacement) { @@ -974,19 +1066,19 @@ public class MediaProvider extends ContentProvider { Logging.trimPersistent(); // Scan all volumes to resolve any staleness - for (String volumeName : getExternalVolumeNames()) { + for (MediaVolume volume : mVolumeCache.getExternalVolumes()) { // Possibly bail before digging into each volume signal.throwIfCanceled(); try { - MediaService.onScanVolume(getContext(), volumeName, REASON_IDLE); + MediaService.onScanVolume(getContext(), volume, REASON_IDLE); } catch (IOException e) { Log.w(TAG, e); } // Ensure that our thumbnails are valid mExternalDatabase.runWithTransaction((db) -> { - ensureThumbnailsValid(volumeName, db); + ensureThumbnailsValid(volume, db); return null; }); } @@ -1018,23 +1110,11 @@ public class MediaProvider extends ContentProvider { }); Log.d(TAG, "Pruned " + stalePackages + " unknown packages"); - // Delete any expired content; we're cautious 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); - final int expiredMedia = mExternalDatabase.runWithTransaction((db) -> { - 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); - } - return c.getCount(); - } - }); - Log.d(TAG, "Deleted " + expiredMedia + " expired items"); + // Delete the expired items or extend them on mounted volumes + final int[] result = deleteOrExtendExpiredItems(signal); + final int deletedExpiredMedia = result[0]; + Log.d(TAG, "Deleted " + deletedExpiredMedia + " expired items"); + Log.d(TAG, "Extended " + result[1] + " expired items"); // Forget any stale volumes mExternalDatabase.runWithTransaction((db) -> { @@ -1068,9 +1148,94 @@ public class MediaProvider extends ContentProvider { final long durationMillis = (SystemClock.elapsedRealtime() - startTime); Metrics.logIdleMaintenance(MediaStore.VOLUME_EXTERNAL, itemCount, - durationMillis, staleThumbnails, expiredMedia); + durationMillis, staleThumbnails, deletedExpiredMedia); + } + + /** + * Delete any expired content on mounted volumes. The expired content on unmounted + * volumes will be deleted when we forget any stale volumes; we're cautious about + * wildly changing clocks, so only delete items within the last week. + * If the items are expired more than one week, extend the expired time of them + * another one week to avoid data loss with incorrect time zone data. We will + * delete it when it is expired next time. + * + * @param signal the cancellation signal + * @return the integer array includes total deleted count and total extended count + */ + @NonNull + private int[] deleteOrExtendExpiredItems(@NonNull CancellationSignal signal) { + final long expiredOneWeek = + ((System.currentTimeMillis() - DateUtils.WEEK_IN_MILLIS) / 1000); + final long now = (System.currentTimeMillis() / 1000); + final Long extendedTime = now + (FileUtils.DEFAULT_DURATION_EXTENDED / 1000); + final int result[] = mExternalDatabase.runWithTransaction((db) -> { + String selection = FileColumns.DATE_EXPIRES + " < " + now; + selection += " AND volume_name in " + bindList(MediaStore.getExternalVolumeNames( + getContext()).toArray()); + String[] projection = new String[]{"volume_name", "_id", + FileColumns.DATE_EXPIRES, FileColumns.DATA}; + try (Cursor c = db.query(true, "files", projection, selection, null, null, null, null, + null, signal)) { + int totalDeleteCount = 0; + int totalExtendedCount = 0; + while (c.moveToNext()) { + final String volumeName = c.getString(0); + final long id = c.getLong(1); + final long dateExpires = c.getLong(2); + // we only delete the items that expire in one week + if (dateExpires > expiredOneWeek) { + totalDeleteCount += delete(Files.getContentUri(volumeName, id), null, null); + } else { + final String oriPath = c.getString(3); + final boolean success = extendExpiredItem(db, oriPath, id, extendedTime); + if (success) { + totalExtendedCount++; + } + } + } + return new int[]{totalDeleteCount, totalExtendedCount}; + } + }); + return result; + } + + /** + * Extend the expired items by renaming the file to new path with new + * timestamp and updating the database for {@link FileColumns#DATA} and + * {@link FileColumns#DATE_EXPIRES} + */ + private boolean extendExpiredItem(@NonNull SQLiteDatabase db, @NonNull String originalPath, + Long id, Long extendedTime) { + final String newPath = FileUtils.getAbsoluteExtendedPath(originalPath, extendedTime); + if (newPath == null) { + return false; + } + + try { + Os.rename(originalPath, newPath); + invalidateFuseDentry(originalPath); + invalidateFuseDentry(newPath); + } catch (ErrnoException e) { + final String errorMessage = "Rename " + originalPath + " to " + newPath + " failed."; + Log.e(TAG, errorMessage, e); + return false; + } + + final ContentValues values = new ContentValues(); + values.put(FileColumns.DATA, newPath); + values.put(FileColumns.DATE_EXPIRES, extendedTime); + final int count = db.update("files", values, "_id=?", new String[]{String.valueOf(id)}); + return count == 1; } + public void onIdleMaintenanceStopped() { + mMediaScanner.onIdleScanStopped(); + } + + /** + * Orphan any content of the given package. This will delete Android/media orphaned files from + * the database. + */ public void onPackageOrphaned(String packageName) { mExternalDatabase.runWithTransaction((db) -> { onPackageOrphaned(db, packageName); @@ -1078,14 +1243,28 @@ public class MediaProvider extends ContentProvider { }); } + /** + * Orphan any content of the given package from the given database. This will delete + * Android/media orphaned files from the database. + */ public void onPackageOrphaned(@NonNull SQLiteDatabase db, @NonNull String packageName) { + // Delete files from Android/media. + String relativePath = "Android/media/" + DatabaseUtils.escapeForLike(packageName) + "/%"; + final int countDeleted = db.delete( + "files", + "relative_path LIKE ? ESCAPE '\\' AND owner_package_name=?", + new String[] {relativePath, packageName}); + Log.d(TAG, "Deleted " + countDeleted + " Android/media items belonging to " + + packageName + " on " + db.getPath()); + + // Orphan rest of files. final ContentValues values = new ContentValues(); values.putNull(FileColumns.OWNER_PACKAGE_NAME); - final int count = db.update("files", values, + final int countOrphaned = db.update("files", values, "owner_package_name=?", new String[] { packageName }); - if (count > 0) { - Log.d(TAG, "Orphaned " + count + " items belonging to " + if (countOrphaned > 0) { + Log.d(TAG, "Orphaned " + countOrphaned + " items belonging to " + packageName + " on " + db.getPath()); } } @@ -1095,22 +1274,20 @@ public class MediaProvider extends ContentProvider { } public Uri scanFile(File file, int reason) { - return mMediaScanner.scanFile(file, reason); + return scanFile(file, reason, null); } public Uri scanFile(File file, int reason, String ownerPackage) { return mMediaScanner.scanFile(file, reason, ownerPackage); } - /** - * Makes MediaScanner scan the given file. - * @param file path of the file to be scanned - * - * Called from JNI in jni/MediaProviderWrapper.cpp - */ - @Keep - public void scanFileForFuse(String file) { - scanFile(new File(file), REASON_DEMAND); + private Uri scanFileAsMediaProvider(File file, int reason) { + final LocalCallingIdentity tokenInner = clearLocalCallingIdentity(); + try { + return scanFile(file, REASON_DEMAND); + } finally { + restoreLocalCallingIdentity(tokenInner); + } } /** @@ -1130,6 +1307,282 @@ public class MediaProvider extends ContentProvider { }); } + private boolean isAppCloneUserPair(int userId1, int userId2) { + UserHandle user1 = UserHandle.of(userId1); + UserHandle user2 = UserHandle.of(userId2); + if (SdkLevel.isAtLeastS()) { + if (mUserCache.userSharesMediaWithParent(user1) + || mUserCache.userSharesMediaWithParent(user2)) { + return true; + } + if (Build.VERSION.DEVICE_INITIAL_SDK_INT >= Build.VERSION_CODES.S) { + // If we're on S or higher, and we shipped with S or higher, only allow the new + // app cloning functionality + return false; + } + // else, fall back to deprecated solution below on updating devices + } + try { + Method isAppCloneUserPair = StorageManager.class.getMethod("isAppCloneUserPair", + int.class, int.class); + return (Boolean) isAppCloneUserPair.invoke(mStorageManager, userId1, userId2); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + Log.w(TAG, "isAppCloneUserPair failed. Users: " + userId1 + " and " + userId2); + return false; + } + } + + /** + * Determines whether the passed in userId forms an app clone user pair with user 0. + * + * @param userId user ID to check + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public boolean isAppCloneUserForFuse(int userId) { + if (!isCrossUserEnabled()) { + Log.d(TAG, "CrossUser not enabled."); + return false; + } + boolean result = isAppCloneUserPair(0, userId); + + Log.w(TAG, "isAppCloneUserPair for user " + userId + ": " + result); + + return result; + } + + /** + * Determines if to allow FUSE_LOOKUP for uid. Might allow uids that don't belong to the + * MediaProvider user, depending on OEM configuration. + * + * @param uid linux uid to check + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public boolean shouldAllowLookupForFuse(int uid, int pathUserId) { + int callingUserId = uid / PER_USER_RANGE; + if (!isCrossUserEnabled()) { + Log.d(TAG, "CrossUser not enabled. Users: " + callingUserId + " and " + pathUserId); + return false; + } + + if (callingUserId != pathUserId && callingUserId != 0 && pathUserId != 0) { + Log.w(TAG, "CrossUser at least one user is 0 check failed. Users: " + callingUserId + + " and " + pathUserId); + return false; + } + + if (isWorkProfile(callingUserId) || isWorkProfile(pathUserId)) { + // Cross-user lookup not allowed if one user in the pair has a profile owner app + Log.w(TAG, "CrossUser work profile check failed. Users: " + callingUserId + " and " + + pathUserId); + return false; + } + + boolean result = isAppCloneUserPair(pathUserId, callingUserId); + if (result) { + Log.i(TAG, "CrossUser allowed. Users: " + callingUserId + " and " + pathUserId); + } else { + Log.w(TAG, "CrossUser isAppCloneUserPair check failed. Users: " + callingUserId + + " and " + pathUserId); + } + + return result; + } + + private boolean isWorkProfile(int userId) { + synchronized (mNonWorkProfileUsers) { + if (mNonWorkProfileUsers.contains(userId)) { + return false; + } + if (userId == 0) { + mNonWorkProfileUsers.add(userId); + // user 0 cannot have a profile owner + return false; + } + } + + List<Integer> uids = new ArrayList<>(); + for (ApplicationInfo ai : mPackageManager.getInstalledApplications(MATCH_DIRECT_BOOT_AWARE + | MATCH_DIRECT_BOOT_UNAWARE | MATCH_ANY_USER)) { + if (((ai.uid / PER_USER_RANGE) == userId) + && mDevicePolicyManager.isProfileOwnerApp(ai.packageName)) { + return true; + } + } + + synchronized (mNonWorkProfileUsers) { + mNonWorkProfileUsers.add(userId); + return false; + } + } + + /** + * Called from FUSE to transform a file + * + * A transform can change the file contents for {@code uid} from {@code src} to {@code dst} + * depending on {@code flags}. This allows the FUSE daemon serve different file contents for + * the same file to different apps. + * + * The only supported transform for now is transcoding which re-encodes a file taken in a modern + * format like HEVC to a legacy format like AVC. + * + * @param src file path to transform + * @param dst file path to save transformed file + * @param flags determines the kind of transform + * @param readUid app that called us requesting transform + * @param openUid app that originally made the open call + * @param mediaCapabilitiesUid app for which the transform decision was made, + * 0 if decision was made with openUid + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public boolean transformForFuse(String src, String dst, int transforms, int transformsReason, + int readUid, int openUid, int mediaCapabilitiesUid) { + if ((transforms & FLAG_TRANSFORM_TRANSCODING) != 0) { + if (mTranscodeHelper.isTranscodeFileCached(src, dst)) { + Log.d(TAG, "Using transcode cache for " + src); + return true; + } + + // In general we always mark the opener as causing transcoding. + // However, if the mediaCapabilitiesUid is available then we mark the reader as causing + // transcoding. This handles the case where a malicious app might want to take + // advantage of mediaCapabilitiesUid by setting it to another app's uid and reading the + // media contents itself; in such cases we'd mark the reader (malicious app) for the + // cost of transcoding. + // + // openUid readUid mediaCapabilitiesUid + // ------------------------------------------------------------------------------------- + // using picker SAF app app + // abusive case bad app bad app victim + // modern to lega- + // -cy sharing modern legacy legacy + // + // we'd not be here in the below case. + // legacy to mode- + // -rn sharing legacy modern modern + + int transcodeUid = openUid; + if (mediaCapabilitiesUid > 0) { + Log.d(TAG, "Fix up transcodeUid to " + readUid + ". openUid " + openUid + + ", mediaCapabilitiesUid " + mediaCapabilitiesUid); + transcodeUid = readUid; + } + return mTranscodeHelper.transcode(src, dst, transcodeUid, transformsReason); + } + return true; + } + + /** + * Called from FUSE to get {@link FileLookupResult} for a {@code path} and {@code uid} + * + * {@link FileLookupResult} contains transforms, transforms completion status and ioPath + * for transform lookup query for a file and uid. + * + * @param path file path to get transforms for + * @param uid app requesting IO form kernel + * @param tid FUSE thread id handling IO request from kernel + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public FileLookupResult onFileLookupForFuse(String path, int uid, int tid) { + uid = getBinderUidForFuse(uid, tid); + if (isSyntheticFilePathForRedactedUri(path, uid)) { + return getFileLookupResultsForRedactedUriPath(uid, path); + } + + String ioPath = ""; + boolean transformsComplete = true; + boolean transformsSupported = mTranscodeHelper.supportsTranscode(path); + int transforms = 0; + int transformsReason = 0; + + if (transformsSupported) { + PendingOpenInfo info = null; + synchronized (mPendingOpenInfo) { + info = mPendingOpenInfo.get(tid); + } + + if (info != null && info.uid == uid) { + transformsReason = info.transcodeReason; + } else { + transformsReason = mTranscodeHelper.shouldTranscode(path, uid, null /* bundle */); + } + + if (transformsReason > 0) { + ioPath = mTranscodeHelper.getIoPath(path, uid); + transformsComplete = mTranscodeHelper.isTranscodeFileCached(path, ioPath); + transforms = FLAG_TRANSFORM_TRANSCODING; + } + } + + return new FileLookupResult(transforms, transformsReason, uid, transformsComplete, + transformsSupported, ioPath); + } + + private boolean isSyntheticFilePathForRedactedUri(String path, int uid) { + if (path == null) return false; + + final String transformsSyntheticDir = getStorageRootPathForUid(uid) + "/" + + REDACTED_URI_DIR; + final String fileName = extractFileName(path); + return fileName != null && path.toLowerCase(Locale.ROOT).startsWith( + transformsSyntheticDir.toLowerCase(Locale.ROOT)) && fileName.startsWith( + REDACTED_URI_ID_PREFIX) && fileName.length() == REDACTED_URI_ID_SIZE; + } + + private boolean isSyntheticDirPath(String path, int uid) { + final String transformsSyntheticDir = getStorageRootPathForUid(uid) + "/" + + TRANSFORMS_SYNTHETIC_DIR; + return path != null && path.toLowerCase(Locale.ROOT).startsWith( + transformsSyntheticDir.toLowerCase(Locale.ROOT)); + } + + private FileLookupResult getFileLookupResultsForRedactedUriPath(int uid, @NonNull String path) { + final LocalCallingIdentity token = clearLocalCallingIdentity(); + final String fileName = extractFileName(path); + + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(FileUtils.getContentUriForPath(path)); + } catch (VolumeNotFoundException e) { + throw new IllegalStateException("Volume not found for file: " + path); + } + + try (final Cursor c = helper.runWithoutTransaction( + (db) -> db.query("files", new String[]{MediaColumns.DATA}, + FileColumns.REDACTED_URI_ID + "=?", new String[]{fileName}, null, null, + null))) { + if (!c.moveToFirst()) { + return new FileLookupResult(FLAG_TRANSFORM_REDACTION, 0, uid, false, true, null); + } + + return new FileLookupResult(FLAG_TRANSFORM_REDACTION, 0, uid, true, true, + c.getString(0)); + } finally { + restoreLocalCallingIdentity(token); + } + } + + public int getBinderUidForFuse(int uid, int tid) { + if (uid != MY_UID) { + return uid; + } + + synchronized (mPendingOpenInfo) { + PendingOpenInfo info = mPendingOpenInfo.get(tid); + if (info == null) { + return uid; + } + return info.uid; + } + } + /** * Returns true if the app denoted by the given {@code uid} and {@code packageName} is allowed * to clear other apps' cache directories. @@ -1342,16 +1795,23 @@ public class MediaProvider extends ContentProvider { * <li> {@code column} is set or * <li> {@code column} is {@link MediaColumns#IS_PENDING} and is set by FUSE and not owned by * calling package. + * <li> {@code column} is {@link MediaColumns#IS_PENDING}, is unset and is waiting for + * metadata update from a deferred scan. * </ul> */ private String getWhereClauseForMatchExclude(@NonNull String column) { if (column.equalsIgnoreCase(MediaColumns.IS_PENDING)) { - final String callingPackage = getCallingPackageOrSelf(); + // Don't include rows that are pending for metadata + final String pendingForMetadata = FileColumns._MODIFIER + "=" + + FileColumns._MODIFIER_CR_PENDING_METADATA; + final String notPending = String.format("(%s=0 AND NOT %s)", column, + pendingForMetadata); final String matchSharedPackagesClause = FileColumns.OWNER_PACKAGE_NAME + " IN " + getSharedPackages(); // Include owned pending files from Fuse - return String.format("%s=0 OR (%s=1 AND %s AND %s)", column, column, + final String pendingFromFuse = String.format("(%s=1 AND %s AND %s)", column, MATCH_PENDING_FROM_FUSE, matchSharedPackagesClause); + return "(" + notPending + " OR " + pendingFromFuse + ")"; } return column + "=0"; } @@ -1473,22 +1933,25 @@ public class MediaProvider extends ContentProvider { public String[] getFilesInDirectoryForFuse(String path, int uid) { final LocalCallingIdentity token = clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path); try { - if (isPrivatePackagePathNotOwnedByCaller(path)) { + if (isPrivatePackagePathNotAccessibleByCaller(path)) { return new String[] {""}; } - // Do not allow apps to list Android/data or Android/obb dirs. Installer and - // MOUNT_EXTERNAL_ANDROID_WRITABLE apps won't be blocked by this, as their OBB dirs - // are mounted to lowerfs directly. + if (shouldBypassFuseRestrictions(/*forWrite*/ false, path)) { + return new String[] {"/"}; + } + + // Do not allow apps to list Android/data or Android/obb dirs. + // On primary volumes, apps that get special access to these directories get it via + // mount views of lowerfs. On secondary volumes, such apps would return early from + // shouldBypassFuseRestrictions above. if (isDataOrObbPath(path)) { return new String[] {""}; } - if (shouldBypassFuseRestrictions(/*forWrite*/ false, path)) { - return new String[] {"/"}; - } // Legacy apps that made is this far don't have the right storage permission and hence // are not allowed to access anything other than their external app directory if (isCallingPackageRequestingLegacy()) { @@ -1540,13 +2003,8 @@ public class MediaProvider extends ContentProvider { * </ul> */ private void scanRenamedDirectoryForFuse(@NonNull String oldPath, @NonNull String newPath) { - final LocalCallingIdentity token = clearLocalCallingIdentity(); - try { - scanFile(new File(oldPath), REASON_DEMAND); - scanFile(new File(newPath), REASON_DEMAND); - } finally { - restoreLocalCallingIdentity(token); - } + scanFileAsMediaProvider(new File(oldPath), REASON_DEMAND); + scanFileAsMediaProvider(new File(newPath), REASON_DEMAND); } /** @@ -1626,16 +2084,25 @@ public class MediaProvider extends ContentProvider { return updateDatabaseForFuseRename(helper, oldPath, newPath, values, Bundle.EMPTY); } + private boolean updateDatabaseForFuseRename(@NonNull DatabaseHelper helper, + @NonNull String oldPath, @NonNull String newPath, @NonNull ContentValues values, + @NonNull Bundle qbExtras) { + return updateDatabaseForFuseRename(helper, oldPath, newPath, values, qbExtras, + FileUtils.getContentUriForPath(oldPath)); + } + /** * Updates database entry for given {@code path} with {@code values} */ private boolean updateDatabaseForFuseRename(@NonNull DatabaseHelper helper, @NonNull String oldPath, @NonNull String newPath, @NonNull ContentValues values, - @NonNull Bundle qbExtras) { - final Uri uriOldPath = FileUtils.getContentUriForPath(oldPath); + @NonNull Bundle qbExtras, Uri uriOldPath) { boolean allowHidden = isCallingPackageAllowedHidden(); final SQLiteQueryBuilder qbForUpdate = getQueryBuilder(TYPE_UPDATE, matchUri(uriOldPath, allowHidden), uriOldPath, qbExtras, null); + if (values.containsKey(FileColumns._MODIFIER)) { + qbForUpdate.allowColumn(FileColumns._MODIFIER); + } final String selection = MediaColumns.DATA + " =? "; int count = 0; boolean retryUpdateWithReplace = false; @@ -1670,17 +2137,25 @@ public class MediaProvider extends ContentProvider { * Gets {@link ContentValues} for updating database entry to {@code path}. */ private ContentValues getContentValuesForFuseRename(String path, String newMimeType, - boolean checkHidden) { + boolean wasHidden, boolean isHidden, boolean isSameMimeType) { ContentValues values = new ContentValues(); values.put(MediaColumns.MIME_TYPE, newMimeType); values.put(MediaColumns.DATA, path); - if (checkHidden && shouldFileBeHidden(new File(path))) { + if (isHidden) { values.put(FileColumns.MEDIA_TYPE, FileColumns.MEDIA_TYPE_NONE); } else { int mediaType = MimeUtils.resolveMediaType(newMimeType); values.put(FileColumns.MEDIA_TYPE, mediaType); } + + if ((!isHidden && wasHidden) || !isSameMimeType) { + // Set the modifier as MODIFIER_FUSE so that apps can scan the file to update the + // metadata. Otherwise, scan will skip scanning this file because rename() doesn't + // change lastModifiedTime and scan assumes there is no change in the file. + values.put(FileColumns._MODIFIER, FileColumns._MODIFIER_FUSE); + } + final boolean allowHidden = isCallingPackageAllowedHidden(); if (!newMimeType.equalsIgnoreCase("null") && matchUri(getContentUriForFile(path, newMimeType), allowHidden) == AUDIO_MEDIA) { @@ -1694,12 +2169,12 @@ public class MediaProvider extends ContentProvider { private ArrayList<String> getIncludedDefaultDirectories() { final ArrayList<String> includedDefaultDirs = new ArrayList<>(); if (checkCallingPermissionVideo(/*forWrite*/ true, null)) { - includedDefaultDirs.add(DIRECTORY_DCIM); - includedDefaultDirs.add(DIRECTORY_PICTURES); - includedDefaultDirs.add(DIRECTORY_MOVIES); + includedDefaultDirs.add(Environment.DIRECTORY_DCIM); + includedDefaultDirs.add(Environment.DIRECTORY_PICTURES); + includedDefaultDirs.add(Environment.DIRECTORY_MOVIES); } else if (checkCallingPermissionImages(/*forWrite*/ true, null)) { - includedDefaultDirs.add(DIRECTORY_DCIM); - includedDefaultDirs.add(DIRECTORY_PICTURES); + includedDefaultDirs.add(Environment.DIRECTORY_DCIM); + includedDefaultDirs.add(Environment.DIRECTORY_PICTURES); } return includedDefaultDirs; } @@ -1864,12 +2339,15 @@ public class MediaProvider extends ContentProvider { final Bundle qbExtras = new Bundle(); qbExtras.putStringArrayList(INCLUDED_DEFAULT_DIRECTORIES, getIncludedDefaultDirectories()); + final boolean wasHidden = FileUtils.isDirectoryHidden(new File(oldPath)); + final boolean isHidden = FileUtils.isDirectoryHidden(new File(newPath)); for (String filePath : fileList) { final String newFilePath = newPath + "/" + filePath; final String mimeType = MimeUtils.resolveMimeType(new File(newFilePath)); if(!updateDatabaseForFuseRename(helper, oldPath + "/" + filePath, newFilePath, - getContentValuesForFuseRename(newFilePath, mimeType, - false /* checkHidden - will be fixed up below */), qbExtras)) { + getContentValuesForFuseRename(newFilePath, mimeType, wasHidden, isHidden, + /* isSameMimeType */ true), + qbExtras)) { Log.e(TAG, "Calling package doesn't have write permission to rename file."); return OsConstants.EPERM; } @@ -1943,14 +2421,24 @@ public class MediaProvider extends ContentProvider { throw new IllegalStateException("Failed to update database row with " + oldPath, e); } + final boolean wasHidden = shouldFileBeHidden(new File(oldPath)); + final boolean isHidden = shouldFileBeHidden(new File(newPath)); helper.beginTransaction(); try { final String newMimeType = MimeUtils.resolveMimeType(new File(newPath)); - if (!updateDatabaseForFuseRename(helper, oldPath, newPath, - getContentValuesForFuseRename(newPath, newMimeType, true /* checkHidden */))) { + final String oldMimeType = MimeUtils.resolveMimeType(new File(oldPath)); + final boolean isSameMimeType = newMimeType.equalsIgnoreCase(oldMimeType); + final ContentValues contentValues = getContentValuesForFuseRename(newPath, newMimeType, + wasHidden, isHidden, isSameMimeType); + + if (!updateDatabaseForFuseRename(helper, oldPath, newPath, contentValues)) { if (!bypassRestrictions) { - Log.e(TAG, "Calling package doesn't have write permission to rename file."); - return OsConstants.EPERM; + // Check for other URI format grants for oldPath only. Check right before + // returning EPERM, to leave positive case performance unaffected. + if (!renameWithOtherUriGrants(helper, oldPath, newPath, contentValues)) { + Log.e(TAG, "Calling package doesn't have write permission to rename file."); + return OsConstants.EPERM; + } } else if (!maybeRemoveOwnerPackageForFuseRename(helper, newPath)) { Log.wtf(TAG, "Couldn't clear owner package name for " + newPath); return OsConstants.EPERM; @@ -1978,15 +2466,31 @@ public class MediaProvider extends ContentProvider { // 3) /sdcard/foo/bar.mp3 => /sdcard/foo/.nomedia // in this case, we need to scan all of /sdcard/foo if (extractDisplayName(oldPath).equals(".nomedia")) { - scanFile(new File(oldPath).getParentFile(), REASON_DEMAND); + scanFileAsMediaProvider(new File(oldPath).getParentFile(), REASON_DEMAND); } if (extractDisplayName(newPath).equals(".nomedia")) { - scanFile(new File(newPath).getParentFile(), REASON_DEMAND); + scanFileAsMediaProvider(new File(newPath).getParentFile(), REASON_DEMAND); } + return 0; } /** + * Rename file by checking for other URI grants on oldPath + * + * We don't support replace scenario by checking for other URI grants on newPath (if it exists). + */ + private boolean renameWithOtherUriGrants(DatabaseHelper helper, String oldPath, String newPath, + ContentValues contentValues) { + final Uri oldPathGrantedUri = getOtherUriGrantsForPath(oldPath, /* forWrite */ true); + if (oldPathGrantedUri == null) { + return false; + } + return updateDatabaseForFuseRename(helper, oldPath, newPath, contentValues, Bundle.EMPTY, + oldPathGrantedUri); + } + + /** * Rename file/directory without imposing any restrictions. * * We don't impose any rename restrictions for apps that bypass scoped storage restrictions. @@ -2022,10 +2526,11 @@ public class MediaProvider extends ContentProvider { final String errorMessage = "Rename " + oldPath + " to " + newPath + " failed. "; final LocalCallingIdentity token = clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), oldPath); try { - if (isPrivatePackagePathNotOwnedByCaller(oldPath) - || isPrivatePackagePathNotOwnedByCaller(newPath)) { + if (isPrivatePackagePathNotAccessibleByCaller(oldPath) + || isPrivatePackagePathNotAccessibleByCaller(newPath)) { return OsConstants.EACCES; } @@ -2034,7 +2539,7 @@ public class MediaProvider extends ContentProvider { return OsConstants.EPERM; } - if (shouldBypassDatabaseForFuse(uid)) { + if (shouldBypassDatabaseAndSetDirtyForFuse(uid, newPath)) { return renameInLowerFs(oldPath, newPath); } @@ -2072,10 +2577,11 @@ public class MediaProvider extends ContentProvider { return OsConstants.EPERM; } + // TODO(b/177049768): We shouldn't use getExternalStorageDirectory for these checks. final File directoryAndroid = new File(Environment.getExternalStorageDirectory(), - DIRECTORY_ANDROID); + DIRECTORY_ANDROID_LOWER_CASE); final File directoryAndroidMedia = new File(directoryAndroid, DIRECTORY_MEDIA); - if (directoryAndroidMedia.getAbsolutePath().equals(oldPath)) { + if (directoryAndroidMedia.getAbsolutePath().equalsIgnoreCase(oldPath)) { // Don't allow renaming 'Android/media' directory. // Android/[data|obb] are bind mounted and these paths don't go through FUSE. Log.e(TAG, errorMessage + oldPath + " is a default folder in app external " @@ -2115,7 +2621,16 @@ public class MediaProvider extends ContentProvider { public int checkUriPermission(@NonNull Uri uri, int uid, /* @Intent.AccessUriMode */ int modeFlags) { final LocalCallingIdentity token = clearLocalCallingIdentity( - LocalCallingIdentity.fromExternal(getContext(), uid)); + LocalCallingIdentity.fromExternal(getContext(), mUserCache, uid)); + + if(isRedactedUri(uri)) { + if((modeFlags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0) { + // we don't allow write grants on redacted uris. + return PackageManager.PERMISSION_DENIED; + } + + uri = getUriForRedactedUri(uri); + } try { final boolean allowHidden = isCallingPackageAllowedHidden(); @@ -2178,9 +2693,14 @@ public class MediaProvider extends ContentProvider { @Override public Cursor query(Uri uri, String[] projection, Bundle queryArgs, CancellationSignal signal) { + return query(uri, projection, queryArgs, signal, /* forSelf */ false); + } + + private Cursor query(Uri uri, String[] projection, Bundle queryArgs, + CancellationSignal signal, boolean forSelf) { Trace.beginSection("query"); try { - return queryInternal(uri, projection, queryArgs, signal); + return queryInternal(uri, projection, queryArgs, signal, forSelf); } catch (FallbackException e) { return e.translateForQuery(getCallingPackageTargetSdkVersion()); } finally { @@ -2189,7 +2709,9 @@ public class MediaProvider extends ContentProvider { } private Cursor queryInternal(Uri uri, String[] projection, Bundle queryArgs, - CancellationSignal signal) throws FallbackException { + CancellationSignal signal, boolean forSelf) throws FallbackException { + final String volumeName = getVolumeName(uri); + PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), volumeName); queryArgs = (queryArgs != null) ? queryArgs : new Bundle(); // INCLUDED_DEFAULT_DIRECTORIES extra should only be set inside MediaProvider. @@ -2198,9 +2720,17 @@ public class MediaProvider extends ContentProvider { final ArraySet<String> honoredArgs = new ArraySet<>(); DatabaseUtils.resolveQueryArgs(queryArgs, honoredArgs::add, this::ensureCustomCollator); + Uri redactedUri = null; + // REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider. + queryArgs.remove(QUERY_ARG_REDACTED_URI); + if (isRedactedUri(uri)) { + redactedUri = uri; + uri = getUriForRedactedUri(uri); + queryArgs.putParcelable(QUERY_ARG_REDACTED_URI, redactedUri); + } + uri = safeUncanonicalize(uri); - final String volumeName = getVolumeName(uri); final int targetSdkVersion = getCallingPackageTargetSdkVersion(); final boolean allowHidden = isCallingPackageAllowedHidden(); final int table = matchUri(uri, allowHidden); @@ -2290,8 +2820,14 @@ public class MediaProvider extends ContentProvider { } } + // Update locale if necessary. + if (helper == mInternalDatabase && !Locale.getDefault().equals(mLastLocale)) { + Log.i(TAG, "Updating locale within queryInternal"); + onLocaleChanged(false); + } + final Cursor c = qb.query(helper, projection, queryArgs, signal); - if (c != null) { + if (c != null && !forSelf) { // As a performance optimization, only configure notifications when // resulting cursor will leave our process final boolean callerIsRemote = mCallingIdentity.get().pid != android.os.Process.myPid(); @@ -2304,9 +2840,131 @@ public class MediaProvider extends ContentProvider { honoredArgs.toArray(new String[honoredArgs.size()])); c.setExtras(extras); } + + // Query was on a redacted URI, update the sensitive information such as the _ID, DATA etc. + if (redactedUri != null && c != null) { + try { + return getRedactedUriCursor(redactedUri, c); + } finally { + c.close(); + } + } + return c; } + private boolean isUriSupportedForRedaction(Uri uri) { + final int match = matchUri(uri, true); + return REDACTED_URI_SUPPORTED_TYPES.contains(match); + } + + private Cursor getRedactedUriCursor(Uri redactedUri, @NonNull Cursor c) { + final HashSet<String> columnNames = new HashSet<>(Arrays.asList(c.getColumnNames())); + final MatrixCursor redactedUriCursor = new MatrixCursor(c.getColumnNames()); + final String redactedUriId = redactedUri.getLastPathSegment(); + + if (!c.moveToFirst()) { + return redactedUriCursor; + } + + // NOTE: It is safe to assume that there will only be one entry corresponding to a + // redacted URI as it corresponds to a unique DB entry. + if (c.getCount() != 1) { + throw new AssertionError("Two rows corresponding to " + redactedUri.toString() + + " found, when only one expected"); + } + + final MatrixCursor.RowBuilder row = redactedUriCursor.newRow(); + for (String columnName : c.getColumnNames()) { + final int colIndex = c.getColumnIndex(columnName); + if (c.getType(colIndex) == FIELD_TYPE_BLOB) { + row.add(c.getBlob(colIndex)); + } else { + row.add(c.getString(colIndex)); + } + } + + String ext = getFileExtensionFromCursor(c, columnNames); + ext = ext == null ? "" : "." + ext; + final String displayName = redactedUriId + ext; + final String data = getPathForRedactedUriId(displayName); + + + updateRow(columnNames, MediaColumns._ID, row, redactedUriId); + updateRow(columnNames, MediaColumns.DISPLAY_NAME, row, displayName); + updateRow(columnNames, MediaColumns.RELATIVE_PATH, row, REDACTED_URI_DIR); + updateRow(columnNames, MediaColumns.BUCKET_DISPLAY_NAME, row, REDACTED_URI_DIR); + updateRow(columnNames, MediaColumns.DATA, row, data); + updateRow(columnNames, MediaColumns.DOCUMENT_ID, row, null); + updateRow(columnNames, MediaColumns.INSTANCE_ID, row, null); + updateRow(columnNames, MediaColumns.BUCKET_ID, row, null); + + return redactedUriCursor; + } + + @Nullable + private static String getFileExtensionFromCursor(@NonNull Cursor c, + @NonNull HashSet<String> columnNames) { + if (columnNames.contains(MediaColumns.DATA)) { + return extractFileExtension(c.getString(c.getColumnIndex(MediaColumns.DATA))); + } + if (columnNames.contains(MediaColumns.DISPLAY_NAME)) { + return extractFileExtension(c.getString(c.getColumnIndex(MediaColumns.DISPLAY_NAME))); + } + return null; + } + + static private String getPathForRedactedUriId(@NonNull String displayName) { + return getStorageRootPathForUid(Binder.getCallingUid()) + "/" + REDACTED_URI_DIR + "/" + + displayName; + } + + static private String getStorageRootPathForUid(int uid) { + return "/storage/emulated/" + (uid / PER_USER_RANGE); + } + + private void updateRow(HashSet<String> columnNames, String columnName, + MatrixCursor.RowBuilder row, Object val) { + if (columnNames.contains(columnName)) { + row.add(columnName, val); + } + } + + private Uri getUriForRedactedUri(Uri redactedUri) { + final Uri.Builder builder = redactedUri.buildUpon(); + builder.path(null); + final List<String> segments = redactedUri.getPathSegments(); + for (int i = 0; i < segments.size() - 1; i++) { + builder.appendPath(segments.get(i)); + } + + DatabaseHelper helper; + try { + helper = getDatabaseForUri(redactedUri); + } catch (VolumeNotFoundException e) { + throw e.rethrowAsIllegalArgumentException(); + } + + try (final Cursor c = helper.runWithoutTransaction( + (db) -> db.query("files", new String[]{MediaColumns._ID}, + FileColumns.REDACTED_URI_ID + "=?", + new String[]{redactedUri.getLastPathSegment()}, null, null, null))) { + if (!c.moveToFirst()) { + throw new IllegalArgumentException( + "Uri: " + redactedUri.toString() + " not found."); + } + + builder.appendPath(c.getString(0)); + return builder.build(); + } + } + + private boolean isRedactedUri(Uri uri) { + String id = uri.getLastPathSegment(); + return id != null && id.startsWith(REDACTED_URI_ID_PREFIX) + && id.length() == REDACTED_URI_ID_SIZE; + } + @Override public String getType(Uri url) { final int match = matchUri(url, true); @@ -2410,13 +3068,25 @@ public class MediaProvider extends ContentProvider { defaultMimeType = "audio/mpeg"; defaultMediaType = FileColumns.MEDIA_TYPE_AUDIO; defaultPrimary = Environment.DIRECTORY_MUSIC; - allowedPrimary = Arrays.asList( - Environment.DIRECTORY_ALARMS, - Environment.DIRECTORY_AUDIOBOOKS, - Environment.DIRECTORY_MUSIC, - Environment.DIRECTORY_NOTIFICATIONS, - Environment.DIRECTORY_PODCASTS, - Environment.DIRECTORY_RINGTONES); + if (SdkLevel.isAtLeastS()) { + allowedPrimary = Arrays.asList( + Environment.DIRECTORY_ALARMS, + Environment.DIRECTORY_AUDIOBOOKS, + Environment.DIRECTORY_MUSIC, + Environment.DIRECTORY_NOTIFICATIONS, + Environment.DIRECTORY_PODCASTS, + Environment.DIRECTORY_RECORDINGS, + Environment.DIRECTORY_RINGTONES); + } else { + allowedPrimary = Arrays.asList( + Environment.DIRECTORY_ALARMS, + Environment.DIRECTORY_AUDIOBOOKS, + Environment.DIRECTORY_MUSIC, + Environment.DIRECTORY_NOTIFICATIONS, + Environment.DIRECTORY_PODCASTS, + FileUtils.DIRECTORY_RECORDINGS, + Environment.DIRECTORY_RINGTONES); + } break; case VIDEO_MEDIA: case VIDEO_MEDIA_ID: @@ -2563,6 +3233,7 @@ public class MediaProvider extends ContentProvider { mimeType = values.getAsString(MediaColumns.MIME_TYPE); // Quick check MIME type against table if (mimeType != null) { + PulledMetrics.logMimeTypeAccess(getCallingUidOrSelf(), mimeType); final int actualMediaType = MimeUtils.resolveMediaType(mimeType); if (defaultMediaType == FileColumns.MEDIA_TYPE_NONE) { // Give callers an opportunity to work with playlists and @@ -2641,9 +3312,9 @@ public class MediaProvider extends ContentProvider { // Next, consider allowing based on allowed primary directory final String[] relativePath = values.getAsString(MediaColumns.RELATIVE_PATH).split("/"); - final String primary = (relativePath.length > 0) ? relativePath[0] : null; + final String primary = extractTopLevelDir(relativePath); if (!validPath) { - validPath = allowedPrimary.contains(primary); + validPath = containsIgnoreCase(allowedPrimary, primary); } // Next, consider allowing paths when referencing a related item @@ -2711,20 +3382,33 @@ public class MediaProvider extends ContentProvider { + "; allowed directories are " + allowedPrimary); } + boolean isFuseThread = isFuseThread(); + // Check if the following are true: + // 1. Not a FUSE thread + // 2. |res| is a child of a default dir and the default dir is missing + // If true, we want to update the mTime of the volume root, after creating the dir + // on the lower filesystem. This fixes some FileManagers relying on the mTime change + // for UI updates + File defaultDirVolumePath = + isFuseThread ? null : checkDefaultDirMissing(resolvedVolumeName, res); // Ensure all parent folders of result file exist res.getParentFile().mkdirs(); if (!res.getParentFile().exists()) { throw new IllegalStateException("Failed to create directory: " + res); } + touchFusePath(defaultDirVolumePath); + values.put(MediaColumns.DATA, res.getAbsolutePath()); // buildFile may have changed the file name, compute values to extract new DISPLAY_NAME. // Note: We can't extract displayName from res.getPath() because for pending & trashed // files DISPLAY_NAME will not be same as file name. - FileUtils.computeValuesFromData(values, isFuseThread()); + FileUtils.computeValuesFromData(values, isFuseThread); } else { assertFileColumnsConsistent(match, uri, values); } + assertPrivatePathNotInValues(values); + // Drop columns that aren't relevant for special tables switch (match) { case AUDIO_ALBUMART: @@ -2744,6 +3428,76 @@ public class MediaProvider extends ContentProvider { } /** + * Check that values don't contain any external private path. + * NOTE: The checks are gated on targetSDK S. + */ + private void assertPrivatePathNotInValues(ContentValues values) + throws IllegalArgumentException { + if (!CompatChanges.isChangeEnabled(ENABLE_CHECKS_FOR_PRIVATE_FILES, + Binder.getCallingUid())) { + // For legacy apps, let the behaviour be as it is. + return; + } + + ArrayList<String> relativePaths = new ArrayList<String>(); + relativePaths.add(extractRelativePath(values.getAsString(MediaColumns.DATA))); + relativePaths.add(values.getAsString(MediaColumns.RELATIVE_PATH)); + /** + * Don't allow apps to insert/update database row to files in Android/data or + * Android/obb dirs. These are app private directories and files in these private + * directories can't be added to public media collection. + */ + for (final String relativePath : relativePaths) { + if (relativePath == null) continue; + + final String[] relativePathSegments = relativePath.split("/", 3); + final String primary = + (relativePathSegments.length > 0) ? relativePathSegments[0] : null; + final String secondary = + (relativePathSegments.length > 1) ? relativePathSegments[1] : ""; + + if (DIRECTORY_ANDROID_LOWER_CASE.equalsIgnoreCase(primary) + && PRIVATE_SUBDIRECTORIES_ANDROID.contains( + secondary.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException( + "Inserting private file: " + relativePath + " is not allowed."); + } + } + } + + /** + * @return the default dir if {@code file} is a child of default dir and it's missing, + * {@code null} otherwise. + */ + private File checkDefaultDirMissing(String volumeName, File file) { + String topLevelDir = FileUtils.extractTopLevelDir(file.getPath()); + if (topLevelDir != null && FileUtils.isDefaultDirectoryName(topLevelDir)) { + try { + File volumePath = getVolumePath(volumeName); + if (!new File(volumePath, topLevelDir).exists()) { + return volumePath; + } + } catch (FileNotFoundException e) { + Log.w(TAG, "Failed to checkDefaultDirMissing for " + file, e); + } + } + return null; + } + + /** Updates mTime of {@code path} on the FUSE filesystem */ + private void touchFusePath(@Nullable File path) { + if (path != null) { + // Touch root of volume to update mTime on FUSE filesystem + // This allows FileManagers that may be relying on mTime changes to update their UI + File fusePath = getFuseFile(path); + if (fusePath != null) { + Log.i(TAG, "Touching FUSE path " + fusePath); + fusePath.setLastModified(System.currentTimeMillis()); + } + } + } + + /** * Check that any requested {@link MediaColumns#DATA} paths actually * live on the storage volume being targeted. */ @@ -2754,7 +3508,7 @@ public class MediaProvider extends ContentProvider { final String volumeName = resolveVolumeName(uri); try { // Quick check that the requested path actually lives on volume - final Collection<File> allowed = getVolumeScanPaths(volumeName); + final Collection<File> allowed = getAllowedVolumePaths(volumeName); final File actual = new File(values.getAsString(MediaColumns.DATA)) .getCanonicalFile(); if (!FileUtils.contains(allowed, actual)) { @@ -2766,7 +3520,7 @@ public class MediaProvider extends ContentProvider { } @Override - public int bulkInsert(Uri uri, ContentValues values[]) { + public int bulkInsert(Uri uri, ContentValues[] values) { final int targetSdkVersion = getCallingPackageTargetSdkVersion(); final boolean allowHidden = isCallingPackageAllowedHidden(); final int match = matchUri(uri, allowHidden); @@ -2775,6 +3529,28 @@ public class MediaProvider extends ContentProvider { return super.bulkInsert(uri, values); } + if (match == AUDIO_PLAYLISTS_ID || match == AUDIO_PLAYLISTS_ID_MEMBERS) { + final String resolvedVolumeName = resolveVolumeName(uri); + + final long playlistId = Long.parseLong(uri.getPathSegments().get(3)); + final Uri playlistUri = ContentUris.withAppendedId( + MediaStore.Audio.Playlists.getContentUri(resolvedVolumeName), playlistId); + + final String audioVolumeName = + MediaStore.VOLUME_INTERNAL.equals(resolvedVolumeName) + ? MediaStore.VOLUME_INTERNAL : MediaStore.VOLUME_EXTERNAL; + + // Require that caller has write access to underlying media + enforceCallingPermission(playlistUri, Bundle.EMPTY, true); + for (ContentValues each : values) { + final long audioId = each.getAsLong(Audio.Playlists.Members.AUDIO_ID); + final Uri audioUri = Audio.Media.getContentUri(audioVolumeName, audioId); + enforceCallingPermission(audioUri, Bundle.EMPTY, false); + } + + return bulkInsertPlaylist(playlistUri, values); + } + final DatabaseHelper helper; try { helper = getDatabaseForUri(uri); @@ -2792,6 +3568,25 @@ public class MediaProvider extends ContentProvider { } } + private int bulkInsertPlaylist(@NonNull Uri uri, @NonNull ContentValues[] values) { + Trace.beginSection("bulkInsertPlaylist"); + try { + try { + return addPlaylistMembers(uri, values); + } catch (SQLiteConstraintException e) { + if (getCallingPackageTargetSdkVersion() >= Build.VERSION_CODES.R) { + throw e; + } else { + return 0; + } + } + } catch (FallbackException e) { + return e.translateForBulkInsert(getCallingPackageTargetSdkVersion()); + } finally { + Trace.endSection(); + } + } + private long insertDirectory(@NonNull SQLiteDatabase db, @NonNull String path) { if (LOGV) Log.v(TAG, "inserting directory " + path); ContentValues values = new ContentValues(); @@ -2948,8 +3743,15 @@ public class MediaProvider extends ContentProvider { } public void onLocaleChanged() { + onLocaleChanged(true); + } + + private void onLocaleChanged(boolean forceUpdate) { mInternalDatabase.runWithTransaction((db) -> { - localizeTitles(db); + if (forceUpdate || !mLastLocale.equals(Locale.getDefault())) { + localizeTitles(db); + mLastLocale = Locale.getDefault(); + } return null; }); } @@ -3047,7 +3849,30 @@ public class MediaProvider extends ContentProvider { values.put(FileColumns.MEDIA_TYPE, mediaType); } + qb.allowColumn(FileColumns._MODIFIER); + if (isCallingPackageSelf() && values.containsKey(FileColumns._MODIFIER)) { + // We can't identify if the call is coming from media scan, hence + // we let ModernMediaScanner send FileColumns._MODIFIER value. + } else if (isFuseThread()) { + values.put(FileColumns._MODIFIER, FileColumns._MODIFIER_FUSE); + } else { + values.put(FileColumns._MODIFIER, FileColumns._MODIFIER_CR); + } + + // There is no meaning of an owner in the internal storage. It is shared by all users. + // So we only set the user_id field in the database for external storage. + qb.allowColumn(FileColumns._USER_ID); + int ownerUserId = FileUtils.extractUserId(path); + if (!helper.mInternal) { + if (isAppCloneUserForFuse(ownerUserId)) { + values.put(FileColumns._USER_ID, ownerUserId); + } else { + values.put(FileColumns._USER_ID, sUserId); + } + } + final long rowId; + Uri newUri = uri; { if (mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) { String name = values.getAsString(Audio.Playlists.NAME); @@ -3074,6 +3899,12 @@ public class MediaProvider extends ContentProvider { values.put(FileColumns.SIZE, file.length()); } } + // Checking if the file/directory is hidden can be expensive based on the depth of + // the directory tree. Call shouldFileBeHidden() only when the caller of insert() + // cares about returned uri. + if (!isCallingPackageSelf() && !isFuseThread() && shouldFileBeHidden(file)) { + newUri = MediaStore.Files.getContentUri(MediaStore.getVolumeName(uri)); + } } rowId = insertAllowingUpsert(qb, helper, values, path); @@ -3084,7 +3915,7 @@ public class MediaProvider extends ContentProvider { } } - return ContentUris.withAppendedId(uri, rowId); + return ContentUris.withAppendedId(newUri, rowId); } /** @@ -3253,7 +4084,12 @@ public class MediaProvider extends ContentProvider { private @Nullable Uri insertInternal(@NonNull Uri uri, @Nullable ContentValues initialValues, @Nullable Bundle extras) throws FallbackException { + final String originalVolumeName = getVolumeName(uri); + PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), originalVolumeName); + extras = (extras != null) ? extras : new Bundle(); + // REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider. + extras.remove(QUERY_ARG_REDACTED_URI); // INCLUDED_DEFAULT_DIRECTORIES extra should only be set inside MediaProvider. extras.remove(INCLUDED_DEFAULT_DIRECTORIES); @@ -3262,7 +4098,6 @@ public class MediaProvider extends ContentProvider { 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() @@ -3278,15 +4113,23 @@ public class MediaProvider extends ContentProvider { if (match == VOLUMES) { String name = initialValues.getAsString("name"); - Uri attachedVolume = attachVolume(name, /* validate */ true); - if (mMediaScannerVolume != null && mMediaScannerVolume.equals(name)) { - final DatabaseHelper helper = getDatabaseForUri( - MediaStore.Files.getContentUri(mMediaScannerVolume)); - helper.mScanStartTime = SystemClock.elapsedRealtime(); + MediaVolume volume = null; + try { + volume = getVolume(name); + Uri attachedVolume = attachVolume(volume, /* validate */ true); + if (mMediaScannerVolume != null && mMediaScannerVolume.equals(name)) { + final DatabaseHelper helper = getDatabaseForUri( + MediaStore.Files.getContentUri(mMediaScannerVolume)); + helper.mScanStartTime = SystemClock.elapsedRealtime(); + } + return attachedVolume; + } catch (FileNotFoundException e) { + Log.w(TAG, "Couldn't find volume with name " + volume.getName()); + return null; } - return attachedVolume; } + final DatabaseHelper helper = getDatabaseForUri(uri); switch (match) { case AUDIO_PLAYLISTS_ID: case AUDIO_PLAYLISTS_ID_MEMBERS: { @@ -3296,8 +4139,11 @@ public class MediaProvider extends ContentProvider { final long audioId = initialValues .getAsLong(MediaStore.Audio.Playlists.Members.AUDIO_ID); + final String audioVolumeName = + MediaStore.VOLUME_INTERNAL.equals(resolvedVolumeName) + ? MediaStore.VOLUME_INTERNAL : MediaStore.VOLUME_EXTERNAL; final Uri audioUri = ContentUris.withAppendedId( - MediaStore.Audio.Media.getContentUri(resolvedVolumeName), audioId); + MediaStore.Audio.Media.getContentUri(audioVolumeName), audioId); // Require that caller has write access to underlying media enforceCallingPermission(playlistUri, Bundle.EMPTY, true); @@ -3307,6 +4153,8 @@ public class MediaProvider extends ContentProvider { // files on disk to ensure that we can reliably migrate between // devices and recover from database corruption final long id = addPlaylistMembers(playlistUri, initialValues); + acceptWithExpansion(helper::notifyInsert, resolvedVolumeName, playlistId, + FileColumns.MEDIA_TYPE_PLAYLIST, false); return ContentUris.withAppendedId(MediaStore.Audio.Playlists.Members .getContentUri(originalVolumeName, playlistId), id); } @@ -3388,7 +4236,6 @@ public class MediaProvider extends ContentProvider { long rowId = -1; Uri newUri = null; - final DatabaseHelper helper = getDatabaseForUri(uri); final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_INSERT, match, uri, extras, null); switch (match) { @@ -3541,7 +4388,7 @@ public class MediaProvider extends ContentProvider { mCallingIdentity.get().setOwned(rowId, true); if (path != null && path.toLowerCase(Locale.ROOT).endsWith("/.nomedia")) { - mMediaScanner.scanFile(new File(path).getParentFile(), REASON_DEMAND); + scanFileAsMediaProvider(new File(path).getParentFile(), REASON_DEMAND); } return newUri; @@ -3624,6 +4471,14 @@ public class MediaProvider extends ContentProvider { } } + /** + * Gets {@link LocalCallingIdentity} for the calling package + * TODO(b/170465810) Change the method name after refactoring. + */ + LocalCallingIdentity getCachedCallingIdentityForTranscoding(int uid) { + return getCachedCallingIdentityForFuse(uid); + } + @Deprecated private String getSharedPackages() { final String[] sharedPackageNames = mCallingIdentity.get().getSharedPackageNames(); @@ -3649,6 +4504,23 @@ public class MediaProvider extends ContentProvider { private static final int TYPE_DELETE = 3; /** + * Creating a new method for Transcoding to avoid any merge conflicts. + * TODO(b/170465810): Remove this when getQueryBuilder code is refactored. + */ + @NonNull SQLiteQueryBuilder getQueryBuilderForTranscoding(int type, int match, + @NonNull Uri uri, @NonNull Bundle extras, @Nullable Consumer<String> honored) { + // Force MediaProvider calling identity when accessing the db from transcoding to avoid + // generating 'strict' SQL e.g forcing owner_package_name matches + // We already handle the required permission checks for the app before we get here + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + return getQueryBuilder(type, match, uri, extras, honored); + } finally { + restoreLocalCallingIdentity(token); + } + } + + /** * Generate a {@link SQLiteQueryBuilder} that is filtered based on the * runtime permissions and/or {@link Uri} grants held by the caller. * <ul> @@ -3702,7 +4574,7 @@ public class MediaProvider extends ContentProvider { final String volumeName = MediaStore.getVolumeName(uri); final String includeVolumes; if (MediaStore.VOLUME_EXTERNAL.equals(volumeName)) { - includeVolumes = bindList(getExternalVolumeNames().toArray()); + includeVolumes = bindList(mVolumeCache.getExternalVolumeNames().toArray()); } else { includeVolumes = bindList(volumeName); } @@ -3710,7 +4582,18 @@ public class MediaProvider extends ContentProvider { final String matchSharedPackagesClause = FileColumns.OWNER_PACKAGE_NAME + " IN " + sharedPackages; - final boolean allowGlobal = checkCallingPermissionGlobal(uri, forWrite); + boolean allowGlobal; + final Uri redactedUri = extras.getParcelable(QUERY_ARG_REDACTED_URI); + if (redactedUri != null) { + if (forWrite) { + throw new UnsupportedOperationException( + "Writes on: " + redactedUri.toString() + " are not supported"); + } + allowGlobal = checkCallingPermissionGlobal(redactedUri, false); + } else { + allowGlobal = checkCallingPermissionGlobal(uri, forWrite); + } + final boolean allowLegacy = forWrite ? isCallingPackageLegacyWrite() : isCallingPackageLegacyRead(); final boolean allowLegacyRead = allowLegacy && !forWrite; @@ -3742,7 +4625,9 @@ public class MediaProvider extends ContentProvider { // Handle callers using legacy filtering final String filter = uri.getQueryParameter("filter"); - boolean includeAllVolumes = false; + // Only accept ALL_VOLUMES parameter up until R, because we're not convinced we want + // to commit to this as an API. + final boolean includeAllVolumes = shouldIncludeRecentlyUnmountedVolumes(uri, extras); final String callingPackage = getCallingPackageOrSelf(); switch (match) { @@ -3945,6 +4830,13 @@ public class MediaProvider extends ContentProvider { qb.setProjectionMap(projectionMap); appendWhereStandalone(qb, "audio._id = audio_id"); + // Since we use audio table along with audio_playlists_map + // for querying, we should only include database rows of + // the attached volumes. + if (!includeAllVolumes) { + appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + + includeVolumes); + } } else { qb.setTables("audio_playlists_map"); qb.setProjectionMap(getProjectionMap(Audio.Playlists.Members.class)); @@ -3980,7 +4872,7 @@ public class MediaProvider extends ContentProvider { } case AUDIO_ARTISTS_ID_ALBUMS: { if (type == TYPE_QUERY) { - qb.setTables("audio_albums"); + qb.setTables("audio_artists_albums"); qb.setProjectionMap(getProjectionMap(Audio.Artists.Albums.class)); final String artistId = uri.getPathSegments().get(3); @@ -4213,6 +5105,36 @@ public class MediaProvider extends ContentProvider { } /** + * @return {@code true} if app requests to include database rows from + * recently unmounted volume. + * {@code false} otherwise. + */ + private boolean shouldIncludeRecentlyUnmountedVolumes(Uri uri, Bundle extras) { + if (isFuseThread()) { + // File path requests don't require to query from unmounted volumes. + return false; + } + + boolean isIncludeVolumesChangeEnabled = SdkLevel.isAtLeastS() && + CompatChanges.isChangeEnabled(ENABLE_INCLUDE_ALL_VOLUMES, Binder.getCallingUid()); + if ("1".equals(uri.getQueryParameter(ALL_VOLUMES))) { + // Support uri parameter only in R OS and below. Apps should use + // MediaStore#QUERY_ARG_RECENTLY_UNMOUNTED_VOLUMES on S OS onwards. + if (!isIncludeVolumesChangeEnabled) { + return true; + } + throw new IllegalArgumentException("Unsupported uri parameter \"all_volumes\""); + } + if (isIncludeVolumesChangeEnabled) { + // MediaStore#QUERY_ARG_INCLUDE_RECENTLY_UNMOUNTED_VOLUMES is only supported on S OS and + // for app targeting targetSdk>=S. + return extras.getBoolean(MediaStore.QUERY_ARG_INCLUDE_RECENTLY_UNMOUNTED_VOLUMES, + false); + } + return false; + } + + /** * Determine if given {@link Uri} has a * {@link MediaColumns#OWNER_PACKAGE_NAME} column. */ @@ -4258,7 +5180,17 @@ public class MediaProvider extends ContentProvider { private int deleteInternal(@NonNull Uri uri, @Nullable Bundle extras) throws FallbackException { + final String volumeName = getVolumeName(uri); + PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), volumeName); + extras = (extras != null) ? extras : new Bundle(); + // REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider. + extras.remove(QUERY_ARG_REDACTED_URI); + + if (isRedactedUri(uri)) { + // we don't support deletion on redacted uris. + return 0; + } // INCLUDED_DEFAULT_DIRECTORIES extra should only be set inside MediaProvider. extras.remove(INCLUDED_DEFAULT_DIRECTORIES); @@ -4267,7 +5199,7 @@ public class MediaProvider extends ContentProvider { final boolean allowHidden = isCallingPackageAllowedHidden(); final int match = matchUri(uri, allowHidden); - switch(match) { + switch (match) { case AUDIO_MEDIA_ID: case AUDIO_PLAYLISTS_ID: case VIDEO_MEDIA_ID: @@ -4295,7 +5227,6 @@ public class MediaProvider extends ContentProvider { int count = 0; - final String volumeName = getVolumeName(uri); final int targetSdkVersion = getCallingPackageTargetSdkVersion(); // handle MEDIA_SCANNER before calling getDatabaseForUri() @@ -4318,6 +5249,7 @@ public class MediaProvider extends ContentProvider { count = 1; } + final DatabaseHelper helper = getDatabaseForUri(uri); switch (match) { case AUDIO_PLAYLISTS_ID_MEMBERS_ID: extras.putString(QUERY_ARG_SQL_SELECTION, @@ -4331,11 +5263,15 @@ public class MediaProvider extends ContentProvider { // Playlist contents are always persisted directly into playlist // files on disk to ensure that we can reliably migrate between // devices and recover from database corruption - return removePlaylistMembers(playlistUri, extras); + int numOfRemovedPlaylistMembers = removePlaylistMembers(playlistUri, extras); + if (numOfRemovedPlaylistMembers > 0) { + acceptWithExpansion(helper::notifyDelete, volumeName, playlistId, + FileColumns.MEDIA_TYPE_PLAYLIST, false); + } + return numOfRemovedPlaylistMembers; } } - final DatabaseHelper helper = getDatabaseForUri(uri); final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_DELETE, match, uri, extras, null); { @@ -4357,6 +5293,7 @@ public class MediaProvider extends ContentProvider { }; final boolean isFilesTable = qb.getTables().equals("files"); final LongSparseArray<String> deletedDownloadIds = new LongSparseArray<>(); + final int[] countPerMediaType = new int[FileColumns.MEDIA_TYPE_COUNT]; if (isFilesTable) { String deleteparam = uri.getQueryParameter(MediaStore.PARAM_DELETE_DATA); if (deleteparam == null || ! deleteparam.equals("false")) { @@ -4370,35 +5307,23 @@ public class MediaProvider extends ContentProvider { final int isDownload = c.getInt(3); final String mimeType = c.getString(4); + // TODO(b/188782594) Consider logging mime type access on delete too. + // Forget that caller is owner of this item mCallingIdentity.get().setOwned(id, false); deleteIfAllowed(uri, extras, data); - count += qb.delete(helper, BaseColumns._ID + "=" + id, null); + int res = qb.delete(helper, BaseColumns._ID + "=" + id, null); + count += res; + // Avoid ArrayIndexOutOfBounds if more mediaTypes are added, + // but mediaTypeSize is not updated + if (res > 0 && mediaType < countPerMediaType.length) { + countPerMediaType[mediaType] += res; + } - // Only need to inform DownloadProvider about the downloads deleted on - // external volume. if (isDownload == 1) { deletedDownloadIds.put(id, mimeType); } - - // Update any playlists that reference this item - if ((mediaType == FileColumns.MEDIA_TYPE_AUDIO) - && helper.isExternal()) { - helper.runWithTransaction((db) -> { - try (Cursor cc = db.query("audio_playlists_map", - new String[] { "playlist_id" }, "audio_id=" + id, - null, "playlist_id", null, null)) { - while (cc.moveToNext()) { - final Uri playlistUri = ContentUris.withAppendedId( - Playlists.getContentUri(volumeName), - cc.getLong(0)); - resolvePlaylistMembers(playlistUri); - } - } - return null; - }); - } } } finally { FileUtils.closeQuietly(c); @@ -4438,23 +5363,73 @@ public class MediaProvider extends ContentProvider { } if (deletedDownloadIds.size() > 0) { - // Do this on a background thread, since we don't want to make binder - // calls as part of a FUSE call. - helper.postBackground(() -> { - getContext().getSystemService(DownloadManager.class) - .onMediaStoreDownloadsDeleted(deletedDownloadIds); - }); + notifyDownloadManagerOnDelete(helper, deletedDownloadIds); + } + + // Check for other URI format grants for File API call only. Check right before + // returning count = 0, to leave positive cases performance unaffected. + if (count == 0 && isFuseThread()) { + count += deleteWithOtherUriGrants(uri, helper, projection, userWhere, userWhereArgs, + extras); } if (isFilesTable && !isCallingPackageSelf()) { Metrics.logDeletion(volumeName, mCallingIdentity.get().uid, - getCallingPackageOrSelf(), count); + getCallingPackageOrSelf(), count, countPerMediaType); } } return count; } + private int deleteWithOtherUriGrants(@NonNull Uri uri, DatabaseHelper helper, + String[] projection, String userWhere, String[] userWhereArgs, + @Nullable Bundle extras) { + try { + Cursor c = queryForSingleItemAsMediaProvider(uri, projection, userWhere, userWhereArgs, + null); + final int mediaType = c.getInt(0); + final String data = c.getString(1); + final long id = c.getLong(2); + final int isDownload = c.getInt(3); + final String mimeType = c.getString(4); + + final Uri uriGranted = getOtherUriGrantsForPath(data, mediaType, Long.toString(id), + /* forWrite */ true); + if (uriGranted != null) { + // 1. delete file + deleteIfAllowed(uriGranted, extras, data); + // 2. delete file row from the db + final boolean allowHidden = isCallingPackageAllowedHidden(); + final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_DELETE, + matchUri(uriGranted, allowHidden), uriGranted, extras, null); + int count = qb.delete(helper, BaseColumns._ID + "=" + id, null); + + if (isDownload == 1) { + final LongSparseArray<String> deletedDownloadIds = new LongSparseArray<>(); + deletedDownloadIds.put(id, mimeType); + notifyDownloadManagerOnDelete(helper, deletedDownloadIds); + } + return count; + } + } catch (FileNotFoundException ignored) { + // Do nothing. Returns 0 files deleted. + } + return 0; + } + + private void notifyDownloadManagerOnDelete(DatabaseHelper helper, + LongSparseArray<String> deletedDownloadIds) { + // Do this on a background thread, since we don't want to make binder + // calls as part of a FUSE call. + helper.postBackground(() -> { + DownloadManager dm = getContext().getSystemService(DownloadManager.class); + if (dm != null) { + dm.onMediaStoreDownloadsDeleted(deletedDownloadIds); + } + }); + } + /** * Executes identical delete repeatedly within a single transaction until * stability is reached. Combined with {@link #ID_NOT_PARENT_CLAUSE}, this @@ -4478,6 +5453,65 @@ public class MediaProvider extends ContentProvider { }); } + @Nullable + @VisibleForTesting + Uri getRedactedUri(@NonNull Uri uri) { + if (!isUriSupportedForRedaction(uri)) { + return null; + } + + DatabaseHelper helper; + try { + helper = getDatabaseForUri(uri); + } catch (VolumeNotFoundException e) { + throw e.rethrowAsIllegalArgumentException(); + } + + try (final Cursor c = helper.runWithoutTransaction( + (db) -> db.query("files", + new String[]{FileColumns.REDACTED_URI_ID}, FileColumns._ID + "=?", + new String[]{uri.getLastPathSegment()}, null, null, null))) { + // Database entry for uri not found. + if (!c.moveToFirst()) return null; + + String redactedUriID = c.getString(c.getColumnIndex(FileColumns.REDACTED_URI_ID)); + if (redactedUriID == null) { + // No redacted has even been created for this uri. Create a new redacted URI ID for + // the uri and store it in the DB. + redactedUriID = REDACTED_URI_ID_PREFIX + UUID.randomUUID().toString().replace("-", + ""); + + ContentValues cv = new ContentValues(); + cv.put(FileColumns.REDACTED_URI_ID, redactedUriID); + int rowsAffected = helper.runWithTransaction( + (db) -> db.update("files", cv, FileColumns._ID + "=?", + new String[]{uri.getLastPathSegment()})); + if (rowsAffected == 0) { + // this shouldn't happen ideally, only reason this might happen is if the db + // entry got deleted in b/w in which case we should return null. + return null; + } + } + + // Create and return a uri with ID = redactedUriID. + final Uri.Builder builder = ContentUris.removeId(uri).buildUpon(); + builder.appendPath(redactedUriID); + + return builder.build(); + } + } + + @NonNull + @VisibleForTesting + List<Uri> getRedactedUri(@NonNull List<Uri> uris) { + ArrayList<Uri> redactedUris = new ArrayList<>(); + for (Uri uri : uris) { + redactedUris.add(getRedactedUri(uri)); + } + + return redactedUris; + } + @Override public Bundle call(String method, String arg, Bundle extras) { Trace.beginSection("call"); @@ -4532,6 +5566,7 @@ public class MediaProvider extends ContentProvider { } case MediaStore.SCAN_FILE_CALL: case MediaStore.SCAN_VOLUME_CALL: { + final int userId = Binder.getCallingUid() / PER_USER_RANGE; final LocalCallingIdentity token = clearLocalCallingIdentity(); final CallingIdentity providerToken = clearCallingIdentity(); try { @@ -4544,7 +5579,13 @@ public class MediaProvider extends ContentProvider { } case MediaStore.SCAN_VOLUME_CALL: { final String volumeName = arg; - MediaService.onScanVolume(getContext(), volumeName, REASON_DEMAND); + try { + MediaVolume volume = mVolumeCache.findVolume(volumeName, + UserHandle.of(userId)); + MediaService.onScanVolume(getContext(), volume, REASON_DEMAND); + } catch (FileNotFoundException e) { + Log.w(TAG, "Failed to find volume " + volumeName, e); + } break; } } @@ -4608,7 +5649,7 @@ public class MediaProvider extends ContentProvider { try (ContentProviderClient client = getContext().getContentResolver() .acquireUnstableContentProviderClient( - MediaStore.EXTERNAL_STORAGE_PROVIDER_AUTHORITY)) { + getExternalStorageProviderAuthority())) { extras.putParcelable(MediaStore.EXTRA_URI, fileUri); return client.call(method, null, extras); } catch (RemoteException e) { @@ -4620,24 +5661,61 @@ public class MediaProvider extends ContentProvider { getContext().enforceCallingUriPermission(documentUri, Intent.FLAG_GRANT_READ_URI_PERMISSION, TAG); - final Uri fileUri; + final int callingPid = mCallingIdentity.get().pid; + final int callingUid = mCallingIdentity.get().uid; + final String callingPackage = getCallingPackage(); + final CallingIdentity token = clearCallingIdentity(); + final String authority = documentUri.getAuthority(); + + if (!authority.equals(MediaDocumentsProvider.AUTHORITY) && + !authority.equals(DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY)) { + throw new IllegalArgumentException("Provider for this Uri is not supported."); + } + try (ContentProviderClient client = getContext().getContentResolver() - .acquireUnstableContentProviderClient( - MediaStore.EXTERNAL_STORAGE_PROVIDER_AUTHORITY)) { - final Bundle res = client.call(method, null, extras); - fileUri = res.getParcelable(MediaStore.EXTRA_URI); + .acquireUnstableContentProviderClient(authority)) { + final Bundle clientRes = client.call(method, null, extras); + final Uri fileUri = clientRes.getParcelable(MediaStore.EXTRA_URI); + final Bundle res = new Bundle(); + final Uri mediaStoreUri = fileUri.getAuthority().equals(MediaStore.AUTHORITY) ? + fileUri : queryForMediaUri(new File(fileUri.getPath()), null); + copyUriPermissionGrants(documentUri, mediaStoreUri, callingPid, + callingUid, callingPackage); + res.putParcelable(MediaStore.EXTRA_URI, mediaStoreUri); + return res; + } catch (FileNotFoundException e) { + throw new IllegalArgumentException(e); } catch (RemoteException e) { throw new IllegalStateException(e); + } finally { + restoreCallingIdentity(token); } - + } + case MediaStore.GET_REDACTED_MEDIA_URI_CALL: { + final Uri uri = extras.getParcelable(MediaStore.EXTRA_URI); + // NOTE: It is ok to update the DB and return a redacted URI for the cases when + // the user code only has read access, hence we don't check for write permission. + enforceCallingPermission(uri, Bundle.EMPTY, false); final LocalCallingIdentity token = clearLocalCallingIdentity(); try { final Bundle res = new Bundle(); - res.putParcelable(MediaStore.EXTRA_URI, - queryForMediaUri(new File(fileUri.getPath()), null)); + res.putParcelable(MediaStore.EXTRA_URI, getRedactedUri(uri)); + return res; + } finally { + restoreLocalCallingIdentity(token); + } + } + case MediaStore.GET_REDACTED_MEDIA_URI_LIST_CALL: { + final List<Uri> uris = extras.getParcelableArrayList(MediaStore.EXTRA_URI_LIST); + // NOTE: It is ok to update the DB and return a redacted URI for the cases when + // the user code only has read access, hence we don't check for write permission. + enforceCallingPermission(uris, false); + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + final Bundle res = new Bundle(); + res.putParcelableArrayList(MediaStore.EXTRA_URI_LIST, + (ArrayList<? extends Parcelable>) getRedactedUri(uris)); return res; - } catch (FileNotFoundException e) { - throw new IllegalArgumentException(e); } finally { restoreLocalCallingIdentity(token); } @@ -4651,11 +5729,87 @@ public class MediaProvider extends ContentProvider { res.putParcelable(MediaStore.EXTRA_RESULT, pi); return res; } + case MediaStore.IS_SYSTEM_GALLERY_CALL: + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + String packageName = arg; + int uid = extras.getInt(MediaStore.EXTRA_IS_SYSTEM_GALLERY_UID); + boolean isSystemGallery = PermissionUtils.checkWriteImagesOrVideoAppOps( + getContext(), uid, packageName, getContext().getAttributionTag()); + Bundle res = new Bundle(); + res.putBoolean(MediaStore.EXTRA_IS_SYSTEM_GALLERY_RESPONSE, isSystemGallery); + return res; + } finally { + restoreLocalCallingIdentity(token); + } default: throw new UnsupportedOperationException("Unsupported call: " + method); } } + private AssetFileDescriptor getOriginalMediaFormatFileDescriptor(Bundle extras) + throws FileNotFoundException { + try (ParcelFileDescriptor inputPfd = + extras.getParcelable(MediaStore.EXTRA_FILE_DESCRIPTOR)) { + final File file = getFileFromFileDescriptor(inputPfd); + if (!mTranscodeHelper.supportsTranscode(file.getPath())) { + // Note that we should be checking if a file is a modern format and not just + // that it supports transcoding, unfortunately, checking modern format + // requires either a db query or media scan which can lead to ANRs if apps + // or the system implicitly call this method as part of a + // MediaPlayer#setDataSource. + throw new FileNotFoundException("Input file descriptor is already original"); + } + + FuseDaemon fuseDaemon = getFuseDaemonForFile(file); + String outputPath = fuseDaemon.getOriginalMediaFormatFilePath(inputPfd); + if (TextUtils.isEmpty(outputPath)) { + throw new FileNotFoundException("Invalid path for original media format file"); + } + + int posixMode = Os.fcntlInt(inputPfd.getFileDescriptor(), F_GETFL, + 0 /* args */); + int modeBits = FileUtils.translateModePosixToPfd(posixMode); + int uid = Binder.getCallingUid(); + + ParcelFileDescriptor pfd = openWithFuse(outputPath, uid, 0 /* mediaCapabilitiesUid */, + modeBits, true /* shouldRedact */, false /* shouldTranscode */, + 0 /* transcodeReason */); + return new AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH); + } catch (IOException e) { + Log.w(TAG, "Failed to fetch original file descriptor", e); + throw new FileNotFoundException("Failed to fetch original file descriptor"); + } catch (ErrnoException e) { + Log.w(TAG, "Failed to fetch access mode for file descriptor", e); + throw new FileNotFoundException("Failed to fetch access mode for file descriptor"); + } + } + + /** + * Grant similar read/write access for mediaStoreUri as the caller has for documentsUri. + * + * Note: This function assumes that read permission check for documentsUri is already enforced. + * Note: This function currently does not check/grant for persisted Uris. Support for this can + * be added eventually, but the calling application will have to call + * ContentResolver#takePersistableUriPermission(Uri, int) for the mediaStoreUri to persist. + * + * @param documentsUri DocumentsProvider format content Uri + * @param mediaStoreUri MediaStore format content Uri + * @param callingPid pid of the caller + * @param callingUid uid of the caller + * @param callingPackage package name of the caller + */ + private void copyUriPermissionGrants(Uri documentsUri, Uri mediaStoreUri, + int callingPid, int callingUid, String callingPackage) { + // No need to check for read permission, as we enforce it already. + int modeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION; + if (getContext().checkUriPermission(documentsUri, callingPid, callingUid, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION) == PERMISSION_GRANTED) { + modeFlags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + } + getContext().grantUriPermission(callingPackage, mediaStoreUri, modeFlags); + } + static List<Uri> collectUris(ClipData clipData) { final ArrayList<Uri> res = new ArrayList<>(); for (int i = 0; i < clipData.getItemCount(); i++) { @@ -4665,6 +5819,26 @@ public class MediaProvider extends ContentProvider { } /** + * Return the filesystem path of the real file on disk that is represented + * by the given {@link ParcelFileDescriptor}. + * + * Copied from {@link ParcelFileDescriptor#getFile} + */ + private static File getFileFromFileDescriptor(ParcelFileDescriptor fileDescriptor) + throws IOException { + try { + final String path = Os.readlink("/proc/self/fd/" + fileDescriptor.getFd()); + if (OsConstants.S_ISREG(Os.stat(path).st_mode)) { + return new File(path); + } else { + throw new IOException("Not a regular file: " + path); + } + } catch (ErrnoException e) { + throw e.rethrowAsIOException(); + } + } + + /** * Generate the {@link PendingIntent} for the given grant request. This * method also checks the incoming arguments for security purposes * before creating the privileged {@link PendingIntent}. @@ -4679,9 +5853,17 @@ public class MediaProvider extends ContentProvider { case IMAGES_MEDIA_ID: case AUDIO_MEDIA_ID: case VIDEO_MEDIA_ID: + case AUDIO_PLAYLISTS_ID: // Caller is requesting a specific media item by its ID, // which means it's valid for requests break; + case FILES_ID: + // Allow only subtitle files + if (!isSubtitleFile(uri)) { + throw new IllegalArgumentException( + "All requested items must be Media items"); + } + break; default: throw new IllegalArgumentException( "All requested items must be referenced by specific ID"); @@ -4720,6 +5902,22 @@ public class MediaProvider extends ContentProvider { } /** + * @return true if the given Files uri has media_type=MEDIA_TYPE_SUBTITLE + */ + private boolean isSubtitleFile(Uri uri) { + final LocalCallingIdentity tokenInner = clearLocalCallingIdentity(); + try (Cursor cursor = queryForSingleItem(uri, new String[]{FileColumns.MEDIA_TYPE}, null, + null, null)) { + return cursor.getInt(0) == FileColumns.MEDIA_TYPE_SUBTITLE; + } catch (FileNotFoundException e) { + Log.e(TAG, "Couldn't find database row for requested uri " + uri, e); + } finally { + restoreLocalCallingIdentity(tokenInner); + } + return false; + } + + /** * Ensure that all local databases have a custom collator registered for the * given {@link ULocale} locale. * @@ -4764,12 +5962,12 @@ public class MediaProvider extends ContentProvider { final long[] knownIdsRaw = knownIds.toArray(); Arrays.sort(knownIdsRaw); - for (String volumeName : getExternalVolumeNames()) { + for (MediaVolume volume : mVolumeCache.getExternalVolumes()) { final List<File> thumbDirs; try { - thumbDirs = getThumbnailDirectories(volumeName); + thumbDirs = getThumbnailDirectories(volume); } catch (FileNotFoundException e) { - Log.w(TAG, "Failed to resolve volume " + volumeName, e); + Log.w(TAG, "Failed to resolve volume " + volume.getName(), e); continue; } @@ -4911,12 +6109,13 @@ public class MediaProvider extends ContentProvider { } }; - private List<File> getThumbnailDirectories(String volumeName) throws FileNotFoundException { - final File volumePath = getVolumePath(volumeName); + private List<File> getThumbnailDirectories(MediaVolume volume) throws FileNotFoundException { + final File volumePath = volume.getPath(); return Arrays.asList( - FileUtils.buildPath(volumePath, DIRECTORY_MUSIC, DIRECTORY_THUMBNAILS), - FileUtils.buildPath(volumePath, DIRECTORY_MOVIES, DIRECTORY_THUMBNAILS), - FileUtils.buildPath(volumePath, DIRECTORY_PICTURES, DIRECTORY_THUMBNAILS)); + FileUtils.buildPath(volumePath, Environment.DIRECTORY_MUSIC, DIRECTORY_THUMBNAILS), + FileUtils.buildPath(volumePath, Environment.DIRECTORY_MOVIES, DIRECTORY_THUMBNAILS), + FileUtils.buildPath(volumePath, Environment.DIRECTORY_PICTURES, + DIRECTORY_THUMBNAILS)); } private void invalidateThumbnails(Uri uri) { @@ -4988,7 +6187,17 @@ public class MediaProvider extends ContentProvider { private int updateInternal(@NonNull Uri uri, @Nullable ContentValues initialValues, @Nullable Bundle extras) throws FallbackException { + final String volumeName = getVolumeName(uri); + PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), volumeName); + extras = (extras != null) ? extras : new Bundle(); + // REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider. + extras.remove(QUERY_ARG_REDACTED_URI); + + if (isRedactedUri(uri)) { + // we don't support update on redacted uris. + return 0; + } // Related items are only considered for new media creation, and they // can't be leveraged to move existing content into blocked locations @@ -5016,10 +6225,10 @@ public class MediaProvider extends ContentProvider { int count; - final String volumeName = getVolumeName(uri); final int targetSdkVersion = getCallingPackageTargetSdkVersion(); final boolean allowHidden = isCallingPackageAllowedHidden(); final int match = matchUri(uri, allowHidden); + final DatabaseHelper helper = getDatabaseForUri(uri); switch (match) { case AUDIO_PLAYLISTS_ID_MEMBERS_ID: @@ -5030,7 +6239,6 @@ public class MediaProvider extends ContentProvider { final long playlistId = Long.parseLong(uri.getPathSegments().get(3)); final Uri playlistUri = ContentUris.withAppendedId( MediaStore.Audio.Playlists.getContentUri(volumeName), playlistId); - if (uri.getBooleanQueryParameter("move", false)) { // Convert explicit request into query; sigh, moveItem() // uses zero-based indexing instead of one-based indexing @@ -5062,11 +6270,13 @@ public class MediaProvider extends ContentProvider { values.put(Playlists.Members.PLAY_ORDER, (index + 1)); addPlaylistMembers(playlistUri, values); } + + acceptWithExpansion(helper::notifyUpdate, volumeName, playlistId, + FileColumns.MEDIA_TYPE_PLAYLIST, false); return 1; } } - final DatabaseHelper helper = getDatabaseForUri(uri); final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE, match, uri, extras, null); // Give callers interacting with a specific media item a chance to @@ -5080,6 +6290,7 @@ public class MediaProvider extends ContentProvider { boolean triggerInvalidate = false; boolean triggerScan = false; + boolean isUriPublished = false; if (initialValues != null) { // IDs are forever; nobody should be editing them initialValues.remove(MediaColumns._ID); @@ -5166,7 +6377,7 @@ public class MediaProvider extends ContentProvider { // make sure metadata is updated if (MediaColumns.IS_PENDING.equals(column)) { triggerScan = true; - + isUriPublished = true; // Explicitly clear columns used to ignore no-op scans, // since we need to force a scan on publish initialValues.putNull(MediaColumns.DATE_MODIFIED); @@ -5301,7 +6512,22 @@ public class MediaProvider extends ContentProvider { initialValues.remove(MediaColumns.DATA); ensureUniqueFileColumns(match, uri, extras, initialValues, beforePath); - final String afterPath = initialValues.getAsString(MediaColumns.DATA); + String afterPath = initialValues.getAsString(MediaColumns.DATA); + + if (isCrossUserEnabled()) { + String afterVolume = extractVolumeName(afterPath); + String afterVolumePath = extractVolumePath(afterPath); + String beforeVolumePath = extractVolumePath(beforePath); + + if (MediaStore.VOLUME_EXTERNAL_PRIMARY.equals(beforeVolume) + && beforeVolume.equals(afterVolume) + && !beforeVolumePath.equals(afterVolumePath)) { + // On cross-user enabled devices, it can happen that a rename intended as + // /storage/emulated/999/foo -> /storage/emulated/999/foo can end up as + // /storage/emulated/999/foo -> /storage/emulated/0/foo. We now fix-up + afterPath = afterPath.replaceFirst(afterVolumePath, beforeVolumePath); + } + } Log.d(TAG, "Moving " + beforePath + " to " + afterPath); try { @@ -5325,6 +6551,8 @@ public class MediaProvider extends ContentProvider { Trace.endSection(); } + assertPrivatePathNotInValues(initialValues); + // Make sure any updated paths look consistent assertFileColumnsConsistent(match, uri, initialValues); @@ -5387,6 +6615,31 @@ public class MediaProvider extends ContentProvider { } } + boolean deferScan = false; + if (triggerScan) { + if (SdkLevel.isAtLeastS() && + CompatChanges.isChangeEnabled(ENABLE_DEFERRED_SCAN, Binder.getCallingUid())) { + if (extras.containsKey(QUERY_ARG_DO_ASYNC_SCAN)) { + throw new IllegalArgumentException("Unsupported argument " + + QUERY_ARG_DO_ASYNC_SCAN + " used in extras"); + } + deferScan = extras.getBoolean(QUERY_ARG_DEFER_SCAN, false); + if (deferScan && initialValues.containsKey(MediaColumns.IS_PENDING) && + (initialValues.getAsInteger(MediaColumns.IS_PENDING) == 1)) { + // if the scan runs in async, ensure that the database row is excluded in + // default query until the metadata is updated by deferred scan. + // Apps will still be able to see this database row when queried with + // QUERY_ARG_MATCH_PENDING=MATCH_INCLUDE + values.put(FileColumns._MODIFIER, FileColumns._MODIFIER_CR_PENDING_METADATA); + qb.allowColumn(FileColumns._MODIFIER); + } + } else { + // Allow apps to use QUERY_ARG_DO_ASYNC_SCAN if the device is R or app is targeting + // targetSDK<=R. + deferScan = extras.getBoolean(QUERY_ARG_DO_ASYNC_SCAN, false); + } + } + count = updateAllowingReplace(qb, helper, values, userWhere, userWhereArgs); // If the caller tried (and failed) to update metadata, the file on disk @@ -5406,14 +6659,22 @@ public class MediaProvider extends ContentProvider { try (Cursor c = queryForSingleItem(updatedUri, new String[] { FileColumns.DATA }, null, null, null)) { final File file = new File(c.getString(0)); - helper.postBlocking(() -> { - final LocalCallingIdentity tokenInner = clearLocalCallingIdentity(); - try { - mMediaScanner.scanFile(file, REASON_DEMAND); - } finally { - restoreLocalCallingIdentity(tokenInner); - } - }); + final boolean notifyTranscodeHelper = isUriPublished; + if (deferScan) { + helper.postBackground(() -> { + scanFileAsMediaProvider(file, REASON_DEMAND); + if (notifyTranscodeHelper) { + notifyTranscodeHelperOnUriPublished(updatedUri); + } + }); + } else { + helper.postBlocking(() -> { + scanFileAsMediaProvider(file, REASON_DEMAND); + if (notifyTranscodeHelper) { + notifyTranscodeHelperOnUriPublished(updatedUri); + } + }); + } } catch (Exception e) { Log.w(TAG, "Failed to update metadata for " + updatedUri, e); } @@ -5428,6 +6689,29 @@ public class MediaProvider extends ContentProvider { return count; } + private void notifyTranscodeHelperOnUriPublished(Uri uri) { + BackgroundThread.getExecutor().execute(() -> { + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + mTranscodeHelper.onUriPublished(uri); + } finally { + restoreLocalCallingIdentity(token); + } + }); + } + + private void notifyTranscodeHelperOnFileOpen(String path, String ioPath, int uid, + int transformsReason) { + BackgroundThread.getExecutor().execute(() -> { + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + mTranscodeHelper.onFileOpen(path, ioPath, uid, transformsReason); + } finally { + restoreLocalCallingIdentity(token); + } + }); + } + /** * Update row(s) that match {@code userWhere} in MediaProvider database with {@code values}. * Treats update as replace for updates with conflicts. @@ -5512,8 +6796,8 @@ public class MediaProvider extends ContentProvider { @NonNull SQLiteDatabase db) { try { // Refresh playlist members based on what we parse from disk - final String volumeName = getVolumeName(playlistUri); final long playlistId = ContentUris.parseId(playlistUri); + final Map<String, Long> membersMap = getAllPlaylistMembers(playlistId); db.delete("audio_playlists_map", "playlist_id=" + playlistId, null); final Path playlistPath = queryForDataFile(playlistUri, null).toPath(); @@ -5524,7 +6808,7 @@ public class MediaProvider extends ContentProvider { for (int i = 0; i < members.size(); i++) { try { final Path audioPath = playlistPath.getParent().resolve(members.get(i)); - final long audioId = queryForPlaylistMember(volumeName, audioPath); + final long audioId = queryForPlaylistMember(audioPath, membersMap); final ContentValues values = new ContentValues(); values.put(Playlists.Members.PLAY_ORDER, i + 1); @@ -5540,18 +6824,42 @@ public class MediaProvider extends ContentProvider { } } + private Map<String, Long> getAllPlaylistMembers(long playlistId) { + final Map<String, Long> membersMap = new ArrayMap<>(); + + final Uri uri = Playlists.Members.getContentUri(MediaStore.VOLUME_EXTERNAL, playlistId); + final String[] projection = new String[] { + Playlists.Members.DATA, + Playlists.Members.AUDIO_ID + }; + try (Cursor c = query(uri, projection, null, null)) { + if (c == null) { + Log.e(TAG, "Cursor is null, failed to create cached playlist member info."); + return membersMap; + } + while (c.moveToNext()) { + membersMap.put(c.getString(0), c.getLong(1)); + } + } + return membersMap; + } + /** * Make two attempts to query this playlist member: first based on the exact * path, and if that fails, fall back to picking a single item matching the * display name. When there are multiple items with the same display name, * we can't resolve between them, and leave this member unresolved. */ - private long queryForPlaylistMember(@NonNull String volumeName, @NonNull Path path) + private long queryForPlaylistMember(@NonNull Path path, @NonNull Map<String, Long> membersMap) throws IOException { - final Uri audioUri = Audio.Media.getContentUri(volumeName); + final String data = path.toFile().getCanonicalPath(); + if (membersMap.containsKey(data)) { + return membersMap.get(data); + } + final Uri audioUri = Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL); try (Cursor c = queryForSingleItem(audioUri, new String[] { BaseColumns._ID }, MediaColumns.DATA + "=?", - new String[] { path.toFile().getCanonicalPath() }, null)) { + new String[] { data }, null)) { return c.getLong(0); } catch (FileNotFoundException ignored) { } @@ -5572,7 +6880,9 @@ public class MediaProvider extends ContentProvider { private long addPlaylistMembers(@NonNull Uri playlistUri, @NonNull ContentValues values) throws FallbackException { final long audioId = values.getAsLong(Audio.Playlists.Members.AUDIO_ID); - final Uri audioUri = Audio.Media.getContentUri(getVolumeName(playlistUri), audioId); + final String volumeName = MediaStore.VOLUME_INTERNAL.equals(getVolumeName(playlistUri)) + ? MediaStore.VOLUME_INTERNAL : MediaStore.VOLUME_EXTERNAL; + final Uri audioUri = Audio.Media.getContentUri(volumeName, audioId); Integer playOrder = values.getAsInteger(Playlists.Members.PLAY_ORDER); playOrder = (playOrder != null) ? (playOrder - 1) : Integer.MAX_VALUE; @@ -5586,12 +6896,13 @@ public class MediaProvider extends ContentProvider { playOrder = playlist.add(playOrder, playlistFile.toPath().getParent().relativize(audioFile.toPath())); playlist.write(playlistFile); + invalidateFuseDentry(playlistFile); resolvePlaylistMembers(playlistUri); // Callers are interested in the actual ID we generated - final Uri membersUri = Playlists.Members.getContentUri( - getVolumeName(playlistUri), ContentUris.parseId(playlistUri)); + final Uri membersUri = Playlists.Members.getContentUri(volumeName, + ContentUris.parseId(playlistUri)); try (Cursor c = query(membersUri, new String[] { BaseColumns._ID }, Playlists.Members.PLAY_ORDER + "=" + (playOrder + 1), null, null)) { c.moveToFirst(); @@ -5603,6 +6914,39 @@ public class MediaProvider extends ContentProvider { } } + private int addPlaylistMembers(@NonNull Uri playlistUri, @NonNull ContentValues[] initialValues) + throws FallbackException { + final String volumeName = getVolumeName(playlistUri); + final String audioVolumeName = + MediaStore.VOLUME_INTERNAL.equals(volumeName) + ? MediaStore.VOLUME_INTERNAL : MediaStore.VOLUME_EXTERNAL; + + try { + final File playlistFile = queryForDataFile(playlistUri, null); + final Playlist playlist = new Playlist(); + playlist.read(playlistFile); + + for (ContentValues values : initialValues) { + final long audioId = values.getAsLong(Audio.Playlists.Members.AUDIO_ID); + final Uri audioUri = Audio.Media.getContentUri(audioVolumeName, audioId); + final File audioFile = queryForDataFile(audioUri, null); + + Integer playOrder = values.getAsInteger(Playlists.Members.PLAY_ORDER); + playOrder = (playOrder != null) ? (playOrder - 1) : Integer.MAX_VALUE; + playlist.add(playOrder, + playlistFile.toPath().getParent().relativize(audioFile.toPath())); + } + playlist.write(playlistFile); + + resolvePlaylistMembers(playlistUri); + } catch (IOException e) { + throw new FallbackException("Failed to update playlist", e, + android.os.Build.VERSION_CODES.R); + } + + return initialValues.length; + } + /** * Move an audio item within the given playlist. */ @@ -5621,6 +6965,7 @@ public class MediaProvider extends ContentProvider { playlist.read(playlistFile); final int finalIndex = playlist.move(fromIndex, toIndex); playlist.write(playlistFile); + invalidateFuseDentry(playlistFile); resolvePlaylistMembers(playlistUri); return finalIndex; @@ -5631,25 +6976,26 @@ public class MediaProvider extends ContentProvider { } /** - * Remove an audio item from the given playlist. + * Removes an audio item or multiple audio items(if targetSDK<R) from the given playlist. */ private int removePlaylistMembers(@NonNull Uri playlistUri, @NonNull Bundle queryArgs) throws FallbackException { - final int index = resolvePlaylistIndex(playlistUri, queryArgs); + final int[] indexes = resolvePlaylistIndexes(playlistUri, queryArgs); try { final File playlistFile = queryForDataFile(playlistUri, null); final Playlist playlist = new Playlist(); playlist.read(playlistFile); final int count; - if (index == -1) { - count = playlist.asList().size(); - playlist.clear(); + if (indexes.length == 0) { + // This means either no playlist members match the query or VolumeNotFoundException + // was thrown. So we don't have anything to delete. + count = 0; } else { - count = 1; - playlist.remove(index); + count = playlist.removeMultiple(indexes); } playlist.write(playlistFile); + invalidateFuseDentry(playlistFile); resolvePlaylistMembers(playlistUri); return count; @@ -5660,10 +7006,37 @@ public class MediaProvider extends ContentProvider { } /** - * Resolve query arguments that are designed to select a specific playlist - * item using its {@link Playlists.Members#PLAY_ORDER}. + * Remove an audio item from the given playlist since the playlist file or the audio file is + * already removed. */ - private int resolvePlaylistIndex(@NonNull Uri playlistUri, @NonNull Bundle queryArgs) { + private void removePlaylistMembers(int mediaType, long id) { + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(Audio.Media.EXTERNAL_CONTENT_URI); + } catch (VolumeNotFoundException e) { + Log.w(TAG, e); + return; + } + + helper.runWithTransaction((db) -> { + final String where; + if (mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) { + where = "playlist_id=?"; + } else { + where = "audio_id=?"; + } + db.delete("audio_playlists_map", where, new String[] { "" + id }); + return null; + }); + } + + /** + * Resolve query arguments that are designed to select specific playlist + * items using the playlist's {@link Playlists.Members#PLAY_ORDER}. + * + * @return an array of the indexes that match the query. + */ + private int[] resolvePlaylistIndexes(@NonNull Uri playlistUri, @NonNull Bundle queryArgs) { final Uri membersUri = Playlists.Members.getContentUri( getVolumeName(playlistUri), ContentUris.parseId(playlistUri)); @@ -5674,32 +7047,62 @@ public class MediaProvider extends ContentProvider { qb = getQueryBuilder(TYPE_DELETE, AUDIO_PLAYLISTS_ID_MEMBERS, membersUri, queryArgs, null); } catch (VolumeNotFoundException ignored) { - return -1; + return new int[0]; } try (Cursor c = qb.query(helper, new String[] { Playlists.Members.PLAY_ORDER }, queryArgs, null)) { - if ((c.getCount() == 1) && c.moveToFirst()) { - return c.getInt(0) - 1; + if ((c.getCount() >= 1) && c.moveToFirst()) { + int size = c.getCount(); + int[] res = new int[size]; + for (int i = 0; i < size; ++i) { + res[i] = c.getInt(0) - 1; + c.moveToNext(); + } + return res; } else { - return -1; + // Cursor size is 0 + return new int[0]; } } } + /** + * Resolve query arguments that are designed to select a specific playlist + * item using its {@link Playlists.Members#PLAY_ORDER}. + * + * @return if there's only 1 item that matches the query, returns its index. Returns -1 + * otherwise. + */ + private int resolvePlaylistIndex(@NonNull Uri playlistUri, @NonNull Bundle queryArgs) { + int[] indexes = resolvePlaylistIndexes(playlistUri, queryArgs); + if (indexes.length == 1) { + return indexes[0]; + } + return -1; + } + @Override public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { - return openFileCommon(uri, mode, null); + return openFileCommon(uri, mode, /*signal*/ null, /*opts*/ null); } @Override public ParcelFileDescriptor openFile(Uri uri, String mode, CancellationSignal signal) throws FileNotFoundException { - return openFileCommon(uri, mode, signal); + return openFileCommon(uri, mode, signal, /*opts*/ null); } - private ParcelFileDescriptor openFileCommon(Uri uri, String mode, CancellationSignal signal) + private ParcelFileDescriptor openFileCommon(Uri uri, String mode, CancellationSignal signal, + @Nullable Bundle opts) throws FileNotFoundException { + opts = opts == null ? new Bundle() : opts; + // REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider. + opts.remove(QUERY_ARG_REDACTED_URI); + if (isRedactedUri(uri)) { + opts.putParcelable(QUERY_ARG_REDACTED_URI, uri); + uri = getUriForRedactedUri(uri); + } uri = safeUncanonicalize(uri); final boolean allowHidden = isCallingPackageAllowedHidden(); @@ -5707,34 +7110,40 @@ public class MediaProvider extends ContentProvider { 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 ensureThumbnail(targetUri, signal); - } - case AUDIO_ALBUMART_FILE_ID: { - final long audioId = Long.parseLong(uri.getPathSegments().get(3)); - final Uri targetUri = ContentUris - .withAppendedId(Audio.Media.getContentUri(volumeName), audioId); - return ensureThumbnail(targetUri, signal); - } - case VIDEO_MEDIA_ID_THUMBNAIL: { - final long videoId = Long.parseLong(uri.getPathSegments().get(3)); - final Uri targetUri = ContentUris - .withAppendedId(Video.Media.getContentUri(volumeName), videoId); - return ensureThumbnail(targetUri, signal); - } - case IMAGES_MEDIA_ID_THUMBNAIL: { - final long imageId = Long.parseLong(uri.getPathSegments().get(3)); - final Uri targetUri = ContentUris - .withAppendedId(Images.Media.getContentUri(volumeName), imageId); - return ensureThumbnail(targetUri, signal); + try { + switch (match) { + case AUDIO_ALBUMART_ID: { + final long albumId = Long.parseLong(uri.getPathSegments().get(3)); + final Uri targetUri = ContentUris + .withAppendedId(Audio.Albums.getContentUri(volumeName), albumId); + return ensureThumbnail(targetUri, signal); + } + case AUDIO_ALBUMART_FILE_ID: { + final long audioId = Long.parseLong(uri.getPathSegments().get(3)); + final Uri targetUri = ContentUris + .withAppendedId(Audio.Media.getContentUri(volumeName), audioId); + return ensureThumbnail(targetUri, signal); + } + case VIDEO_MEDIA_ID_THUMBNAIL: { + final long videoId = Long.parseLong(uri.getPathSegments().get(3)); + final Uri targetUri = ContentUris + .withAppendedId(Video.Media.getContentUri(volumeName), videoId); + return ensureThumbnail(targetUri, signal); + } + case IMAGES_MEDIA_ID_THUMBNAIL: { + final long imageId = Long.parseLong(uri.getPathSegments().get(3)); + final Uri targetUri = ContentUris + .withAppendedId(Images.Media.getContentUri(volumeName), imageId); + return ensureThumbnail(targetUri, signal); + } } + } finally { + // We have to log separately here because openFileAndEnforcePathPermissionsHelper calls + // a public MediaProvider API and so logs the access there. + PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), volumeName); } - return openFileAndEnforcePathPermissionsHelper(uri, match, mode, signal); + return openFileAndEnforcePathPermissionsHelper(uri, match, mode, signal, opts); } @Override @@ -5753,6 +7162,21 @@ public class MediaProvider extends ContentProvider { Bundle opts, CancellationSignal signal) throws FileNotFoundException { uri = safeUncanonicalize(uri); + if (opts != null && opts.containsKey(MediaStore.EXTRA_FILE_DESCRIPTOR)) { + // This is called as part of MediaStore#getOriginalMediaFormatFileDescriptor + // We don't need to use the |uri| because the input fd already identifies the file and + // we actually don't have a valid URI, we are going to identify the file via the fd. + // While identifying the file, we also perform the following security checks. + // 1. Find the FUSE file with the associated inode + // 2. Verify that the binder caller opened it + // 3. Verify the access level the fd is opened with (r/w) + // 4. Open the original (non-transcoded) file *with* redaction enabled and the access + // level from #3 + // 5. Return the fd from #4 to the app or throw an exception if any of the conditions + // are not met + return getOriginalMediaFormatFileDescriptor(opts); + } + // TODO: enforce that caller has access to this uri // Offer thumbnail of media, when requested @@ -5764,7 +7188,7 @@ public class MediaProvider extends ContentProvider { } // Worst case, return the underlying file - return new AssetFileDescriptor(openFileCommon(uri, "r", signal), 0, + return new AssetFileDescriptor(openFileCommon(uri, "r", signal, opts), 0, AssetFileDescriptor.UNKNOWN_LENGTH); } @@ -5921,6 +7345,23 @@ public class MediaProvider extends ContentProvider { } /** + * Query the given {@link Uri} as MediaProvider, expecting only a single item to be found. + * + * @throws FileNotFoundException if no items were found, or multiple items + * were found, or there was trouble reading the data. + */ + Cursor queryForSingleItemAsMediaProvider(Uri uri, String[] projection, String selection, + String[] selectionArgs, CancellationSignal signal) + throws FileNotFoundException { + final LocalCallingIdentity tokenInner = clearLocalCallingIdentity(); + try { + return queryForSingleItem(uri, projection, selection, selectionArgs, signal); + } finally { + restoreLocalCallingIdentity(tokenInner); + } + } + + /** * Query the given {@link Uri}, expecting only a single item to be found. * * @throws FileNotFoundException if no items were found, or multiple items @@ -5929,7 +7370,7 @@ public class MediaProvider extends ContentProvider { Cursor queryForSingleItem(Uri uri, String[] projection, String selection, String[] selectionArgs, CancellationSignal signal) throws FileNotFoundException { final Cursor c = query(uri, projection, - DatabaseUtils.createSqlQueryBundle(selection, selectionArgs, null), signal); + DatabaseUtils.createSqlQueryBundle(selection, selectionArgs, null), signal, true); if (c == null) { throw new FileNotFoundException("Missing cursor for " + uri); } else if (c.getCount() < 1) { @@ -5962,12 +7403,36 @@ public class MediaProvider extends ContentProvider { } } - private File getFuseFile(File file) { + public File getFuseFile(File file) { String filePath = file.getPath().replaceFirst( "/storage/", "/mnt/user/" + UserHandle.myUserId() + "/"); return new File(filePath); } + private ParcelFileDescriptor openWithFuse(String filePath, int uid, int mediaCapabilitiesUid, + int modeBits, boolean shouldRedact, boolean shouldTranscode, int transcodeReason) + throws FileNotFoundException { + Log.d(TAG, "Open with FUSE. FilePath: " + filePath + + ". Uid: " + uid + + ". Media Capabilities Uid: " + mediaCapabilitiesUid + + ". ShouldRedact: " + shouldRedact + + ". ShouldTranscode: " + shouldTranscode); + + int tid = android.os.Process.myTid(); + synchronized (mPendingOpenInfo) { + mPendingOpenInfo.put(tid, + new PendingOpenInfo(uid, mediaCapabilitiesUid, shouldRedact, transcodeReason)); + } + + try { + return FileUtils.openSafely(getFuseFile(new File(filePath)), modeBits); + } finally { + synchronized (mPendingOpenInfo) { + mPendingOpenInfo.remove(tid); + } + } + } + private @NonNull FuseDaemon getFuseDaemonForFile(@NonNull File file) throws FileNotFoundException { final FuseDaemon daemon = ExternalStorageServiceImpl.getFuseDaemon(getVolumeId(file)); @@ -6006,10 +7471,16 @@ public class MediaProvider extends ContentProvider { * a "/mnt/user" path. */ private ParcelFileDescriptor openFileAndEnforcePathPermissionsHelper(Uri uri, int match, - String mode, CancellationSignal signal) throws FileNotFoundException { + String mode, CancellationSignal signal, @NonNull Bundle opts) + throws FileNotFoundException { int modeBits = ParcelFileDescriptor.parseMode(mode); boolean forWrite = (modeBits & ParcelFileDescriptor.MODE_WRITE_ONLY) != 0; + final Uri redactedUri = opts.getParcelable(QUERY_ARG_REDACTED_URI); if (forWrite) { + if (redactedUri != null) { + throw new UnsupportedOperationException( + "Write is not supported on " + redactedUri.toString()); + } // Upgrade 'w' only to 'rw'. This allows us acquire a WR_LOCK when calling // #shouldOpenWithFuse modeBits |= ParcelFileDescriptor.MODE_READ_WRITE; @@ -6041,7 +7512,11 @@ public class MediaProvider extends ContentProvider { restoreLocalCallingIdentity(token); } - checkAccess(uri, Bundle.EMPTY, file, forWrite); + if (redactedUri == null) { + checkAccess(uri, Bundle.EMPTY, file, forWrite); + } else { + checkAccess(redactedUri, Bundle.EMPTY, file, false); + } // We don't check ownership for files with IS_PENDING set by FUSE if (isPending && !isPendingFromFuse(file)) { @@ -6050,12 +7525,13 @@ public class MediaProvider extends ContentProvider { final boolean callerIsOwner = Objects.equals(getCallingPackageOrSelf(), ownerPackageName); // Figure out if we need to redact contents - final boolean redactionNeeded = callerIsOwner ? false : isRedactionNeeded(uri); + final boolean redactionNeeded = + (redactedUri != null) || (!callerIsOwner && isRedactionNeeded(uri)); final RedactionInfo redactionInfo; try { redactionInfo = redactionNeeded ? getRedactionRanges(file) : new RedactionInfo(new long[0], new long[0]); - } catch(IOException e) { + } catch (IOException e) { throw new IllegalStateException(e); } @@ -6083,7 +7559,7 @@ public class MediaProvider extends ContentProvider { update(uri, values, null, null); break; default: - mMediaScanner.scanFile(file, REASON_DEMAND); + scanFileAsMediaProvider(file, REASON_DEMAND); break; } } catch (Exception e2) { @@ -6095,33 +7571,23 @@ public class MediaProvider extends ContentProvider { // First, handle any redaction that is needed for caller final ParcelFileDescriptor pfd; final String filePath = file.getPath(); + final int uid = Binder.getCallingUid(); + final int transcodeReason = mTranscodeHelper.shouldTranscode(filePath, uid, opts); + final boolean shouldTranscode = transcodeReason > 0; + int mediaCapabilitiesUid = opts.getInt(MediaStore.EXTRA_MEDIA_CAPABILITIES_UID); + if (!shouldTranscode || mediaCapabilitiesUid < Process.FIRST_APPLICATION_UID) { + // Although 0 is a valid UID, it's not a valid app uid. + // So, we use it to signify that mediaCapabilitiesUid is not set. + mediaCapabilitiesUid = 0; + } if (redactionInfo.redactionRanges.length > 0) { - if (SystemProperties.getBoolean(PROP_FUSE, false)) { - // If fuse is enabled, we can provide an fd that points to the fuse - // file system and handle redaction in the fuse handler when the caller reads. - Log.i(TAG, "Redacting with new FUSE for " + filePath); - long tid = android.os.Process.myTid(); - synchronized (mShouldRedactThreadIds) { - mShouldRedactThreadIds.add(tid); - } - try { - pfd = FileUtils.openSafely(getFuseFile(file), modeBits); - } finally { - synchronized (mShouldRedactThreadIds) { - mShouldRedactThreadIds.remove(mShouldRedactThreadIds.indexOf(tid)); - } - } - } else { - // TODO(b/135341978): Remove this and associated code - // when fuse is on by default. - Log.i(TAG, "Redacting with old FUSE for " + filePath); - pfd = RedactingFileDescriptor.open( - getContext(), - file, - modeBits, - redactionInfo.redactionRanges, - redactionInfo.freeOffsets); - } + // If fuse is enabled, we can provide an fd that points to the fuse + // file system and handle redaction in the fuse handler when the caller reads. + pfd = openWithFuse(filePath, uid, mediaCapabilitiesUid, modeBits, + true /* shouldRedact */, shouldTranscode, transcodeReason); + } else if (shouldTranscode) { + pfd = openWithFuse(filePath, uid, mediaCapabilitiesUid, modeBits, + false /* shouldRedact */, shouldTranscode, transcodeReason); } else { FuseDaemon daemon = null; try { @@ -6132,22 +7598,23 @@ public class MediaProvider extends ContentProvider { // Always acquire a readLock. This allows us make multiple opens via lower // filesystem boolean shouldOpenWithFuse = daemon != null - && daemon.shouldOpenWithFuse(filePath, true /* forRead */, lowerFsFd.getFd()); + && daemon.shouldOpenWithFuse(filePath, true /* forRead */, + lowerFsFd.getFd()); - if (SystemProperties.getBoolean(PROP_FUSE, false) && shouldOpenWithFuse) { + if (shouldOpenWithFuse) { // If the file is already opened on the FUSE mount with VFS caching enabled // we return an upper filesystem fd (via FUSE) to avoid file corruption // resulting from cache inconsistencies between the upper and lower // filesystem caches - Log.w(TAG, "Using FUSE for " + filePath); - pfd = FileUtils.openSafely(getFuseFile(file), modeBits); + pfd = openWithFuse(filePath, uid, mediaCapabilitiesUid, modeBits, + false /* shouldRedact */, shouldTranscode, transcodeReason); try { lowerFsFd.close(); } catch (IOException e) { Log.w(TAG, "Failed to close lower filesystem fd " + file.getPath(), e); } } else { - Log.i(TAG, "Using lower FS for " + filePath); + Log.i(TAG, "Open with lower FS for " + filePath + ". Uid: " + uid); if (forWrite) { // When opening for write on the lower filesystem, invalidate the VFS dentry // so subsequent open/getattr calls will return correctly. @@ -6227,6 +7694,27 @@ public class MediaProvider extends ContentProvider { return mCallingIdentity.get().hasPermission(PERMISSION_IS_LEGACY_GRANTED); } + private boolean shouldBypassDatabase(int uid) { + if (uid != android.os.Process.SHELL_UID && isCallingPackageManager()) { + return mCallingIdentity.get().shouldBypassDatabase(false /*isSystemGallery*/); + } else if (isCallingPackageSystemGallery()) { + if (isCallingPackageLegacyWrite()) { + // We bypass db operations for legacy system galleries with W_E_S (see b/167307393). + // Tracking a longer term solution in b/168784136. + return true; + } else if (isCallingPackageRequestingLegacy()) { + // If requesting legacy, app should have W_E_S along with SystemGallery appops. + return false; + } else if (!SdkLevel.isAtLeastS()) { + // We don't parse manifest flags for SdkLevel<=R yet. Hence, we don't bypass + // database updates for SystemGallery targeting R or above on R OS. + return false; + } + return mCallingIdentity.get().shouldBypassDatabase(true /*isSystemGallery*/); + } + return false; + } + private static int getFileMediaType(String path) { final File file = new File(path); final String mimeType = MimeUtils.resolveMimeType(file); @@ -6253,7 +7741,7 @@ public class MediaProvider extends ContentProvider { * <li>the calling identity is an app targeting Q or older versions AND is requesting legacy * storage * <li>the calling identity holds {@code MANAGE_EXTERNAL_STORAGE} - * <li>the calling identity owns filePath (eg /Android/data/com.foo) + * <li>the calling identity owns or has access to the filePath (eg /Android/data/com.foo) * <li>the calling identity has permission to write images and the given file is an image file * <li>the calling identity has permission to write video and the given file is an video file * </ul> @@ -6269,9 +7757,8 @@ public class MediaProvider extends ContentProvider { return true; } - // Files under the apps own private directory - final String appSpecificDir = extractPathOwnerPackageName(filePath); - if (appSpecificDir != null && isCallingIdentitySharedPackageName(appSpecificDir)) { + // Check if the caller has access to private app directories. + if (isUidAllowedAccessToDataOrObbPathForFuse(mCallingIdentity.get().uid, filePath)) { return true; } @@ -6286,9 +7773,10 @@ public class MediaProvider extends ContentProvider { /** * Returns true if the passed in path is an application-private data directory - * (such as Android/data/com.foo or Android/obb/com.foo) that does not belong to the caller. + * (such as Android/data/com.foo or Android/obb/com.foo) that does not belong to the caller and + * the caller does not have special access. */ - private boolean isPrivatePackagePathNotOwnedByCaller(String path) { + private boolean isPrivatePackagePathNotAccessibleByCaller(String path) { // Files under the apps own private directory final String appSpecificDir = extractPathOwnerPackageName(path); @@ -6296,33 +7784,33 @@ public class MediaProvider extends ContentProvider { return false; } - final String relativePath = extractRelativePath(path); // Android/media is not considered private, because it contains media that is explicitly // scanned and shared by other apps - if (relativePath.startsWith("Android/media")) { + if (isExternalMediaDirectory(path)) { return false; } - - // This is a private-package path; return true if not owned by the caller - return !isCallingIdentitySharedPackageName(appSpecificDir); + return !isUidAllowedAccessToDataOrObbPathForFuse(mCallingIdentity.get().uid, path); } - private boolean shouldBypassDatabaseForFuse(int uid) { - final LocalCallingIdentity token = - clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); - try { - if (uid != android.os.Process.SHELL_UID && isCallingPackageManager()) { - return true; - } - // We bypass db operations for legacy system galleries with W_E_S (see b/167307393). - // Tracking a longer term solution in b/168784136. - if (isCallingPackageLegacyWrite() && isCallingPackageSystemGallery()) { - return true; + private boolean shouldBypassDatabaseAndSetDirtyForFuse(int uid, String path) { + if (shouldBypassDatabase(uid)) { + synchronized (mNonHiddenPaths) { + File file = new File(path); + String key = file.getParent(); + boolean maybeHidden = !mNonHiddenPaths.containsKey(key); + + if (maybeHidden) { + File topNoMediaDir = FileUtils.getTopLevelNoMedia(new File(path)); + if (topNoMediaDir == null) { + mNonHiddenPaths.put(key, 0); + } else { + mMediaScanner.onDirectoryDirty(topNoMediaDir); + } + } } - return false; - } finally { - restoreLocalCallingIdentity(token); + return true; } + return false; } /** @@ -6384,6 +7872,34 @@ public class MediaProvider extends ContentProvider { } } + private static class LRUCache<K, V> extends LinkedHashMap<K, V> { + private final int mMaxSize; + + public LRUCache(int maxSize) { + this.mMaxSize = maxSize; + } + + @Override + protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { + return size() > mMaxSize; + } + } + + private static final class PendingOpenInfo { + public final int uid; + public final int mediaCapabilitiesUid; + public final boolean shouldRedact; + public final int transcodeReason; + + public PendingOpenInfo(int uid, int mediaCapabilitiesUid, boolean shouldRedact, + int transcodeReason) { + this.uid = uid; + this.mediaCapabilitiesUid = mediaCapabilitiesUid; + this.shouldRedact = shouldRedact; + this.transcodeReason = transcodeReason; + } + } + /** * Calculates the ranges that need to be redacted for the given file and user that wants to * access the file. @@ -6394,44 +7910,51 @@ public class MediaProvider extends ContentProvider { * @return Ranges that should be redacted. * * @throws IOException if an error occurs while calculating the redaction ranges - * - * Called from JNI in jni/MediaProviderWrapper.cpp */ - @Keep @NonNull - public long[] getRedactionRangesForFuse(String path, int uid, int tid) throws IOException { - final File file = new File(path); - - // When we're calculating redaction ranges for MediaProvider, it means we're actually - // calculating redaction ranges for another app that called to MediaProvider through Binder. - // If the tid is in mShouldRedactThreadIds, we should redact, otherwise, we don't redact - if (uid == android.os.Process.myUid()) { - boolean shouldRedact = false; - synchronized (mShouldRedactThreadIds) { - shouldRedact = mShouldRedactThreadIds.indexOf(tid) != -1; - } - if (shouldRedact) { - return getRedactionRanges(file).redactionRanges; - } else { - return new long[0]; + private long[] getRedactionRangesForFuse(String path, String ioPath, int original_uid, int uid, + int tid, boolean forceRedaction) throws IOException { + // |ioPath| might refer to a transcoded file path (which is not indexed in the db) + // |path| will always refer to a valid _data column + // We use |ioPath| for the filesystem access because in the case of transcoding, + // we want to get redaction ranges from the transcoded file and *not* the original file + final File file = new File(ioPath); + + if (forceRedaction) { + return getRedactionRanges(file).redactionRanges; + } + + // When calculating redaction ranges initiated from MediaProvider, the redaction policy + // is slightly different from the FUSE initiated opens redaction policy. targetSdk=29 from + // MediaProvider requires redaction, but targetSdk=29 apps from FUSE don't require redaction + // Hence, we check the mPendingOpenInfo object (populated when opens are initiated from + // MediaProvider) if there's a pending open from MediaProvider with matching tid and uid and + // use the shouldRedact decision there if there's one. + synchronized (mPendingOpenInfo) { + PendingOpenInfo info = mPendingOpenInfo.get(tid); + if (info != null && info.uid == original_uid) { + boolean shouldRedact = info.shouldRedact; + if (shouldRedact) { + return getRedactionRanges(file).redactionRanges; + } else { + return new long[0]; + } } } final LocalCallingIdentity token = clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); - - long[] res = new long[0]; try { if (!isRedactionNeeded() || shouldBypassFuseRestrictions(/*forWrite*/ false, path)) { - return res; + return new long[0]; } final Uri contentUri = FileUtils.getContentUriForPath(path); final String[] projection = new String[]{ MediaColumns.OWNER_PACKAGE_NAME, MediaColumns._ID }; final String selection = MediaColumns.DATA + "=?"; - final String[] selectionArgs = new String[] { path }; + final String[] selectionArgs = new String[]{path}; final String ownerPackageName; final Uri item; try (final Cursor c = queryForSingleItem(contentUri, projection, selection, @@ -6450,17 +7973,22 @@ public class MediaProvider extends ContentProvider { final boolean callerIsOwner = Objects.equals(getCallingPackageOrSelf(), ownerPackageName); + + if (callerIsOwner) { + return new long[0]; + } + final boolean callerHasUriPermission = getContext().checkUriPermission( item, mCallingIdentity.get().pid, mCallingIdentity.get().uid, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) == PERMISSION_GRANTED; - - if (!callerIsOwner && !callerHasUriPermission) { - res = getRedactionRanges(file).redactionRanges; + if (callerHasUriPermission) { + return new long[0]; } + + return getRedactionRanges(file).redactionRanges; } finally { restoreLocalCallingIdentity(token); } - return res; } /** @@ -6534,73 +8062,200 @@ public class MediaProvider extends ContentProvider { * @param path the path of the file to be opened * @param uid UID of the app requesting to open the file * @param forWrite specifies if the file is to be opened for write - * @return 0 upon success. {@link OsConstants#EACCES} if the operation is illegal or not - * permitted for the given {@code uid} or if the calling package is a legacy app that doesn't - * have right storage permission. + * @return {@link FileOpenResult} with {@code status} {@code 0} upon success and + * {@link FileOpenResult} with {@code status} {@link OsConstants#EACCES} if the operation is + * illegal or not permitted for the given {@code uid} or if the calling package is a legacy app + * that doesn't have right storage permission. * * Called from JNI in jni/MediaProviderWrapper.cpp */ @Keep - public int isOpenAllowedForFuse(String path, int uid, boolean forWrite) { + public FileOpenResult onFileOpenForFuse(String path, String ioPath, int uid, int tid, + int transformsReason, boolean forWrite, boolean redact, boolean logTransformsMetrics) { final LocalCallingIdentity token = clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path); + + boolean isSuccess = false; + + final int originalUid = getBinderUidForFuse(uid, tid); + int mediaCapabilitiesUid = 0; + final PendingOpenInfo pendingOpenInfo; + synchronized (mPendingOpenInfo) { + pendingOpenInfo = mPendingOpenInfo.get(tid); + } + + if (pendingOpenInfo != null && pendingOpenInfo.uid == originalUid) { + mediaCapabilitiesUid = pendingOpenInfo.mediaCapabilitiesUid; + } + try { - if (isPrivatePackagePathNotOwnedByCaller(path)) { + boolean forceRedaction = false; + String redactedUriId = null; + if (isSyntheticFilePathForRedactedUri(path, uid)) { + if (forWrite) { + // Redacted URIs are not allowed to update EXIF headers. + return new FileOpenResult(OsConstants.EACCES /* status */, originalUid, + mediaCapabilitiesUid, new long[0]); + } + + redactedUriId = extractFileName(path); + + // If path is redacted Uris' path, ioPath must be the real path, ioPath must + // haven been updated to the real path during onFileLookupForFuse. + path = ioPath; + + // Irrespective of the permissions we want to redact in this case. + redact = true; + forceRedaction = true; + } else if (isSyntheticDirPath(path, uid)) { + // we don't support any other transformations under .transforms/synthetic dir + return new FileOpenResult(OsConstants.ENOENT /* status */, originalUid, + mediaCapabilitiesUid, new long[0]); + } + + if (isPrivatePackagePathNotAccessibleByCaller(path)) { Log.e(TAG, "Can't open a file in another app's external directory!"); - return OsConstants.ENOENT; + return new FileOpenResult(OsConstants.ENOENT, originalUid, mediaCapabilitiesUid, + new long[0]); } if (shouldBypassFuseRestrictions(forWrite, path)) { - return 0; + isSuccess = true; + return new FileOpenResult(0 /* status */, originalUid, mediaCapabilitiesUid, + redact ? getRedactionRangesForFuse(path, ioPath, originalUid, uid, tid, + forceRedaction) : new long[0]); } // Legacy apps that made is this far don't have the right storage permission and hence // are not allowed to access anything other than their external app directory if (isCallingPackageRequestingLegacy()) { - return OsConstants.EACCES; + return new FileOpenResult(OsConstants.EACCES /* status */, originalUid, + mediaCapabilitiesUid, new long[0]); } final Uri contentUri = FileUtils.getContentUriForPath(path); final String[] projection = new String[]{ MediaColumns._ID, MediaColumns.OWNER_PACKAGE_NAME, - MediaColumns.IS_PENDING}; + MediaColumns.IS_PENDING, + FileColumns.MEDIA_TYPE}; final String selection = MediaColumns.DATA + "=?"; - final String[] selectionArgs = new String[] { path }; - final Uri fileUri; + final String[] selectionArgs = new String[]{path}; + final long id; + final int mediaType; final boolean isPending; String ownerPackageName = null; - try (final Cursor c = queryForSingleItem(contentUri, projection, selection, + try (final Cursor c = queryForSingleItemAsMediaProvider(contentUri, projection, + selection, selectionArgs, null)) { - fileUri = ContentUris.withAppendedId(contentUri, c.getInt(0)); + id = c.getLong(0); ownerPackageName = c.getString(1); isPending = c.getInt(2) != 0; + mediaType = c.getInt(3); } - final File file = new File(path); - checkAccess(fileUri, Bundle.EMPTY, file, forWrite); - + Uri fileUri = MediaStore.Files.getContentUri(extractVolumeName(path), id); // We don't check ownership for files with IS_PENDING set by FUSE if (isPending && !isPendingFromFuse(new File(path))) { requireOwnershipForItem(ownerPackageName, fileUri); } - return 0; - } catch (FileNotFoundException e) { + + // Check that path looks consistent before uri checks + if (!FileUtils.contains(Environment.getStorageDirectory(), file)) { + checkWorldReadAccess(file.getAbsolutePath()); + } + + try { + // checkAccess throws FileNotFoundException only from checkWorldReadAccess(), + // which we already check above. Hence, handling only SecurityException. + if (redactedUriId != null) { + fileUri = ContentUris.removeId(fileUri).buildUpon().appendPath( + redactedUriId).build(); + } + checkAccess(fileUri, Bundle.EMPTY, file, forWrite); + } catch (SecurityException e) { + // Check for other Uri formats only when the single uri check flow fails. + // Throw the previous exception if the multi-uri checks failed. + final String uriId = redactedUriId == null ? Long.toString(id) : redactedUriId; + if (getOtherUriGrantsForPath(path, mediaType, uriId, forWrite) == null) { + throw e; + } + } + isSuccess = true; + return new FileOpenResult(0 /* status */, originalUid, mediaCapabilitiesUid, + redact ? getRedactionRangesForFuse(path, ioPath, originalUid, uid, tid, + forceRedaction) : new long[0]); + } catch (IOException e) { // We are here because - // * App doesn't have read permission to the requested path, hence queryForSingleItem - // couldn't return a valid db row, or, // * There is no db row corresponding to the requested path, which is more unlikely. - // In both of these cases, it means that app doesn't have access permission to the file. - Log.e(TAG, "Couldn't find file: " + path); - return OsConstants.EACCES; + // * getRedactionRangesForFuse couldn't fetch the redaction info correctly + // In all of these cases, it means that app doesn't have access permission to the file. + Log.e(TAG, "Couldn't find file: " + path, e); + return new FileOpenResult(OsConstants.EACCES /* status */, originalUid, + mediaCapabilitiesUid, new long[0]); } catch (IllegalStateException | SecurityException e) { Log.e(TAG, "Permission to access file: " + path + " is denied"); - return OsConstants.EACCES; + return new FileOpenResult(OsConstants.EACCES /* status */, originalUid, + mediaCapabilitiesUid, new long[0]); } finally { + if (isSuccess && logTransformsMetrics) { + notifyTranscodeHelperOnFileOpen(path, ioPath, originalUid, transformsReason); + } restoreLocalCallingIdentity(token); } } + private @Nullable Uri getOtherUriGrantsForPath(String path, boolean forWrite) { + final Uri contentUri = FileUtils.getContentUriForPath(path); + final String[] projection = new String[]{ + MediaColumns._ID, + FileColumns.MEDIA_TYPE}; + final String selection = MediaColumns.DATA + "=?"; + final String[] selectionArgs = new String[]{path}; + final String id; + final int mediaType; + try (final Cursor c = queryForSingleItemAsMediaProvider(contentUri, projection, selection, + selectionArgs, null)) { + id = c.getString(0); + mediaType = c.getInt(1); + return getOtherUriGrantsForPath(path, mediaType, id, forWrite); + } catch (FileNotFoundException ignored) { + } + return null; + } + + @Nullable + private Uri getOtherUriGrantsForPath(String path, int mediaType, String id, boolean forWrite) { + List<Uri> otherUris = new ArrayList<Uri>(); + final Uri mediaUri = getMediaUriForFuse(extractVolumeName(path), mediaType, id); + otherUris.add(mediaUri); + final Uri externalMediaUri = getMediaUriForFuse(MediaStore.VOLUME_EXTERNAL, mediaType, id); + otherUris.add(externalMediaUri); + return getPermissionGrantedUri(otherUris, forWrite); + } + + @NonNull + private Uri getMediaUriForFuse(@NonNull String volumeName, int mediaType, String id) { + Uri uri = MediaStore.Files.getContentUri(volumeName); + switch (mediaType) { + case FileColumns.MEDIA_TYPE_IMAGE: + uri = MediaStore.Images.Media.getContentUri(volumeName); + break; + case FileColumns.MEDIA_TYPE_VIDEO: + uri = MediaStore.Video.Media.getContentUri(volumeName); + break; + case FileColumns.MEDIA_TYPE_AUDIO: + uri = MediaStore.Audio.Media.getContentUri(volumeName); + break; + case FileColumns.MEDIA_TYPE_PLAYLIST: + uri = MediaStore.Audio.Playlists.getContentUri(volumeName); + break; + } + + return uri.buildUpon().appendPath(id).build(); + } + /** * Returns {@code true} if {@link #mCallingIdentity#getSharedPackages(String)} contains the * given package name, {@code false} otherwise. @@ -6622,23 +8277,31 @@ public class MediaProvider extends ContentProvider { */ @NonNull private Uri getContentUriForFile(@NonNull String filePath, @NonNull String mimeType) { - final String volName = FileUtils.getVolumeName(getContext(), new File(filePath)); + final String volName; + try { + volName = FileUtils.getVolumeName(getContext(), new File(filePath)); + } catch (FileNotFoundException e) { + throw new IllegalStateException("Couldn't get volume name for " + filePath); + } Uri uri = Files.getContentUri(volName); - final String topLevelDir = extractTopLevelDir(filePath); + String topLevelDir = extractTopLevelDir(filePath); if (topLevelDir == null) { // If the file path doesn't match the external storage directory, we use the files URI // as default and let #insert enforce the restrictions return uri; } + topLevelDir = topLevelDir.toLowerCase(Locale.ROOT); + switch (topLevelDir) { - case DIRECTORY_PODCASTS: - case DIRECTORY_RINGTONES: - case DIRECTORY_ALARMS: - case DIRECTORY_NOTIFICATIONS: - case DIRECTORY_AUDIOBOOKS: + case DIRECTORY_PODCASTS_LOWER_CASE: + case DIRECTORY_RINGTONES_LOWER_CASE: + case DIRECTORY_ALARMS_LOWER_CASE: + case DIRECTORY_NOTIFICATIONS_LOWER_CASE: + case DIRECTORY_AUDIOBOOKS_LOWER_CASE: + case DIRECTORY_RECORDINGS_LOWER_CASE: uri = Audio.Media.getContentUri(volName); break; - case DIRECTORY_MUSIC: + case DIRECTORY_MUSIC_LOWER_CASE: if (MimeUtils.isPlaylistMimeType(mimeType)) { uri = Audio.Playlists.getContentUri(volName); } else if (!MimeUtils.isSubtitleMimeType(mimeType)) { @@ -6646,7 +8309,7 @@ public class MediaProvider extends ContentProvider { uri = Audio.Media.getContentUri(volName); } break; - case DIRECTORY_MOVIES: + case DIRECTORY_MOVIES_LOWER_CASE: if (MimeUtils.isPlaylistMimeType(mimeType)) { uri = Audio.Playlists.getContentUri(volName); } else if (!MimeUtils.isSubtitleMimeType(mimeType)) { @@ -6654,16 +8317,16 @@ public class MediaProvider extends ContentProvider { uri = Video.Media.getContentUri(volName); } break; - case DIRECTORY_DCIM: - case DIRECTORY_PICTURES: + case DIRECTORY_DCIM_LOWER_CASE: + case DIRECTORY_PICTURES_LOWER_CASE: if (MimeUtils.isImageMimeType(mimeType)) { uri = Images.Media.getContentUri(volName); } else { uri = Video.Media.getContentUri(volName); } break; - case DIRECTORY_DOWNLOADS: - case DIRECTORY_DOCUMENTS: + case DIRECTORY_DOWNLOADS_LOWER_CASE: + case DIRECTORY_DOCUMENTS_LOWER_CASE: break; default: Log.w(TAG, "Forgot to handle a top level directory in getContentUriForFile?"); @@ -6671,6 +8334,15 @@ public class MediaProvider extends ContentProvider { return uri; } + private boolean containsIgnoreCase(@Nullable List<String> stringsList, @Nullable String item) { + if (item == null || stringsList == null) return false; + + for (String current : stringsList) { + if (item.equalsIgnoreCase(current)) return true; + } + return false; + } + private boolean fileExists(@NonNull String absolutePath) { // We don't care about specific columns in the match, // we just want to check IF there's a match @@ -6690,14 +8362,6 @@ public class MediaProvider extends ContentProvider { } } - private boolean isExternalMediaDirectory(@NonNull String path) { - final String relativePath = extractRelativePath(path); - if (relativePath != null) { - return relativePath.startsWith("Android/media"); - } - return false; - } - private Uri insertFileForFuse(@NonNull String path, @NonNull Uri uri, @NonNull String mimeType, boolean useData) { ContentValues values = new ContentValues(); @@ -6717,7 +8381,7 @@ public class MediaProvider extends ContentProvider { /** * Enforces file creation restrictions (see return values) for the given file on behalf of the - * app with the given {@code uid}. If the file is is added to the shared storage, creates a + * app with the given {@code uid}. If the file is added to the shared storage, creates a * database entry for it. * <p> Does NOT create file. * @@ -6741,9 +8405,10 @@ public class MediaProvider extends ContentProvider { public int insertFileIfNecessaryForFuse(@NonNull String path, int uid) { final LocalCallingIdentity token = clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path); try { - if (isPrivatePackagePathNotOwnedByCaller(path)) { + if (isPrivatePackagePathNotAccessibleByCaller(path)) { Log.e(TAG, "Can't create a file in another app's external directory"); return OsConstants.ENOENT; } @@ -6753,7 +8418,14 @@ public class MediaProvider extends ContentProvider { return OsConstants.EPERM; } - if (shouldBypassDatabaseForFuse(uid)) { + if (shouldBypassDatabaseAndSetDirtyForFuse(uid, path)) { + if (path.endsWith("/.nomedia")) { + File parent = new File(path).getParentFile(); + synchronized (mNonHiddenPaths) { + mNonHiddenPaths.keySet().removeIf( + k -> FileUtils.contains(parent, new File(k))); + } + } return 0; } @@ -6859,13 +8531,15 @@ public class MediaProvider extends ContentProvider { public int deleteFileForFuse(@NonNull String path, int uid) throws IOException { final LocalCallingIdentity token = clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path); + try { - if (isPrivatePackagePathNotOwnedByCaller(path)) { + if (isPrivatePackagePathNotAccessibleByCaller(path)) { Log.e(TAG, "Can't delete a file in another app's external directory!"); return OsConstants.ENOENT; } - if (shouldBypassDatabaseForFuse(uid)) { + if (shouldBypassDatabaseAndSetDirtyForFuse(uid, path)) { return deleteFileUnchecked(path); } @@ -6921,10 +8595,11 @@ public class MediaProvider extends ContentProvider { @NonNull String path, int uid, boolean forCreate) { final LocalCallingIdentity token = clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path); try { // App dirs are not indexed, so we don't create an entry for the file. - if (isPrivatePackagePathNotOwnedByCaller(path)) { + if (isPrivatePackagePathNotAccessibleByCaller(path)) { Log.e(TAG, "Can't modify another app's external directory!"); return OsConstants.EACCES; } @@ -6974,25 +8649,28 @@ public class MediaProvider extends ContentProvider { public int isOpendirAllowedForFuse(@NonNull String path, int uid, boolean forWrite) { final LocalCallingIdentity token = clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path); try { if ("/storage/emulated".equals(path)) { return OsConstants.EPERM; } - if (isPrivatePackagePathNotOwnedByCaller(path)) { + if (isPrivatePackagePathNotAccessibleByCaller(path)) { Log.e(TAG, "Can't access another app's external directory!"); return OsConstants.ENOENT; } - // Do not allow apps to open Android/data or Android/obb dirs. Installer and - // MOUNT_EXTERNAL_ANDROID_WRITABLE apps won't be blocked by this, as their OBB dirs - // are mounted to lowerfs directly. + if (shouldBypassFuseRestrictions(forWrite, path)) { + return 0; + } + + // Do not allow apps to open Android/data or Android/obb dirs. + // On primary volumes, apps that get special access to these directories get it via + // mount views of lowerfs. On secondary volumes, such apps would return early from + // shouldBypassFuseRestrictions above. if (isDataOrObbPath(path)) { return OsConstants.EACCES; } - if (shouldBypassFuseRestrictions(forWrite, path)) { - return 0; - } // Legacy apps that made is this far don't have the right storage permission and hence // are not allowed to access anything other than their external app directory if (isCallingPackageRequestingLegacy()) { @@ -7026,16 +8704,143 @@ public class MediaProvider extends ContentProvider { } @Keep - public boolean isUidForPackageForFuse(@NonNull String packageName, int uid) { + public boolean isUidAllowedAccessToDataOrObbPathForFuse(int uid, String path) { final LocalCallingIdentity token = clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); try { - return isCallingIdentitySharedPackageName(packageName); + // Files under the apps own private directory + final String appSpecificDir = extractPathOwnerPackageName(path); + + if (appSpecificDir != null && isCallingIdentitySharedPackageName(appSpecificDir)) { + return true; + } + // This is a private-package path; return true if accessible by the caller + return isUidAllowedSpecialPrivatePathAccess(uid, path); + } finally { + restoreLocalCallingIdentity(token); + } + } + + /** + * @return true iff the caller has installer privileges which gives write access to obb dirs. + * <p> Assumes that {@code mCallingIdentity} has been properly set to reflect the calling + * package. + */ + private boolean isCallingIdentityAllowedInstallerAccess(int uid) { + final boolean hasWrite = mCallingIdentity.get(). + hasPermission(PERMISSION_WRITE_EXTERNAL_STORAGE); + + if (!hasWrite) { + return false; + } + + // We're only willing to give out installer access if they also hold + // runtime permission; this is a firm CDD requirement + final boolean hasInstall = mCallingIdentity.get(). + hasPermission(PERMISSION_INSTALL_PACKAGES); + + if (hasInstall) { + return true; + } + // OPSTR_REQUEST_INSTALL_PACKAGES is granted/denied per package but vold can't + // update mountpoints of a specific package. So, check the appop for all packages + // sharing the uid and allow same level of storage access for all packages even if + // one of the packages has the appop granted. + // To maintain consistency of access in primary volume and secondary volumes use the same + // logic as we do for Zygote.MOUNT_EXTERNAL_INSTALLER view. + return mCallingIdentity.get().hasPermission(APPOP_REQUEST_INSTALL_PACKAGES_FOR_SHARED_UID); + } + + private String getExternalStorageProviderAuthority() { + if (SdkLevel.isAtLeastS()) { + return getExternalStorageProviderAuthorityFromDocumentsContract(); + } + return MediaStore.EXTERNAL_STORAGE_PROVIDER_AUTHORITY; + } + + @RequiresApi(Build.VERSION_CODES.S) + private String getExternalStorageProviderAuthorityFromDocumentsContract() { + return DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY; + } + + private String getDownloadsProviderAuthority() { + if (SdkLevel.isAtLeastS()) { + return getDownloadsProviderAuthorityFromDocumentsContract(); + } + return DOWNLOADS_PROVIDER_AUTHORITY; + } + + @RequiresApi(Build.VERSION_CODES.S) + private String getDownloadsProviderAuthorityFromDocumentsContract() { + return DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY; + } + + private boolean isCallingIdentityDownloadProvider(int uid) { + return uid == mDownloadsAuthorityAppId; + } + + private boolean isCallingIdentityExternalStorageProvider(int uid) { + return uid == mExternalStorageAuthorityAppId; + } + + private boolean isCallingIdentityMtp(int uid) { + return mCallingIdentity.get().hasPermission(PERMISSION_ACCESS_MTP); + } + + /** + * The following apps have access to all private-app directories on secondary volumes: + * * ExternalStorageProvider + * * DownloadProvider + * * Signature apps with ACCESS_MTP permission granted + * (Note: For Android R we also allow privileged apps with ACCESS_MTP to access all + * private-app directories, this additional access is removed for Android S+). + * + * Installer apps can only access private-app directories on Android/obb. + * + * @param uid UID of the calling package + * @param path the path of the file to access + */ + private boolean isUidAllowedSpecialPrivatePathAccess(int uid, String path) { + final LocalCallingIdentity token = + clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + try { + if (SdkLevel.isAtLeastS()) { + return isMountModeAllowedPrivatePathAccess(uid, getCallingPackage(), path); + } else { + if (isCallingIdentityDownloadProvider(uid) || + isCallingIdentityExternalStorageProvider(uid) || isCallingIdentityMtp( + uid)) { + return true; + } + return (isObbOrChildPath(path) && isCallingIdentityAllowedInstallerAccess(uid)); + } } finally { restoreLocalCallingIdentity(token); } } + @RequiresApi(Build.VERSION_CODES.S) + private boolean isMountModeAllowedPrivatePathAccess(int uid, String packageName, String path) { + // This is required as only MediaProvider (package with WRITE_MEDIA_STORAGE) can access + // mount modes. + final CallingIdentity token = clearCallingIdentity(); + try { + final int mountMode = mStorageManager.getExternalStorageMountMode(uid, packageName); + switch (mountMode) { + case StorageManager.MOUNT_MODE_EXTERNAL_ANDROID_WRITABLE: + case StorageManager.MOUNT_MODE_EXTERNAL_PASS_THROUGH: + return true; + case StorageManager.MOUNT_MODE_EXTERNAL_INSTALLER: + return isObbOrChildPath(path); + } + } catch (Exception e) { + Log.w(TAG, "Caller does not have the permissions to access mount modes: ", e); + } finally { + restoreCallingIdentity(token); + } + return false; + } + private boolean checkCallingPermissionGlobal(Uri uri, boolean forWrite) { // System internals can work with all media if (isCallingPackageSelf() || isCallingPackageShell()) { @@ -7063,14 +8868,49 @@ public class MediaProvider extends ContentProvider { } // 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 isUriPermissionGranted(uri, forWrite); + } + + /** + * Returns any uri that is granted from the set of Uris passed. + */ + private @Nullable Uri getPermissionGrantedUri(@NonNull List<Uri> uris, boolean forWrite) { + if (SdkLevel.isAtLeastS()) { + int[] res = checkUriPermissions(uris, mCallingIdentity.get().pid, + mCallingIdentity.get().uid, forWrite); + if (res.length != uris.size()) { + return null; + } + for (int i = 0; i < uris.size(); i++) { + if (res[i] == PERMISSION_GRANTED) { + return uris.get(i); + } + } + } else { + for (Uri uri : uris) { + if (isUriPermissionGranted(uri, forWrite)) { + return uri; + } + } } + return null; + } - return false; + @RequiresApi(Build.VERSION_CODES.S) + private int[] checkUriPermissions(@NonNull List<Uri> uris, int pid, int uid, boolean forWrite) { + final int modeFlags = forWrite + ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION + : Intent.FLAG_GRANT_READ_URI_PERMISSION; + return getContext().checkUriPermissions(uris, pid, uid, modeFlags); + } + + private boolean isUriPermissionGranted(Uri uri, boolean forWrite) { + final int modeFlags = forWrite + ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION + : Intent.FLAG_GRANT_READ_URI_PERMISSION; + int uriPermission = getContext().checkUriPermission(uri, mCallingIdentity.get().pid, + mCallingIdentity.get().uid, modeFlags); + return uriPermission == PERMISSION_GRANTED; } @VisibleForTesting @@ -7078,6 +8918,72 @@ public class MediaProvider extends ContentProvider { return FuseDaemon.native_is_fuse_thread(); } + @VisibleForTesting + public boolean getBooleanDeviceConfig(String key, boolean defaultValue) { + if (!canReadDeviceConfig(key, defaultValue)) { + return defaultValue; + } + + final long token = Binder.clearCallingIdentity(); + try { + return DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT, key, + defaultValue); + } finally { + Binder.restoreCallingIdentity(token); + } + } + + @VisibleForTesting + public int getIntDeviceConfig(String key, int defaultValue) { + if (!canReadDeviceConfig(key, defaultValue)) { + return defaultValue; + } + + final long token = Binder.clearCallingIdentity(); + try { + return DeviceConfig.getInt(DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT, key, + defaultValue); + } finally { + Binder.restoreCallingIdentity(token); + } + } + + @VisibleForTesting + public String getStringDeviceConfig(String key, String defaultValue) { + if (!canReadDeviceConfig(key, defaultValue)) { + return defaultValue; + } + + final long token = Binder.clearCallingIdentity(); + try { + return DeviceConfig.getString(DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT, key, + defaultValue); + } finally { + Binder.restoreCallingIdentity(token); + } + } + + private static <T> boolean canReadDeviceConfig(String key, T defaultValue) { + if (SdkLevel.isAtLeastS()) { + return true; + } + + Log.w(TAG, "Cannot read device config before Android S. Returning defaultValue: " + + defaultValue + " for key: " + key); + return false; + } + + @VisibleForTesting + public void addOnPropertiesChangedListener(OnPropertiesChangedListener listener) { + if (!SdkLevel.isAtLeastS()) { + Log.w(TAG, "Cannot add device config changed listener before Android S"); + return; + } + + DeviceConfig.addOnPropertiesChangedListener(DeviceConfig.NAMESPACE_STORAGE_NATIVE_BOOT, + BackgroundThread.getExecutor(), listener); + } + @Deprecated private boolean checkCallingPermissionAudio(boolean forWrite, String callingPackage) { if (forWrite) { @@ -7126,6 +9032,12 @@ public class MediaProvider extends ContentProvider { } } + private void enforceCallingPermission(@NonNull Collection<Uri> uris, boolean forWrite) { + for (Uri uri : uris) { + enforceCallingPermission(uri, Bundle.EMPTY, forWrite); + } + } + private void enforceCallingPermissionInternal(@NonNull Uri uri, @NonNull Bundle extras, boolean forWrite) { Objects.requireNonNull(uri); @@ -7138,6 +9050,16 @@ public class MediaProvider extends ContentProvider { return; } + // For redacted URI proceed with its corresponding URI as query builder doesn't support + // redacted URIs for fetching a database row + // NOTE: The grants (if any) must have been on redacted URI hence global check requires + // redacted URI + Uri redactedUri = null; + if (isRedactedUri(uri)) { + redactedUri = uri; + uri = getUriForRedactedUri(uri); + } + final DatabaseHelper helper; try { helper = getDatabaseForUri(uri); @@ -7151,7 +9073,8 @@ public class MediaProvider extends ContentProvider { // First, check to see if caller has direct write access if (forWrite) { final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE, table, uri, extras, null); - try (Cursor c = qb.query(helper, new String[0], + qb.allowColumn(SQLiteQueryBuilder.ROWID_COLUMN); + try (Cursor c = qb.query(helper, new String[] { SQLiteQueryBuilder.ROWID_COLUMN }, null, null, null, null, null, null, null)) { if (c.moveToFirst()) { // Direct write access granted, yay! @@ -7174,7 +9097,8 @@ public class MediaProvider extends ContentProvider { // Second, check to see if caller has direct read access final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_QUERY, table, uri, extras, null); - try (Cursor c = qb.query(helper, new String[0], + qb.allowColumn(SQLiteQueryBuilder.ROWID_COLUMN); + try (Cursor c = qb.query(helper, new String[] { SQLiteQueryBuilder.ROWID_COLUMN }, null, null, null, null, null, null, null)) { if (c.moveToFirst()) { if (!forWrite) { @@ -7201,6 +9125,7 @@ public class MediaProvider extends ContentProvider { } } + if (redactedUri != null) uri = redactedUri; throw new SecurityException(getCallingPackageOrSelf() + " has no access to " + uri); } @@ -7327,6 +9252,15 @@ public class MediaProvider extends ContentProvider { } } + public int translateForBulkInsert(int targetSdkVersion) { + if (targetSdkVersion >= mThrowSdkVersion) { + throw new IllegalArgumentException(getMessage()); + } else { + Log.w(TAG, getMessage()); + return 0; + } + } + public int translateForUpdateDelete(int targetSdkVersion) { if (targetSdkVersion >= mThrowSdkVersion) { throw new IllegalArgumentException(getMessage()); @@ -7352,10 +9286,30 @@ public class MediaProvider extends ContentProvider { } } + /** + * Creating a new method for Transcoding to avoid any merge conflicts. + * TODO(b/170465810): Remove this when the code is refactored. + */ + @NonNull DatabaseHelper getDatabaseForUriForTranscoding(Uri uri) + throws VolumeNotFoundException { + return getDatabaseForUri(uri); + } + private @NonNull DatabaseHelper getDatabaseForUri(Uri uri) throws VolumeNotFoundException { final String volumeName = resolveVolumeName(uri); - synchronized (mAttachedVolumeNames) { - if (!mAttachedVolumeNames.contains(volumeName)) { + synchronized (mAttachedVolumes) { + boolean volumeAttached = false; + UserHandle user = mCallingIdentity.get().getUser(); + for (MediaVolume vol : mAttachedVolumes) { + if (vol.getName().equals(volumeName) && vol.isVisibleToUser(user)) { + volumeAttached = true; + break; + } + } + if (!volumeAttached) { + // Dump some more debug info + Log.e(TAG, "Volume " + volumeName + " not found, calling identity: " + + user + ", attached volumes: " + mAttachedVolumes); throw new VolumeNotFoundException(volumeName); } } @@ -7390,42 +9344,43 @@ public class MediaProvider extends ContentProvider { return MediaStore.AUTHORITY_URI.buildUpon().appendPath(volumeName).build(); } - public Uri attachVolume(String volume, boolean validate) { + public Uri attachVolume(MediaVolume volume, boolean validate) { if (mCallingIdentity.get().pid != android.os.Process.myPid()) { throw new SecurityException( "Opening and closing databases not allowed."); } + final String volumeName = volume.getName(); + // Quick check for shady volume names - MediaStore.checkArgumentVolumeName(volume); + MediaStore.checkArgumentVolumeName(volumeName); // Quick check that volume actually exists - if (!MediaStore.VOLUME_INTERNAL.equals(volume) && validate) { + if (!MediaStore.VOLUME_INTERNAL.equals(volumeName) && validate) { try { - getVolumePath(volume); + getVolumePath(volumeName); } catch (IOException e) { throw new IllegalArgumentException( "Volume " + volume + " currently unavailable", e); } } - synchronized (mAttachedVolumeNames) { - mAttachedVolumeNames.add(volume); + synchronized (mAttachedVolumes) { + mAttachedVolumes.add(volume); } final ContentResolver resolver = getContext().getContentResolver(); - final Uri uri = getBaseContentUri(volume); - resolver.notifyChange(getBaseContentUri(volume), null); + final Uri uri = getBaseContentUri(volumeName); + // TODO(b/182396009) we probably also want to notify clone profile (and vice versa) + resolver.notifyChange(getBaseContentUri(volumeName), null); if (LOGV) Log.v(TAG, "Attached volume: " + volume); - if (!MediaStore.VOLUME_INTERNAL.equals(volume)) { + if (!MediaStore.VOLUME_INTERNAL.equals(volumeName)) { // Also notify on synthetic view of all devices resolver.notifyChange(getBaseContentUri(MediaStore.VOLUME_EXTERNAL), null); ForegroundThread.getExecutor().execute(() -> { - final DatabaseHelper helper = MediaStore.VOLUME_INTERNAL.equals(volume) - ? mInternalDatabase : mExternalDatabase; - helper.runWithTransaction((db) -> { + mExternalDatabase.runWithTransaction((db) -> { ensureDefaultFolders(volume, db); ensureThumbnailsValid(volume, db); return null; @@ -7434,26 +9389,39 @@ public class MediaProvider extends ContentProvider { // We just finished the database operation above, we know that // it's ready to answer queries, so notify our DocumentProvider // so it can answer queries without risking ANR - MediaDocumentsProvider.onMediaStoreReady(getContext(), volume); + MediaDocumentsProvider.onMediaStoreReady(getContext(), volumeName); }); } return uri; } private void detachVolume(Uri uri) { - detachVolume(MediaStore.getVolumeName(uri)); + final String volumeName = MediaStore.getVolumeName(uri); + try { + detachVolume(getVolume(volumeName)); + } catch (FileNotFoundException e) { + Log.e(TAG, "Couldn't find volume for URI " + uri, e) ; + } } - public void detachVolume(String volume) { + public boolean isVolumeAttached(MediaVolume volume) { + synchronized (mAttachedVolumes) { + return mAttachedVolumes.contains(volume); + } + } + + public void detachVolume(MediaVolume volume) { if (mCallingIdentity.get().pid != android.os.Process.myPid()) { throw new SecurityException( "Opening and closing databases not allowed."); } + final String volumeName = volume.getName(); + // Quick check for shady volume names - MediaStore.checkArgumentVolumeName(volume); + MediaStore.checkArgumentVolumeName(volumeName); - if (MediaStore.VOLUME_INTERNAL.equals(volume)) { + if (MediaStore.VOLUME_INTERNAL.equals(volumeName)) { throw new UnsupportedOperationException( "Deleting the internal volume is not allowed"); } @@ -7461,24 +9429,24 @@ public class MediaProvider extends ContentProvider { // Signal any scanning to shut down mMediaScanner.onDetachVolume(volume); - synchronized (mAttachedVolumeNames) { - mAttachedVolumeNames.remove(volume); + synchronized (mAttachedVolumes) { + mAttachedVolumes.remove(volume); } final ContentResolver resolver = getContext().getContentResolver(); - final Uri uri = getBaseContentUri(volume); - resolver.notifyChange(getBaseContentUri(volume), null); + final Uri uri = getBaseContentUri(volumeName); + resolver.notifyChange(getBaseContentUri(volumeName), null); - if (!MediaStore.VOLUME_INTERNAL.equals(volume)) { + if (!MediaStore.VOLUME_INTERNAL.equals(volumeName)) { // Also notify on synthetic view of all devices resolver.notifyChange(getBaseContentUri(MediaStore.VOLUME_EXTERNAL), null); } - if (LOGV) Log.v(TAG, "Detached volume: " + volume); + if (LOGV) Log.v(TAG, "Detached volume: " + volumeName); } - @GuardedBy("mAttachedVolumeNames") - private final ArraySet<String> mAttachedVolumeNames = new ArraySet<>(); + @GuardedBy("mAttachedVolumes") + private final ArraySet<MediaVolume> mAttachedVolumes = new ArraySet<>(); @GuardedBy("mCustomCollators") private final ArraySet<String> mCustomCollators = new ArraySet<>(); @@ -7486,6 +9454,7 @@ public class MediaProvider extends ContentProvider { private DatabaseHelper mInternalDatabase; private DatabaseHelper mExternalDatabase; + private TranscodeHelper mTranscodeHelper; // name of the volume currently being scanned by the media scanner (or null) private String mMediaScannerVolume; @@ -7543,6 +9512,9 @@ public class MediaProvider extends ContentProvider { static final int DOWNLOADS = 800; static final int DOWNLOADS_ID = 801; + private static final HashSet<Integer> REDACTED_URI_SUPPORTED_TYPES = new HashSet<>( + Arrays.asList(AUDIO_MEDIA_ID, IMAGES_MEDIA_ID, VIDEO_MEDIA_ID, FILES_ID, DOWNLOADS_ID)); + private LocalUriMatcher mUriMatcher; private static final String[] PATH_PROJECTION = new String[] { @@ -7642,7 +9614,7 @@ public class MediaProvider extends ContentProvider { */ private static final ArraySet<String> sMutableColumns = new ArraySet<>(); - { + static { sMutableColumns.add(MediaStore.MediaColumns.DATA); sMutableColumns.add(MediaStore.MediaColumns.RELATIVE_PATH); sMutableColumns.add(MediaStore.MediaColumns.DISPLAY_NAME); @@ -7673,7 +9645,7 @@ public class MediaProvider extends ContentProvider { */ private static final ArraySet<String> sPlacementColumns = new ArraySet<>(); - { + static { sPlacementColumns.add(MediaStore.MediaColumns.DATA); sPlacementColumns.add(MediaStore.MediaColumns.RELATIVE_PATH); sPlacementColumns.add(MediaStore.MediaColumns.DISPLAY_NAME); @@ -7755,6 +9727,10 @@ public class MediaProvider extends ContentProvider { return mCallingIdentity.get().hasPermission(PERMISSION_IS_SYSTEM_GALLERY); } + private int getCallingUidOrSelf() { + return mCallingIdentity.get().uid; + } + @Deprecated private String getCallingPackageOrSelf() { return mCallingIdentity.get().getPackageName(); @@ -7804,11 +9780,20 @@ public class MediaProvider extends ContentProvider { @Override public void dump(FileDescriptor fd, PrintWriter writer, String[] args) { writer.println("mThumbSize=" + mThumbSize); - synchronized (mAttachedVolumeNames) { - writer.println("mAttachedVolumeNames=" + mAttachedVolumeNames); + synchronized (mAttachedVolumes) { + writer.println("mAttachedVolumes=" + mAttachedVolumes); } writer.println(); + mVolumeCache.dump(writer); + writer.println(); + + mUserCache.dump(writer); + writer.println(); + + mTranscodeHelper.dump(writer); + writer.println(); + Logging.dumpPersistent(writer); } } diff --git a/src/com/android/providers/media/MediaService.java b/src/com/android/providers/media/MediaService.java index d7c6babd..4178aa46 100644 --- a/src/com/android/providers/media/MediaService.java +++ b/src/com/android/providers/media/MediaService.java @@ -27,7 +27,10 @@ import android.content.Context; import android.content.Intent; import android.media.RingtoneManager; import android.net.Uri; +import android.os.Bundle; import android.os.Trace; +import android.os.UserHandle; +import android.os.storage.StorageVolume; import android.provider.MediaStore; import android.util.Log; @@ -41,6 +44,21 @@ import java.io.IOException; public class MediaService extends JobIntentService { private static final int JOB_ID = -300; + private static final String ACTION_SCAN_VOLUME + = "com.android.providers.media.action.SCAN_VOLUME"; + + private static final String EXTRA_MEDIAVOLUME = "MediaVolume"; + + private static final String EXTRA_SCAN_REASON = "scan_reason"; + + + public static void queueVolumeScan(Context context, MediaVolume volume, int reason) { + Intent intent = new Intent(ACTION_SCAN_VOLUME); + intent.putExtra(EXTRA_MEDIAVOLUME, volume) ; + intent.putExtra(EXTRA_SCAN_REASON, reason); + enqueueWork(context, intent); + } + public static void enqueueWork(Context context, Intent work) { enqueueWork(context, MediaService.class, JOB_ID, work); } @@ -68,7 +86,13 @@ public class MediaService extends JobIntentService { break; } case Intent.ACTION_MEDIA_MOUNTED: { - onScanVolume(this, intent.getData(), REASON_MOUNTED); + onMediaMountedBroadcast(this, intent); + break; + } + case ACTION_SCAN_VOLUME: { + final MediaVolume volume = intent.getParcelableExtra(EXTRA_MEDIAVOLUME); + int reason = intent.getIntExtra(EXTRA_SCAN_REASON, REASON_DEMAND); + onScanVolume(this, volume, reason); break; } default: { @@ -100,21 +124,42 @@ public class MediaService extends JobIntentService { } } - private static void onScanVolume(Context context, Uri uri, int reason) + private static void onMediaMountedBroadcast(Context context, Intent intent) throws IOException { - final File file = new File(uri.getPath()).getCanonicalFile(); - final String volumeName = FileUtils.getVolumeName(context, file); - - onScanVolume(context, volumeName, reason); + final StorageVolume volume = intent.getParcelableExtra(StorageVolume.EXTRA_STORAGE_VOLUME); + if (volume != null) { + MediaVolume mediaVolume = MediaVolume.fromStorageVolume(volume); + try (ContentProviderClient cpc = context.getContentResolver() + .acquireContentProviderClient(MediaStore.AUTHORITY)) { + if (!((MediaProvider)cpc.getLocalContentProvider()).isVolumeAttached(mediaVolume)) { + // This can happen on some legacy app clone implementations, where the + // framework is modified to send MEDIA_MOUNTED broadcasts for clone volumes + // to u0 MediaProvider; these volumes are not reported through the usual + // volume attach events, so we need to scan them here if they weren't + // attached previously + onScanVolume(context, mediaVolume, REASON_MOUNTED); + } else { + Log.i(TAG, "Volume " + mediaVolume + " already attached"); + } + } + } else { + Log.e(TAG, "Couldn't retrieve StorageVolume from intent"); + } } - public static void onScanVolume(Context context, String volumeName, int reason) + public static void onScanVolume(Context context, MediaVolume volume, int reason) throws IOException { + final String volumeName = volume.getName(); + UserHandle owner = volume.getUser(); + if (owner == null) { + // Can happen for the internal volume + owner = context.getUser(); + } // If we're about to scan any external storage, scan internal first // to ensure that we have ringtones ready to roll before a possibly very // long external storage scan if (!MediaStore.VOLUME_INTERNAL.equals(volumeName)) { - onScanVolume(context, MediaStore.VOLUME_INTERNAL, reason); + onScanVolume(context, MediaVolume.fromInternal(), reason); RingtoneManager.ensureDefaultRingtones(context); } @@ -123,7 +168,7 @@ public class MediaService extends JobIntentService { // in the situation where a volume is ejected mid-scan final Uri broadcastUri; if (!MediaStore.VOLUME_INTERNAL.equals(volumeName)) { - broadcastUri = Uri.fromFile(FileUtils.getVolumePath(context, volumeName)); + broadcastUri = Uri.fromFile(volume.getPath()); } else { broadcastUri = null; } @@ -131,7 +176,7 @@ public class MediaService extends JobIntentService { try (ContentProviderClient cpc = context.getContentResolver() .acquireContentProviderClient(MediaStore.AUTHORITY)) { final MediaProvider provider = ((MediaProvider) cpc.getLocalContentProvider()); - provider.attachVolume(volumeName, /* validate */ true); + provider.attachVolume(volume, /* validate */ true); final ContentResolver resolver = ContentResolver.wrap(cpc.getLocalContentProvider()); @@ -140,20 +185,24 @@ public class MediaService extends JobIntentService { Uri scanUri = resolver.insert(MediaStore.getMediaScannerUri(), values); if (broadcastUri != null) { - context.sendBroadcast( - new Intent(Intent.ACTION_MEDIA_SCANNER_STARTED, broadcastUri)); + context.sendBroadcastAsUser( + new Intent(Intent.ACTION_MEDIA_SCANNER_STARTED, broadcastUri), owner); } - for (File dir : FileUtils.getVolumeScanPaths(context, volumeName)) { - provider.scanDirectory(dir, reason); + if (MediaStore.VOLUME_INTERNAL.equals(volumeName)) { + for (File dir : FileUtils.getVolumeScanPaths(context, volumeName)) { + provider.scanDirectory(dir, reason); + } + } else { + provider.scanDirectory(volume.getPath(), reason); } resolver.delete(scanUri, null, null); } finally { if (broadcastUri != null) { - context.sendBroadcast( - new Intent(Intent.ACTION_MEDIA_SCANNER_FINISHED, broadcastUri)); + context.sendBroadcastAsUser( + new Intent(Intent.ACTION_MEDIA_SCANNER_FINISHED, broadcastUri), owner); } } } diff --git a/src/com/android/providers/media/MediaUpgradeReceiver.java b/src/com/android/providers/media/MediaUpgradeReceiver.java index 5864b780..abb326ac 100644 --- a/src/com/android/providers/media/MediaUpgradeReceiver.java +++ b/src/com/android/providers/media/MediaUpgradeReceiver.java @@ -23,6 +23,7 @@ import android.content.SharedPreferences; import android.provider.Column; import android.util.Log; +import com.android.providers.media.util.ForegroundThread; import com.android.providers.media.util.Metrics; import java.io.File; @@ -45,7 +46,14 @@ public class MediaUpgradeReceiver extends BroadcastReceiver { public void onReceive(Context context, Intent intent) { // We are now running with the system up, but no apps started, // so can do whatever cleanup after an upgrade that we want. + ForegroundThread.getExecutor().execute(() -> { + // Run database migration on a separate thread so that main thread + // is available for handling other MediaService requests. + tryMigratingDatabases(context); + }); + } + private void tryMigratingDatabases(Context context) { // Lookup the last known database version SharedPreferences prefs = context.getSharedPreferences(TAG, Context.MODE_PRIVATE); int prefVersion = prefs.getInt(PREF_DB_VERSION, 0); diff --git a/src/com/android/providers/media/MediaVolume.java b/src/com/android/providers/media/MediaVolume.java new file mode 100644 index 00000000..1ebf5d9c --- /dev/null +++ b/src/com/android/providers/media/MediaVolume.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2021 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 android.os.Parcel; +import android.os.Parcelable; +import android.os.UserHandle; +import android.os.storage.StorageVolume; +import android.provider.MediaStore; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.util.Objects; + +/** + * MediaVolume is a MediaProvider-internal representation of a storage volume. + * + * Before MediaVolume, volumes inside MediaProvider were represented by their name; + * but now that MediaProvider handles volumes on behalf on multiple users, the name of a volume + * might no longer be unique. So MediaVolume holds both a name and a user. The user may be + * null on volumes without an owner (eg public volumes). + * + * In addition to that, we keep the path and ID of the volume cached in here as well + * for easy access. + */ +public final class MediaVolume implements Parcelable { + /** + * Name of the volume. + */ + private final @NonNull String mName; + + /** + * User to which the volume belongs to; might be null in case of public volumes. + */ + private final @Nullable UserHandle mUser; + + /** + * Path on which the volume is mounted. + */ + private final @Nullable File mPath; + + /** + * Unique ID of the volume; eg "external;0" + */ + private final @Nullable String mId; + + public @NonNull String getName() { + return mName; + } + + public @Nullable UserHandle getUser() { + return mUser; + } + + public @Nullable File getPath() { + return mPath; + } + + public @Nullable String getId() { + return mId; + } + + private MediaVolume (@NonNull String name, UserHandle user, File path, String id) { + this.mName = name; + this.mUser = user; + this.mPath = path; + this.mId = id; + } + + private MediaVolume (Parcel in) { + this.mName = in.readString(); + this.mUser = in.readParcelable(null); + this.mPath = new File(in.readString()); + this.mId = in.readString(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + MediaVolume that = (MediaVolume) obj; + return Objects.equals(mName, that.mName) && + Objects.equals(mUser, that.mUser) && + Objects.equals(mPath, that.mPath) && + Objects.equals(mId, that.mId); + } + + @Override + public int hashCode() { + return Objects.hash(mName, mUser, mPath, mId); + } + + public boolean isVisibleToUser(UserHandle user) { + return mUser == null || user.equals(mUser); + } + + @NonNull + public static MediaVolume fromStorageVolume(StorageVolume storageVolume) { + String name = storageVolume.getMediaStoreVolumeName(); + UserHandle user = storageVolume.getOwner(); + File path = storageVolume.getDirectory(); + String id = storageVolume.getId(); + return new MediaVolume(name, user, path, id); + } + + public static MediaVolume fromInternal() { + String name = MediaStore.VOLUME_INTERNAL; + + return new MediaVolume(name, null, null, null); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mName); + dest.writeParcelable(mUser, flags); + dest.writeString(mPath.toString()); + dest.writeString(mId); + } + + @Override + public String toString() { + return "MediaVolume name: [" + mName + "] id: [" + mId + "] user: [" + mUser + "] path: [" + + mPath + "]"; + } + + public static final @android.annotation.NonNull Creator<MediaVolume> CREATOR + = new Creator<MediaVolume>() { + @Override + public MediaVolume createFromParcel(Parcel in) { + return new MediaVolume(in); + } + + @Override + public MediaVolume[] newArray(int size) { + return new MediaVolume[size]; + } + }; +} diff --git a/src/com/android/providers/media/PermissionActivity.java b/src/com/android/providers/media/PermissionActivity.java index 6a3ccedb..ee374481 100644 --- a/src/com/android/providers/media/PermissionActivity.java +++ b/src/com/android/providers/media/PermissionActivity.java @@ -22,10 +22,14 @@ import static com.android.providers.media.MediaProvider.VIDEO_MEDIA_ID; import static com.android.providers.media.MediaProvider.collectUris; import static com.android.providers.media.util.DatabaseUtils.getAsBoolean; import static com.android.providers.media.util.Logging.TAG; +import static com.android.providers.media.util.PermissionUtils.checkPermissionAccessMediaLocation; +import static com.android.providers.media.util.PermissionUtils.checkPermissionManageMedia; +import static com.android.providers.media.util.PermissionUtils.checkPermissionManager; +import static com.android.providers.media.util.PermissionUtils.checkPermissionReadStorage; import android.app.Activity; import android.app.AlertDialog; -import android.app.ProgressDialog; +import android.app.Dialog; import android.content.ContentProviderOperation; import android.content.ContentResolver; import android.content.ContentValues; @@ -50,7 +54,6 @@ import android.os.Handler; import android.provider.MediaStore; import android.provider.MediaStore.MediaColumns; import android.text.TextUtils; -import android.text.format.DateUtils; import android.util.DisplayMetrics; import android.util.Log; import android.util.Size; @@ -60,10 +63,12 @@ import android.view.ViewGroup; import android.view.WindowManager; import android.view.accessibility.AccessibilityEvent; import android.widget.ImageView; +import android.widget.ProgressBar; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.android.providers.media.MediaProvider.LocalUriMatcher; import com.android.providers.media.util.Metrics; @@ -101,16 +106,30 @@ public class PermissionActivity extends Activity { private String volumeName; private ApplicationInfo appInfo; - private ProgressDialog progressDialog; + private AlertDialog actionDialog; + private AsyncTask<Void, Void, Void> positiveActionTask; + private Dialog progressDialog; private TextView titleView; + private Handler mHandler; + private Runnable mShowProgressDialogRunnable = () -> { + // We will show the progress dialog, add the dim effect back. + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + progressDialog.show(); + }; private static final Long LEAST_SHOW_PROGRESS_TIME_MS = 300L; + private static final Long BEFORE_SHOW_PROGRESS_TIME_MS = 300L; + + @VisibleForTesting + static final String VERB_WRITE = "write"; + @VisibleForTesting + static final String VERB_TRASH = "trash"; + @VisibleForTesting + static final String VERB_FAVORITE = "favorite"; + @VisibleForTesting + static final String VERB_UNFAVORITE = "unfavorite"; - private static final String VERB_WRITE = "write"; - private static final String VERB_TRASH = "trash"; private static final String VERB_UNTRASH = "untrash"; - private static final String VERB_FAVORITE = "favorite"; - private static final String VERB_UNFAVORITE = "unfavorite"; private static final String VERB_DELETE = "delete"; private static final String DATA_AUDIO = "audio"; @@ -124,6 +143,8 @@ public class PermissionActivity extends Activity { private static final int ORDER_AUDIO = 3; private static final int ORDER_GENERIC = 4; + private static final int MAX_THUMBS = 3; + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -132,6 +153,12 @@ public class PermissionActivity extends Activity { getWindow().addSystemFlags( WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS); setFinishOnTouchOutside(false); + // remove the dim effect + // We may not show the progress dialog, if we don't remove the dim effect, + // it may have flicker. + getWindow().addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + getWindow().setDimAmount(0.0f); + // All untrusted input values here were validated when generating the // original PendingIntent @@ -150,30 +177,21 @@ public class PermissionActivity extends Activity { return; } - progressDialog = new ProgressDialog(this); + mHandler = new Handler(getMainLooper()); + // Create Progress dialog + createProgressDialog(); - // Favorite-related requests are automatically granted for now; we still - // make developers go through this no-op dialog flow to preserve our - // ability to start prompting in the future - switch (verb) { - case VERB_FAVORITE: - case VERB_UNFAVORITE: { - onPositiveAction(null, 0); - return; - } + if (!shouldShowActionDialog(this, -1 /* pid */, appInfo.uid, getCallingPackage(), + null /* attributionTag */, verb)) { + onPositiveAction(null, 0); + return; } // Kick off async loading of description to show in dialog final View bodyView = getLayoutInflater().inflate(R.layout.permission_body, null, false); + handleImageViewVisibility(bodyView, uris); new DescriptionTask(bodyView).execute(uris); - final CharSequence message = resolveMessageText(); - if (!TextUtils.isEmpty(message)) { - final TextView messageView = bodyView.requireViewById(R.id.message); - messageView.setVisibility(View.VISIBLE); - messageView.setText(message); - } - final AlertDialog.Builder builder = new AlertDialog.Builder(this); // We set the title in message so that the text doesn't get truncated builder.setMessage(resolveTitleText()); @@ -182,11 +200,11 @@ public class PermissionActivity extends Activity { builder.setCancelable(false); builder.setView(bodyView); - final AlertDialog dialog = builder.show(); + actionDialog = builder.show(); // The title is being set as a message above. // We need to style it like the default AlertDialog title - TextView dialogMessage = (TextView) dialog.findViewById( + TextView dialogMessage = (TextView) actionDialog.findViewById( android.R.id.message); if (dialogMessage != null) { dialogMessage.setTextAppearance(R.style.PermissionAlertDialogTitle); @@ -194,15 +212,51 @@ public class PermissionActivity extends Activity { Log.w(TAG, "Couldn't find message element"); } - final WindowManager.LayoutParams params = dialog.getWindow().getAttributes(); + final WindowManager.LayoutParams params = actionDialog.getWindow().getAttributes(); params.width = getResources().getDimensionPixelSize(R.dimen.permission_dialog_width); - dialog.getWindow().setAttributes(params); + actionDialog.getWindow().setAttributes(params); // Hunt around to find the title of our newly created dialog so we can // adjust accessibility focus once descriptions have been loaded - titleView = (TextView) findViewByPredicate(dialog.getWindow().getDecorView(), (view) -> { - return (view instanceof TextView) && view.isImportantForAccessibility(); - }); + titleView = (TextView) findViewByPredicate(actionDialog.getWindow().getDecorView(), + (view) -> { + return (view instanceof TextView) && view.isImportantForAccessibility(); + }); + } + + private void createProgressDialog() { + final ProgressBar progressBar = new ProgressBar(this); + final int padding = getResources().getDimensionPixelOffset(R.dimen.dialog_space); + + progressBar.setIndeterminate(true); + progressBar.setPadding(0, padding / 2, 0, padding); + progressDialog = new AlertDialog.Builder(this) + .setTitle(resolveProgressMessageText()) + .setView(progressBar) + .setCancelable(false) + .create(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mHandler.removeCallbacks(mShowProgressDialogRunnable); + // Cancel and interrupt the AsyncTask of the positive action. This avoids + // calling the old activity during "onPostExecute", but the AsyncTask could + // still finish its background task. For now we are ok with: + // 1. the task potentially runs again after the configuration is changed + // 2. the task completed successfully, but the activity doesn't return + // the response. + if (positiveActionTask != null) { + positiveActionTask.cancel(true /* mayInterruptIfRunning */); + } + // Dismiss the dialogs to avoid the window is leaked + if (actionDialog != null) { + actionDialog.dismiss(); + } + if (progressDialog != null) { + progressDialog.dismiss(); + } } private void onPositiveAction(@Nullable DialogInterface dialog, int which) { @@ -212,9 +266,11 @@ public class PermissionActivity extends Activity { ((AlertDialog) dialog).getButton(AlertDialog.BUTTON_NEGATIVE).setEnabled(false); } - progressDialog.show(); final long startTime = System.currentTimeMillis(); - new AsyncTask<Void, Void, Void>() { + + mHandler.postDelayed(mShowProgressDialogRunnable, BEFORE_SHOW_PROGRESS_TIME_MS); + + positiveActionTask = new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { Log.d(TAG, "User allowed grant for " + uris); @@ -257,23 +313,30 @@ public class PermissionActivity extends Activity { } catch (Exception e) { Log.w(TAG, e); } + return null; } @Override protected void onPostExecute(Void result) { setResult(Activity.RESULT_OK); - // Don't dismiss the progress dialog too quick, it will cause bad UX. - final long duration = System.currentTimeMillis() - startTime; - if (duration > LEAST_SHOW_PROGRESS_TIME_MS) { - progressDialog.dismiss(); + mHandler.removeCallbacks(mShowProgressDialogRunnable); + + if (!progressDialog.isShowing()) { finish(); } else { - Handler handler = new Handler(getMainLooper()); - handler.postDelayed(() -> { + // Don't dismiss the progress dialog too quick, it will cause bad UX. + final long duration = + System.currentTimeMillis() - startTime - BEFORE_SHOW_PROGRESS_TIME_MS; + if (duration > LEAST_SHOW_PROGRESS_TIME_MS) { progressDialog.dismiss(); finish(); - }, LEAST_SHOW_PROGRESS_TIME_MS - duration); + } else { + mHandler.postDelayed(() -> { + progressDialog.dismiss(); + finish(); + }, LEAST_SHOW_PROGRESS_TIME_MS - duration); + } } } }.execute(); @@ -309,6 +372,67 @@ public class PermissionActivity extends Activity { return keyCode == KeyEvent.KEYCODE_BACK; } + @VisibleForTesting + static boolean shouldShowActionDialog(@NonNull Context context, int pid, int uid, + @NonNull String packageName, @Nullable String attributionTag, @NonNull String verb) { + // Favorite-related requests are automatically granted for now; we still + // make developers go through this no-op dialog flow to preserve our + // ability to start prompting in the future + if (TextUtils.equals(VERB_FAVORITE, verb) || TextUtils.equals(VERB_UNFAVORITE, verb)) { + return false; + } + + // check READ_EXTERNAL_STORAGE and MANAGE_EXTERNAL_STORAGE permissions + if (!checkPermissionReadStorage(context, pid, uid, packageName, attributionTag) + && !checkPermissionManager(context, pid, uid, packageName, attributionTag)) { + Log.d(TAG, "No permission READ_EXTERNAL_STORAGE or MANAGE_EXTERNAL_STORAGE"); + return true; + } + // check MANAGE_MEDIA permission + if (!checkPermissionManageMedia(context, pid, uid, packageName, attributionTag)) { + Log.d(TAG, "No permission MANAGE_MEDIA"); + return true; + } + + // if verb is write, check ACCESS_MEDIA_LOCATION permission + if (TextUtils.equals(verb, VERB_WRITE) && !checkPermissionAccessMediaLocation(context, pid, + uid, packageName, attributionTag)) { + Log.d(TAG, "No permission ACCESS_MEDIA_LOCATION"); + return true; + } + return false; + } + + private void handleImageViewVisibility(View bodyView, List<Uri> uris) { + if (uris.isEmpty()) { + return; + } + if (uris.size() == 1) { + // Set visible to the thumb_full to avoid the size + // changed of the dialog in full decoding. + final ImageView thumbFull = bodyView.requireViewById(R.id.thumb_full); + thumbFull.setVisibility(View.VISIBLE); + } else { + // If the size equals 2, we will remove thumb1 later. + // Set visible to the thumb2 and thumb3 first to avoid + // the size changed of the dialog. + ImageView thumb = bodyView.requireViewById(R.id.thumb2); + thumb.setVisibility(View.VISIBLE); + thumb = bodyView.requireViewById(R.id.thumb3); + thumb.setVisibility(View.VISIBLE); + // If the count of thumbs equals to MAX_THUMBS, set visible to thumb1. + if (uris.size() == MAX_THUMBS) { + thumb = bodyView.requireViewById(R.id.thumb1); + thumb.setVisibility(View.VISIBLE); + } else if (uris.size() > MAX_THUMBS) { + // If the count is larger than MAX_THUMBS, set visible to + // thumb_more_container. + final View container = bodyView.requireViewById(R.id.thumb_more_container); + container.setVisibility(View.VISIBLE); + } + } + } + /** * Resolve a label that represents the app denoted by given {@link ApplicationInfo}. */ @@ -393,43 +517,24 @@ public class PermissionActivity extends Activity { } /** - * Resolve the dialog message string to be displayed to the user, if any. - * All arguments have been bound and this string is ready to be displayed. + * Resolve the progress message string to be displayed to the user. All + * arguments have been bound and this string is ready to be displayed. */ - private @Nullable CharSequence resolveMessageText() { - final String resName = "permission_" + verb + "_" + data + "_info"; + private @Nullable CharSequence resolveProgressMessageText() { + final String resName = "permission_progress_" + verb + "_" + data; final int resId = getResources().getIdentifier(resName, "plurals", getResources().getResourcePackageName(R.string.app_label)); if (resId != 0) { final int count = uris.size(); - final long durationMillis = (values.getAsLong(MediaColumns.DATE_EXPIRES) * 1000) - - System.currentTimeMillis(); - final long durationDays = (durationMillis + DateUtils.DAY_IN_MILLIS) - / DateUtils.DAY_IN_MILLIS; final CharSequence text = getResources().getQuantityText(resId, count); - return TextUtils.expandTemplate(text, label, String.valueOf(count), - String.valueOf(durationDays)); + return TextUtils.expandTemplate(text, String.valueOf(count)); } else { - // Only some actions have a secondary message string; it's okay if + // Only some actions have a progress message string; it's okay if // there isn't one defined return null; } } - private @NonNull CharSequence resolvePositiveText() { - final String resName = "permission_" + verb + "_grant"; - final int resId = getResources().getIdentifier(resName, "string", - getResources().getResourcePackageName(R.string.app_label)); - return getResources().getText(resId); - } - - private @NonNull CharSequence resolveNegativeText() { - final String resName = "permission_" + verb + "_deny"; - final int resId = getResources().getIdentifier(resName, "string", - getResources().getResourcePackageName(R.string.app_label)); - return getResources().getText(resId); - } - /** * Recursively walk the given view hierarchy looking for the first * {@link View} which matches the given predicate. @@ -456,8 +561,6 @@ public class PermissionActivity extends Activity { * displayed in the body of the dialog. */ private class DescriptionTask extends AsyncTask<List<Uri>, Void, List<Description>> { - private static final int MAX_THUMBS = 3; - private View bodyView; private Resources res; @@ -482,29 +585,7 @@ public class PermissionActivity extends Activity { // If we're only asking for single item, load the full image if (uris.size() == 1) { - // Set visible to the thumb_full to avoid the size - // changed of the dialog in full decoding. - final ImageView thumbFull = bodyView.requireViewById(R.id.thumb_full); - thumbFull.setVisibility(View.VISIBLE); loadFlags |= Description.LOAD_FULL; - } else { - // If the size equals 2, we will remove thumb1 later. - // Set visible to the thumb2 and thumb3 first to avoid - // the size changed of the dialog. - ImageView thumb = bodyView.requireViewById(R.id.thumb2); - thumb.setVisibility(View.VISIBLE); - thumb = bodyView.requireViewById(R.id.thumb3); - thumb.setVisibility(View.VISIBLE); - // If the count of thumbs equals to MAX_THUMBS, set visible to thumb1. - if (uris.size() == MAX_THUMBS) { - thumb = bodyView.requireViewById(R.id.thumb1); - thumb.setVisibility(View.VISIBLE); - } else if (uris.size() > MAX_THUMBS) { - // If the count is larger than MAX_THUMBS, set visible to - // thumb_more_container. - final View container = bodyView.requireViewById(R.id.thumb_more_container); - container.setVisibility(View.VISIBLE); - } } // Sort the uris in DATA_GENERIC case (Image, Video, Audio, Others) @@ -643,6 +724,9 @@ public class PermissionActivity extends Activity { private void bindAsText(@NonNull List<Description> results) { final List<CharSequence> list = new ArrayList<>(); for (int i = 0; i < results.size(); i++) { + if (TextUtils.isEmpty(results.get(i).contentDescription)) { + continue; + } list.add(results.get(i).contentDescription); if (list.size() >= MAX_THUMBS && results.size() > list.size()) { @@ -653,10 +737,11 @@ public class PermissionActivity extends Activity { break; } } - - final TextView text = bodyView.requireViewById(R.id.list); - text.setText(TextUtils.join("\n", list)); - text.setVisibility(View.VISIBLE); + if (!list.isEmpty()) { + final TextView text = bodyView.requireViewById(R.id.list); + text.setText(TextUtils.join("\n", list)); + text.setVisibility(View.VISIBLE); + } } } @@ -708,7 +793,9 @@ public class PermissionActivity extends Activity { Log.w(TAG, e); if (thumbnail == null && full == null) { final String mimeType = resolver.getType(uri); - mimeIcon = resolver.getTypeInfo(mimeType).getIcon(); + if (mimeType != null) { + mimeIcon = resolver.getTypeInfo(mimeType).getIcon(); + } } } } diff --git a/src/com/android/providers/media/TranscodeHelper.java b/src/com/android/providers/media/TranscodeHelper.java new file mode 100644 index 00000000..488c40de --- /dev/null +++ b/src/com/android/providers/media/TranscodeHelper.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2021 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 android.net.Uri; +import android.os.Bundle; +import java.io.PrintWriter; + +/** Interface over MediaTranscodeManager access */ +public interface TranscodeHelper { + public void freeCache(long bytes); + + public void onAnrDelayStarted(String packageName, int uid, int tid, int reason); + + public boolean transcode(String src, String dst, int uid, int reason); + + public String getIoPath(String path, int uid); + + public int shouldTranscode(String path, int uid, Bundle bundle); + + public boolean supportsTranscode(String path); + + public void onUriPublished(Uri uri); + + public void onFileOpen(String path, String ioPath, int uid, int transformsReason); + + public boolean isTranscodeFileCached(String path, String transcodePath); + + public boolean deleteCachedTranscodeFile(long rowId); + + public void dump(PrintWriter writer); +} diff --git a/src/com/android/providers/media/TranscodeHelperImpl.java b/src/com/android/providers/media/TranscodeHelperImpl.java new file mode 100644 index 00000000..feeddeba --- /dev/null +++ b/src/com/android/providers/media/TranscodeHelperImpl.java @@ -0,0 +1,1857 @@ +/* + * Copyright (C) 2020 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.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; +import static android.provider.MediaStore.Files.FileColumns.TRANSCODE_COMPLETE; +import static android.provider.MediaStore.Files.FileColumns.TRANSCODE_EMPTY; +import static android.provider.MediaStore.MATCH_EXCLUDE; +import static android.provider.MediaStore.QUERY_ARG_MATCH_PENDING; +import static android.provider.MediaStore.QUERY_ARG_MATCH_TRASHED; + +import static com.android.providers.media.MediaProvider.VolumeNotFoundException; +import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA; +import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN; +import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_CLIENT_TIMEOUT; +import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_SERVICE_ERROR; +import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_SESSION_CANCELED; +import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__TRANSCODE_RESULT__FAIL; +import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__TRANSCODE_RESULT__SUCCESS; +import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__TRANSCODE_RESULT__UNDEFINED; + +import android.annotation.IntRange; +import android.annotation.LongDef; +import android.app.ActivityManager; +import android.app.ActivityManager.OnUidImportanceListener; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.compat.CompatChanges; +import android.compat.annotation.ChangeId; +import android.compat.annotation.Disabled; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.InstallSourceInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.Property; +import android.content.res.XmlResourceParser; +import android.database.Cursor; +import android.media.ApplicationMediaCapabilities; +import android.media.MediaFeature; +import android.media.MediaFormat; +import android.media.MediaTranscodingManager; +import android.media.MediaTranscodingManager.VideoTranscodingRequest; +import android.media.MediaTranscodingManager.TranscodingRequest.VideoFormatResolver; +import android.media.MediaTranscodingManager.TranscodingSession; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.os.ParcelFileDescriptor; +import android.os.Process; +import android.os.SystemClock; +import android.os.SystemProperties; +import android.os.UserHandle; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; +import android.provider.MediaStore; +import android.provider.MediaStore.Files.FileColumns; +import android.provider.MediaStore.MediaColumns; +import android.provider.MediaStore.Video.VideoColumns; +import android.text.TextUtils; +import android.util.ArrayMap; +import android.util.ArraySet; +import android.util.Log; +import android.util.Pair; +import android.util.SparseArray; +import android.widget.Toast; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.modules.utils.BackgroundThread; +import com.android.modules.utils.build.SdkLevel; +import com.android.providers.media.util.FileUtils; +import com.android.providers.media.util.ForegroundThread; +import com.android.providers.media.util.SQLiteQueryBuilder; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.io.RandomAccessFile; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@RequiresApi(Build.VERSION_CODES.S) +public class TranscodeHelperImpl implements TranscodeHelper { + private static final String TAG = "TranscodeHelper"; + private static final boolean DEBUG = SystemProperties.getBoolean("persist.sys.fuse.log", false); + private static final float MAX_APP_NAME_SIZE_PX = 500f; + + // Notice the pairing of the keys.When you change a DEVICE_CONFIG key, then please also change + // the corresponding SYS_PROP key too; and vice-versa. + // Keeping the whole strings separate for the ease of text search. + private static final String TRANSCODE_ENABLED_SYS_PROP_KEY = + "persist.sys.fuse.transcode_enabled"; + private static final String TRANSCODE_ENABLED_DEVICE_CONFIG_KEY = "transcode_enabled"; + private static final String TRANSCODE_DEFAULT_SYS_PROP_KEY = + "persist.sys.fuse.transcode_default"; + private static final String TRANSCODE_DEFAULT_DEVICE_CONFIG_KEY = "transcode_default"; + private static final String TRANSCODE_USER_CONTROL_SYS_PROP_KEY = + "persist.sys.fuse.transcode_user_control"; + private static final String TRANSCODE_COMPAT_MANIFEST_KEY = "transcode_compat_manifest"; + private static final String TRANSCODE_COMPAT_STALE_KEY = "transcode_compat_stale"; + private static final String TRANSCODE_MAX_DURATION_MS_KEY = "transcode_max_duration_ms"; + + private static final int MY_UID = android.os.Process.myUid(); + private static final int MAX_TRANSCODE_DURATION_MS = (int) TimeUnit.MINUTES.toMillis(1); + + /** + * Force enable an app to support the HEVC media capability + * + * Apps should declare their supported media capabilities in their manifest but this flag can be + * used to force an app into supporting HEVC, hence avoiding transcoding while accessing media + * encoded in HEVC. + * + * Setting this flag will override any OS level defaults for apps. It is disabled by default, + * meaning that the OS defaults would take precedence. + * + * Setting this flag and {@code FORCE_DISABLE_HEVC_SUPPORT} is an undefined + * state and will result in the OS ignoring both flags. + */ + @ChangeId + @Disabled + private static final long FORCE_ENABLE_HEVC_SUPPORT = 174228127L; + + /** + * Force disable an app from supporting the HEVC media capability + * + * Apps should declare their supported media capabilities in their manifest but this flag can be + * used to force an app into not supporting HEVC, hence forcing transcoding while accessing + * media encoded in HEVC. + * + * Setting this flag will override any OS level defaults for apps. It is disabled by default, + * meaning that the OS defaults would take precedence. + * + * Setting this flag and {@code FORCE_ENABLE_HEVC_SUPPORT} is an undefined state + * and will result in the OS ignoring both flags. + */ + @ChangeId + @Disabled + private static final long FORCE_DISABLE_HEVC_SUPPORT = 174227820L; + + @VisibleForTesting + static final int FLAG_HEVC = 1 << 0; + @VisibleForTesting + static final int FLAG_SLOW_MOTION = 1 << 1; + private static final int FLAG_HDR_10 = 1 << 2; + private static final int FLAG_HDR_10_PLUS = 1 << 3; + private static final int FLAG_HDR_HLG = 1 << 4; + private static final int FLAG_HDR_DOLBY_VISION = 1 << 5; + private static final int MEDIA_FORMAT_FLAG_MASK = FLAG_HEVC | FLAG_SLOW_MOTION + | FLAG_HDR_10 | FLAG_HDR_10_PLUS | FLAG_HDR_HLG | FLAG_HDR_DOLBY_VISION; + + @LongDef({ + FLAG_HEVC, + FLAG_SLOW_MOTION, + FLAG_HDR_10, + FLAG_HDR_10_PLUS, + FLAG_HDR_HLG, + FLAG_HDR_DOLBY_VISION + }) + @Retention(RetentionPolicy.SOURCE) + public @interface ApplicationMediaCapabilitiesFlags { + } + + /** Coefficient to 'guess' how long a transcoding session might take */ + private static final double TRANSCODING_TIMEOUT_COEFFICIENT = 2; + /** Coefficient to 'guess' how large a transcoded file might be */ + private static final double TRANSCODING_SIZE_COEFFICIENT = 2; + + /** + * Copied from MediaProvider.java + * TODO(b/170465810): Remove this when getQueryBuilder code is refactored. + */ + private static final int TYPE_QUERY = 0; + private static final int TYPE_UPDATE = 2; + + private static final int MAX_FINISHED_TRANSCODING_SESSION_STORE_COUNT = 16; + private static final String DIRECTORY_CAMERA = "Camera"; + + private static final boolean IS_TRANSCODING_SUPPORTED = SdkLevel.isAtLeastS(); + + private final Object mLock = new Object(); + private final Context mContext; + private final MediaProvider mMediaProvider; + private final PackageManager mPackageManager; + private final StorageManager mStorageManager; + private final ActivityManager mActivityManager; + private final File mTranscodeDirectory; + @GuardedBy("mLock") + private UUID mTranscodeVolumeUuid; + + @GuardedBy("mLock") + private final Map<String, StorageTranscodingSession> mStorageTranscodingSessions = + new ArrayMap<>(); + + // These are for dumping purpose only. + // We keep these separately because the probability of getting cancelled and error'ed sessions + // is pretty low, and we are limiting the count of what we keep. So, we don't wanna miss out + // on dumping the cancelled and error'ed sessions. + @GuardedBy("mLock") + private final Map<StorageTranscodingSession, Boolean> mSuccessfulTranscodeSessions = + createFinishedTranscodingSessionMap(); + @GuardedBy("mLock") + private final Map<StorageTranscodingSession, Boolean> mCancelledTranscodeSessions = + createFinishedTranscodingSessionMap(); + @GuardedBy("mLock") + private final Map<StorageTranscodingSession, Boolean> mErroredTranscodeSessions = + createFinishedTranscodingSessionMap(); + + private final TranscodeUiNotifier mTranscodingUiNotifier; + private final TranscodeDenialController mTranscodeDenialController; + private final SessionTiming mSessionTiming; + @GuardedBy("mLock") + private final Map<String, Integer> mAppCompatMediaCapabilities = new ArrayMap<>(); + @GuardedBy("mLock") + private boolean mIsTranscodeEnabled; + + private static final String[] TRANSCODE_CACHE_INFO_PROJECTION = + {FileColumns._ID, FileColumns._TRANSCODE_STATUS}; + private static final String TRANSCODE_WHERE_CLAUSE = + FileColumns.DATA + "=?" + " and mime_type not like 'null'"; + + public TranscodeHelperImpl(Context context, MediaProvider mediaProvider) { + mContext = context; + mPackageManager = context.getPackageManager(); + mStorageManager = context.getSystemService(StorageManager.class); + mActivityManager = context.getSystemService(ActivityManager.class); + mMediaProvider = mediaProvider; + mTranscodeDirectory = new File("/storage/emulated/" + UserHandle.myUserId(), + DIRECTORY_TRANSCODE); + mTranscodeDirectory.mkdirs(); + mSessionTiming = new SessionTiming(); + mTranscodingUiNotifier = new TranscodeUiNotifier(context, mSessionTiming); + mIsTranscodeEnabled = isTranscodeEnabled(); + int maxTranscodeDurationMs = + mMediaProvider.getIntDeviceConfig(TRANSCODE_MAX_DURATION_MS_KEY, + MAX_TRANSCODE_DURATION_MS); + mTranscodeDenialController = new TranscodeDenialController(mActivityManager, + mTranscodingUiNotifier, maxTranscodeDurationMs); + + parseTranscodeCompatManifest(); + // The storage namespace is a boot namespace so we actually don't expect this to be changed + // after boot, but it is useful for tests + mMediaProvider.addOnPropertiesChangedListener(properties -> parseTranscodeCompatManifest()); + } + + /** + * Regex that matches path of transcode file. The regex only + * matches emulated volume, for files in other volumes we don't + * seamlessly transcode. + */ + private static final Pattern PATTERN_TRANSCODE_PATH = Pattern.compile( + "(?i)^/storage/emulated/(?:[0-9]+)/\\.transforms/transcode/(?:\\d+)$"); + private static final String DIRECTORY_TRANSCODE = ".transforms/transcode"; + /** + * @return true if the file path matches transcode file path. + */ + private static boolean isTranscodeFile(@NonNull String path) { + final Matcher matcher = PATTERN_TRANSCODE_PATH.matcher(path); + return matcher.matches(); + } + + public void freeCache(long bytes) { + File[] files = mTranscodeDirectory.listFiles(); + for (File file : files) { + if (bytes <= 0) { + return; + } + if (file.exists() && file.isFile()) { + long size = file.length(); + boolean deleted = file.delete(); + if (deleted) { + bytes -= size; + } + } + } + } + + private UUID getTranscodeVolumeUuid() { + synchronized (mLock) { + if (mTranscodeVolumeUuid != null) { + return mTranscodeVolumeUuid; + } + } + + StorageVolume vol = mStorageManager.getStorageVolume(mTranscodeDirectory); + if (vol != null) { + synchronized (mLock) { + mTranscodeVolumeUuid = vol.getStorageUuid(); + return mTranscodeVolumeUuid; + } + } else { + Log.w(TAG, "Failed to get storage volume UUID for: " + mTranscodeDirectory); + return null; + } + } + + /** + * @return transcode file's path for given {@code rowId} + */ + @NonNull + private String getTranscodePath(long rowId) { + return new File(mTranscodeDirectory, String.valueOf(rowId)).getAbsolutePath(); + } + + public void onAnrDelayStarted(String packageName, int uid, int tid, int reason) { + if (!isTranscodeEnabled()) { + return; + } + + if (uid == MY_UID) { + Log.w(TAG, "Skipping ANR delay handling for MediaProvider"); + return; + } + + logVerbose("Checking transcode status during ANR of " + packageName); + + Set<StorageTranscodingSession> sessions = new ArraySet<>(); + synchronized (mLock) { + sessions.addAll(mStorageTranscodingSessions.values()); + } + + for (StorageTranscodingSession session: sessions) { + if (session.isUidBlocked(uid)) { + session.setAnr(); + Log.i(TAG, "Package: " + packageName + " with uid: " + uid + + " and tid: " + tid + " is blocked on transcoding: " + session); + // TODO(b/170973510): Show UI + } + } + } + + // TODO(b/170974147): This should probably use a cache so we don't need to ask the + // package manager every time for the package name or installer name + private String getMetricsSafeNameForUid(int uid) { + String name = mPackageManager.getNameForUid(uid); + if (name == null) { + Log.w(TAG, "null package name received from getNameForUid for uid " + uid + + ", logging uid instead."); + return Integer.toString(uid); + } else if (name.isEmpty()) { + Log.w(TAG, "empty package name received from getNameForUid for uid " + uid + + ", logging uid instead"); + return ":empty_package_name:" + uid; + } else { + try { + InstallSourceInfo installInfo = mPackageManager.getInstallSourceInfo(name); + ApplicationInfo applicationInfo = mPackageManager.getApplicationInfo(name, 0); + if (installInfo.getInstallingPackageName() == null + && ((applicationInfo.flags & ApplicationInfo.FLAG_SYSTEM) == 0)) { + // For privacy reasons, we don't log metrics for side-loaded packages that + // are not system packages + return ":installer_adb:" + uid; + } + return name; + } catch (PackageManager.NameNotFoundException e) { + Log.w(TAG, "Unable to check installer for uid: " + uid, e); + return ":name_not_found:" + uid; + } + } + } + + private void reportTranscodingResult(int uid, boolean success, int errorCode, int failureReason, + long transcodingDurationMs, + int transcodingReason, String src, String dst, boolean hasAnr) { + BackgroundThread.getExecutor().execute(() -> { + try (Cursor c = queryFileForTranscode(src, + new String[]{MediaColumns.DURATION, MediaColumns.CAPTURE_FRAMERATE, + MediaColumns.WIDTH, MediaColumns.HEIGHT})) { + if (c != null && c.moveToNext()) { + MediaProviderStatsLog.write( + TRANSCODING_DATA, + getMetricsSafeNameForUid(uid), + MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_TYPE__READ_TRANSCODE, + success ? new File(dst).length() : -1, + success ? TRANSCODING_DATA__TRANSCODE_RESULT__SUCCESS : + TRANSCODING_DATA__TRANSCODE_RESULT__FAIL, + transcodingDurationMs, + c.getLong(0) /* video_duration */, + c.getLong(1) /* capture_framerate */, + transcodingReason, + c.getLong(2) /* width */, + c.getLong(3) /* height */, + hasAnr, + failureReason, + errorCode); + } + } + }); + } + + public boolean transcode(String src, String dst, int uid, int reason) { + // This can only happen when we are in a version that supports transcoding. + // So, no need to check for the SDK version here. + + StorageTranscodingSession storageSession = null; + TranscodingSession transcodingSession = null; + CountDownLatch latch = null; + long startTime = SystemClock.elapsedRealtime(); + boolean result = false; + int errorCode = TranscodingSession.ERROR_SERVICE_DIED; + int failureReason = TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_SERVICE_ERROR; + + try { + synchronized (mLock) { + storageSession = mStorageTranscodingSessions.get(src); + if (storageSession == null) { + latch = new CountDownLatch(1); + try { + transcodingSession = enqueueTranscodingSession(src, dst, uid, latch); + if (transcodingSession == null) { + Log.e(TAG, "Failed to enqueue request due to Service unavailable"); + throw new IllegalStateException("Failed to enqueue request"); + } + } catch (UnsupportedOperationException | IOException e) { + throw new IllegalStateException(e); + } + storageSession = new StorageTranscodingSession(transcodingSession, latch, + src, dst); + mStorageTranscodingSessions.put(src, storageSession); + } else { + latch = storageSession.latch; + transcodingSession = storageSession.session; + if (latch == null || transcodingSession == null) { + throw new IllegalStateException("Uninitialised TranscodingSession for uid: " + + uid + ". Path: " + src); + } + } + storageSession.addBlockedUid(uid); + } + + failureReason = waitTranscodingResult(uid, src, transcodingSession, latch); + errorCode = transcodingSession.getErrorCode(); + result = failureReason == TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN; + + if (result) { + updateTranscodeStatus(src, TRANSCODE_COMPLETE); + } else { + logEvent("Transcoding failed for " + src + ". session: ", transcodingSession); + // Attempt to workaround potential media transcoding deadlock + // Cancelling a deadlocked session seems to unblock the transcoder + transcodingSession.cancel(); + } + } finally { + if (storageSession == null) { + Log.w(TAG, "Failed to create a StorageTranscodingSession"); + // We were unable to even queue the request. Which means the media service is + // in a very bad state + reportTranscodingResult(uid, result, errorCode, failureReason, + SystemClock.elapsedRealtime() - startTime, reason, + src, dst, false /* hasAnr */); + return false; + } + + storageSession.notifyFinished(failureReason, errorCode); + if (errorCode == TranscodingSession.ERROR_DROPPED_BY_SERVICE) { + // If the transcoding service drops a request for a uid the uid will be denied + // transcoding access until the next boot, notify the denial controller which may + // also show a denial UI + mTranscodeDenialController.onTranscodingDropped(uid); + } + reportTranscodingResult(uid, result, errorCode, failureReason, + SystemClock.elapsedRealtime() - startTime, reason, + src, dst, storageSession.hasAnr()); + } + return result; + } + + /** + * Returns IO path for a {@code path} and {@code uid} + * + * IO path is the actual path to be used on the lower fs for IO via FUSE. For some file + * transforms, this path might be different from the path the app is requesting IO on. + * + * @param path file path to get an IO path for + * @param uid app requesting IO + * + */ + public String getIoPath(String path, int uid) { + // This can only happen when we are in a version that supports transcoding. + // So, no need to check for the SDK version here. + + Pair<Long, Integer> cacheInfo = getTranscodeCacheInfoFromDB(path); + final long rowId = cacheInfo.first; + if (rowId == -1) { + // No database row found, The file is pending/trashed or not added to database yet. + // Assuming that no transcoding needed. + return path; + } + + int transcodeStatus = cacheInfo.second; + final String transcodePath = getTranscodePath(rowId); + final File transcodeFile = new File(transcodePath); + + if (transcodeFile.exists()) { + return transcodePath; + } + + if (transcodeStatus == TRANSCODE_COMPLETE) { + // The transcode file doesn't exist but db row is marked as TRANSCODE_COMPLETE, + // update db row to TRANSCODE_EMPTY so that cache state remains valid. + updateTranscodeStatus(path, TRANSCODE_EMPTY); + } + + final File file = new File(path); + long maxFileSize = (long) (file.length() * 2); + mTranscodeDirectory.mkdirs(); + try (RandomAccessFile raf = new RandomAccessFile(transcodeFile, "rw")) { + raf.setLength(maxFileSize); + } catch (IOException e) { + Log.e(TAG, "Failed to initialise transcoding for file " + path, e); + transcodeFile.delete(); + return transcodePath; + } + + return transcodePath; + } + + private static int getMediaCapabilitiesUid(int uid, Bundle bundle) { + if (bundle == null || !bundle.containsKey(MediaStore.EXTRA_MEDIA_CAPABILITIES_UID)) { + return uid; + } + + int mediaCapabilitiesUid = bundle.getInt(MediaStore.EXTRA_MEDIA_CAPABILITIES_UID); + if (mediaCapabilitiesUid >= Process.FIRST_APPLICATION_UID) { + logVerbose( + "Media capabilities uid " + mediaCapabilitiesUid + ", passed for uid " + uid); + return mediaCapabilitiesUid; + } + Log.w(TAG, "Ignoring invalid media capabilities uid " + mediaCapabilitiesUid + + " for uid: " + uid); + return uid; + } + + // TODO(b/173491972): Generalize to consider other file/app media capabilities beyond hevc + /** + * @return 0 or >0 representing whether we should transcode or not. + * 0 means we should not transcode, otherwise we should transcode and the value is the + * reason that will be logged to statsd as a transcode reason. Possible values are: + * <ul> + * <li>MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__SYSTEM_DEFAULT=1 + * <li>MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__SYSTEM_CONFIG=2 + * <li>MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_MANIFEST=3 + * <li>MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_COMPAT=4 + * <li>MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_EXTRA=5 + * </ul> + * + */ + public int shouldTranscode(String path, int uid, Bundle bundle) { + boolean isTranscodeEnabled = isTranscodeEnabled(); + updateConfigs(isTranscodeEnabled); + + if (!isTranscodeEnabled) { + logVerbose("Transcode not enabled"); + return 0; + } + + uid = getMediaCapabilitiesUid(uid, bundle); + logVerbose("Checking shouldTranscode for: " + path + ". Uid: " + uid); + + if (!supportsTranscode(path) || uid < Process.FIRST_APPLICATION_UID || uid == MY_UID) { + logVerbose("Transcode not supported"); + // Never transcode in any of these conditions + // 1. Path doesn't support transcode + // 2. Uid is from native process on device + // 3. Uid is ourselves, which can happen when we are opening a file via FUSE for + // redaction on behalf of another app via ContentResolver + return 0; + } + + // Transcode only if file needs transcoding + Pair<Integer, Long> result = getFileFlagsAndDurationMs(path); + int fileFlags = result.first; + long durationMs = result.second; + + if (fileFlags == 0) { + // Nothing to transcode + logVerbose("File is not HEVC"); + return 0; + } + + int accessReason = doesAppNeedTranscoding(uid, bundle, fileFlags, durationMs); + if (accessReason != 0 && mTranscodeDenialController.checkFileAccess(uid, durationMs)) { + logVerbose("Transcoding denied"); + return 0; + } + return accessReason; + } + + @VisibleForTesting + int doesAppNeedTranscoding(int uid, Bundle bundle, int fileFlags, long durationMs) { + // Check explicit Bundle provided + if (bundle != null) { + if (bundle.getBoolean(MediaStore.EXTRA_ACCEPT_ORIGINAL_MEDIA_FORMAT, false)) { + logVerbose("Original format requested"); + return 0; + } + + ApplicationMediaCapabilities capabilities = + bundle.getParcelable(MediaStore.EXTRA_MEDIA_CAPABILITIES); + if (capabilities != null) { + Pair<Integer, Integer> flags = capabilitiesToMediaFormatFlags(capabilities); + Optional<Boolean> appExtraResult = checkAppMediaSupport(flags.first, flags.second, + fileFlags, "app_extra"); + if (appExtraResult.isPresent()) { + if (appExtraResult.get()) { + return MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_EXTRA; + } + return 0; + } + // Bundle didn't have enough information to make decision, continue + } + } + + // Check app compat support + Optional<Boolean> appCompatResult = checkAppCompatSupport(uid, fileFlags); + if (appCompatResult.isPresent()) { + if (appCompatResult.get()) { + return MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_COMPAT; + } + return 0; + } + // App compat didn't have enough information to make decision, continue + + // If we are here then the file supports HEVC, so we only check if the package is in the + // mAppCompatCapabilities. If it's there, we will respect that value. + LocalCallingIdentity identity = mMediaProvider.getCachedCallingIdentityForTranscoding(uid); + final String[] callingPackages = identity.getSharedPackageNames(); + + // Check app manifest support + for (String callingPackage : callingPackages) { + Optional<Boolean> appManifestResult = checkManifestSupport(callingPackage, identity, + fileFlags); + if (appManifestResult.isPresent()) { + if (appManifestResult.get()) { + return MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_MANIFEST; + } + return 0; + } + // App manifest didn't have enough information to make decision, continue + + // TODO(b/169327180): We should also check app's targetSDK version to verify if app + // still qualifies to be on these lists. + // Check config compat manifest + synchronized (mLock) { + if (mAppCompatMediaCapabilities.containsKey(callingPackage)) { + int configCompatFlags = mAppCompatMediaCapabilities.get(callingPackage); + int supportedFlags = configCompatFlags; + int unsupportedFlags = ~configCompatFlags & MEDIA_FORMAT_FLAG_MASK; + + Optional<Boolean> systemConfigResult = checkAppMediaSupport(supportedFlags, + unsupportedFlags, fileFlags, "system_config"); + if (systemConfigResult.isPresent()) { + if (systemConfigResult.get()) { + return MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__SYSTEM_CONFIG; + } + return 0; + } + // Should never get here because the supported & unsupported flags should span + // the entire universe of file flags + } + } + } + + // TODO: Need to add transcode_default as flags + if (shouldTranscodeDefault()) { + logVerbose("Default behavior should transcode"); + return MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__SYSTEM_DEFAULT; + } else { + logVerbose("Default behavior should not transcode"); + return 0; + } + } + + /** + * Checks if transcode is required for the given app media capabilities and file media formats + * + * @param appSupportedMediaFormatFlags bit mask of media capabilites explicitly supported by an + * app, e.g 001 indicating HEVC support + * @param appUnsupportedMediaFormatFlags bit mask of media capabilites explicitly not supported + * by an app, e.g 10 indicating HDR_10 is not supportted + * @param fileMediaFormatFlags bit mask of media capabilites contained in a file e.g 101 + * indicating HEVC and HDR_10 media file + * + * @return {@code Optional} containing {@code boolean}. {@code true} means transcode is + * required, {@code false} means transcode is not required and {@code empty} means a decision + * could not be made. + */ + private Optional<Boolean> checkAppMediaSupport(int appSupportedMediaFormatFlags, + int appUnsupportedMediaFormatFlags, int fileMediaFormatFlags, String type) { + if ((appSupportedMediaFormatFlags & appUnsupportedMediaFormatFlags) != 0) { + Log.w(TAG, "Ignoring app media capabilities for type: [" + type + + "]. Supported and unsupported capapbilities are not mutually exclusive"); + return Optional.empty(); + } + + // As an example: + // 1. appSupportedMediaFormatFlags=001 # App supports HEVC + // 2. appUnsupportedMediaFormatFlags=100 # App does not support HDR_10 + // 3. fileSupportedMediaFormatFlags=101 # File contains HEVC and HDR_10 + + // File contains HDR_10 but app explicitly doesn't support it + int fileMediaFormatsUnsupportedByApp = + fileMediaFormatFlags & appUnsupportedMediaFormatFlags; + if (fileMediaFormatsUnsupportedByApp != 0) { + // If *any* file media formats are unsupported by the app we need to transcode + logVerbose("App media capability check for type: [" + type + "]" + ". transcode=true"); + return Optional.of(true); + } + + // fileMediaFormatsSupportedByApp=001 # File contains HEVC but app explicitly supports HEVC + int fileMediaFormatsSupportedByApp = appSupportedMediaFormatFlags & fileMediaFormatFlags; + // fileMediaFormatsNotSupportedByApp=100 # File contains HDR_10 but app doesn't support it + int fileMediaFormatsNotSupportedByApp = + fileMediaFormatsSupportedByApp ^ fileMediaFormatFlags; + if (fileMediaFormatsNotSupportedByApp == 0) { + logVerbose("App media capability check for type: [" + type + "]" + ". transcode=false"); + // If *all* file media formats are supported by the app, we don't need to transcode + return Optional.of(false); + } + + // If there are some file media formats that are neither supported nor unsupported by the + // app we can't make a decision yet + return Optional.empty(); + } + + private Pair<Integer, Long> getFileFlagsAndDurationMs(String path) { + final String[] projection = new String[] { + FileColumns._VIDEO_CODEC_TYPE, + VideoColumns.COLOR_STANDARD, + VideoColumns.COLOR_TRANSFER, + MediaColumns.DURATION + }; + + try (Cursor cursor = queryFileForTranscode(path, projection)) { + if (cursor == null || !cursor.moveToNext()) { + logVerbose("Couldn't find database row"); + return Pair.create(0, 0L); + } + + int result = 0; + if (isHevc(cursor.getString(0))) { + result |= FLAG_HEVC; + } + if (isHdr10Plus(cursor.getInt(1), cursor.getInt(2))) { + result |= FLAG_HDR_10_PLUS; + } + return Pair.create(result, cursor.getLong(3)); + } + } + + private static boolean isHevc(String mimeType) { + return MediaFormat.MIMETYPE_VIDEO_HEVC.equalsIgnoreCase(mimeType); + } + + private static boolean isHdr10Plus(int colorStandard, int colorTransfer) { + return (colorStandard == MediaFormat.COLOR_STANDARD_BT2020) && + (colorTransfer == MediaFormat.COLOR_TRANSFER_ST2084 + || colorTransfer == MediaFormat.COLOR_TRANSFER_HLG); + } + + private static boolean isModernFormat(String mimeType, int colorStandard, int colorTransfer) { + return isHevc(mimeType) || isHdr10Plus(colorStandard, colorTransfer); + } + + public boolean supportsTranscode(String path) { + File file = new File(path); + String name = file.getName(); + final String cameraRelativePath = + String.format("%s/%s/", Environment.DIRECTORY_DCIM, DIRECTORY_CAMERA); + + return !isTranscodeFile(path) && name.toLowerCase(Locale.ROOT).endsWith(".mp4") + && path.startsWith("/storage/emulated/") + && cameraRelativePath.equalsIgnoreCase(FileUtils.extractRelativePath(path)); + } + + private Optional<Boolean> checkAppCompatSupport(int uid, int fileFlags) { + int supportedFlags = 0; + int unsupportedFlags = 0; + boolean hevcSupportEnabled = CompatChanges.isChangeEnabled(FORCE_ENABLE_HEVC_SUPPORT, uid); + boolean hevcSupportDisabled = CompatChanges.isChangeEnabled(FORCE_DISABLE_HEVC_SUPPORT, + uid); + if (hevcSupportEnabled) { + supportedFlags = FLAG_HEVC; + logVerbose("App compat hevc support enabled"); + } + + if (hevcSupportDisabled) { + unsupportedFlags = FLAG_HEVC; + logVerbose("App compat hevc support disabled"); + } + return checkAppMediaSupport(supportedFlags, unsupportedFlags, fileFlags, "app_compat"); + } + + /** + * @return {@code true} if HEVC is explicitly supported by the manifest of {@code packageName}, + * {@code false} otherwise. + */ + private Optional<Boolean> checkManifestSupport(String packageName, + LocalCallingIdentity identity, int fileFlags) { + // TODO(b/169327180): + // 1. Support beyond HEVC + // 2. Shared package names policy: + // If appA and appB share the same uid. And appA supports HEVC but appB doesn't. + // Should we assume entire uid supports or doesn't? + // For now, we assume uid supports, but this might change in future + int supportedFlags = identity.getApplicationMediaCapabilitiesSupportedFlags(); + int unsupportedFlags = identity.getApplicationMediaCapabilitiesUnsupportedFlags(); + if (supportedFlags != -1 && unsupportedFlags != -1) { + return checkAppMediaSupport(supportedFlags, unsupportedFlags, fileFlags, + "cached_app_manifest"); + } + + try { + Property mediaCapProperty = mPackageManager.getProperty( + PackageManager.PROPERTY_MEDIA_CAPABILITIES, packageName); + XmlResourceParser parser = mPackageManager.getResourcesForApplication(packageName) + .getXml(mediaCapProperty.getResourceId()); + ApplicationMediaCapabilities capability = ApplicationMediaCapabilities.createFromXml( + parser); + Pair<Integer, Integer> flags = capabilitiesToMediaFormatFlags(capability); + supportedFlags = flags.first; + unsupportedFlags = flags.second; + identity.setApplicationMediaCapabilitiesFlags(supportedFlags, unsupportedFlags); + + return checkAppMediaSupport(supportedFlags, unsupportedFlags, fileFlags, + "app_manifest"); + } catch (PackageManager.NameNotFoundException | UnsupportedOperationException e) { + return Optional.empty(); + } + } + + @ApplicationMediaCapabilitiesFlags + private Pair<Integer, Integer> capabilitiesToMediaFormatFlags( + ApplicationMediaCapabilities capability) { + int supportedFlags = 0; + int unsupportedFlags = 0; + + // MimeType + if (capability.isFormatSpecified(MediaFormat.MIMETYPE_VIDEO_HEVC)) { + if (capability.isVideoMimeTypeSupported(MediaFormat.MIMETYPE_VIDEO_HEVC)) { + supportedFlags |= FLAG_HEVC; + } else { + unsupportedFlags |= FLAG_HEVC; + } + } + + // HdrType + if (capability.isFormatSpecified(MediaFeature.HdrType.HDR10)) { + if (capability.isHdrTypeSupported(MediaFeature.HdrType.HDR10)) { + supportedFlags |= FLAG_HDR_10; + } else { + unsupportedFlags |= FLAG_HDR_10; + } + } + + if (capability.isFormatSpecified(MediaFeature.HdrType.HDR10_PLUS)) { + if (capability.isHdrTypeSupported(MediaFeature.HdrType.HDR10_PLUS)) { + supportedFlags |= FLAG_HDR_10_PLUS; + } else { + unsupportedFlags |= FLAG_HDR_10_PLUS; + } + } + + if (capability.isFormatSpecified(MediaFeature.HdrType.HLG)) { + if (capability.isHdrTypeSupported(MediaFeature.HdrType.HLG)) { + supportedFlags |= FLAG_HDR_HLG; + } else { + unsupportedFlags |= FLAG_HDR_HLG; + } + } + + if (capability.isFormatSpecified(MediaFeature.HdrType.DOLBY_VISION)) { + if (capability.isHdrTypeSupported(MediaFeature.HdrType.DOLBY_VISION)) { + supportedFlags |= FLAG_HDR_DOLBY_VISION; + } else { + unsupportedFlags |= FLAG_HDR_DOLBY_VISION; + } + } + + return Pair.create(supportedFlags, unsupportedFlags); + } + + private boolean getBooleanProperty(String sysPropKey, String deviceConfigKey, + boolean defaultValue) { + // If the user wants to override the default, respect that; otherwise use the DeviceConfig + // which is filled with the values sent from server. + if (SystemProperties.getBoolean(TRANSCODE_USER_CONTROL_SYS_PROP_KEY, false)) { + return SystemProperties.getBoolean(sysPropKey, defaultValue); + } + + return mMediaProvider.getBooleanDeviceConfig(deviceConfigKey, defaultValue); + } + + private Pair<Long, Integer> getTranscodeCacheInfoFromDB(String path) { + try (Cursor cursor = queryFileForTranscode(path, TRANSCODE_CACHE_INFO_PROJECTION)) { + if (cursor != null && cursor.moveToNext()) { + return Pair.create(cursor.getLong(0), cursor.getInt(1)); + } + } + return Pair.create((long) -1, TRANSCODE_EMPTY); + } + + // called from MediaProvider + public void onUriPublished(Uri uri) { + if (!isTranscodeEnabled()) { + return; + } + + try (Cursor c = mMediaProvider.queryForSingleItem(uri, + new String[]{ + FileColumns._VIDEO_CODEC_TYPE, + FileColumns.SIZE, + FileColumns.OWNER_PACKAGE_NAME, + FileColumns.DATA, + MediaColumns.DURATION, + MediaColumns.CAPTURE_FRAMERATE, + MediaColumns.WIDTH, + MediaColumns.HEIGHT + }, + null, null, null)) { + if (supportsTranscode(c.getString(3))) { + if (isHevc(c.getString(0))) { + MediaProviderStatsLog.write( + TRANSCODING_DATA, + c.getString(2) /* owner_package_name */, + MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_TYPE__HEVC_WRITE, + c.getLong(1) /* file size */, + TRANSCODING_DATA__TRANSCODE_RESULT__UNDEFINED, + -1 /* transcoding_duration */, + c.getLong(4) /* video_duration */, + c.getLong(5) /* capture_framerate */, + -1 /* transcode_reason */, + c.getLong(6) /* width */, + c.getLong(7) /* height */, + false /* hit_anr */, + TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN, + TranscodingSession.ERROR_NONE); + + } else { + MediaProviderStatsLog.write( + TRANSCODING_DATA, + c.getString(2) /* owner_package_name */, + MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_TYPE__AVC_WRITE, + c.getLong(1) /* file size */, + TRANSCODING_DATA__TRANSCODE_RESULT__UNDEFINED, + -1 /* transcoding_duration */, + c.getLong(4) /* video_duration */, + c.getLong(5) /* capture_framerate */, + -1 /* transcode_reason */, + c.getLong(6) /* width */, + c.getLong(7) /* height */, + false /* hit_anr */, + TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN, + TranscodingSession.ERROR_NONE); + } + } + } catch (Exception e) { + Log.w(TAG, "Couldn't get cursor for scanned file", e); + } + } + + public void onFileOpen(String path, String ioPath, int uid, int transformsReason) { + if (!isTranscodeEnabled()) { + return; + } + + String[] resolverInfoProjection = new String[] { + FileColumns._VIDEO_CODEC_TYPE, + FileColumns.SIZE, + MediaColumns.DURATION, + MediaColumns.CAPTURE_FRAMERATE, + MediaColumns.WIDTH, + MediaColumns.HEIGHT, + VideoColumns.COLOR_STANDARD, + VideoColumns.COLOR_TRANSFER + }; + + try (Cursor c = queryFileForTranscode(path, resolverInfoProjection)) { + if (c != null && c.moveToNext()) { + if (supportsTranscode(path) + && isModernFormat(c.getString(0), c.getInt(6), c.getInt(7))) { + if (transformsReason == 0) { + MediaProviderStatsLog.write( + TRANSCODING_DATA, + getMetricsSafeNameForUid(uid) /* owner_package_name */, + MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_TYPE__READ_DIRECT, + c.getLong(1) /* file size */, + TRANSCODING_DATA__TRANSCODE_RESULT__UNDEFINED, + -1 /* transcoding_duration */, + c.getLong(2) /* video_duration */, + c.getLong(3) /* capture_framerate */, + -1 /* transcode_reason */, + c.getLong(4) /* width */, + c.getLong(5) /* height */, + false /*hit_anr*/, + TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN, + TranscodingSession.ERROR_NONE); + } else if (isTranscodeFileCached(path, ioPath)) { + MediaProviderStatsLog.write( + TRANSCODING_DATA, + getMetricsSafeNameForUid(uid) /* owner_package_name */, + MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_TYPE__READ_CACHE, + c.getLong(1) /* file size */, + TRANSCODING_DATA__TRANSCODE_RESULT__UNDEFINED, + -1 /* transcoding_duration */, + c.getLong(2) /* video_duration */, + c.getLong(3) /* capture_framerate */, + transformsReason /* transcode_reason */, + c.getLong(4) /* width */, + c.getLong(5) /* height */, + false /*hit_anr*/, + TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN, + TranscodingSession.ERROR_NONE); + } // else if file is not in cache, we'll log at read(2) when we transcode + } + } + } catch (IllegalStateException e) { + Log.w(TAG, "Unable to log metrics on file open", e); + } + } + + public boolean isTranscodeFileCached(String path, String transcodePath) { + // This can only happen when we are in a version that supports transcoding. + // So, no need to check for the SDK version here. + + if (SystemProperties.getBoolean("persist.sys.fuse.disable_transcode_cache", false)) { + // Caching is disabled. Hence, delete the cached transcode file. + return false; + } + + Pair<Long, Integer> cacheInfo = getTranscodeCacheInfoFromDB(path); + final long rowId = cacheInfo.first; + if (rowId != -1) { + final int transcodeStatus = cacheInfo.second; + boolean result = transcodePath.equalsIgnoreCase(getTranscodePath(rowId)) && + transcodeStatus == TRANSCODE_COMPLETE && + new File(transcodePath).exists(); + if (result) { + logEvent("Transcode cache hit: " + path, null /* session */); + } + return result; + } + return false; + } + + @Nullable + private MediaFormat getVideoTrackFormat(String path) { + String[] resolverInfoProjection = new String[]{ + FileColumns._VIDEO_CODEC_TYPE, + MediaStore.MediaColumns.WIDTH, + MediaStore.MediaColumns.HEIGHT, + MediaStore.MediaColumns.BITRATE, + MediaStore.MediaColumns.CAPTURE_FRAMERATE + }; + try (Cursor c = queryFileForTranscode(path, resolverInfoProjection)) { + if (c != null && c.moveToNext()) { + String codecType = c.getString(0); + int width = c.getInt(1); + int height = c.getInt(2); + int bitRate = c.getInt(3); + float framerate = c.getFloat(4); + + // TODO(b/169849854): Get this info from Manifest, for now if app got here it + // definitely doesn't support hevc + ApplicationMediaCapabilities capability = + new ApplicationMediaCapabilities.Builder().build(); + MediaFormat sourceFormat = MediaFormat.createVideoFormat( + codecType, width, height); + if (framerate > 0) { + sourceFormat.setFloat(MediaFormat.KEY_FRAME_RATE, framerate); + } + VideoFormatResolver resolver = new VideoFormatResolver(capability, sourceFormat); + MediaFormat resolvedFormat = resolver.resolveVideoFormat(); + resolvedFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate); + + return resolvedFormat; + } + } + throw new IllegalStateException("Couldn't get video format info from database for " + path); + } + + private TranscodingSession enqueueTranscodingSession(String src, String dst, int uid, + final CountDownLatch latch) throws UnsupportedOperationException, IOException { + // Fetch the service lazily to improve memory usage + final MediaTranscodingManager mediaTranscodeManager = + mContext.getSystemService(MediaTranscodingManager.class); + File file = new File(src); + File transcodeFile = new File(dst); + + // These are file URIs (effectively file paths) and even if the |transcodeFile| is + // inaccesible via FUSE, it works because the transcoding service calls into the + // MediaProvider to open them and within the MediaProvider, it is opened directly on + // the lower fs. + Uri uri = Uri.fromFile(file); + Uri transcodeUri = Uri.fromFile(transcodeFile); + + ParcelFileDescriptor srcPfd = ParcelFileDescriptor.open(file, + ParcelFileDescriptor.MODE_READ_ONLY); + ParcelFileDescriptor dstPfd = ParcelFileDescriptor.open(transcodeFile, + ParcelFileDescriptor.MODE_READ_WRITE); + + MediaFormat format = getVideoTrackFormat(src); + + VideoTranscodingRequest request = + new VideoTranscodingRequest.Builder(uri, transcodeUri, format) + .setClientUid(uid) + .setSourceFileDescriptor(srcPfd) + .setDestinationFileDescriptor(dstPfd) + .build(); + TranscodingSession session = mediaTranscodeManager.enqueueRequest(request, + ForegroundThread.getExecutor(), + s -> { + mTranscodingUiNotifier.stop(s, src); + finishTranscodingResult(uid, src, s, latch); + mSessionTiming.logSessionEnd(s); + }); + session.setOnProgressUpdateListener(ForegroundThread.getExecutor(), + (s, progress) -> mTranscodingUiNotifier.setProgress(s, src, progress)); + + mSessionTiming.logSessionStart(session); + mTranscodingUiNotifier.start(session, src); + logEvent("Transcoding start: " + src + ". Uid: " + uid, session); + return session; + } + + /** + * Returns an {@link Integer} indicating whether the transcoding {@code session} was successful + * or not. + * + * @return {@link TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN} on success, + * otherwise indicates failure. + */ + private int waitTranscodingResult(int uid, String src, TranscodingSession session, + CountDownLatch latch) { + UUID uuid = getTranscodeVolumeUuid(); + try { + if (uuid != null) { + // tid is 0 since we can't really get the apps tid over binder + mStorageManager.notifyAppIoBlocked(uuid, uid, 0 /* tid */, + StorageManager.APP_IO_BLOCKED_REASON_TRANSCODING); + } + + int timeout = getTranscodeTimeoutSeconds(src); + + String waitStartLog = "Transcoding wait start: " + src + ". Uid: " + uid + ". Timeout: " + + timeout + "s"; + logEvent(waitStartLog, session); + + boolean latchResult = latch.await(timeout, TimeUnit.SECONDS); + int sessionResult = session.getResult(); + boolean transcodeResult = sessionResult == TranscodingSession.RESULT_SUCCESS; + + String waitEndLog = "Transcoding wait end: " + src + ". Uid: " + uid + ". Timeout: " + + !latchResult + ". Success: " + transcodeResult; + logEvent(waitEndLog, session); + + if (sessionResult == TranscodingSession.RESULT_SUCCESS) { + return TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN; + } else if (sessionResult == TranscodingSession.RESULT_CANCELED) { + return TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_SESSION_CANCELED; + } else if (!latchResult) { + return TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_CLIENT_TIMEOUT; + } else { + return TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_SERVICE_ERROR; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.w(TAG, "Transcoding latch interrupted." + session); + return TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_CLIENT_TIMEOUT; + } finally { + if (uuid != null) { + // tid is 0 since we can't really get the apps tid over binder + mStorageManager.notifyAppIoResumed(uuid, uid, 0 /* tid */, + StorageManager.APP_IO_BLOCKED_REASON_TRANSCODING); + } + } + } + + private int getTranscodeTimeoutSeconds(String file) { + double sizeMb = (new File(file).length() / (1024 * 1024)); + // Ensure size is at least 1MB so transcoding timeout is at least the timeout coefficient + sizeMb = Math.max(sizeMb, 1); + return (int) (sizeMb * TRANSCODING_TIMEOUT_COEFFICIENT); + } + + private void finishTranscodingResult(int uid, String src, TranscodingSession session, + CountDownLatch latch) { + final StorageTranscodingSession finishedSession; + + synchronized (mLock) { + latch.countDown(); + session.cancel(); + + finishedSession = mStorageTranscodingSessions.remove(src); + + switch (session.getResult()) { + case TranscodingSession.RESULT_SUCCESS: + mSuccessfulTranscodeSessions.put(finishedSession, false /* placeholder */); + break; + case TranscodingSession.RESULT_CANCELED: + mCancelledTranscodeSessions.put(finishedSession, false /* placeholder */); + break; + case TranscodingSession.RESULT_ERROR: + mErroredTranscodeSessions.put(finishedSession, false /* placeholder */); + break; + default: + Log.w(TAG, "TranscodingSession.RESULT_NONE received for a finished session"); + } + } + + logEvent("Transcoding end: " + src + ". Uid: " + uid, session); + } + + private boolean updateTranscodeStatus(String path, int transcodeStatus) { + final Uri uri = FileUtils.getContentUriForPath(path); + // TODO(b/170465810): Replace this with matchUri when the code is refactored. + final int match = MediaProvider.FILES; + final SQLiteQueryBuilder qb = mMediaProvider.getQueryBuilderForTranscoding(TYPE_UPDATE, + match, uri, Bundle.EMPTY, null); + final String[] selectionArgs = new String[]{path}; + + ContentValues values = new ContentValues(); + values.put(FileColumns._TRANSCODE_STATUS, transcodeStatus); + final boolean success = qb.update(getDatabaseHelperForUri(uri), values, + TRANSCODE_WHERE_CLAUSE, selectionArgs) == 1; + if (!success) { + Log.w(TAG, "Transcoding status update to: " + transcodeStatus + " failed for " + path); + } + return success; + } + + public boolean deleteCachedTranscodeFile(long rowId) { + return new File(mTranscodeDirectory, String.valueOf(rowId)).delete(); + } + + private DatabaseHelper getDatabaseHelperForUri(Uri uri) { + final DatabaseHelper helper; + try { + return mMediaProvider.getDatabaseForUriForTranscoding(uri); + } catch (VolumeNotFoundException e) { + throw new IllegalStateException("Volume not found while querying transcode path", e); + } + } + + /** + * @return given {@code projection} columns from database for given {@code path}. + * Note that cursor might be empty if there is no database row or file is pending or trashed. + * TODO(b/170465810): Optimize these queries by bypassing getQueryBuilder(). These queries are + * always on Files table and doesn't have any dependency on calling package. i.e., query is + * always called with callingPackage=self. + */ + @Nullable + private Cursor queryFileForTranscode(String path, String[] projection) { + final Uri uri = FileUtils.getContentUriForPath(path); + // TODO(b/170465810): Replace this with matchUri when the code is refactored. + final int match = MediaProvider.FILES; + final SQLiteQueryBuilder qb = mMediaProvider.getQueryBuilderForTranscoding(TYPE_QUERY, + match, uri, Bundle.EMPTY, null); + final String[] selectionArgs = new String[]{path}; + + Bundle extras = new Bundle(); + extras.putInt(QUERY_ARG_MATCH_PENDING, MATCH_EXCLUDE); + extras.putInt(QUERY_ARG_MATCH_TRASHED, MATCH_EXCLUDE); + extras.putString(ContentResolver.QUERY_ARG_SQL_SELECTION, TRANSCODE_WHERE_CLAUSE); + extras.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs); + return qb.query(getDatabaseHelperForUri(uri), projection, extras, null); + } + + private boolean isTranscodeEnabled() { + return IS_TRANSCODING_SUPPORTED && getBooleanProperty(TRANSCODE_ENABLED_SYS_PROP_KEY, + TRANSCODE_ENABLED_DEVICE_CONFIG_KEY, true /* defaultValue */); + } + + private boolean shouldTranscodeDefault() { + return getBooleanProperty(TRANSCODE_DEFAULT_SYS_PROP_KEY, + TRANSCODE_DEFAULT_DEVICE_CONFIG_KEY, false /* defaultValue */); + } + + private void updateConfigs(boolean transcodeEnabled) { + synchronized (mLock) { + boolean isTranscodeEnabledChanged = transcodeEnabled != mIsTranscodeEnabled; + + if (isTranscodeEnabledChanged) { + Log.i(TAG, "Reloading transcode configs. transcodeEnabled: " + transcodeEnabled + + ". lastTranscodeEnabled: " + mIsTranscodeEnabled); + + mIsTranscodeEnabled = transcodeEnabled; + parseTranscodeCompatManifest(); + } + } + } + + private void parseTranscodeCompatManifest() { + synchronized (mLock) { + // Clear the transcode_compat manifest before parsing. If transcode is disabled, + // nothing will be parsed, effectively leaving the compat manifest empty. + mAppCompatMediaCapabilities.clear(); + if (!mIsTranscodeEnabled) { + return; + } + + Set<String> stalePackages = getTranscodeCompatStale(); + parseTranscodeCompatManifestFromResourceLocked(stalePackages); + parseTranscodeCompatManifestFromDeviceConfigLocked(); + } + } + + /** @return {@code true} if the manifest was parsed successfully, {@code false} otherwise */ + private boolean parseTranscodeCompatManifestFromDeviceConfigLocked() { + final String[] manifest = mMediaProvider.getStringDeviceConfig( + TRANSCODE_COMPAT_MANIFEST_KEY, "").split(","); + + if (manifest.length == 0 || manifest[0].isEmpty()) { + Log.i(TAG, "Empty device config transcode compat manifest"); + return false; + } + if ((manifest.length % 2) != 0) { + Log.w(TAG, "Uneven number of items in device config transcode compat manifest"); + return false; + } + + String packageName = ""; + int packageCompatValue; + int i = 0; + int count = 0; + while (i < manifest.length - 1) { + try { + packageName = manifest[i++]; + packageCompatValue = Integer.parseInt(manifest[i++]); + synchronized (mLock) { + // Lock is already held, explicitly hold again to make error prone happy + mAppCompatMediaCapabilities.put(packageName, packageCompatValue); + count++; + } + } catch (NumberFormatException e) { + Log.w(TAG, "Failed to parse media capability from device config for package: " + + packageName, e); + } + } + + Log.i(TAG, "Parsed " + count + " packages from device config"); + return count != 0; + } + + /** @return {@code true} if the manifest was parsed successfully, {@code false} otherwise */ + private boolean parseTranscodeCompatManifestFromResourceLocked(Set<String> stalePackages) { + InputStream inputStream = mContext.getResources().openRawResource( + R.raw.transcode_compat_manifest); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + int count = 0; + try { + while (reader.ready()) { + String line = reader.readLine(); + String packageName = ""; + int packageCompatValue; + + if (line == null) { + Log.w(TAG, "Unexpected null line while parsing transcode compat manifest"); + continue; + } + + String[] lineValues = line.split(","); + if (lineValues.length != 2) { + Log.w(TAG, "Failed to read line while parsing transcode compat manifest"); + continue; + } + try { + packageName = lineValues[0]; + packageCompatValue = Integer.parseInt(lineValues[1]); + + if (stalePackages.contains(packageName)) { + Log.i(TAG, "Skipping stale package in transcode compat manifest: " + + packageName); + continue; + } + + synchronized (mLock) { + // Lock is already held, explicitly hold again to make error prone happy + mAppCompatMediaCapabilities.put(packageName, packageCompatValue); + count++; + } + } catch (NumberFormatException e) { + Log.w(TAG, "Failed to parse media capability from resource for package: " + + packageName, e); + } + } + } catch (IOException e) { + Log.w(TAG, "Failed to read transcode compat manifest", e); + } + + Log.i(TAG, "Parsed " + count + " packages from resource"); + return count != 0; + } + + private Set<String> getTranscodeCompatStale() { + Set<String> stalePackages = new ArraySet<>(); + final String[] staleConfig = mMediaProvider.getStringDeviceConfig( + TRANSCODE_COMPAT_STALE_KEY, "").split(","); + + if (staleConfig.length == 0 || staleConfig[0].isEmpty()) { + Log.i(TAG, "Empty transcode compat stale"); + return stalePackages; + } + + for (String stalePackage : staleConfig) { + stalePackages.add(stalePackage); + } + + int size = stalePackages.size(); + Log.i(TAG, "Parsed " + size + " stale packages from device config"); + return stalePackages; + } + + public void dump(PrintWriter writer) { + writer.println("isTranscodeEnabled=" + isTranscodeEnabled()); + writer.println("shouldTranscodeDefault=" + shouldTranscodeDefault()); + + synchronized (mLock) { + writer.println("mAppCompatMediaCapabilities=" + mAppCompatMediaCapabilities); + writer.println("mStorageTranscodingSessions=" + mStorageTranscodingSessions); + + dumpFinishedSessions(writer); + } + } + + private void dumpFinishedSessions(PrintWriter writer) { + synchronized (mLock) { + writer.println("mSuccessfulTranscodeSessions=" + mSuccessfulTranscodeSessions.keySet()); + + writer.println("mCancelledTranscodeSessions=" + mCancelledTranscodeSessions.keySet()); + + writer.println("mErroredTranscodeSessions=" + mErroredTranscodeSessions.keySet()); + } + } + + private static void logEvent(String event, @Nullable TranscodingSession session) { + Log.d(TAG, event + (session == null ? "" : session)); + } + + private static void logVerbose(String message) { + if (DEBUG) { + Log.v(TAG, message); + } + } + + // We want to keep track of only the most recent [MAX_FINISHED_TRANSCODING_SESSION_STORE_COUNT] + // finished transcoding sessions. + private static LinkedHashMap createFinishedTranscodingSessionMap() { + return new LinkedHashMap<StorageTranscodingSession, Boolean>() { + @Override + protected boolean removeEldestEntry(Entry eldest) { + return size() > MAX_FINISHED_TRANSCODING_SESSION_STORE_COUNT; + } + }; + } + + @VisibleForTesting + static int getMyUid() { + return MY_UID; + } + + private static class StorageTranscodingSession { + private static final DateTimeFormatter DATE_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + + public final TranscodingSession session; + public final CountDownLatch latch; + private final String mSrcPath; + private final String mDstPath; + @GuardedBy("latch") + private final Set<Integer> mBlockedUids = new ArraySet<>(); + private final LocalDateTime mStartTime; + @GuardedBy("latch") + private LocalDateTime mFinishTime; + @GuardedBy("latch") + private boolean mHasAnr; + @GuardedBy("latch") + private int mFailureReason; + @GuardedBy("latch") + private int mErrorCode; + + public StorageTranscodingSession(TranscodingSession session, CountDownLatch latch, + String srcPath, String dstPath) { + this.session = session; + this.latch = latch; + this.mSrcPath = srcPath; + this.mDstPath = dstPath; + this.mStartTime = LocalDateTime.now(); + mErrorCode = TranscodingSession.ERROR_NONE; + mFailureReason = TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN; + } + + public void addBlockedUid(int uid) { + session.addClientUid(uid); + } + + public boolean isUidBlocked(int uid) { + return session.getClientUids().contains(uid); + } + + public void setAnr() { + synchronized (latch) { + mHasAnr = true; + } + } + + public boolean hasAnr() { + synchronized (latch) { + return mHasAnr; + } + } + + public void notifyFinished(int failureReason, int errorCode) { + synchronized (latch) { + mFinishTime = LocalDateTime.now(); + mFailureReason = failureReason; + mErrorCode = errorCode; + } + } + + @Override + public String toString() { + String startTime = mStartTime.format(DATE_FORMAT); + String finishTime = "NONE"; + String durationMs = "NONE"; + boolean hasAnr; + int failureReason; + int errorCode; + + synchronized (latch) { + if (mFinishTime != null) { + finishTime = mFinishTime.format(DATE_FORMAT); + durationMs = String.valueOf(mStartTime.until(mFinishTime, ChronoUnit.MILLIS)); + } + hasAnr = mHasAnr; + failureReason = mFailureReason; + errorCode = mErrorCode; + } + + return String.format("<%s. Src: %s. Dst: %s. BlockedUids: %s. DurationMs: %sms" + + ". Start: %s. Finish: %sms. HasAnr: %b. FailureReason: %d. ErrorCode: %d>", + session.toString(), mSrcPath, mDstPath, session.getClientUids(), durationMs, + startTime, finishTime, hasAnr, failureReason, errorCode); + } + } + + private static class TranscodeUiNotifier { + private static final int PROGRESS_MAX = 100; + private static final int ALERT_DISMISS_DELAY_MS = 1000; + private static final int SHOW_PROGRESS_THRESHOLD_TIME_MS = 1000; + private static final String TRANSCODE_ALERT_CHANNEL_ID = "native_transcode_alert_channel"; + private static final String TRANSCODE_ALERT_CHANNEL_NAME = "Native Transcode Alerts"; + private static final String TRANSCODE_PROGRESS_CHANNEL_ID = + "native_transcode_progress_channel"; + private static final String TRANSCODE_PROGRESS_CHANNEL_NAME = "Native Transcode Progress"; + + // Related to notification settings + private static final String TRANSCODE_NOTIFICATION_SYS_PROP_KEY = + "persist.sys.fuse.transcode_notification"; + private static final boolean NOTIFICATION_ALLOWED_DEFAULT_VALUE = false; + + private final Context mContext; + private final NotificationManagerCompat mNotificationManager; + private final PackageManager mPackageManager; + // Builder for creating alert notifications. + private final NotificationCompat.Builder mAlertBuilder; + // Builder for creating progress notifications. + private final NotificationCompat.Builder mProgressBuilder; + private final SessionTiming mSessionTiming; + + TranscodeUiNotifier(Context context, SessionTiming sessionTiming) { + mContext = context; + mNotificationManager = NotificationManagerCompat.from(context); + mPackageManager = context.getPackageManager(); + createAlertNotificationChannel(context); + createProgressNotificationChannel(context); + mAlertBuilder = createAlertNotificationBuilder(context); + mProgressBuilder = createProgressNotificationBuilder(context); + mSessionTiming = sessionTiming; + } + + void start(TranscodingSession session, String filePath) { + if (!notificationEnabled()) { + return; + } + ForegroundThread.getHandler().post(() -> { + mAlertBuilder.setContentTitle(getString(mContext, + R.string.transcode_processing_started)); + mAlertBuilder.setContentText(FileUtils.extractDisplayName(filePath)); + final int notificationId = session.getSessionId(); + mNotificationManager.notify(notificationId, mAlertBuilder.build()); + }); + } + + void stop(TranscodingSession session, String filePath) { + if (!notificationEnabled()) { + return; + } + endSessionWithMessage(session, filePath, getResultMessageForSession(mContext, session)); + } + + void denied(int uid) { + String appName = getAppName(uid); + if (appName == null) { + Log.w(TAG, "Not showing denial, no app name "); + return; + } + + final Handler handler = ForegroundThread.getHandler(); + handler.post(() -> { + Toast.makeText(mContext, + mContext.getResources().getString(R.string.transcode_denied, appName), + Toast.LENGTH_LONG).show(); + }); + } + + void setProgress(TranscodingSession session, String filePath, + @IntRange(from = 0, to = PROGRESS_MAX) int progress) { + if (!notificationEnabled()) { + return; + } + if (shouldShowProgress(session)) { + mProgressBuilder.setContentText(FileUtils.extractDisplayName(filePath)); + mProgressBuilder.setProgress(PROGRESS_MAX, progress, /* indeterminate= */ false); + final int notificationId = session.getSessionId(); + mNotificationManager.notify(notificationId, mProgressBuilder.build()); + } + } + + private boolean shouldShowProgress(TranscodingSession session) { + return (System.currentTimeMillis() - mSessionTiming.getSessionStartTime(session)) + > SHOW_PROGRESS_THRESHOLD_TIME_MS; + } + + private void endSessionWithMessage(TranscodingSession session, String filePath, + String message) { + final Handler handler = ForegroundThread.getHandler(); + handler.post(() -> { + mAlertBuilder.setContentTitle(message); + mAlertBuilder.setContentText(FileUtils.extractDisplayName(filePath)); + final int notificationId = session.getSessionId(); + mNotificationManager.notify(notificationId, mAlertBuilder.build()); + // Auto-dismiss after a delay. + handler.postDelayed(() -> mNotificationManager.cancel(notificationId), + ALERT_DISMISS_DELAY_MS); + }); + } + + private String getAppName(int uid) { + String name = mPackageManager.getNameForUid(uid); + if (name == null) { + Log.w(TAG, "Couldn't find name"); + return null; + } + + final ApplicationInfo aInfo; + try { + aInfo = mPackageManager.getApplicationInfo(name, 0); + } catch (PackageManager.NameNotFoundException e) { + Log.w(TAG, "unable to look up package name", e); + return null; + } + + // If the label contains new line characters it may push the security + // message below the fold of the dialog. Labels shouldn't have new line + // characters anyways, so we just delete all of the newlines (if there are any). + return aInfo.loadSafeLabel(mPackageManager, MAX_APP_NAME_SIZE_PX, + TextUtils.SAFE_STRING_FLAG_SINGLE_LINE).toString(); + } + + private static String getString(Context context, int resourceId) { + return context.getResources().getString(resourceId); + } + + private static void createAlertNotificationChannel(Context context) { + NotificationChannel channel = new NotificationChannel(TRANSCODE_ALERT_CHANNEL_ID, + TRANSCODE_ALERT_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH); + NotificationManager notificationManager = context.getSystemService( + NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + + private static void createProgressNotificationChannel(Context context) { + NotificationChannel channel = new NotificationChannel(TRANSCODE_PROGRESS_CHANNEL_ID, + TRANSCODE_PROGRESS_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW); + NotificationManager notificationManager = context.getSystemService( + NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + + private static NotificationCompat.Builder createAlertNotificationBuilder(Context context) { + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, + TRANSCODE_ALERT_CHANNEL_ID); + builder.setAutoCancel(false) + .setOngoing(true) + .setSmallIcon(R.drawable.thumb_clip); + return builder; + } + + private static NotificationCompat.Builder createProgressNotificationBuilder( + Context context) { + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, + TRANSCODE_PROGRESS_CHANNEL_ID); + builder.setAutoCancel(false) + .setOngoing(true) + .setContentTitle(getString(context, R.string.transcode_processing)) + .setSmallIcon(R.drawable.thumb_clip); + return builder; + } + + private static String getResultMessageForSession(Context context, + TranscodingSession session) { + switch (session.getResult()) { + case TranscodingSession.RESULT_CANCELED: + return getString(context, R.string.transcode_processing_cancelled); + case TranscodingSession.RESULT_ERROR: + return getString(context, R.string.transcode_processing_error); + case TranscodingSession.RESULT_SUCCESS: + return getString(context, R.string.transcode_processing_success); + default: + return getString(context, R.string.transcode_processing_error); + } + } + + private static boolean notificationEnabled() { + return SystemProperties.getBoolean(TRANSCODE_NOTIFICATION_SYS_PROP_KEY, + NOTIFICATION_ALLOWED_DEFAULT_VALUE); + } + } + + private static class TranscodeDenialController implements OnUidImportanceListener { + private final int mMaxDurationMs; + private final ActivityManager mActivityManager; + private final TranscodeUiNotifier mUiNotifier; + private final Object mLock = new Object(); + @GuardedBy("mLock") + private final Set<Integer> mActiveDeniedUids = new ArraySet<>(); + @GuardedBy("mLock") + private final Set<Integer> mDroppedUids = new ArraySet<>(); + + TranscodeDenialController(ActivityManager activityManager, TranscodeUiNotifier uiNotifier, + int maxDurationMs) { + mActivityManager = activityManager; + mUiNotifier = uiNotifier; + mMaxDurationMs = maxDurationMs; + } + + @Override + public void onUidImportance(int uid, int importance) { + if (importance != IMPORTANCE_FOREGROUND) { + synchronized (mLock) { + if (mActiveDeniedUids.remove(uid) && mActiveDeniedUids.isEmpty()) { + // Stop the uid listener if this is the last uid triggering a denial UI + mActivityManager.removeOnUidImportanceListener(this); + } + } + } + } + + /** @return {@code true} if file access should be denied, {@code false} otherwise */ + boolean checkFileAccess(int uid, long durationMs) { + boolean shouldDeny = false; + synchronized (mLock) { + shouldDeny = durationMs > mMaxDurationMs || mDroppedUids.contains(uid); + } + + if (!shouldDeny) { + // Nothing to do + return false; + } + + synchronized (mLock) { + if (!mActiveDeniedUids.contains(uid) + && mActivityManager.getUidImportance(uid) == IMPORTANCE_FOREGROUND) { + // Show UI for the first denial while foreground + mUiNotifier.denied(uid); + + if (mActiveDeniedUids.isEmpty()) { + // Start a uid listener if this is the first uid triggering a denial UI + mActivityManager.addOnUidImportanceListener(this, IMPORTANCE_FOREGROUND); + } + mActiveDeniedUids.add(uid); + } + } + return true; + } + + void onTranscodingDropped(int uid) { + synchronized (mLock) { + mDroppedUids.add(uid); + } + // Notify about file access, so we might show a denial UI + checkFileAccess(uid, 0 /* duration */); + } + } + + private static final class SessionTiming { + // This should be accessed only in foreground thread. + private final SparseArray<Long> mSessionStartTimes = new SparseArray<>(); + + // Call this only in foreground thread. + private long getSessionStartTime(MediaTranscodingManager.TranscodingSession session) { + return mSessionStartTimes.get(session.getSessionId()); + } + + private void logSessionStart(MediaTranscodingManager.TranscodingSession session) { + ForegroundThread.getHandler().post( + () -> mSessionStartTimes.append(session.getSessionId(), + System.currentTimeMillis())); + } + + private void logSessionEnd(MediaTranscodingManager.TranscodingSession session) { + ForegroundThread.getHandler().post( + () -> mSessionStartTimes.remove(session.getSessionId())); + } + } +} diff --git a/src/com/android/providers/media/TranscodeHelperNoOp.java b/src/com/android/providers/media/TranscodeHelperNoOp.java new file mode 100644 index 00000000..355f21d4 --- /dev/null +++ b/src/com/android/providers/media/TranscodeHelperNoOp.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2021 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 android.net.Uri; +import android.os.Bundle; +import java.io.PrintWriter; + +/** + * No-op transcode helper to avoid loading MediaTranscodeManager classes in Android R + */ +public class TranscodeHelperNoOp implements TranscodeHelper { + public void freeCache(long bytes) {} + + public void onAnrDelayStarted(String packageName, int uid, int tid, int reason) {} + + public boolean transcode(String src, String dst, int uid, int reason) { + return false; + } + + public String getIoPath(String path, int uid) { + return null; + } + + public int shouldTranscode(String path, int uid, Bundle bundle) { + return 0; + } + + public boolean supportsTranscode(String path) { + return false; + } + + public void onUriPublished(Uri uri) {} + + public void onFileOpen(String path, String ioPath, int uid, int transformsReason) {} + + public boolean isTranscodeFileCached(String path, String transcodePath) { + return false; + } + + public boolean deleteCachedTranscodeFile(long rowId) { + return false; + } + + public void dump(PrintWriter writer) {} +} diff --git a/src/com/android/providers/media/VolumeCache.java b/src/com/android/providers/media/VolumeCache.java new file mode 100644 index 00000000..5495a26f --- /dev/null +++ b/src/com/android/providers/media/VolumeCache.java @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2021 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 com.android.providers.media.util.Logging.TAG; + +import android.content.Context; +import android.net.Uri; +import android.os.UserHandle; +import android.os.UserManager; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; +import android.provider.MediaStore; +import android.util.ArrayMap; +import android.util.ArraySet; +import android.util.Log; +import android.util.LongSparseArray; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; + +import com.android.providers.media.util.FileUtils; +import com.android.providers.media.util.UserCache; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The VolumeCache class keeps track of all the volumes that are available, + * as well as their scan paths. + */ +public class VolumeCache { + private final Context mContext; + + private final Object mLock = new Object(); + + private final UserManager mUserManager; + private final UserCache mUserCache; + + @GuardedBy("mLock") + private final ArrayList<MediaVolume> mExternalVolumes = new ArrayList<>(); + + @GuardedBy("mLock") + private final Map<MediaVolume, Collection<File>> mCachedVolumeScanPaths = new ArrayMap<>(); + + @GuardedBy("mLock") + private Collection<File> mCachedInternalScanPaths; + + public VolumeCache(Context context, UserCache userCache) { + mContext = context; + mUserManager = context.getSystemService(UserManager.class); + mUserCache = userCache; + } + + public @NonNull List<MediaVolume> getExternalVolumes() { + synchronized(mLock) { + return new ArrayList<>(mExternalVolumes); + } + } + + public @NonNull Set<String> getExternalVolumeNames() { + synchronized (mLock) { + ArraySet<String> volNames = new ArraySet<String>(); + for (MediaVolume vol : mExternalVolumes) { + volNames.add(vol.getName()); + } + return volNames; + } + } + + public @NonNull MediaVolume findVolume(@NonNull String volumeName, @NonNull UserHandle user) + throws FileNotFoundException { + synchronized (mLock) { + for (MediaVolume vol : mExternalVolumes) { + if (vol.getName().equals(volumeName) && vol.isVisibleToUser(user)) { + return vol; + } + } + } + + throw new FileNotFoundException("Couldn't find volume with name " + volumeName); + } + + public @NonNull File getVolumePath(@NonNull String volumeName, @NonNull UserHandle user) + throws FileNotFoundException { + synchronized (mLock) { + try { + MediaVolume volume = findVolume(volumeName, user); + return volume.getPath(); + } catch (FileNotFoundException e) { + Log.w(TAG, "getVolumePath for unknown volume: " + volumeName); + // Try again by using FileUtils below + } + + final Context userContext = mUserCache.getContextForUser(user); + return FileUtils.getVolumePath(userContext, volumeName); + } + } + + public @NonNull Collection<File> getVolumeScanPaths(@NonNull String volumeName, + @NonNull UserHandle user) throws FileNotFoundException { + synchronized (mLock) { + if (MediaStore.VOLUME_INTERNAL.equals(volumeName)) { + // Internal is shared by all users + return mCachedInternalScanPaths; + } + try { + MediaVolume volume = findVolume(volumeName, user); + if (mCachedVolumeScanPaths.containsKey(volume)) { + return mCachedVolumeScanPaths.get(volume); + } + } catch (FileNotFoundException e) { + Log.w(TAG, "Didn't find cached volume scan paths for " + volumeName); + } + + // Nothing found above; let's ask directly + final Context userContext = mUserCache.getContextForUser(user); + final Collection<File> res = FileUtils.getVolumeScanPaths(userContext, volumeName); + + return res; + } + } + + public @NonNull MediaVolume findVolumeForFile(@NonNull File file) throws FileNotFoundException { + synchronized (mLock) { + for (MediaVolume volume : mExternalVolumes) { + if (FileUtils.contains(volume.getPath(), file)) { + return volume; + } + } + } + + Log.w(TAG, "Didn't find any volume for getVolume(" + file.getPath() + ")"); + // Nothing found above; let's ask directly + final StorageManager sm = mContext.getSystemService(StorageManager.class); + final StorageVolume volume = sm.getStorageVolume(file); + if (volume == null) { + throw new FileNotFoundException("Missing volume for " + file); + } + + return MediaVolume.fromStorageVolume(volume); + } + + public @NonNull String getVolumeId(@NonNull File file) throws FileNotFoundException { + MediaVolume volume = findVolumeForFile(file); + + return volume.getId(); + } + + @GuardedBy("mLock") + private void updateExternalVolumesForUserLocked(Context userContext) { + final StorageManager sm = userContext.getSystemService(StorageManager.class); + for (String volumeName : MediaStore.getExternalVolumeNames(userContext)) { + try { + final Uri uri = MediaStore.Files.getContentUri(volumeName); + final StorageVolume storageVolume = sm.getStorageVolume(uri); + MediaVolume volume = MediaVolume.fromStorageVolume(storageVolume); + mExternalVolumes.add(volume); + mCachedVolumeScanPaths.put(volume, FileUtils.getVolumeScanPaths(userContext, + volume.getName())); + } catch (IllegalStateException | FileNotFoundException e) { + Log.wtf(TAG, "Failed to update volume " + volumeName, e); + } + } + } + + public void update() { + synchronized (mLock) { + mCachedVolumeScanPaths.clear(); + try { + mCachedInternalScanPaths = FileUtils.getVolumeScanPaths(mContext, + MediaStore.VOLUME_INTERNAL); + } catch (FileNotFoundException e) { + Log.wtf(TAG, "Failed to update volume " + MediaStore.VOLUME_INTERNAL,e ); + } + mExternalVolumes.clear(); + List<UserHandle> users = mUserCache.updateAndGetUsers(); + for (UserHandle user : users) { + Context userContext = mUserCache.getContextForUser(user); + updateExternalVolumesForUserLocked(userContext); + } + } + } + + public void dump(PrintWriter writer) { + writer.println("Volume cache state:"); + synchronized (mLock) { + for (MediaVolume volume : mExternalVolumes) { + writer.println(" " + volume.toString()); + } + } + } +} diff --git a/src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java b/src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java index 0c1cb945..1f8f3cdf 100644 --- a/src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java +++ b/src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java @@ -18,6 +18,7 @@ package com.android.providers.media.fuse; import static com.android.providers.media.scan.MediaScanner.REASON_MOUNTED; +import android.annotation.BytesLong; import android.content.ContentProviderClient; import android.os.Environment; import android.os.OperationCanceledException; @@ -32,11 +33,14 @@ import androidx.annotation.Nullable; import com.android.providers.media.MediaProvider; import com.android.providers.media.MediaService; +import com.android.providers.media.MediaVolume; import java.io.File; import java.io.IOException; import java.util.HashMap; import java.util.Map; +import java.util.Objects; +import java.util.UUID; /** * Handles filesystem I/O from other apps. @@ -48,9 +52,14 @@ public final class ExternalStorageServiceImpl extends ExternalStorageService { private static final Map<String, FuseDaemon> sFuseDaemons = new HashMap<>(); @Override - public void onStartSession(String sessionId, /* @SessionFlag */ int flag, + public void onStartSession(@NonNull String sessionId, /* @SessionFlag */ int flag, @NonNull ParcelFileDescriptor deviceFd, @NonNull File upperFileSystemPath, @NonNull File lowerFileSystemPath) { + Objects.requireNonNull(sessionId); + Objects.requireNonNull(deviceFd); + Objects.requireNonNull(upperFileSystemPath); + Objects.requireNonNull(lowerFileSystemPath); + MediaProvider mediaProvider = getMediaProvider(); synchronized (sLock) { @@ -70,22 +79,25 @@ public final class ExternalStorageServiceImpl extends ExternalStorageService { } @Override - public void onVolumeStateChanged(StorageVolume vol) throws IOException { + public void onVolumeStateChanged(@NonNull StorageVolume vol) throws IOException { + Objects.requireNonNull(vol); + MediaProvider mediaProvider = getMediaProvider(); - String volumeName = vol.getMediaStoreVolumeName(); switch(vol.getState()) { case Environment.MEDIA_MOUNTED: - mediaProvider.attachVolume(volumeName, /* validate */ false); + MediaVolume volume = MediaVolume.fromStorageVolume(vol); + mediaProvider.attachVolume(volume, /* validate */ false); + MediaService.queueVolumeScan(mediaProvider.getContext(), volume, REASON_MOUNTED); break; case Environment.MEDIA_UNMOUNTED: case Environment.MEDIA_EJECTING: case Environment.MEDIA_REMOVED: case Environment.MEDIA_BAD_REMOVAL: - mediaProvider.detachVolume(volumeName); + mediaProvider.detachVolume(MediaVolume.fromStorageVolume(vol)); break; default: - Log.i(TAG, "Ignoring volume state for vol:" + volumeName + Log.i(TAG, "Ignoring volume state for vol:" + vol.getMediaStoreVolumeName() + ". State: " + vol.getState()); } // Check for invalidation of cached volumes @@ -93,7 +105,9 @@ public final class ExternalStorageServiceImpl extends ExternalStorageService { } @Override - public void onEndSession(String sessionId) { + public void onEndSession(@NonNull String sessionId) { + Objects.requireNonNull(sessionId); + FuseDaemon daemon = onExitSession(sessionId); if (daemon == null) { @@ -108,7 +122,24 @@ public final class ExternalStorageServiceImpl extends ExternalStorageService { } } - public FuseDaemon onExitSession(String sessionId) { + @Override + public void onFreeCache(@NonNull UUID volumeUuid, @BytesLong long bytes) throws IOException { + Objects.requireNonNull(volumeUuid); + + Log.i(TAG, "Free cache requested for " + bytes + " bytes"); + getMediaProvider().freeCache(bytes); + } + + @Override + public void onAnrDelayStarted(@NonNull String packageName, int uid, int tid, int reason) { + Objects.requireNonNull(packageName); + + getMediaProvider().onAnrDelayStarted(packageName, uid, tid, reason); + } + + public FuseDaemon onExitSession(@NonNull String sessionId) { + Objects.requireNonNull(sessionId); + Log.i(TAG, "Exiting session for id: " + sessionId); synchronized (sLock) { return sFuseDaemons.remove(sessionId); diff --git a/src/com/android/providers/media/fuse/FuseDaemon.java b/src/com/android/providers/media/fuse/FuseDaemon.java index 9a433c96..11752493 100644 --- a/src/com/android/providers/media/fuse/FuseDaemon.java +++ b/src/com/android/providers/media/fuse/FuseDaemon.java @@ -24,6 +24,8 @@ import androidx.annotation.NonNull; import com.android.internal.annotations.GuardedBy; import com.android.providers.media.MediaProvider; +import java.io.File; +import java.io.IOException; import java.util.Objects; /** @@ -82,6 +84,20 @@ public final class FuseDaemon extends Thread { // Wait for native_start waitForStart(); + + // Initialize device id + initializeDeviceId(); + } + + private void initializeDeviceId() { + synchronized (mLock) { + if (mPtr == 0) { + Log.e(TAG, "initializeDeviceId failed, FUSE daemon unavailable"); + return; + } + String path = mMediaProvider.getFuseFile(new File(mPath)).getAbsolutePath(); + native_initialize_device_id(mPtr, path); + } } private void waitForStart() { @@ -151,6 +167,16 @@ public final class FuseDaemon extends Thread { } } + public String getOriginalMediaFormatFilePath(ParcelFileDescriptor fileDescriptor) + throws IOException { + synchronized (mLock) { + if (mPtr == 0) { + throw new IOException("FUSE daemon unavailable"); + } + return native_get_original_media_format_file_path(mPtr, fileDescriptor.getFd()); + } + } + private native long native_new(MediaProvider mediaProvider); // Takes ownership of the passed in file descriptor! @@ -161,5 +187,7 @@ public final class FuseDaemon extends Thread { int fd); private native void native_invalidate_fuse_dentry_cache(long daemon, String path); private native boolean native_is_started(long daemon); + private native String native_get_original_media_format_file_path(long daemon, int fd); + private native void native_initialize_device_id(long daemon, String path); public static native boolean native_is_fuse_thread(); } diff --git a/src/com/android/providers/media/metrics/PulledMetrics.java b/src/com/android/providers/media/metrics/PulledMetrics.java new file mode 100644 index 00000000..599eee50 --- /dev/null +++ b/src/com/android/providers/media/metrics/PulledMetrics.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2021 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.metrics; + +import static com.android.providers.media.MediaProviderStatsLog.GENERAL_EXTERNAL_STORAGE_ACCESS_STATS; +import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA; + +import android.app.StatsManager; +import android.content.Context; +import android.util.Log; +import android.util.StatsEvent; + +import androidx.annotation.NonNull; + +import com.android.modules.utils.BackgroundThread; +import com.android.providers.media.fuse.FuseDaemon; + +import java.util.List; + +/** A class to initialise and log metrics pulled by statsd. */ +public class PulledMetrics { + private static final String TAG = "PulledMetrics"; + + private static final StatsPullCallbackHandler STATS_PULL_CALLBACK_HANDLER = + new StatsPullCallbackHandler(); + + private static final StorageAccessMetrics storageAccessMetrics = new StorageAccessMetrics(); + + private static boolean isInitialized = false; + + public static void initialize(Context context) { + if (isInitialized) { + return; + } + + final StatsManager statsManager = context.getSystemService(StatsManager.class); + if (statsManager == null) { + Log.e(TAG, "Error retrieving StatsManager. Cannot initialize PulledMetrics."); + } else { + Log.d(TAG, "Registering callback with StatsManager"); + + try { + // use the same callback handler for registering for all the tags. + statsManager.setPullAtomCallback(TRANSCODING_DATA, null /* metadata */, + BackgroundThread.getExecutor(), + STATS_PULL_CALLBACK_HANDLER); + statsManager.setPullAtomCallback( + GENERAL_EXTERNAL_STORAGE_ACCESS_STATS, + /*metadata*/null, + BackgroundThread.getExecutor(), + STATS_PULL_CALLBACK_HANDLER); + isInitialized = true; + } catch (NullPointerException e) { + Log.w(TAG, "Pulled metrics not supported. Could not register.", e); + } + } + } + + // Storage Access Metrics log functions + + /** + * Logs the mime type that was accessed by the given {@code uid}. + * Does nothing if the stats puller is not initialized. + */ + public static void logMimeTypeAccess(int uid, @NonNull String mimeType) { + if (!isInitialized) { + return; + } + + storageAccessMetrics.logMimeType(uid, mimeType); + } + + /** + * Logs the storage access and attributes it to the given {@code uid}. + * + * <p>Should only be called from a FUSE thread. + */ + public static void logFileAccessViaFuse(int uid, @NonNull String file) { + if (!isInitialized) { + return; + } + + storageAccessMetrics.logAccessViaFuse(uid, file); + } + + /** + * Logs the storage access and attributes it to the given {@code uid}. + * + * <p>This is a no-op if it's called on a FUSE thread. + */ + public static void logVolumeAccessViaMediaProvider(int uid, @NonNull String volumeName) { + if (!isInitialized) { + return; + } + + // We don't log if it's a FUSE thread because logAccessViaFuse should handle that. + if (FuseDaemon.native_is_fuse_thread()) { + return; + } + storageAccessMetrics.logAccessViaMediaProvider(uid, volumeName); + } + + private static class StatsPullCallbackHandler implements StatsManager.StatsPullAtomCallback { + @Override + public int onPullAtom(int atomTag, List<StatsEvent> data) { + // handle the tags appropriately. + List<StatsEvent> events = pullEvents(atomTag); + if (events == null) { + return StatsManager.PULL_SKIP; + } + + data.addAll(events); + return StatsManager.PULL_SUCCESS; + } + + private List<StatsEvent> pullEvents(int atomTag) { + switch (atomTag) { + case TRANSCODING_DATA: + return TranscodeMetrics.pullStatsEvents(); + case GENERAL_EXTERNAL_STORAGE_ACCESS_STATS: + return storageAccessMetrics.pullStatsEvents(); + default: + return null; + } + } + } +} diff --git a/src/com/android/providers/media/metrics/StorageAccessMetrics.java b/src/com/android/providers/media/metrics/StorageAccessMetrics.java new file mode 100644 index 00000000..ce54e3f3 --- /dev/null +++ b/src/com/android/providers/media/metrics/StorageAccessMetrics.java @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2021 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.metrics; + +import static com.android.providers.media.MediaProviderStatsLog.GENERAL_EXTERNAL_STORAGE_ACCESS_STATS; + +import static java.util.stream.Collectors.toList; + +import android.os.Process; +import android.os.SystemClock; +import android.provider.MediaStore; +import android.util.ArraySet; +import android.util.Log; +import android.util.SparseArray; +import android.util.StatsEvent; +import android.util.proto.ProtoOutputStream; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.providers.media.MediaProviderStatsLog; +import com.android.providers.media.util.FileUtils; +import com.android.providers.media.util.MimeUtils; + +import com.google.common.annotations.VisibleForTesting; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Metrics for {@link MediaProviderStatsLog#GENERAL_EXTERNAL_STORAGE_ACCESS_STATS}. This class + * gathers stats separately for each UID that accesses external storage. + */ +class StorageAccessMetrics { + + private static final String TAG = "StorageAccessMetrics"; + + @VisibleForTesting + static final int UID_SAMPLES_COUNT_LIMIT = 50; + + private final int mMyUid = Process.myUid(); + + @GuardedBy("mLock") + private final SparseArray<PackageStorageAccessStats> mAccessStatsPerPackage = + new SparseArray<>(); + @GuardedBy("mLock") + private long mStartTimeMillis = SystemClock.uptimeMillis(); + + private final Object mLock = new Object(); + + + /** + * Logs the mime type that was accessed by the given {@code uid}. + */ + void logMimeType(int uid, @NonNull String mimeType) { + if (mimeType == null) { + Log.w(TAG, "Attempted to log null mime type access"); + return; + } + + synchronized (mLock) { + getOrGeneratePackageStatsObjectLocked(uid).mMimeTypes.add(mimeType); + } + } + + /** + * Logs the storage access and attributes it to the given {@code uid}. + * + * <p>Should only be called from a FUSE thread. + */ + void logAccessViaFuse(int uid, @NonNull String file) { + // We don't log the access if it's MediaProvider accessing. + if (mMyUid == uid) { + return; + } + + incrementFilePathAccesses(uid); + final String volumeName = MediaStore.getVolumeName( + FileUtils.getContentUriForPath(file)); + logGeneralExternalStorageAccess(uid, volumeName); + logMimeTypeFromFile(uid, file); + } + + + /** + * Logs the storage access and attributes it to the given {@code uid}. + */ + void logAccessViaMediaProvider(int uid, @NonNull String volumeName) { + // We also don't log the access if it's MediaProvider accessing. + if (mMyUid == uid) { + return; + } + + logGeneralExternalStorageAccess(uid, volumeName); + } + + /** + * Use this to log whenever a package accesses external storage via ContentResolver or FUSE. + * The given volume name helps us determine whether this was an access on primary or secondary + * storage. + */ + private void logGeneralExternalStorageAccess(int uid, @NonNull String volumeName) { + switch (volumeName) { + case MediaStore.VOLUME_EXTERNAL: + case MediaStore.VOLUME_EXTERNAL_PRIMARY: + incrementTotalAccesses(uid); + break; + case MediaStore.VOLUME_INTERNAL: + case MediaStore.VOLUME_DEMO: + case MediaStore.MEDIA_SCANNER_VOLUME: + break; + default: + // Secondary external storage + incrementTotalAccesses(uid); + incrementSecondaryStorageAccesses(uid); + } + } + + /** + * Logs that the mime type of the given {@param file} was accessed by the given {@param uid}. + */ + private void logMimeTypeFromFile(int uid, @Nullable String file) { + logMimeType(uid, MimeUtils.resolveMimeType(new File(file))); + } + + private void incrementTotalAccesses(int uid) { + synchronized (mLock) { + getOrGeneratePackageStatsObjectLocked(uid).mTotalAccesses += 1; + } + } + + private void incrementFilePathAccesses(int uid) { + synchronized (mLock) { + getOrGeneratePackageStatsObjectLocked(uid).mFilePathAccesses += 1; + } + } + + private void incrementSecondaryStorageAccesses(int uid) { + synchronized (mLock) { + getOrGeneratePackageStatsObjectLocked(uid).mSecondaryStorageAccesses += 1; + } + } + + @GuardedBy("mLock") + private PackageStorageAccessStats getOrGeneratePackageStatsObjectLocked(int uid) { + PackageStorageAccessStats stats = mAccessStatsPerPackage.get(uid); + if (stats == null) { + stats = new PackageStorageAccessStats(uid); + mAccessStatsPerPackage.put(uid, stats); + } + return stats; + } + + /** + * Returns the list of {@link StatsEvent} since latest reset, for a random subset of tracked + * uids if there are more than {@link #UID_SAMPLES_COUNT_LIMIT} in total. Returns {@code null} + * when the time since reset is non-positive. + */ + @Nullable + List<StatsEvent> pullStatsEvents() { + synchronized (mLock) { + final long timeInterval = SystemClock.uptimeMillis() - mStartTimeMillis; + List<PackageStorageAccessStats> stats = getSampleStats(); + resetStats(); + return stats + .stream() + .map(s -> s.toNormalizedStats(timeInterval).toStatsEvent()) + .collect(toList()); + } + } + + @VisibleForTesting + List<PackageStorageAccessStats> getSampleStats() { + synchronized (mLock) { + List<PackageStorageAccessStats> result = new ArrayList<>(); + + List<Integer> sampledUids = new ArrayList<>(); + for (int i = 0; i < mAccessStatsPerPackage.size(); i++) { + sampledUids.add(mAccessStatsPerPackage.keyAt(i)); + } + + if (sampledUids.size() > UID_SAMPLES_COUNT_LIMIT) { + Collections.shuffle(sampledUids); + sampledUids = sampledUids.subList(0, UID_SAMPLES_COUNT_LIMIT); + } + for (Integer uid : sampledUids) { + PackageStorageAccessStats stats = mAccessStatsPerPackage.get(uid); + result.add(stats); + } + + return result; + } + } + + private void resetStats() { + synchronized (mLock) { + mAccessStatsPerPackage.clear(); + mStartTimeMillis = SystemClock.uptimeMillis(); + } + } + + @VisibleForTesting + static class PackageStorageAccessStats { + private final int mUid; + int mTotalAccesses = 0; + int mFilePathAccesses = 0; + int mSecondaryStorageAccesses = 0; + + final ArraySet<String> mMimeTypes = new ArraySet<>(); + + PackageStorageAccessStats(int uid) { + this.mUid = uid; + } + + PackageStorageAccessStats toNormalizedStats(long timeInterval) { + this.mTotalAccesses = normalizeAccessesPerDay(mTotalAccesses, timeInterval); + this.mFilePathAccesses = normalizeAccessesPerDay(mFilePathAccesses, timeInterval); + this.mSecondaryStorageAccesses = + normalizeAccessesPerDay(mSecondaryStorageAccesses, timeInterval); + return this; + } + + StatsEvent toStatsEvent() { + return StatsEvent.newBuilder() + .setAtomId(GENERAL_EXTERNAL_STORAGE_ACCESS_STATS) + .writeInt(mUid) + .writeInt(mTotalAccesses) + .writeInt(mFilePathAccesses) + .writeInt(mSecondaryStorageAccesses) + .writeByteArray(getMimeTypesAsProto().getBytes()) + .build(); + } + + private ProtoOutputStream getMimeTypesAsProto() { + ProtoOutputStream proto = new ProtoOutputStream(); + for (int i = 0; i < mMimeTypes.size(); i++) { + String mime = mMimeTypes.valueAt(i); + proto.write(/*fieldId*/ProtoOutputStream.FIELD_TYPE_STRING + | ProtoOutputStream.FIELD_COUNT_REPEATED + | 1, + mime); + } + return proto; + } + + private static int normalizeAccessesPerDay(int value, long interval) { + if (interval <= 0) { + return -1; + } + + double multiplier = Double.valueOf(TimeUnit.DAYS.toMillis(1)) / interval; + double normalizedValue = value * multiplier; + return Double.valueOf(normalizedValue).intValue(); + } + + @VisibleForTesting + int getUid() { + return mUid; + } + } +} diff --git a/src/com/android/providers/media/metrics/TranscodeMetrics.java b/src/com/android/providers/media/metrics/TranscodeMetrics.java new file mode 100644 index 00000000..4c4e1d88 --- /dev/null +++ b/src/com/android/providers/media/metrics/TranscodeMetrics.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2021 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.metrics; + +import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA; + +import android.app.StatsManager; +import android.util.StatsEvent; + +import com.android.internal.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +/** + * Stores metrics for transcode sessions to be shared with statsd. + */ +final class TranscodeMetrics { + private static final List<TranscodingStatsData> TRANSCODING_STATS_DATA = new ArrayList<>(); + + // PLEASE update these if there's a change in the proto message, per the limit set in + // StatsEvent#MAX_PULL_PAYLOAD_SIZE + private static final int STATS_DATA_SAMPLE_LIMIT = 300; + private static final int STATS_DATA_COUNT_HARD_LIMIT = 500; // for safety + + // Total data save requests we've received for one statsd pull cycle. + // This can be greater than TRANSCODING_STATS_DATA.size() since we might not add all the + // incoming data because of the hard limit on the size. + private static int sTotalStatsDataCount = 0; + + static List<StatsEvent> pullStatsEvents() { + synchronized (TRANSCODING_STATS_DATA) { + if (TRANSCODING_STATS_DATA.size() > STATS_DATA_SAMPLE_LIMIT) { + doRandomSampling(); + } + + List<StatsEvent> result = getStatsEvents(); + resetStatsData(); + return result; + } + } + + private static List<StatsEvent> getStatsEvents() { + synchronized (TRANSCODING_STATS_DATA) { + List<StatsEvent> result = new ArrayList<>(); + StatsEvent event; + int dataCountToFill = Math.min(TRANSCODING_STATS_DATA.size(), STATS_DATA_SAMPLE_LIMIT); + for (int i = 0; i < dataCountToFill; ++i) { + TranscodingStatsData statsData = TRANSCODING_STATS_DATA.get(i); + event = StatsEvent.newBuilder().setAtomId(TRANSCODING_DATA) + .writeString(statsData.mRequestorPackage) + .writeInt(statsData.mAccessType) + .writeLong(statsData.mFileSizeBytes) + .writeInt(statsData.mTranscodeResult) + .writeLong(statsData.mTranscodeDurationMillis) + .writeLong(statsData.mFileDurationMillis) + .writeLong(statsData.mFrameRate) + .writeInt(statsData.mAccessReason).build(); + + result.add(event); + } + return result; + } + } + + /** + * The random samples would get collected in the first {@code STATS_DATA_SAMPLE_LIMIT} positions + * inside {@code TRANSCODING_STATS_DATA} + */ + private static void doRandomSampling() { + Random random = new Random(System.currentTimeMillis()); + + synchronized (TRANSCODING_STATS_DATA) { + for (int i = 0; i < STATS_DATA_SAMPLE_LIMIT; ++i) { + int randomIndex = random.nextInt(TRANSCODING_STATS_DATA.size() - i /* bound */) + + i; + Collections.swap(TRANSCODING_STATS_DATA, i, randomIndex); + } + } + } + + @VisibleForTesting + static void resetStatsData() { + synchronized (TRANSCODING_STATS_DATA) { + TRANSCODING_STATS_DATA.clear(); + sTotalStatsDataCount = 0; + } + } + + /** Saves the statsd data that'd eventually be shared in the pull callback. */ + @VisibleForTesting + static void saveStatsData(TranscodingStatsData transcodingStatsData) { + checkAndLimitStatsDataSizeAfterAddition(transcodingStatsData); + } + + private static void checkAndLimitStatsDataSizeAfterAddition( + TranscodingStatsData transcodingStatsData) { + synchronized (TRANSCODING_STATS_DATA) { + ++sTotalStatsDataCount; + + if (TRANSCODING_STATS_DATA.size() < STATS_DATA_COUNT_HARD_LIMIT) { + TRANSCODING_STATS_DATA.add(transcodingStatsData); + return; + } + + // Depending on how much transcoding we are doing, we might end up accumulating a lot of + // data by the time statsd comes back with the pull callback. + // We don't want to just keep growing our memory usage. + // So we simply randomly choose an element to remove with equal likeliness. + Random random = new Random(System.currentTimeMillis()); + int replaceIndex = random.nextInt(sTotalStatsDataCount /* bound */); + + if (replaceIndex < STATS_DATA_COUNT_HARD_LIMIT) { + TRANSCODING_STATS_DATA.set(replaceIndex, transcodingStatsData); + } + } + } + + @VisibleForTesting + static int getSavedStatsDataCount() { + return TRANSCODING_STATS_DATA.size(); + } + + @VisibleForTesting + static int getTotalStatsDataCount() { + return sTotalStatsDataCount; + } + + @VisibleForTesting + static int getStatsDataCountHardLimit() { + return STATS_DATA_COUNT_HARD_LIMIT; + } + + @VisibleForTesting + static int getStatsDataSampleLimit() { + return STATS_DATA_SAMPLE_LIMIT; + } + + /** This is the data to populate the proto shared with statsd. */ + static final class TranscodingStatsData { + private final String mRequestorPackage; + private final short mAccessType; + private final long mFileSizeBytes; + private final short mTranscodeResult; + private final long mTranscodeDurationMillis; + private final long mFileDurationMillis; + private final long mFrameRate; + private final short mAccessReason; + + TranscodingStatsData(String requestorPackage, int accessType, long fileSizeBytes, + int transcodeResult, long transcodeDurationMillis, + long videoDurationMillis, long frameRate, short transcodeReason) { + mRequestorPackage = requestorPackage; + mAccessType = (short) accessType; + mFileSizeBytes = fileSizeBytes; + mTranscodeResult = (short) transcodeResult; + mTranscodeDurationMillis = transcodeDurationMillis; + mFileDurationMillis = videoDurationMillis; + mFrameRate = frameRate; + mAccessReason = transcodeReason; + } + } +} diff --git a/src/com/android/providers/media/photopicker/PhotoPickerActivity.java b/src/com/android/providers/media/photopicker/PhotoPickerActivity.java new file mode 100644 index 00000000..66182bc2 --- /dev/null +++ b/src/com/android/providers/media/photopicker/PhotoPickerActivity.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2020 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.photopicker; + +import android.app.Activity; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ContentUris; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.MediaStore; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ListView; + +import com.android.providers.media.R; + +import com.google.common.collect.ImmutableList; + +/** + * Photo Picker allows users to choose one or more photos and/or videos to share with an app. The + * app does not get access to all photos/videos. + */ +public class PhotoPickerActivity extends Activity { + + public static final String TAG = "PhotoPickerActivity"; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // TODO(b/168001592) Change layout to show photos & options. + setContentView(R.layout.photo_picker); + Button button = findViewById(R.id.button); + button.setOnClickListener(v -> respondEmpty()); + + // TODO(b/168001592) Handle multiple selection option. + + // TODO(b/168001592) Filter using given mime type. + + // TODO(b/168001592) Show a photo grid instead of ListView. + ListView photosList = findViewById(R.id.names_list); + ArrayAdapter<PhotoEntry> photosAdapter = new ArrayAdapter<>( + this, android.R.layout.simple_list_item_1); + photosList.setAdapter(photosAdapter); + // Clicking an item in the list returns its URI for now. + photosList.setOnItemClickListener((parent, view, position, id) -> { + respondPhoto(photosAdapter.getItem(position)); + }); + + // Show the list of photo names for now. + ImmutableList.Builder<PhotoEntry> imageRowsBuilder = ImmutableList.builder(); + String[] projection = new String[] { + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DISPLAY_NAME + }; + // TODO(b/168001592) call query() from worker thread. + Cursor cursor = getApplicationContext().getContentResolver().query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, null, null); + int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID); + int nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME); + // TODO(b/168001592) Use better image loading (e.g. use paging, glide). + while (cursor.moveToNext()) { + imageRowsBuilder.add( + new PhotoEntry(cursor.getLong(idColumn), cursor.getString(nameColumn))); + } + photosAdapter.addAll(imageRowsBuilder.build()); + } + + private void respondPhoto(PhotoEntry photoEntry) { + Uri contentUri = ContentUris.withAppendedId( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + photoEntry.id); + + Intent response = new Intent(); + // TODO(b/168001592) Confirm if this flag is enough to grant the access we want. + response.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + + // TODO(b/168001592) Use a better label and accurate mime types. + if (getIntent().getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)) { + ClipDescription clipDescription = new ClipDescription( + "Photo Picker ClipData", + new String[]{"image/*", "video/*"}); + ClipData clipData = new ClipData(clipDescription, new ClipData.Item(contentUri)); + response.setClipData(clipData); + } else { + response.setData(contentUri); + } + + setResult(Activity.RESULT_OK, response); + finish(); + } + + + private void respondEmpty() { + setResult(Activity.RESULT_OK); + finish(); + } + + private static class PhotoEntry { + private long id; + private String name; + + PhotoEntry(long id, String name) { + this.id = id; + this.name = name; + } + + @Override + public String toString() { + return name; + } + } +} diff --git a/src/com/android/providers/media/playlist/Playlist.java b/src/com/android/providers/media/playlist/Playlist.java index 53bdeebd..fd0b570d 100644 --- a/src/com/android/providers/media/playlist/Playlist.java +++ b/src/com/android/providers/media/playlist/Playlist.java @@ -29,8 +29,10 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -61,6 +63,9 @@ public class Playlist { PlaylistPersister.resolvePersister(file).read(in, mItems); } catch (FileNotFoundException e) { Log.w(TAG, "Treating missing file as empty playlist"); + } catch (InvalidPathException e) { + Log.w(TAG, "Broken playlist file", e); + clear(); } } @@ -109,6 +114,28 @@ public class Playlist { return index; } + /** + * Removes existing playlist items that correspond to the given indexes. + * If an index is out of bounds, then it's ignored. + * + * @return the number of deleted items + */ + public int removeMultiple(int... indexes) { + int res = 0; + Arrays.sort(indexes); + + for (int i = indexes.length - 1; i >= 0; --i) { + final int size = mItems.size(); + // Ignore items that are out of bounds + if (indexes[i] >=0 && indexes[i] < size) { + mItems.remove(indexes[i]); + res++; + } + } + + return res; + } + private static int constrain(int amount, int low, int high) { return amount < low ? low : (amount > high ? high : amount); } diff --git a/src/com/android/providers/media/scan/LegacyMediaScanner.java b/src/com/android/providers/media/scan/LegacyMediaScanner.java index 8d84569a..d8d3bed4 100644 --- a/src/com/android/providers/media/scan/LegacyMediaScanner.java +++ b/src/com/android/providers/media/scan/LegacyMediaScanner.java @@ -21,6 +21,8 @@ import android.net.Uri; import androidx.annotation.Nullable; +import com.android.providers.media.MediaVolume; + import java.io.File; @Deprecated @@ -52,7 +54,17 @@ public class LegacyMediaScanner implements MediaScanner { } @Override - public void onDetachVolume(String volumeName) { + public void onDetachVolume(MediaVolume volume) { + throw new UnsupportedOperationException(); + } + + @Override + public void onIdleScanStopped() { + throw new UnsupportedOperationException(); + } + + @Override + public void onDirectoryDirty(File file) { throw new UnsupportedOperationException(); } } diff --git a/src/com/android/providers/media/scan/MediaScanner.java b/src/com/android/providers/media/scan/MediaScanner.java index b217da1c..45d2a243 100644 --- a/src/com/android/providers/media/scan/MediaScanner.java +++ b/src/com/android/providers/media/scan/MediaScanner.java @@ -26,6 +26,8 @@ import android.net.Uri; import androidx.annotation.Nullable; +import com.android.providers.media.MediaVolume; + import java.io.File; public interface MediaScanner { @@ -38,5 +40,7 @@ public interface MediaScanner { public void scanDirectory(File file, int reason); public Uri scanFile(File file, int reason); public Uri scanFile(File file, int reason, @Nullable String ownerPackage); - public void onDetachVolume(String volumeName); + public void onDetachVolume(MediaVolume volume); + public void onIdleScanStopped(); + public void onDirectoryDirty(File file); } diff --git a/src/com/android/providers/media/scan/ModernMediaScanner.java b/src/com/android/providers/media/scan/ModernMediaScanner.java index 9658827f..8927bfa5 100644 --- a/src/com/android/providers/media/scan/ModernMediaScanner.java +++ b/src/com/android/providers/media/scan/ModernMediaScanner.java @@ -37,6 +37,7 @@ import static android.media.MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH; import static android.media.MediaMetadataRetriever.METADATA_KEY_MIMETYPE; import static android.media.MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS; import static android.media.MediaMetadataRetriever.METADATA_KEY_TITLE; +import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_CODEC_MIME_TYPE; import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT; import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION; import static android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH; @@ -47,6 +48,8 @@ import static android.provider.MediaStore.UNKNOWN_STRING; import static android.text.format.DateUtils.HOUR_IN_MILLIS; import static android.text.format.DateUtils.MINUTE_IN_MILLIS; +import static com.android.providers.media.util.Metrics.translateReason; + import android.content.ContentProviderClient; import android.content.ContentProviderOperation; import android.content.ContentProviderResult; @@ -58,6 +61,7 @@ import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.drm.DrmManagerClient; import android.drm.DrmSupportInfo; +import android.graphics.BitmapFactory; import android.media.ExifInterface; import android.media.MediaMetadataRetriever; import android.mtp.MtpConstants; @@ -88,11 +92,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import com.android.modules.utils.build.SdkLevel; +import com.android.providers.media.MediaVolume; import com.android.providers.media.util.DatabaseUtils; import com.android.providers.media.util.ExifUtils; import com.android.providers.media.util.FileUtils; import com.android.providers.media.util.IsoInterface; -import com.android.providers.media.util.Logging; import com.android.providers.media.util.LongArray; import com.android.providers.media.util.Metrics; import com.android.providers.media.util.MimeUtils; @@ -100,6 +105,7 @@ import com.android.providers.media.util.XmpInterface; import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.IOException; import java.nio.file.FileVisitResult; import java.nio.file.FileVisitor; @@ -111,6 +117,7 @@ import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; @@ -147,21 +154,34 @@ public class ModernMediaScanner implements MediaScanner { // TODO: deprecate playlist editing // TODO: deprecate PARENT column, since callers can't see directories - @GuardedBy("sDateFormat") - private static final SimpleDateFormat sDateFormat; + @GuardedBy("S_DATE_FORMAT") + private static final SimpleDateFormat S_DATE_FORMAT; + @GuardedBy("S_DATE_FORMAT_WITH_MILLIS") + private static final SimpleDateFormat S_DATE_FORMAT_WITH_MILLIS; static { - sDateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss"); - sDateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + S_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd'T'HHmmss"); + S_DATE_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC")); + + S_DATE_FORMAT_WITH_MILLIS = new SimpleDateFormat("yyyyMMdd'T'HHmmss.SSS"); + S_DATE_FORMAT_WITH_MILLIS.setTimeZone(TimeZone.getTimeZone("UTC")); } private static final int BATCH_SIZE = 32; + private static final int MAX_XMP_SIZE_BYTES = 1024 * 1024; + // |excludeDirs * 2| < 1000 which is the max SQL expression size + // Because we add |excludeDir| and |excludeDir/| in the SQL expression to match dir and subdirs + // See SQLITE_MAX_EXPR_DEPTH in sqlite3.c + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + static final int MAX_EXCLUDE_DIRS = 450; private static final Pattern PATTERN_VISIBLE = Pattern.compile( - "(?i)^/storage/[^/]+(?:/[0-9]+)?(?:/Android/sandbox/([^/]+))?$"); + "(?i)^/storage/[^/]+(?:/[0-9]+)?$"); private static final Pattern PATTERN_INVISIBLE = Pattern.compile( - "(?i)^/storage/[^/]+(?:/[0-9]+)?(?:/Android/sandbox/([^/]+))?/" + - "(?:(?:Android/(?:data|obb)$)|(?:(?:Movies|Music|Pictures)/.thumbnails$))"); + "(?i)^/storage/[^/]+(?:/[0-9]+)?/" + + "(?:(?:Android/(?:data|obb|sandbox)$)|" + + "(?:\\.transforms$)|" + + "(?:(?:Movies|Music|Pictures)/.thumbnails$))"); private static final Pattern PATTERN_YEAR = Pattern.compile("([1-9][0-9][0-9][0-9])"); @@ -170,13 +190,15 @@ public class ModernMediaScanner implements MediaScanner { private final Context mContext; private final DrmManagerClient mDrmClient; + @GuardedBy("mPendingCleanDirectories") + private final Set<String> mPendingCleanDirectories = new ArraySet<>(); /** - * Map from volume name to signals that can be used to cancel any active - * scan operations on those volumes. + * List of active scans. */ - @GuardedBy("mSignals") - private final ArrayMap<String, CancellationSignal> mSignals = new ArrayMap<>(); + @GuardedBy("mActiveScans") + + private final List<Scan> mActiveScans = new ArrayList<>(); /** * Holder that contains a reference count of the number of threads @@ -225,6 +247,8 @@ public class ModernMediaScanner implements MediaScanner { try (Scan scan = new Scan(file, reason, /*ownerPackage*/ null)) { scan.run(); } catch (OperationCanceledException ignored) { + } catch (FileNotFoundException e) { + Log.e(TAG, "Couldn't find directory to scan", e) ; } } @@ -240,27 +264,51 @@ public class ModernMediaScanner implements MediaScanner { return scan.getFirstResult(); } catch (OperationCanceledException ignored) { return null; + } catch (FileNotFoundException e) { + Log.e(TAG, "Couldn't find file to scan", e) ; + return null; } } @Override - public void onDetachVolume(String volumeName) { - synchronized (mSignals) { - final CancellationSignal signal = mSignals.remove(volumeName); - if (signal != null) { - signal.cancel(); + public void onDetachVolume(MediaVolume volume) { + synchronized (mActiveScans) { + for (Scan scan : mActiveScans) { + if (volume.equals(scan.mVolume)) { + scan.mSignal.cancel(); + } } } } - private CancellationSignal getOrCreateSignal(String volumeName) { - synchronized (mSignals) { - CancellationSignal signal = mSignals.get(volumeName); - if (signal == null) { - signal = new CancellationSignal(); - mSignals.put(volumeName, signal); + @Override + public void onIdleScanStopped() { + synchronized (mActiveScans) { + for (Scan scan : mActiveScans) { + if (scan.mReason == REASON_IDLE) { + scan.mSignal.cancel(); + } } - return signal; + } + } + + @Override + public void onDirectoryDirty(File dir) { + synchronized (mPendingCleanDirectories) { + mPendingCleanDirectories.remove(dir.getPath()); + FileUtils.setDirectoryDirty(dir, /*isDirty*/ true); + } + } + + private void addActiveScan(Scan scan) { + synchronized (mActiveScans) { + mActiveScans.add(scan); + } + } + + private void removeActiveScan(Scan scan) { + synchronized (mActiveScans) { + mActiveScans.remove(scan); } } @@ -275,10 +323,12 @@ public class ModernMediaScanner implements MediaScanner { private final File mRoot; private final int mReason; + private final MediaVolume mVolume; private final String mVolumeName; private final Uri mFilesUri; private final CancellationSignal mSignal; private final String mOwnerPackage; + private final List<String> mExcludeDirs; private final long mStartGeneration; private final boolean mSingleFile; @@ -299,8 +349,15 @@ public class ModernMediaScanner implements MediaScanner { * indicates that one or more of the current file's parents is a hidden directory. */ private int mHiddenDirCount; + /** + * Indicates if the nomedia directory tree is dirty. When a nomedia directory is dirty, we + * mark the top level nomedia as dirty. Hence if one of the sub directory in the nomedia + * directory is dirty, we consider the whole top level nomedia directory tree as dirty. + */ + private boolean mIsDirectoryTreeDirty; - public Scan(File root, int reason, @Nullable String ownerPackage) { + public Scan(File root, int reason, @Nullable String ownerPackage) + throws FileNotFoundException { Trace.beginSection("ctor"); mClient = mContext.getContentResolver() @@ -309,19 +366,35 @@ public class ModernMediaScanner implements MediaScanner { mRoot = root; mReason = reason; - mVolumeName = FileUtils.getVolumeName(mContext, root); + + if (FileUtils.contains(Environment.getStorageDirectory(), root)) { + mVolume = MediaVolume.fromStorageVolume(FileUtils.getStorageVolume(mContext, root)); + } else { + mVolume = MediaVolume.fromInternal(); + } + mVolumeName = mVolume.getName(); mFilesUri = MediaStore.Files.getContentUri(mVolumeName); - mSignal = getOrCreateSignal(mVolumeName); + mSignal = new CancellationSignal(); mStartGeneration = MediaStore.getGeneration(mResolver, mVolumeName); mSingleFile = mRoot.isFile(); mOwnerPackage = ownerPackage; + mExcludeDirs = new ArrayList<>(); Trace.endSection(); } @Override public void run() { + addActiveScan(this); + try { + runInternal(); + } finally { + removeActiveScan(this); + } + } + + private void runInternal() { final long startTime = SystemClock.elapsedRealtime(); // First, scan everything that should be visible under requested @@ -377,6 +450,49 @@ public class ModernMediaScanner implements MediaScanner { } } + private String buildExcludeDirClause(int count) { + if (count == 0) { + return ""; + } + String notLikeClause = FileColumns.DATA + " NOT LIKE ? ESCAPE '\\'"; + String andClause = " AND "; + StringBuilder sb = new StringBuilder(); + sb.append("("); + for (int i = 0; i < count; i++) { + // Append twice because we want to match the path itself and the expanded path + // using the SQL % LIKE operator. For instance, to exclude /sdcard/foo and all + // subdirs, we need the following: + // "NOT LIKE '/sdcard/foo/%' AND "NOT LIKE '/sdcard/foo'" + // The first clause matches *just* subdirs, and the second clause matches the dir + // itself + sb.append(notLikeClause); + sb.append(andClause); + sb.append(notLikeClause); + if (i != count - 1) { + sb.append(andClause); + } + } + sb.append(")"); + return sb.toString(); + } + + private void addEscapedAndExpandedPath(String path, List<String> paths) { + String escapedPath = DatabaseUtils.escapeForLike(path); + paths.add(escapedPath + "/%"); + paths.add(escapedPath); + } + + private String[] buildSqlSelectionArgs() { + List<String> escapedPaths = new ArrayList<>(); + + addEscapedAndExpandedPath(mRoot.getAbsolutePath(), escapedPaths); + for (String dir : mExcludeDirs) { + addEscapedAndExpandedPath(dir, escapedPaths); + } + + return escapedPaths.toArray(new String[0]); + } + private void reconcileAndClean() { final long[] scannedIds = mScannedIds.toArray(); Arrays.sort(scannedIds); @@ -392,26 +508,45 @@ public class ModernMediaScanner implements MediaScanner { + MtpConstants.FORMAT_ABSTRACT_AV_PLAYLIST; final String dataClause = "(" + FileColumns.DATA + " LIKE ? ESCAPE '\\' OR " + FileColumns.DATA + " LIKE ? ESCAPE '\\')"; + final String excludeDirClause = buildExcludeDirClause(mExcludeDirs.size()); final String generationClause = FileColumns.GENERATION_ADDED + " <= " + mStartGeneration; + final String sqlSelection = formatClause + " AND " + dataClause + " AND " + + generationClause + + (excludeDirClause.isEmpty() ? "" : " AND " + excludeDirClause); final Bundle queryArgs = new Bundle(); - queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SELECTION, - formatClause + " AND " + dataClause + " AND " + generationClause); - final String pathEscapedForLike = DatabaseUtils.escapeForLike(mRoot.getAbsolutePath()); + queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SELECTION, sqlSelection); queryArgs.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, - new String[] {pathEscapedForLike + "/%", pathEscapedForLike}); + buildSqlSelectionArgs()); queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SORT_ORDER, FileColumns._ID + " DESC"); - queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_EXCLUDE); + queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE); queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE); queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_FAVORITE, MediaStore.MATCH_INCLUDE); - try (Cursor c = mResolver.query(mFilesUri, new String[] { FileColumns._ID }, - queryArgs, mSignal)) { + final int[] countPerMediaType = new int[FileColumns.MEDIA_TYPE_COUNT]; + try (Cursor c = mResolver.query(mFilesUri, + new String[]{FileColumns._ID, FileColumns.MEDIA_TYPE, FileColumns.DATE_EXPIRES, + FileColumns.IS_PENDING}, queryArgs, mSignal)) { while (c.moveToNext()) { final long id = c.getLong(0); if (Arrays.binarySearch(scannedIds, id) < 0) { + final long dateExpire = c.getLong(2); + final boolean isPending = c.getInt(3) == 1; + // Don't delete the pending item which is not expired. + // If the scan is triggered between invoking + // ContentResolver#insert() and ContentResolver#openFileDescriptor(), + // it raises the FileNotFoundException b/166063754. + if (isPending && dateExpire > System.currentTimeMillis() / 1000) { + continue; + } mUnknownIds.add(id); + final int mediaType = c.getInt(1); + // Avoid ArrayIndexOutOfBounds if more mediaTypes are added, + // but mediaTypeSize is not updated + if (mediaType < countPerMediaType.length) { + countPerMediaType[mediaType]++; + } } } } finally { @@ -433,6 +568,10 @@ public class ModernMediaScanner implements MediaScanner { } applyPending(); } finally { + if (mUnknownIds.size() > 0) { + String scanReason = "scan triggered by reason: " + translateReason(mReason); + Metrics.logDeletionPersistent(mVolumeName, scanReason, countPerMediaType); + } Trace.endSection(); } } @@ -504,11 +643,6 @@ public class ModernMediaScanner implements MediaScanner { @Override public void close() { - // Sanity check that we drained any pending operations - if (!mPending.isEmpty()) { - throw new IllegalStateException(); - } - // Release any locks we're still holding, typically when we // encountered an exception; we snapshot the original list so we're // not confused as it's mutated by release operations @@ -529,6 +663,27 @@ public class ModernMediaScanner implements MediaScanner { return FileVisitResult.SKIP_SUBTREE; } + synchronized (mPendingCleanDirectories) { + if (mIsDirectoryTreeDirty) { + // Directory tree is dirty, continue scanning subtree. + } else if (FileUtils.isDirectoryDirty(FileUtils.getTopLevelNoMedia(dir.toFile()))) { + // Track the directory dirty status for directory tree in mIsDirectoryDirty. + // This removes additional dirty state check for subdirectories of nomedia + // directory. + mIsDirectoryTreeDirty = true; + mPendingCleanDirectories.add(dir.toFile().getPath()); + } else { + Log.d(TAG, "Skipping preVisitDirectory " + dir.toFile()); + if (mExcludeDirs.size() <= MAX_EXCLUDE_DIRS) { + mExcludeDirs.add(dir.toFile().getPath()); + return FileVisitResult.SKIP_SUBTREE; + } else { + Log.w(TAG, "ExcludeDir size exceeded, not skipping preVisitDirectory " + + dir.toFile()); + } + } + } + // Acquire lock on this directory to ensure parallel scans don't // overlap and confuse each other acquireDirectoryLock(dir); @@ -567,11 +722,8 @@ public class ModernMediaScanner implements MediaScanner { actualMimeType = mDrmClient.getOriginalMimeType(realFile.getPath()); } - int actualMediaType = FileColumns.MEDIA_TYPE_NONE; - if (actualMimeType != null) { - actualMediaType = resolveMediaTypeFromFilePath(realFile, actualMimeType, - /*isHidden*/ mHiddenDirCount > 0); - } + int actualMediaType = mediaTypeFromMimeType( + realFile, actualMimeType, FileColumns.MEDIA_TYPE_NONE); Trace.beginSection("checkChanged"); @@ -585,7 +737,7 @@ public class ModernMediaScanner implements MediaScanner { queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_FAVORITE, MediaStore.MATCH_INCLUDE); final String[] projection = new String[] {FileColumns._ID, FileColumns.DATE_MODIFIED, FileColumns.SIZE, FileColumns.MIME_TYPE, FileColumns.MEDIA_TYPE, - FileColumns.IS_PENDING}; + FileColumns.IS_PENDING, FileColumns._MODIFIER}; final Matcher matcher = FileUtils.PATTERN_EXPIRES_FILE.matcher(realFile.getName()); // If IS_PENDING is set by FUSE, we should scan the file and update IS_PENDING to zero. @@ -595,8 +747,6 @@ public class ModernMediaScanner implements MediaScanner { try (Cursor c = mResolver.query(mFilesUri, projection, queryArgs, mSignal)) { if (c.moveToFirst()) { existingId = c.getLong(0); - final long dateModified = c.getLong(1); - final long size = c.getLong(2); final String mimeType = c.getString(3); final int mediaType = c.getInt(4); isPendingFromFuse &= c.getInt(5) != 0; @@ -611,18 +761,36 @@ public class ModernMediaScanner implements MediaScanner { mFirstId = existingId; } - final boolean sameTime = (lastModifiedTime(realFile, attrs) == dateModified); - final boolean sameSize = (attrs.size() == size); - final boolean sameMimeType = mimeType == null ? actualMimeType == null : - mimeType.equalsIgnoreCase(actualMimeType); - final boolean sameMediaType = (actualMediaType == mediaType); - final boolean isSame = sameTime && sameSize && sameMediaType && sameMimeType - && !isPendingFromFuse; - if (attrs.isDirectory() || isSame) { + if (attrs.isDirectory()) { + if (LOGV) Log.v(TAG, "Skipping directory " + file); + return FileVisitResult.CONTINUE; + } + + final boolean sameMetadata = + hasSameMetadata(attrs, realFile, isPendingFromFuse, c); + final boolean sameMediaType = actualMediaType == mediaType; + if (sameMetadata && sameMediaType) { if (LOGV) Log.v(TAG, "Skipping unchanged " + file); return FileVisitResult.CONTINUE; } + + // For this special case we may have changed mime type from the file's metadata. + // This is safe because mime_type cannot be changed outside of scanning. + if (sameMetadata + && "video/mp4".equalsIgnoreCase(actualMimeType) + && "audio/mp4".equalsIgnoreCase(mimeType)) { + if (LOGV) Log.v(TAG, "Skipping unchanged video/audio " + file); + return FileVisitResult.CONTINUE; + } } + + // Since we allow top-level mime type to be customised, we need to do this early + // on, so the file is later scanned as the appropriate type (otherwise, this + // audio filed would be scanned as video and it would be missing the correct + // metadata). + actualMimeType = updateM4aMimeType(realFile, actualMimeType); + actualMediaType = + mediaTypeFromMimeType(realFile, actualMimeType, actualMediaType); } finally { Trace.endSection(); } @@ -636,6 +804,7 @@ public class ModernMediaScanner implements MediaScanner { Trace.endSection(); } if (op != null) { + op.withValue(FileColumns._MODIFIER, FileColumns._MODIFIER_MEDIA_SCAN); // Add owner package name to new insertions when package name is provided. if (op.build().isInsert() && !attrs.isDirectory() && mOwnerPackage != null) { op.withValue(MediaColumns.OWNER_PACKAGE_NAME, mOwnerPackage); @@ -651,6 +820,51 @@ public class ModernMediaScanner implements MediaScanner { return FileVisitResult.CONTINUE; } + private int mediaTypeFromMimeType( + File file, String mimeType, int defaultMediaType) { + if (mimeType != null) { + return resolveMediaTypeFromFilePath( + file, mimeType, /*isHidden*/ mHiddenDirCount > 0); + } + return defaultMediaType; + } + + private boolean hasSameMetadata( + BasicFileAttributes attrs, File realFile, boolean isPendingFromFuse, Cursor c) { + final long dateModified = c.getLong(1); + final boolean sameTime = (lastModifiedTime(realFile, attrs) == dateModified); + + final long size = c.getLong(2); + final boolean sameSize = (attrs.size() == size); + + final boolean isScanned = + c.getInt(6) == FileColumns._MODIFIER_MEDIA_SCAN; + + return sameTime && sameSize && !isPendingFromFuse && isScanned; + } + + /** + * For this one very narrow case, we allow mime types to be customised when the top levels + * differ. This opens the given file, so avoid calling unless really necessary. This + * returns the defaultMimeType for non-m4a files or if opening the file throws an exception. + */ + private String updateM4aMimeType(File file, String defaultMimeType) { + if ("video/mp4".equalsIgnoreCase(defaultMimeType)) { + try ( + FileInputStream is = new FileInputStream(file); + MediaMetadataRetriever mmr = new MediaMetadataRetriever()) { + mmr.setDataSource(is.getFD()); + String refinedMimeType = mmr.extractMetadata(METADATA_KEY_MIMETYPE); + if ("audio/mp4".equalsIgnoreCase(refinedMimeType)) { + return refinedMimeType; + } + } catch (Exception e) { + return defaultMimeType; + } + } + return defaultMimeType; + } + @Override public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { @@ -673,6 +887,15 @@ public class ModernMediaScanner implements MediaScanner { // allow other parallel scans to proceed releaseDirectoryLock(dir); + if (mIsDirectoryTreeDirty) { + synchronized (mPendingCleanDirectories) { + if (mPendingCleanDirectories.remove(dir.toFile().getPath())) { + // If |dir| is still clean, then persist + FileUtils.setDirectoryDirty(dir.toFile(), false /* isDirty */); + mIsDirectoryTreeDirty = false; + } + } + } return FileVisitResult.CONTINUE; } @@ -895,7 +1118,16 @@ public class ModernMediaScanner implements MediaScanner { op.withValue(MediaColumns.DOCUMENT_ID, xmp.getDocumentId()); op.withValue(MediaColumns.INSTANCE_ID, xmp.getInstanceId()); op.withValue(MediaColumns.ORIGINAL_DOCUMENT_ID, xmp.getOriginalDocumentId()); - op.withValue(MediaColumns.XMP, xmp.getRedactedXmp()); + op.withValue(MediaColumns.XMP, maybeTruncateXmp(xmp)); + } + + private static byte[] maybeTruncateXmp(XmpInterface xmp) { + byte[] redacted = xmp.getRedactedXmp(); + if (redacted.length > MAX_XMP_SIZE_BYTES) { + return new byte[0]; + } + + return redacted; } /** @@ -935,6 +1167,40 @@ public class ModernMediaScanner implements MediaScanner { } } + private static void withResolutionValues( + @NonNull ContentProviderOperation.Builder op, + @NonNull ExifInterface exif, @NonNull File file) { + final Optional<?> width = parseOptionalOrZero( + exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)); + final Optional<?> height = parseOptionalOrZero( + exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)); + final Optional<String> resolution = parseOptionalResolution(width, height); + if (resolution.isPresent()) { + withOptionalValue(op, MediaColumns.WIDTH, width); + withOptionalValue(op, MediaColumns.HEIGHT, height); + op.withValue(MediaColumns.RESOLUTION, resolution.get()); + } else { + withBitmapResolutionValues(op, file); + } + } + + private static void withBitmapResolutionValues( + @NonNull ContentProviderOperation.Builder op, + @NonNull File file) { + final BitmapFactory.Options bitmapOptions = new BitmapFactory.Options(); + bitmapOptions.inSampleSize = 1; + bitmapOptions.inJustDecodeBounds = true; + bitmapOptions.outWidth = 0; + bitmapOptions.outHeight = 0; + BitmapFactory.decodeFile(file.getAbsolutePath(), bitmapOptions); + + final Optional<?> width = parseOptionalOrZero(bitmapOptions.outWidth); + final Optional<?> height = parseOptionalOrZero(bitmapOptions.outHeight); + withOptionalValue(op, MediaColumns.WIDTH, width); + withOptionalValue(op, MediaColumns.HEIGHT, height); + withOptionalValue(op, MediaColumns.RESOLUTION, parseOptionalResolution(width, height)); + } + private static @NonNull ContentProviderOperation.Builder scanItemDirectory(long existingId, File file, BasicFileAttributes attrs, String mimeType, String volumeName) { final ContentProviderOperation.Builder op = newUpsert(volumeName, existingId); @@ -958,6 +1224,11 @@ public class ModernMediaScanner implements MediaScanner { sAudioTypes.put(Environment.DIRECTORY_PODCASTS, AudioColumns.IS_PODCAST); sAudioTypes.put(Environment.DIRECTORY_AUDIOBOOKS, AudioColumns.IS_AUDIOBOOK); sAudioTypes.put(Environment.DIRECTORY_MUSIC, AudioColumns.IS_MUSIC); + if (SdkLevel.isAtLeastS()) { + sAudioTypes.put(Environment.DIRECTORY_RECORDINGS, AudioColumns.IS_RECORDING); + } else { + sAudioTypes.put(FileUtils.DIRECTORY_RECORDINGS, AudioColumns.IS_RECORDING); + } } private static @NonNull ContentProviderOperation.Builder scanItemAudio(long existingId, @@ -968,6 +1239,7 @@ public class ModernMediaScanner implements MediaScanner { op.withValue(MediaColumns.ARTIST, UNKNOWN_STRING); op.withValue(MediaColumns.ALBUM, file.getParentFile().getName()); + op.withValue(AudioColumns.TRACK, null); final String lowPath = file.getAbsolutePath().toLowerCase(Locale.ROOT); boolean anyMatch = false; @@ -1045,6 +1317,7 @@ public class ModernMediaScanner implements MediaScanner { op.withValue(VideoColumns.COLOR_STANDARD, null); op.withValue(VideoColumns.COLOR_TRANSFER, null); op.withValue(VideoColumns.COLOR_RANGE, null); + op.withValue(FileColumns._VIDEO_CODEC_TYPE, null); try (FileInputStream is = new FileInputStream(file)) { try (MediaMetadataRetriever mmr = new MediaMetadataRetriever()) { @@ -1067,6 +1340,8 @@ public class ModernMediaScanner implements MediaScanner { parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_TRANSFER))); withOptionalValue(op, VideoColumns.COLOR_RANGE, parseOptional(mmr.extractMetadata(METADATA_KEY_COLOR_RANGE))); + withOptionalValue(op, FileColumns._VIDEO_CODEC_TYPE, + parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_CODEC_MIME_TYPE))); } // Also hunt around for XMP metadata @@ -1091,12 +1366,8 @@ public class ModernMediaScanner implements MediaScanner { try (FileInputStream is = new FileInputStream(file)) { final ExifInterface exif = new ExifInterface(is); - withOptionalValue(op, MediaColumns.WIDTH, - parseOptionalOrZero(exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH))); - withOptionalValue(op, MediaColumns.HEIGHT, - parseOptionalOrZero(exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH))); - withOptionalValue(op, MediaColumns.RESOLUTION, - parseOptionalResolution(exif)); + withResolutionValues(op, exif, file); + withOptionalValue(op, MediaColumns.DATE_TAKEN, parseOptionalDateTaken(exif, lastModifiedTime(file, attrs) * 1000)); withOptionalValue(op, MediaColumns.ORIENTATION, @@ -1256,11 +1527,7 @@ public class ModernMediaScanner implements MediaScanner { @NonNull MediaMetadataRetriever mmr) { final Optional<?> width = parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_WIDTH)); final Optional<?> height = parseOptional(mmr.extractMetadata(METADATA_KEY_VIDEO_HEIGHT)); - if (width.isPresent() && height.isPresent()) { - return Optional.of(width.get() + "\u00d7" + height.get()); - } else { - return Optional.empty(); - } + return parseOptionalResolution(width, height); } @VisibleForTesting @@ -1268,11 +1535,7 @@ public class ModernMediaScanner implements MediaScanner { @NonNull MediaMetadataRetriever mmr) { final Optional<?> width = parseOptional(mmr.extractMetadata(METADATA_KEY_IMAGE_WIDTH)); final Optional<?> height = parseOptional(mmr.extractMetadata(METADATA_KEY_IMAGE_HEIGHT)); - if (width.isPresent() && height.isPresent()) { - return Optional.of(width.get() + "\u00d7" + height.get()); - } else { - return Optional.empty(); - } + return parseOptionalResolution(width, height); } @VisibleForTesting @@ -1282,26 +1545,46 @@ public class ModernMediaScanner implements MediaScanner { exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)); final Optional<?> height = parseOptionalOrZero( exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)); + return parseOptionalResolution(width, height); + } + + private static @NonNull Optional<String> parseOptionalResolution( + @NonNull Optional<?> width, @NonNull Optional<?> height) { if (width.isPresent() && height.isPresent()) { return Optional.of(width.get() + "\u00d7" + height.get()); - } else { - return Optional.empty(); } + return Optional.empty(); } @VisibleForTesting static @NonNull Optional<Long> parseOptionalDate(@Nullable String date) { if (TextUtils.isEmpty(date)) return Optional.empty(); try { - synchronized (sDateFormat) { - final long value = sDateFormat.parse(date).getTime(); - return (value > 0) ? Optional.of(value) : Optional.empty(); + synchronized (S_DATE_FORMAT_WITH_MILLIS) { + return parseDateWithFormat(date, S_DATE_FORMAT_WITH_MILLIS); } } catch (ParseException e) { + // Log and try without millis as well + Log.d(TAG, String.format( + "Parsing date with millis failed for [%s]. We will retry without millis", + date)); + } + try { + synchronized (S_DATE_FORMAT) { + return parseDateWithFormat(date, S_DATE_FORMAT); + } + } catch (ParseException e) { + Log.d(TAG, String.format("Parsing date without millis failed for [%s]", date)); return Optional.empty(); } } + private static Optional<Long> parseDateWithFormat( + @Nullable String date, SimpleDateFormat dateFormat) throws ParseException { + final long value = dateFormat.parse(date).getTime(); + return (value > 0) ? Optional.of(value) : Optional.empty(); + } + @VisibleForTesting static @NonNull Optional<Integer> parseOptionalYear(@Nullable String value) { final Optional<String> parsedValue = parseOptional(value); @@ -1347,12 +1630,6 @@ public class ModernMediaScanner implements MediaScanner { if (fileMimeType.regionMatches(true, 0, refinedMimeType, 0, refinedSplit + 1)) { return Optional.of(refinedMimeType); - } else if ("video/mp4".equalsIgnoreCase(fileMimeType) - && "audio/mp4".equalsIgnoreCase(refinedMimeType)) { - // We normally only allow MIME types to be customized when the - // top-level type agrees, but this one very narrow case is added to - // support a music service that was writing "m4a" files as "mp4". - return Optional.of(refinedMimeType); } else { return Optional.empty(); } diff --git a/src/com/android/providers/media/scan/NullMediaScanner.java b/src/com/android/providers/media/scan/NullMediaScanner.java index 3dfe4a24..7a1a3961 100644 --- a/src/com/android/providers/media/scan/NullMediaScanner.java +++ b/src/com/android/providers/media/scan/NullMediaScanner.java @@ -23,6 +23,8 @@ import android.util.Log; import androidx.annotation.Nullable; +import com.android.providers.media.MediaVolume; + import java.io.File; /** @@ -61,7 +63,17 @@ public class NullMediaScanner implements MediaScanner { } @Override - public void onDetachVolume(String volumeName) { + public void onDetachVolume(MediaVolume volume) { + // Ignored + } + + @Override + public void onIdleScanStopped() { + // Ignored + } + + @Override + public void onDirectoryDirty(File file) { // Ignored } } diff --git a/src/com/android/providers/media/util/ExifUtils.java b/src/com/android/providers/media/util/ExifUtils.java index 7593ec8a..f3a23eb1 100644 --- a/src/com/android/providers/media/util/ExifUtils.java +++ b/src/com/android/providers/media/util/ExifUtils.java @@ -34,6 +34,7 @@ import android.media.ExifInterface; import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; import java.text.ParsePosition; import java.text.SimpleDateFormat; @@ -145,19 +146,26 @@ public class ExifUtils { long msecs = datetime.getTime(); if (subSecs != null) { - try { - long sub = Long.parseLong(subSecs); - while (sub > 1000) { - sub /= 10; - } - msecs += sub; - } catch (NumberFormatException e) { - // Ignored - } + msecs += parseSubSeconds(subSecs); } return msecs; } catch (IllegalArgumentException e) { return -1; } } + + @VisibleForTesting + static @CurrentTimeMillisLong long parseSubSeconds(@NonNull String subSec) { + try { + final int len = Math.min(subSec.length(), 3); + long sub = Long.parseLong(subSec.substring(0, len)); + for (int i = len; i < 3; i++) { + sub *= 10; + } + return sub; + } catch (NumberFormatException e) { + // Ignored + } + return 0L; + } } diff --git a/src/com/android/providers/media/util/FileUtils.java b/src/com/android/providers/media/util/FileUtils.java index 47c5bc14..faa80b62 100644 --- a/src/com/android/providers/media/util/FileUtils.java +++ b/src/com/android/providers/media/util/FileUtils.java @@ -45,10 +45,15 @@ import static com.android.providers.media.util.Logging.TAG; import android.content.ClipDescription; import android.content.ContentValues; import android.content.Context; +import android.content.pm.PackageManager; import android.net.Uri; +import android.os.Build; import android.os.Environment; import android.os.ParcelFileDescriptor; +import android.os.UserHandle; +import android.os.SystemProperties; import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; import android.provider.MediaStore; import android.provider.MediaStore.MediaColumns; import android.system.ErrnoException; @@ -63,6 +68,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import com.android.modules.utils.build.SdkLevel; + import java.io.File; import java.io.FileDescriptor; import java.io.FileNotFoundException; @@ -89,6 +96,11 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; public class FileUtils { + // Even though vfat allows 255 UCS-2 chars, we might eventually write to + // ext4 through a FUSE layer, so use that limit. + @VisibleForTesting + static final int MAX_FILENAME_BYTES = 255; + /** * Drop-in replacement for {@link ParcelFileDescriptor#open(File, int)} * which adds security features like {@link OsConstants#O_CLOEXEC} and @@ -386,18 +398,31 @@ public class FileUtils { } } + private static final int MAX_READ_STRING_SIZE = 4096; + /** * Read given {@link File} as a single {@link String}. Returns - * {@link Optional#empty()} when the file doesn't exist. + * {@link Optional#empty()} when + * <ul> + * <li> the file doesn't exist or + * <li> the size of the file exceeds {@code MAX_READ_STRING_SIZE} + * </ul> */ public static @NonNull Optional<String> readString(@NonNull File file) throws IOException { try { - final String value = new String(Files.readAllBytes(file.toPath()), - StandardCharsets.UTF_8); - return Optional.of(value); - } catch (NoSuchFileException e) { - return Optional.empty(); - } + if (file.length() <= MAX_READ_STRING_SIZE) { + final String value = new String(Files.readAllBytes(file.toPath()), + StandardCharsets.UTF_8); + return Optional.of(value); + } + // When file size exceeds MAX_READ_STRING_SIZE, file is either + // corrupted or doesn't the contain expected data. Hence we return + // Optional.empty() which will be interpreted as empty file. + Logging.logPersistent(String.format("Ignored reading %s, file size exceeds %d", file, + MAX_READ_STRING_SIZE)); + } catch (NoSuchFileException ignored) { + } + return Optional.empty(); } /** @@ -507,9 +532,8 @@ public class FileUtils { res.append('_'); } } - // Even though vfat allows 255 UCS-2 chars, we might eventually write to - // ext4 through a FUSE layer, so use that limit. - trimFilename(res, 255); + + trimFilename(res, MAX_FILENAME_BYTES); return res.toString(); } @@ -573,7 +597,7 @@ public class FileUtils { int i = Integer.parseInt(dcfStrict.group(2)); @Override public String next() { - final String res = String.format("%s%04d", prefix, i); + final String res = String.format(Locale.US, "%s%04d", prefix, i); i++; return res; } @@ -589,11 +613,14 @@ public class FileUtils { // Generate names like "IMG_20190102_030405~2" final String prefix = dcfRelaxed.group(1); return new Iterator<String>() { - int i = TextUtils.isEmpty(dcfRelaxed.group(2)) ? 1 + int i = TextUtils.isEmpty(dcfRelaxed.group(2)) + ? 1 : Integer.parseInt(dcfRelaxed.group(2)); @Override public String next() { - final String res = (i == 1) ? prefix : String.format("%s~%d", prefix, i); + final String res = (i == 1) + ? prefix + : String.format(Locale.US, "%s~%d", prefix, i); i++; return res; } @@ -807,8 +834,15 @@ public class FileUtils { } final Uri uri = MediaStore.Files.getContentUri(volumeName); - final File path = context.getSystemService(StorageManager.class).getStorageVolume(uri) - .getDirectory(); + File path = null; + + try { + path = context.getSystemService(StorageManager.class).getStorageVolume(uri) + .getDirectory(); + } catch (IllegalStateException e) { + Log.w("Ignoring volume not found exception", e); + } + if (path != null) { return path; } else { @@ -829,21 +863,49 @@ public class FileUtils { } /** + * Return StorageVolume corresponding to the file on Path + */ + public static @NonNull StorageVolume getStorageVolume(@NonNull Context context, + @NonNull File path) throws FileNotFoundException { + int userId = extractUserId(path.getPath()); + Context userContext = context; + if (userId >= 0 && (context.getUser().getIdentifier() != userId)) { + // This volume is for a different user than our context, create a context + // for that user to retrieve the correct volume. + try { + userContext = context.createPackageContextAsUser("system", 0, + UserHandle.of(userId)); + } catch (PackageManager.NameNotFoundException e) { + throw new FileNotFoundException("Can't get package context for user " + userId); + } + } + + StorageVolume volume = userContext.getSystemService(StorageManager.class) + .getStorageVolume(path); + if (volume == null) { + throw new FileNotFoundException("Can't find volume for " + path.getPath()); + } + + return volume; + } + + /** * Return volume name which hosts the given path. */ - public static @NonNull String getVolumeName(@NonNull Context context, @NonNull File path) { + public static @NonNull String getVolumeName(@NonNull Context context, @NonNull File path) + throws FileNotFoundException { if (contains(Environment.getStorageDirectory(), path)) { - return context.getSystemService(StorageManager.class).getStorageVolume(path) - .getMediaStoreVolumeName(); + StorageVolume volume = getStorageVolume(context, path); + return volume.getMediaStoreVolumeName(); } else { return MediaStore.VOLUME_INTERNAL; } } public static final Pattern PATTERN_DOWNLOADS_FILE = Pattern.compile( - "(?i)^/storage/[^/]+/(?:[0-9]+/)?(?:Android/sandbox/[^/]+/)?Download/.+"); + "(?i)^/storage/[^/]+/(?:[0-9]+/)?Download/.+"); public static final Pattern PATTERN_DOWNLOADS_DIRECTORY = Pattern.compile( - "(?i)^/storage/[^/]+/(?:[0-9]+/)?(?:Android/sandbox/[^/]+/)?Download/?"); + "(?i)^/storage/[^/]+/(?:[0-9]+/)?Download/?"); public static final Pattern PATTERN_EXPIRES_FILE = Pattern.compile( "(?i)^\\.(pending|trashed)-(\\d+)-([^/]+)$"); public static final Pattern PATTERN_PENDING_FILEPATH_FOR_SQL = Pattern.compile( @@ -871,6 +933,12 @@ public class FileUtils { */ public static final long DEFAULT_DURATION_TRASHED = 30 * DateUtils.DAY_IN_MILLIS; + /** + * Default duration that expired items should be extended in + * {@link #runIdleMaintenance}. + */ + public static final long DEFAULT_DURATION_EXTENDED = 7 * DateUtils.DAY_IN_MILLIS; + public static boolean isDownload(@NonNull String path) { return PATTERN_DOWNLOADS_FILE.matcher(path).matches(); } @@ -879,40 +947,87 @@ public class FileUtils { return PATTERN_DOWNLOADS_DIRECTORY.matcher(path).matches(); } + private static final boolean PROP_CROSS_USER_ALLOWED = + SystemProperties.getBoolean("external_storage.cross_user.enabled", false); + + private static final String PROP_CROSS_USER_ROOT = isCrossUserEnabled() + ? SystemProperties.get("external_storage.cross_user.root", "") : ""; + + private static final String PROP_CROSS_USER_ROOT_PATTERN = ((PROP_CROSS_USER_ROOT.isEmpty()) + ? "" : "(?:" + PROP_CROSS_USER_ROOT + "/)?"); + /** * Regex that matches paths in all well-known package-specific directories, * and which captures the package name as the first group. */ public static final Pattern PATTERN_OWNED_PATH = Pattern.compile( - "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(?:data|media|obb|sandbox)/([^/]+)(/?.*)?"); + "(?i)^/storage/[^/]+/(?:[0-9]+/)?" + + PROP_CROSS_USER_ROOT_PATTERN + + "Android/(?:data|media|obb)/([^/]+)(/?.*)?"); /** * Regex that matches Android/obb or Android/data path. */ public static final Pattern PATTERN_DATA_OR_OBB_PATH = Pattern.compile( - "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(?:data|obb)/?$"); + "(?i)^/storage/[^/]+/(?:[0-9]+/)?" + + PROP_CROSS_USER_ROOT_PATTERN + + "Android/(?:data|obb)/?$"); + + /** + * Regex that matches Android/obb paths. + */ + public static final Pattern PATTERN_OBB_OR_CHILD_PATH = Pattern.compile( + "(?i)^/storage/[^/]+/(?:[0-9]+/)?" + + PROP_CROSS_USER_ROOT_PATTERN + + "Android/(?:obb)(/?.*)"); + + /** + * The recordings directory. This is used for R OS. For S OS or later, + * we use {@link Environment#DIRECTORY_RECORDINGS} directly. + */ + public static final String DIRECTORY_RECORDINGS = "Recordings"; @VisibleForTesting - public static final String[] DEFAULT_FOLDER_NAMES = { - 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, - Environment.DIRECTORY_DOCUMENTS, - Environment.DIRECTORY_AUDIOBOOKS, - }; + public static final String[] DEFAULT_FOLDER_NAMES; + static { + if (SdkLevel.isAtLeastS()) { + DEFAULT_FOLDER_NAMES = new String[]{ + 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, + Environment.DIRECTORY_DOCUMENTS, + Environment.DIRECTORY_AUDIOBOOKS, + Environment.DIRECTORY_RECORDINGS, + }; + } else { + DEFAULT_FOLDER_NAMES = new String[]{ + 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, + Environment.DIRECTORY_DOCUMENTS, + Environment.DIRECTORY_AUDIOBOOKS, + DIRECTORY_RECORDINGS, + }; + } + } /** - * Regex that matches paths for {@link MediaColumns#RELATIVE_PATH}; it - * captures both top-level paths and sandboxed paths. + * Regex that matches paths for {@link MediaColumns#RELATIVE_PATH} */ private static final Pattern PATTERN_RELATIVE_PATH = Pattern.compile( - "(?i)^/storage/(?:emulated/[0-9]+/|[^/]+/)(Android/sandbox/([^/]+)/)?"); + "(?i)^/storage/(?:emulated/[0-9]+/|[^/]+/)"); /** * Regex that matches paths under well-known storage paths. @@ -920,13 +1035,33 @@ public class FileUtils { private static final Pattern PATTERN_VOLUME_NAME = Pattern.compile( "(?i)^/storage/([^/]+)"); + /** + * Regex that matches user-ids under well-known storage paths. + */ + private static final Pattern PATTERN_USER_ID = Pattern.compile( + "(?i)^/storage/emulated/([0-9]+)"); + private static final String CAMERA_RELATIVE_PATH = String.format("%s/%s/", Environment.DIRECTORY_DCIM, "Camera"); + public static boolean isCrossUserEnabled() { + return PROP_CROSS_USER_ALLOWED || SdkLevel.isAtLeastS(); + } + private static @Nullable String normalizeUuid(@Nullable String fsUuid) { return fsUuid != null ? fsUuid.toLowerCase(Locale.ROOT) : null; } + public static int extractUserId(@Nullable String data) { + if (data == null) return -1; + final Matcher matcher = PATTERN_USER_ID.matcher(data); + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)); + } + + return -1; + } + public static @Nullable String extractVolumePath(@Nullable String data) { if (data == null) return null; final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data); @@ -1012,6 +1147,21 @@ public class FileUtils { } } + public static boolean isExternalMediaDirectory(@NonNull String path) { + return isExternalMediaDirectory(path, PROP_CROSS_USER_ROOT); + } + + @VisibleForTesting + static boolean isExternalMediaDirectory(@NonNull String path, String crossUserRoot) { + final String relativePath = extractRelativePath(path); + if (relativePath != null) { + final String externalMediaDir = (crossUserRoot == null || crossUserRoot.isEmpty()) + ? "Android/media" : crossUserRoot + "/Android/media"; + return relativePath.startsWith(externalMediaDir); + } + return false; + } + /** * Returns true if relative path is Android/data or Android/obb path. */ @@ -1022,6 +1172,15 @@ public class FileUtils { } /** + * Returns true if relative path is Android/obb path. + */ + public static boolean isObbOrChildPath(String path) { + if (path == null) return false; + final Matcher m = PATTERN_OBB_OR_CHILD_PATH.matcher(path); + return m.matches(); + } + + /** * Returns the name of the top level directory, or null if the path doesn't go through the * external storage directory. */ @@ -1031,8 +1190,26 @@ public class FileUtils { if (relativePath == null) { return null; } - final String[] relativePathSegments = relativePath.split("/"); - return relativePathSegments.length > 0 ? relativePathSegments[0] : null; + + return extractTopLevelDir(relativePath.split("/")); + } + + @Nullable + public static String extractTopLevelDir(String[] relativePathSegments) { + return extractTopLevelDir(relativePathSegments, PROP_CROSS_USER_ROOT); + } + + @VisibleForTesting + @Nullable + static String extractTopLevelDir(String[] relativePathSegments, String crossUserRoot) { + if (relativePathSegments == null) return null; + + final String topLevelDir = relativePathSegments.length > 0 ? relativePathSegments[0] : null; + if (crossUserRoot != null && crossUserRoot.equals(topLevelDir)) { + return relativePathSegments.length > 1 ? relativePathSegments[1] : null; + } + + return topLevelDir; } public static boolean isDefaultDirectoryName(@Nullable String dirName) { @@ -1146,13 +1323,21 @@ public class FileUtils { if (!isForFuse && getAsBoolean(values, MediaColumns.IS_PENDING, false)) { final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES, (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000); - resolvedDisplayName = String.format(".%s-%d-%s", - FileUtils.PREFIX_PENDING, dateExpires, displayName); + final String combinedString = String.format( + Locale.US, ".%s-%d-%s", FileUtils.PREFIX_PENDING, dateExpires, displayName); + // trim the file name to avoid ENAMETOOLONG error + // after trim the file, if the user unpending the file, + // the file name is not the original one + resolvedDisplayName = trimFilename(combinedString, MAX_FILENAME_BYTES); } else if (getAsBoolean(values, MediaColumns.IS_TRASHED, false)) { final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES, (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000); - resolvedDisplayName = String.format(".%s-%d-%s", - FileUtils.PREFIX_TRASHED, dateExpires, displayName); + final String combinedString = String.format( + Locale.US, ".%s-%d-%s", FileUtils.PREFIX_TRASHED, dateExpires, displayName); + // trim the file name to avoid ENAMETOOLONG error + // after trim the file, if the user untrashes the file, + // the file name is not the original one + resolvedDisplayName = trimFilename(combinedString, MAX_FILENAME_BYTES); } else { resolvedDisplayName = displayName; } @@ -1264,12 +1449,42 @@ public class FileUtils { return false; } + if (isScreenshotsDirNonHidden(relativePath, name)) { + nomedia.delete(); + return false; + } + // .nomedia is present which makes this directory as hidden directory Logging.logPersistent("Observed non-standard " + nomedia); return true; } /** + * Consider Screenshots directory in root directory or inside well-known directory as always + * non-hidden. Nomedia file in these directories will not be able to hide these directories. + * i.e., some examples of directories that will be considered non-hidden are + * <ul> + * <li> /storage/emulated/0/Screenshots or + * <li> /storage/emulated/0/DCIM/Screenshots or + * <li> /storage/emulated/0/Pictures/Screenshots ... + * </ul> + * Some examples of directories that can be considered as hidden with nomedia are + * <ul> + * <li> /storage/emulated/0/foo/Screenshots or + * <li> /storage/emulated/0/DCIM/Foo/Screenshots or + * <li> /storage/emulated/0/Pictures/foo/bar/Screenshots ... + * </ul> + */ + private static boolean isScreenshotsDirNonHidden(@NonNull String[] relativePath, + @NonNull String name) { + if (name.equalsIgnoreCase(Environment.DIRECTORY_SCREENSHOTS)) { + return (relativePath.length == 1 && + (TextUtils.isEmpty(relativePath[0]) || isDefaultDirectoryName(relativePath[0]))); + } + return false; + } + + /** * Test if this given file should be considered hidden. */ @VisibleForTesting @@ -1322,4 +1537,85 @@ public class FileUtils { } return status; } + + /** + * @return {@code true} if {@code dir} is dirty and should be scanned, {@code false} otherwise. + */ + public static boolean isDirectoryDirty(File dir) { + File nomedia = new File(dir, ".nomedia"); + if (nomedia.exists()) { + try { + Optional<String> expectedPath = readString(nomedia); + // Returns true If .nomedia file is empty or content doesn't match |dir| + // Returns false otherwise + return !expectedPath.isPresent() + || !expectedPath.get().equals(dir.getPath()); + } catch (IOException e) { + Log.w(TAG, "Failed to read directory dirty" + dir); + } + } + return true; + } + + /** + * {@code isDirty} == {@code true} will force {@code dir} scanning even if it's hidden + * {@code isDirty} == {@code false} will skip {@code dir} scanning on next scan. + */ + public static void setDirectoryDirty(File dir, boolean isDirty) { + File nomedia = new File(dir, ".nomedia"); + if (nomedia.exists()) { + try { + writeString(nomedia, isDirty ? Optional.of("") : Optional.of(dir.getPath())); + } catch (IOException e) { + Log.w(TAG, "Failed to change directory dirty: " + dir + ". isDirty: " + isDirty); + } + } + } + + /** + * @return the folder containing the top-most .nomedia in {@code file} hierarchy. + * E.g input as /sdcard/foo/bar/ will return /sdcard/foo + * even if foo and bar contain .nomedia files. + * + * Returns {@code null} if there's no .nomedia in hierarchy + */ + public static File getTopLevelNoMedia(@NonNull File file) { + File topNoMediaDir = null; + + File parent = file; + while (parent != null) { + File nomedia = new File(parent, ".nomedia"); + if (nomedia.exists()) { + topNoMediaDir = parent; + } + parent = parent.getParentFile(); + } + + return topNoMediaDir; + } + + /** + * Generate the extended absolute path from the expired file path + * E.g. the input expiredFilePath is /storage/emulated/0/DCIM/.trashed-1621147340-test.jpg + * The returned result is /storage/emulated/0/DCIM/.trashed-1888888888-test.jpg + * + * @hide + */ + @Nullable + public static String getAbsoluteExtendedPath(@NonNull String expiredFilePath, + long extendedTime) { + final String displayName = extractDisplayName(expiredFilePath); + + final Matcher matcher = PATTERN_EXPIRES_FILE.matcher(displayName); + if (matcher.matches()) { + final String newDisplayName = String.format(Locale.US, ".%s-%d-%s", matcher.group(1), + extendedTime, matcher.group(3)); + final int lastSlash = expiredFilePath.lastIndexOf('/'); + final String newPath = expiredFilePath.substring(0, lastSlash + 1).concat( + newDisplayName); + return newPath; + } + + return null; + } } diff --git a/src/com/android/providers/media/util/IsoInterface.java b/src/com/android/providers/media/util/IsoInterface.java index 5de4b6bc..03b46c96 100644 --- a/src/com/android/providers/media/util/IsoInterface.java +++ b/src/com/android/providers/media/util/IsoInterface.java @@ -177,14 +177,25 @@ public class IsoInterface { return null; } - box.data = new byte[(int) (len - box.headerSize)]; + try { + box.data = new byte[(int) (len - box.headerSize)]; + } catch (OutOfMemoryError e) { + Log.w(TAG, "Couldn't read large uuid box", e); + return null; + } Os.read(fd, box.data, 0, box.data.length); } else if (type == BOX_XMP) { if (len > Integer.MAX_VALUE) { Log.w(TAG, "Skipping abnormally large xmp box"); return null; } - box.data = new byte[(int) (len - box.headerSize)]; + + try { + box.data = new byte[(int) (len - box.headerSize)]; + } catch (OutOfMemoryError e) { + Log.w(TAG, "Couldn't read large xmp box", e); + return null; + } Os.read(fd, box.data, 0, box.data.length); } else if (type == BOX_META && len != headerSize) { // The format of this differs in ISO and QT encoding: diff --git a/src/com/android/providers/media/util/Logging.java b/src/com/android/providers/media/util/Logging.java index f62c361d..ff69e5c4 100644 --- a/src/com/android/providers/media/util/Logging.java +++ b/src/com/android/providers/media/util/Logging.java @@ -23,7 +23,9 @@ import android.os.SystemProperties; import android.text.format.DateUtils; import android.util.Log; +import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.io.File; import java.io.IOException; @@ -50,16 +52,26 @@ public class Logging { private static final int PERSISTENT_SIZE = 32 * 1024; private static final int PERSISTENT_COUNT = 4; private static final long PERSISTENT_AGE = DateUtils.WEEK_IN_MILLIS; + private static final SimpleDateFormat DATE_FORMAT = + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + private static final Object LOCK = new Object(); + @GuardedBy("LOCK") private static Path sPersistentDir; - private static SimpleDateFormat sDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + @GuardedBy("LOCK") + private static Path sPersistentFile; + @GuardedBy("LOCK") + private static Writer sWriter; /** * Initialize persistent logging which is then available through * {@link #logPersistent(String)} and {@link #dumpPersistent(PrintWriter)}. */ public static void initPersistent(@NonNull File persistentDir) { - sPersistentDir = persistentDir.toPath(); + synchronized (LOCK) { + sPersistentDir = persistentDir.toPath(); + closeWriterAndUpdatePathLocked(null); + } } /** @@ -68,28 +80,68 @@ public class Logging { public static void logPersistent(@NonNull String msg) { Log.i(TAG, msg); - if (sPersistentDir == null) return; - try (Writer w = Files.newBufferedWriter(resolveCurrentPersistentFile(), CREATE, APPEND)) { - w.write(sDateFormat.format(new Date()) + " " + msg + "\n"); - } catch (IOException e) { - Log.w(TAG, "Failed to persist: " + e); + synchronized (LOCK) { + if (sPersistentDir == null) return; + + try { + Path path = resolveCurrentPersistentFileLocked(); + if (!path.equals(sPersistentFile)) { + closeWriterAndUpdatePathLocked(path); + } + + if (sWriter == null) { + sWriter = Files.newBufferedWriter(path, CREATE, APPEND); + } + + sWriter.write(DATE_FORMAT.format(new Date()) + " " + msg + "\n"); + // Flush to guarantee that all our writes have been sent to the filesystem + sWriter.flush(); + } catch (IOException e) { + closeWriterAndUpdatePathLocked(null); + Log.w(TAG, "Failed to write: " + sPersistentFile, e); + } + } + } + + @GuardedBy("LOCK") + private static void closeWriterAndUpdatePathLocked(@Nullable Path newPath) { + if (sWriter != null) { + try { + sWriter.close(); + } catch (IOException ignored) { + Log.w(TAG, "Failed to close: " + sPersistentFile, ignored); + } + sWriter = null; } + sPersistentFile = newPath; } /** * Trim any persistent logs, typically called during idle maintenance. */ public static void trimPersistent() { - if (sPersistentDir == null) return; - FileUtils.deleteOlderFiles(sPersistentDir.toFile(), PERSISTENT_COUNT, PERSISTENT_AGE); + File persistentDir = null; + synchronized (LOCK) { + if (sPersistentDir == null) return; + persistentDir = sPersistentDir.toFile(); + + closeWriterAndUpdatePathLocked(sPersistentFile); + } + + FileUtils.deleteOlderFiles(persistentDir, PERSISTENT_COUNT, PERSISTENT_AGE); } /** * Dump any persistent logs. */ public static void dumpPersistent(@NonNull PrintWriter pw) { - if (sPersistentDir == null) return; - try (Stream<Path> stream = Files.list(sPersistentDir)) { + Path persistentDir = null; + synchronized (LOCK) { + if (sPersistentDir == null) return; + persistentDir = sPersistentDir; + } + + try (Stream<Path> stream = Files.list(persistentDir)) { stream.sorted().forEach((path) -> { dumpPersistentFile(path, pw); }); @@ -117,14 +169,12 @@ public class Logging { * starts new files when the current file is larger than * {@link #PERSISTENT_SIZE}. */ - private static @NonNull Path resolveCurrentPersistentFile() throws IOException { - try (Stream<Path> stream = Files.list(sPersistentDir)) { - Optional<Path> latest = stream.max(Comparator.naturalOrder()); - if (latest.isPresent() && latest.get().toFile().length() < PERSISTENT_SIZE) { - return latest.get(); - } else { - return sPersistentDir.resolve(String.valueOf(System.currentTimeMillis())); - } + @GuardedBy("LOCK") + private static @NonNull Path resolveCurrentPersistentFileLocked() throws IOException { + if (sPersistentFile != null && sPersistentFile.toFile().length() < PERSISTENT_SIZE) { + return sPersistentFile; } + + return sPersistentDir.resolve(String.valueOf(System.currentTimeMillis())); } } diff --git a/src/com/android/providers/media/util/Metrics.java b/src/com/android/providers/media/util/Metrics.java index 86a03023..024151db 100644 --- a/src/com/android/providers/media/util/Metrics.java +++ b/src/com/android/providers/media/util/Metrics.java @@ -60,12 +60,30 @@ public class Metrics { normalizedInsertCount, normalizedUpdateCount, normalizedDeleteCount); } - public static void logDeletion(@NonNull String volumeName, int uid, String packageName, - int itemCount) { - Logging.logPersistent(String.format( - "Deleted %3$d items on %1$s due to %2$s", - volumeName, packageName, itemCount)); + /** + * Logs persistent deletion logs on-device. + */ + public static void logDeletionPersistent(@NonNull String volumeName, String reason, + int[] countPerMediaType) { + final StringBuilder builder = new StringBuilder("Deleted "); + for (int count: countPerMediaType) { + builder.append(count).append(' '); + } + builder.append("items on ") + .append(volumeName) + .append(" due to ") + .append(reason); + + Logging.logPersistent(builder.toString()); + } + /** + * Logs persistent deletion logs on-device and stats metrics. Count of items per-media-type + * are not uploaded to MediaProviderStats logs. + */ + public static void logDeletion(@NonNull String volumeName, int uid, String packageName, + int itemCount, int[] countPerMediaType) { + logDeletionPersistent(volumeName, packageName, countPerMediaType); MediaProviderStatsLog.write(MEDIA_CONTENT_DELETED, translateVolumeName(volumeName), uid, itemCount); } @@ -93,10 +111,10 @@ public class Metrics { } public static void logSchemaChange(@NonNull String volumeName, int versionFrom, int versionTo, - long itemCount, long durationMillis) { + long itemCount, long durationMillis, @NonNull String databaseUuid) { Logging.logPersistent(String.format( - "Changed schema version on %s from %d to %d, %d items taking %dms", - volumeName, versionFrom, versionTo, itemCount, durationMillis)); + "Changed schema version on %s from %d to %d, %d items taking %dms UUID %s", + volumeName, versionFrom, versionTo, itemCount, durationMillis, databaseUuid)); final float normalizedDurationMillis = ((float) durationMillis) / itemCount; diff --git a/src/com/android/providers/media/util/PermissionUtils.java b/src/com/android/providers/media/util/PermissionUtils.java index adbe0e21..5b3639c0 100644 --- a/src/com/android/providers/media/util/PermissionUtils.java +++ b/src/com/android/providers/media/util/PermissionUtils.java @@ -16,13 +16,19 @@ package com.android.providers.media.util; +import static android.Manifest.permission.ACCESS_MEDIA_LOCATION; +import static android.Manifest.permission.ACCESS_MTP; import static android.Manifest.permission.BACKUP; +import static android.Manifest.permission.INSTALL_PACKAGES; import static android.Manifest.permission.MANAGE_EXTERNAL_STORAGE; +import static android.Manifest.permission.MANAGE_MEDIA; import static android.Manifest.permission.READ_EXTERNAL_STORAGE; import static android.Manifest.permission.UPDATE_DEVICE_STATS; import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE; import static android.app.AppOpsManager.MODE_ALLOWED; +import static android.app.AppOpsManager.OPSTR_REQUEST_INSTALL_PACKAGES; import static android.app.AppOpsManager.OPSTR_LEGACY_STORAGE; +import static android.app.AppOpsManager.OPSTR_NO_ISOLATED_STORAGE; import static android.app.AppOpsManager.OPSTR_READ_MEDIA_AUDIO; import static android.app.AppOpsManager.OPSTR_READ_MEDIA_IMAGES; import static android.app.AppOpsManager.OPSTR_READ_MEDIA_VIDEO; @@ -42,8 +48,6 @@ import androidx.annotation.VisibleForTesting; public class PermissionUtils { - public static final String OPSTR_NO_ISOLATED_STORAGE = "android:no_isolated_storage"; - // Callers must hold both the old and new permissions, so that we can // handle obscure cases like when an app targets Q but was installed on // a device that was originally running on P before being upgraded to Q. @@ -76,13 +80,9 @@ public class PermissionUtils { */ public static boolean checkPermissionManager(@NonNull Context context, int pid, int uid, @NonNull String packageName, @Nullable String attributionTag) { - if (checkPermissionForDataDelivery(context, MANAGE_EXTERNAL_STORAGE, pid, uid, + return checkPermissionForDataDelivery(context, MANAGE_EXTERNAL_STORAGE, pid, uid, packageName, attributionTag, - generateAppOpMessage(packageName,sOpDescription.get()))) { - return true; - } - // Fallback to OPSTR_NO_ISOLATED_STORAGE app op. - return checkNoIsolatedStorageGranted(context, uid, packageName, attributionTag); + generateAppOpMessage(packageName,sOpDescription.get())); } /** @@ -116,10 +116,34 @@ public class PermissionUtils { generateAppOpMessage(packageName,sOpDescription.get())); } - public static boolean checkIsLegacyStorageGranted( - @NonNull Context context, int uid, String packageName) { - return context.getSystemService(AppOpsManager.class) - .unsafeCheckOp(OPSTR_LEGACY_STORAGE, uid, packageName) == MODE_ALLOWED; + /** + * Check if the given package has been granted the + * android.Manifest.permission#ACCESS_MEDIA_LOCATION permission. + */ + public static boolean checkPermissionAccessMediaLocation(@NonNull Context context, int pid, + int uid, @NonNull String packageName, @Nullable String attributionTag) { + return checkPermissionForDataDelivery(context, ACCESS_MEDIA_LOCATION, pid, uid, packageName, + attributionTag, generateAppOpMessage(packageName, sOpDescription.get())); + } + + /** + * Check if the given package has been granted the + * android.Manifest.permission#MANAGE_MEDIA permission. + */ + public static boolean checkPermissionManageMedia(@NonNull Context context, int pid, int uid, + @NonNull String packageName, @Nullable String attributionTag) { + return checkPermissionForDataDelivery(context, MANAGE_MEDIA, pid, uid, packageName, + attributionTag, generateAppOpMessage(packageName, sOpDescription.get())); + } + + public static boolean checkIsLegacyStorageGranted(@NonNull Context context, int uid, + String packageName, @Nullable String attributionTag) { + if (context.getSystemService(AppOpsManager.class) + .unsafeCheckOp(OPSTR_LEGACY_STORAGE, uid, packageName) == MODE_ALLOWED) { + return true; + } + // Check OPSTR_NO_ISOLATED_STORAGE app op. + return checkNoIsolatedStorageGranted(context, uid, packageName, attributionTag); } public static boolean checkPermissionReadAudio(@NonNull Context context, int pid, int uid, @@ -185,6 +209,18 @@ public class PermissionUtils { generateAppOpMessage(packageName, sOpDescription.get())); } + public static boolean checkPermissionInstallPackages(@NonNull Context context, int pid, int uid, + @NonNull String packageName, @Nullable String attributionTag) { + return checkPermissionForDataDelivery(context, INSTALL_PACKAGES, pid, + uid, packageName, attributionTag, null); + } + + public static boolean checkPermissionAccessMtp(@NonNull Context context, int pid, int uid, + @NonNull String packageName, @Nullable String attributionTag) { + return checkPermissionForDataDelivery(context, ACCESS_MTP, pid, + uid, packageName, attributionTag, null); + } + /** * Returns {@code true} if the given package has write images or write video app op, which * indicates the package is a system gallery. @@ -199,6 +235,20 @@ public class PermissionUtils { generateAppOpMessage(packageName, sOpDescription.get())); } + /** + * Returns {@code true} if any package for the given uid has request_install_packages app op. + */ + public static boolean checkAppOpRequestInstallPackagesForSharedUid(@NonNull Context context, + int uid, @NonNull String[] sharedPackageNames, @Nullable String attributionTag) { + for (String packageName : sharedPackageNames) { + if (checkAppOp(context, OPSTR_REQUEST_INSTALL_PACKAGES, uid, packageName, + attributionTag, generateAppOpMessage(packageName, sOpDescription.get()))) { + return true; + } + } + return false; + } + @VisibleForTesting static boolean checkNoIsolatedStorageGranted(@NonNull Context context, int uid, @NonNull String packageName, @Nullable String attributionTag) { @@ -379,6 +429,7 @@ public class PermissionUtils { private static boolean isAppOpPermission(String permission) { switch (permission) { case MANAGE_EXTERNAL_STORAGE: + case MANAGE_MEDIA: return true; } return false; @@ -386,6 +437,7 @@ public class PermissionUtils { private static boolean isRuntimePermission(String permission) { switch (permission) { + case ACCESS_MEDIA_LOCATION: case READ_EXTERNAL_STORAGE: case WRITE_EXTERNAL_STORAGE: return true; diff --git a/src/com/android/providers/media/util/SQLiteQueryBuilder.java b/src/com/android/providers/media/util/SQLiteQueryBuilder.java index e68cb800..cedd3538 100644 --- a/src/com/android/providers/media/util/SQLiteQueryBuilder.java +++ b/src/com/android/providers/media/util/SQLiteQueryBuilder.java @@ -45,6 +45,8 @@ import androidx.annotation.VisibleForTesting; import com.android.providers.media.DatabaseHelper; +import com.google.common.base.Strings; + import java.util.Arrays; import java.util.Collection; import java.util.Iterator; @@ -61,6 +63,9 @@ import java.util.regex.Pattern; * {@link SQLiteDatabase} objects. */ public class SQLiteQueryBuilder { + + public static final String ROWID_COLUMN = "rowid"; + private static final String TAG = "SQLiteQueryBuilder"; private static final Pattern sAggregationPattern = Pattern.compile( @@ -229,6 +234,14 @@ public class SQLiteQueryBuilder { } } + /** Adds {@code column} to the projection map. */ + public void allowColumn(String column) { + if (mProjectionMap == null) { + mProjectionMap = new ArrayMap<>(); + } + mProjectionMap.put(column, column); + } + /** * Gets the projection map for the query, as last configured by * {@link #setProjectionMap(Map)}. @@ -516,7 +529,7 @@ public class SQLiteQueryBuilder { if (isStrictGrammar()) { enforceStrictGrammar(selection, groupBy, having, sortOrder, limit); } - if (isStrict()) { + if (isStrict() && hasUserWhere(selection)) { // Validate the user-supplied selection to detect syntactic anomalies // in the selection string that could indicate a SQL injection attempt. // The idea is to ensure that the selection clause is a valid SQL expression @@ -636,7 +649,7 @@ public class SQLiteQueryBuilder { if (isStrictGrammar()) { enforceStrictGrammar(selection, null, null, null, null); } - if (isStrict()) { + if (isStrict() && hasUserWhere(selection)) { // Validate the user-supplied selection to detect syntactic anomalies // in the selection string that could indicate a SQL injection attempt. // The idea is to ensure that the selection clause is a valid SQL expression @@ -718,7 +731,7 @@ public class SQLiteQueryBuilder { if (isStrictGrammar()) { enforceStrictGrammar(selection, null, null, null, null); } - if (isStrict()) { + if (isStrict() && hasUserWhere(selection)) { // Validate the user-supplied selection to detect syntactic anomalies // in the selection string that could indicate a SQL injection attempt. // The idea is to ensure that the selection clause is a valid SQL expression @@ -753,14 +766,24 @@ public class SQLiteQueryBuilder { return com.android.providers.media.util.DatabaseUtils.executeUpdateDelete(db, sql, sqlArgs); } + private static boolean hasUserWhere(@Nullable String selection) { + return !Strings.isNullOrEmpty(selection); + } + private void enforceStrictColumns(@Nullable String[] projection) { Objects.requireNonNull(mProjectionMap, "No projection map defined"); + if (!isStrictColumns()) { + return; + } computeProjection(projection); } private void enforceStrictColumns(@NonNull ContentValues values) { Objects.requireNonNull(mProjectionMap, "No projection map defined"); + if (!isStrictColumns()) { + return; + } final ArrayMap<String, Object> rawValues = com.android.providers.media.util.DatabaseUtils .getValues(values); @@ -1026,7 +1049,10 @@ public class SQLiteQueryBuilder { if (column != null) { return column; } else { - throw new IllegalArgumentException("Invalid column " + userColumn); + if (isStrictColumns()) { + throw new IllegalArgumentException("Invalid column " + userColumn); + } + return userColumn; } } @@ -1122,7 +1148,13 @@ public class SQLiteQueryBuilder { } } - private static boolean shouldAppendRowId(ContentValues values) { - return !values.containsKey(MediaColumns._ID) && values.containsKey(MediaColumns.DATA); + @VisibleForTesting + boolean shouldAppendRowId(ContentValues values) { + // When no projectionMap provided, don't add the row + final boolean hasIdInProjectionMap = mProjectionMap != null && mProjectionMap.containsKey( + MediaColumns._ID) && TextUtils.equals(mProjectionMap.get(MediaColumns._ID), + MediaColumns._ID); + return !values.containsKey(MediaColumns._ID) && values.containsKey(MediaColumns.DATA) + && hasIdInProjectionMap; } } diff --git a/src/com/android/providers/media/util/UserCache.java b/src/com/android/providers/media/util/UserCache.java new file mode 100644 index 00000000..4b7dec9e --- /dev/null +++ b/src/com/android/providers/media/util/UserCache.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2021 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.util; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Process; +import android.os.UserHandle; +import android.os.UserManager; +import android.util.LongSparseArray; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; + +import com.android.modules.utils.build.SdkLevel; + +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.List; + +/** + * UserCache is a class that keeps track of all users that the current MediaProvider + * instance is responsible for. By default, it handles storage for the user it is running as, + * but as of Android API 31, it will also handle storage for profiles that share media + * with their parent - profiles for which @link{UserManager#isMediaSharedWithParent} is set. + * + * It also keeps a cache of user contexts, for improving these lookups. + * + * Note that we don't use the USER_ broadcasts for keeping this state up to date, because they + * aren't guaranteed to be received before the volume events for a user. + */ +public class UserCache { + final Object mLock = new Object(); + final Context mContext; + final UserManager mUserManager; + + @GuardedBy("mLock") + final LongSparseArray<Context> mUserContexts = new LongSparseArray<>(); + + @GuardedBy("mLock") + final ArrayList<UserHandle> mUsers = new ArrayList<>(); + + public UserCache(Context context) { + mContext = context; + mUserManager = context.getSystemService(UserManager.class); + + update(); + } + + private void update() { + List<UserHandle> profiles = mUserManager.getEnabledProfiles(); + synchronized (mLock) { + mUsers.clear(); + // Add the user we're running as by default + mUsers.add(Process.myUserHandle()); + if (!SdkLevel.isAtLeastS()) { + // Before S, we only handle the owner user + return; + } + // And find all profiles that share media with us + for (UserHandle profile : profiles) { + if (!profile.equals(mContext.getUser())) { + // Check if it's a profile that shares media with us + Context userContext = getContextForUser(profile); + if (userContext.getSystemService(UserManager.class).isMediaSharedWithParent()) { + mUsers.add(profile); + } + } + } + } + } + + public @NonNull List<UserHandle> updateAndGetUsers() { + update(); + synchronized (mLock) { + return (List<UserHandle>) mUsers.clone(); + } + } + + public @NonNull List<UserHandle> getUsersCached() { + synchronized (mLock) { + return (List<UserHandle>) mUsers.clone(); + } + } + + public @NonNull Context getContextForUser(@NonNull UserHandle user) { + Context userContext; + synchronized (mLock) { + userContext = mUserContexts.get(user.getIdentifier()); + if (userContext != null) { + return userContext; + } + } + try { + userContext = mContext.createPackageContextAsUser("system", 0, user); + synchronized (mLock) { + mUserContexts.put(user.getIdentifier(), userContext); + } + return userContext; + } catch (PackageManager.NameNotFoundException e) { + throw new RuntimeException("Failed to create context for user " + user, e); + } + } + + /** + * Returns whether the passed in user shares media with its parent (or peer). + * + * @param user user to check + * @return whether the user shares media with its parent + */ + public boolean userSharesMediaWithParent(@NonNull UserHandle user) { + if (Process.myUserHandle().equals(user)) { + // Early return path - the owner user doesn't have a parent + return false; + } + boolean found = userSharesMediaWithParentCached(user); + if (!found) { + // Update the cache and try again + update(); + found = userSharesMediaWithParentCached(user); + } + return found; + } + + /** + * Returns whether the passed in user shares media with its parent (or peer). + * Note that the value returned here is based on cached data; it relies on + * other callers to keep the user cache up-to-date. + * + * @param user user to check + * @return whether the user shares media with its parent + */ + public boolean userSharesMediaWithParentCached(@NonNull UserHandle user) { + synchronized (mLock) { + // It must be a user that we manage, and not equal to the main user that we run as + return !Process.myUserHandle().equals(user) && mUsers.contains(user); + } + } + + public void dump(PrintWriter writer) { + writer.println("User cache state:"); + synchronized (mLock) { + for (UserHandle user : mUsers) { + writer.println(" user: " + user); + } + } + } +} diff --git a/src/com/android/providers/media/util/XmpInterface.java b/src/com/android/providers/media/util/XmpInterface.java index 1a316e7f..f5ead468 100644 --- a/src/com/android/providers/media/util/XmpInterface.java +++ b/src/com/android/providers/media/util/XmpInterface.java @@ -65,7 +65,7 @@ public class XmpInterface { private static final String NAME_INSTANCE_ID = "InstanceID"; private final LongArray mRedactedRanges = new LongArray(); - private byte[] mRedactedXmp; + private @NonNull byte[] mRedactedXmp; private String mFormat; private String mDocumentId; private String mInstanceId; @@ -223,7 +223,7 @@ public class XmpInterface { return mOriginalDocumentId; } - public @Nullable byte[] getRedactedXmp() { + public @NonNull byte[] getRedactedXmp() { return mRedactedXmp; } |
