summaryrefslogtreecommitdiffstats
path: root/src/com/android
diff options
context:
space:
mode:
authorXin Li <delphij@google.com>2021-10-07 23:50:48 +0000
committerGerrit Code Review <noreply-gerritcodereview@google.com>2021-10-07 23:50:48 +0000
commit65c8bd6f10fa52c110caa4e95eef9193e7b604c8 (patch)
treee82cb75a38776840eb3954ddefe2782c4c7f4291 /src/com/android
parent2fb199ae6dbce46353833a508d33745bd4cff23d (diff)
parent8b4f9d9b74f051da6b377ae3147cc36d599afc5f (diff)
downloadplatform_packages_providers_MediaProvider-master.tar.gz
platform_packages_providers_MediaProvider-master.tar.bz2
platform_packages_providers_MediaProvider-master.zip
Merge "Merge Android 12"HEADmaster
Diffstat (limited to 'src/com/android')
-rw-r--r--src/com/android/providers/media/DatabaseHelper.java448
-rw-r--r--src/com/android/providers/media/FileLookupResult.java40
-rw-r--r--src/com/android/providers/media/FileOpenResult.java34
-rw-r--r--src/com/android/providers/media/IdleService.java5
-rw-r--r--src/com/android/providers/media/LocalCallingIdentity.java278
-rw-r--r--src/com/android/providers/media/MediaDocumentsProvider.java48
-rw-r--r--src/com/android/providers/media/MediaProvider.java3095
-rw-r--r--src/com/android/providers/media/MediaService.java81
-rw-r--r--src/com/android/providers/media/MediaUpgradeReceiver.java8
-rw-r--r--src/com/android/providers/media/MediaVolume.java159
-rw-r--r--src/com/android/providers/media/PermissionActivity.java275
-rw-r--r--src/com/android/providers/media/TranscodeHelper.java46
-rw-r--r--src/com/android/providers/media/TranscodeHelperImpl.java1857
-rw-r--r--src/com/android/providers/media/TranscodeHelperNoOp.java60
-rw-r--r--src/com/android/providers/media/VolumeCache.java213
-rw-r--r--src/com/android/providers/media/fuse/ExternalStorageServiceImpl.java47
-rw-r--r--src/com/android/providers/media/fuse/FuseDaemon.java28
-rw-r--r--src/com/android/providers/media/metrics/PulledMetrics.java141
-rw-r--r--src/com/android/providers/media/metrics/StorageAccessMetrics.java279
-rw-r--r--src/com/android/providers/media/metrics/TranscodeMetrics.java179
-rw-r--r--src/com/android/providers/media/photopicker/PhotoPickerActivity.java131
-rw-r--r--src/com/android/providers/media/playlist/Playlist.java27
-rw-r--r--src/com/android/providers/media/scan/LegacyMediaScanner.java14
-rw-r--r--src/com/android/providers/media/scan/MediaScanner.java6
-rw-r--r--src/com/android/providers/media/scan/ModernMediaScanner.java443
-rw-r--r--src/com/android/providers/media/scan/NullMediaScanner.java14
-rw-r--r--src/com/android/providers/media/util/ExifUtils.java26
-rw-r--r--src/com/android/providers/media/util/FileUtils.java384
-rw-r--r--src/com/android/providers/media/util/IsoInterface.java15
-rw-r--r--src/com/android/providers/media/util/Logging.java88
-rw-r--r--src/com/android/providers/media/util/Metrics.java34
-rw-r--r--src/com/android/providers/media/util/PermissionUtils.java76
-rw-r--r--src/com/android/providers/media/util/SQLiteQueryBuilder.java44
-rw-r--r--src/com/android/providers/media/util/UserCache.java162
-rw-r--r--src/com/android/providers/media/util/XmpInterface.java4
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;
}