Merge "Extract top level directory for public volumes" into rvc-dev
diff --git a/apex/framework/Android.bp b/apex/framework/Android.bp
index bf35418..07f3451 100644
--- a/apex/framework/Android.bp
+++ b/apex/framework/Android.bp
@@ -52,6 +52,7 @@
 stubs_defaults {
     name: "framework-mediaprovider-stubs-srcs-defaults",
     srcs: [":framework-mediaprovider-sources"],
+    dist: { dest: "framework-mediaprovider.txt" },
 }
 
 droidstubs {
@@ -90,18 +91,21 @@
     name: "framework-mediaprovider-stubs-publicapi",
     srcs: [":framework-mediaprovider-stubs-srcs-publicapi"],
     defaults: ["framework-module-stubs-lib-defaults-publicapi"],
+    dist: { dest: "framework-mediaprovider.jar" },
 }
 
 java_library {
     name: "framework-mediaprovider-stubs-systemapi",
     srcs: [":framework-mediaprovider-stubs-srcs-systemapi"],
     defaults: ["framework-module-stubs-lib-defaults-systemapi"],
+    dist: { dest: "framework-mediaprovider.jar" },
 }
 
 java_library {
     name: "framework-mediaprovider-stubs-module_libs_api",
     srcs: [":framework-mediaprovider-stubs-srcs-module_libs_api"],
     defaults: ["framework-module-stubs-lib-defaults-module_libs_api"],
+    dist: { dest: "framework-mediaprovider.jar" },
 }
 
 java_library {
diff --git a/src/com/android/providers/media/DatabaseHelper.java b/src/com/android/providers/media/DatabaseHelper.java
index a61075b..1688ee3 100644
--- a/src/com/android/providers/media/DatabaseHelper.java
+++ b/src/com/android/providers/media/DatabaseHelper.java
@@ -148,6 +148,8 @@
 
     public interface OnLegacyMigrationListener {
         public void onStarted(ContentProviderClient client, String volumeName);
+        public void onProgress(ContentProviderClient client, String volumeName,
+                long progress, long total);
         public void onFinished(ContentProviderClient client, String volumeName);
     }
 
@@ -366,11 +368,15 @@
 
             mSchemaLock.writeLock().lock();
             try {
+                // Temporarily drop indexes to improve migration performance
+                makePristineIndexes(db);
                 migrateFromLegacy(db);
+                createLatestIndexes(db, mInternal);
             } finally {
                 mSchemaLock.writeLock().unlock();
             }
         }
+        Log.v(TAG, "onOpen() finished for " + mName);
     }
 
     @GuardedBy("mProjectionMapCache")
@@ -759,23 +765,9 @@
                     + "play_order INTEGER NOT NULL)");
         }
 
-        db.execSQL("CREATE INDEX image_id_index on thumbnails(image_id)");
-        db.execSQL("CREATE INDEX video_id_index on videothumbnails(video_id)");
-        db.execSQL("CREATE INDEX album_id_idx ON files(album_id)");
-        db.execSQL("CREATE INDEX artist_id_idx ON files(artist_id)");
-        db.execSQL("CREATE INDEX genre_id_idx ON files(genre_id)");
-        db.execSQL("CREATE INDEX bucket_index on files(bucket_id,media_type,datetaken, _id)");
-        db.execSQL("CREATE INDEX bucket_name on files(bucket_id,media_type,bucket_display_name)");
-        db.execSQL("CREATE INDEX format_index ON files(format)");
-        db.execSQL("CREATE INDEX media_type_index ON files(media_type)");
-        db.execSQL("CREATE INDEX parent_index ON files(parent)");
-        db.execSQL("CREATE INDEX path_index ON files(_data)");
-        db.execSQL("CREATE INDEX sort_index ON files(datetaken ASC, _id ASC)");
-        db.execSQL("CREATE INDEX title_idx ON files(title)");
-        db.execSQL("CREATE INDEX titlekey_index ON files(title_key)");
-
         createLatestViews(db, mInternal);
         createLatestTriggers(db, mInternal);
+        createLatestIndexes(db, mInternal);
 
         // 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
@@ -853,10 +845,16 @@
 
                     // To avoid SQLITE_NOMEM errors, we need to periodically
                     // flush the current transaction and start another one
-                    if ((c.getPosition() % 1_000) == 0) {
+                    if ((c.getPosition() % 2_000) == 0) {
                         db.setTransactionSuccessful();
                         db.endTransaction();
                         db.beginTransaction();
+
+                        // And announce that we're actively making progress
+                        final int progress = c.getPosition();
+                        final int total = c.getCount();
+                        Log.v(TAG, "Migrated " + progress + " of " + total + "...");
+                        mMigrationListener.onProgress(client, mVolumeName, progress, total);
                     }
                 }
 
@@ -1036,6 +1034,36 @@
                 + " BEGIN SELECT _DELETE(" + deleteArg + "); END");
     }
 
+    private static void makePristineIndexes(SQLiteDatabase db) {
+        // drop all indexes
+        Cursor c = db.query("sqlite_master", new String[] {"name"}, "type is 'index'",
+                null, null, null, null);
+        while (c.moveToNext()) {
+            if (c.getString(0).startsWith("sqlite_")) continue;
+            db.execSQL("DROP INDEX IF EXISTS " + c.getString(0));
+        }
+        c.close();
+    }
+
+    private static void createLatestIndexes(SQLiteDatabase db, boolean internal) {
+        makePristineIndexes(db);
+
+        db.execSQL("CREATE INDEX image_id_index on thumbnails(image_id)");
+        db.execSQL("CREATE INDEX video_id_index on videothumbnails(video_id)");
+        db.execSQL("CREATE INDEX album_id_idx ON files(album_id)");
+        db.execSQL("CREATE INDEX artist_id_idx ON files(artist_id)");
+        db.execSQL("CREATE INDEX genre_id_idx ON files(genre_id)");
+        db.execSQL("CREATE INDEX bucket_index on files(bucket_id,media_type,datetaken, _id)");
+        db.execSQL("CREATE INDEX bucket_name on files(bucket_id,media_type,bucket_display_name)");
+        db.execSQL("CREATE INDEX format_index ON files(format)");
+        db.execSQL("CREATE INDEX media_type_index ON files(media_type)");
+        db.execSQL("CREATE INDEX parent_index ON files(parent)");
+        db.execSQL("CREATE INDEX path_index ON files(_data)");
+        db.execSQL("CREATE INDEX sort_index ON files(datetaken ASC, _id ASC)");
+        db.execSQL("CREATE INDEX title_idx ON files(title)");
+        db.execSQL("CREATE INDEX titlekey_index ON files(title_key)");
+    }
+
     private static void updateCollationKeys(SQLiteDatabase db) {
         // Delete albums and artists, then clear the modification time on songs, which
         // will cause the media scanner to rescan everything, rebuilding the artist and
@@ -1425,6 +1453,12 @@
                 // Empty version bump to ensure triggers are recreated
             }
 
+            // If this is the legacy database, it's not worth recomputing data
+            // values locally, since they'll be recomputed after the migration
+            if (mLegacyProvider) {
+                recomputeDataValues = false;
+            }
+
             if (recomputeDataValues) {
                 recomputeDataValues(db, internal);
             }
diff --git a/src/com/android/providers/media/MediaDocumentsProvider.java b/src/com/android/providers/media/MediaDocumentsProvider.java
index 72ec916..65fac9b 100644
--- a/src/com/android/providers/media/MediaDocumentsProvider.java
+++ b/src/com/android/providers/media/MediaDocumentsProvider.java
@@ -130,10 +130,12 @@
     static final String TYPE_DOCUMENTS_BUCKET = "documents_bucket";
     static final String TYPE_DOCUMENT = "document";
 
-    private static boolean sReturnedImagesEmpty = false;
-    private static boolean sReturnedVideosEmpty = false;
-    private static boolean sReturnedAudioEmpty = false;
-    private static boolean sReturnedDocumentsEmpty = false;
+    private static volatile boolean sMediaStoreReady = false;
+
+    private static volatile boolean sReturnedImagesEmpty = false;
+    private static volatile boolean sReturnedVideosEmpty = false;
+    private static volatile boolean sReturnedAudioEmpty = false;
+    private static volatile boolean sReturnedDocumentsEmpty = false;
 
     private static String joinNewline(String... args) {
         return TextUtils.join("\n", args);
@@ -201,6 +203,15 @@
     }
 
     /**
+     * When underlying provider is ready, we kick off a notification of roots
+     * changed so they can be refreshed.
+     */
+    static void onMediaStoreReady(Context context, String volumeName) {
+        sMediaStoreReady = true;
+        notifyRootsChanged(context);
+    }
+
+    /**
      * When inserting the first item of each type, we need to trigger a roots
      * refresh to clear a previously reported {@link Root#FLAG_EMPTY}.
      */
@@ -558,10 +569,15 @@
     @Override
     public Cursor queryRoots(String[] projection) throws FileNotFoundException {
         final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection));
-        includeImagesRoot(result);
-        includeVideosRoot(result);
-        includeAudioRoot(result);
-        includeDocumentsRoot(result);
+        // Skip all roots when the underlying provider isn't ready yet so that
+        // we avoid triggering an ANR; we'll circle back to notify and refresh
+        // once it's ready
+        if (sMediaStoreReady) {
+            includeImagesRoot(result);
+            includeVideosRoot(result);
+            includeAudioRoot(result);
+            includeDocumentsRoot(result);
+        }
         return result;
     }
 
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index 762dbf4..50ce212 100644
--- a/src/com/android/providers/media/MediaProvider.java
+++ b/src/com/android/providers/media/MediaProvider.java
@@ -636,6 +636,12 @@
         }
 
         @Override
+        public void onProgress(ContentProviderClient client, String volumeName,
+                long progress, long total) {
+            // TODO: notify blocked threads of progress once we can change APIs
+        }
+
+        @Override
         public void onFinished(ContentProviderClient client, String volumeName) {
             MediaStore.finishLegacyMigration(ContentResolver.wrap(client), volumeName);
         }
@@ -847,7 +853,6 @@
                 AppOpsManager.OPSTR_CAMERA
         }, context.getMainExecutor(), mActiveListener);
 
-
         mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_READ_EXTERNAL_STORAGE,
                 null /* all packages */, mModeListener);
         mAppOpsManager.startWatchingMode(AppOpsManager.OPSTR_WRITE_EXTERNAL_STORAGE,
@@ -2081,8 +2086,9 @@
     }
 
     private void ensureUniqueFileColumns(int match, @NonNull Uri uri, @NonNull Bundle extras,
-            @NonNull ContentValues values) throws VolumeArgumentException {
-        ensureFileColumns(match, uri, extras, values, true, null /* currentPath */);
+            @NonNull ContentValues values, @Nullable String currentPath)
+            throws VolumeArgumentException {
+        ensureFileColumns(match, uri, extras, values, true, currentPath);
     }
 
     private void ensureNonUniqueFileColumns(int match, @NonNull Uri uri,
@@ -2305,8 +2311,10 @@
             // Require that content lives under well-defined directories to help
             // keep the user's content organized
 
-            // Start by saying unchanged paths are valid
-            boolean validPath = res.getAbsolutePath().equals(currentPath);
+            // Start by saying unchanged directories are valid
+            final String currentDir = (currentPath != null)
+                    ? new File(currentPath).getParent() : null;
+            boolean validPath = res.getParent().equals(currentDir);
 
             // Next, consider allowing based on allowed primary directory
             final String[] relativePath = values.getAsString(MediaColumns.RELATIVE_PATH).split("/");
@@ -2646,7 +2654,7 @@
 
         // Make sure all file-related columns are defined
         try {
-            ensureUniqueFileColumns(match, uri, extras, values);
+            ensureUniqueFileColumns(match, uri, extras, values, null);
         } catch (VolumeArgumentException e) {
             if (getCallingPackageTargetSdkVersion() >= Build.VERSION_CODES.Q) {
                 throw new IllegalArgumentException(e.getMessage());
@@ -3028,7 +3036,7 @@
                         MediaStore.Images.Media.getContentUri(resolvedVolumeName), imageId),
                         extras, true);
 
-                ensureUniqueFileColumns(match, uri, extras, initialValues);
+                ensureUniqueFileColumns(match, uri, extras, initialValues, null);
 
                 rowId = qb.insert(helper, initialValues);
                 if (rowId > 0) {
@@ -3050,7 +3058,7 @@
                         MediaStore.Video.Media.getContentUri(resolvedVolumeName), videoId),
                         Bundle.EMPTY, true);
 
-                ensureUniqueFileColumns(match, uri, extras, initialValues);
+                ensureUniqueFileColumns(match, uri, extras, initialValues, null);
 
                 rowId = qb.insert(helper, initialValues);
                 if (rowId > 0) {
@@ -3132,7 +3140,7 @@
                     throw new UnsupportedOperationException("no internal album art allowed");
                 }
 
-                ensureUniqueFileColumns(match, uri, extras, initialValues);
+                ensureUniqueFileColumns(match, uri, extras, initialValues, null);
 
                 rowId = qb.insert(helper, initialValues);
                 if (rowId > 0) {
@@ -4700,6 +4708,11 @@
                     // make sure metadata is updated
                     if (MediaColumns.IS_PENDING.equals(column)) {
                         triggerScan = true;
+
+                        // Explicitly clear columns used to ignore no-op scans,
+                        // since we need to force a scan on publish
+                        initialValues.putNull(MediaColumns.DATE_MODIFIED);
+                        initialValues.putNull(MediaColumns.SIZE);
                     }
                 }
 
@@ -4817,7 +4830,7 @@
                 // Now that we've confirmed an actual movement is taking place,
                 // ensure we have a unique destination
                 initialValues.remove(MediaColumns.DATA);
-                ensureUniqueFileColumns(match, uri, extras, initialValues);
+                ensureUniqueFileColumns(match, uri, extras, initialValues, beforePath);
 
                 final String afterPath = initialValues.getAsString(MediaColumns.DATA);
 
@@ -4845,6 +4858,19 @@
         if (initialValues.containsKey(FileColumns.DATA)) {
             // If we're changing paths, invalidate any thumbnails
             triggerInvalidate = true;
+
+            // If the new file exists, trigger a scan to adjust any metadata
+            // that might be derived from the path
+            final String data = initialValues.getAsString(FileColumns.DATA);
+            if (!TextUtils.isEmpty(data) && new File(data).exists()) {
+                triggerScan = true;
+            }
+        }
+
+        // If we're already doing this update from an internal scan, no need to
+        // kick off another no-op scan
+        if (isCallingPackageSystem()) {
+            triggerScan = false;
         }
 
         // Since the update mutation may prevent us from matching items after
@@ -6093,6 +6119,7 @@
         if (useData) {
             values.put(FileColumns.DATA, getAbsoluteSanitizedPath(path));
         } else {
+            values.put(FileColumns.VOLUME_NAME, extractVolumeName(path));
             values.put(FileColumns.RELATIVE_PATH, extractRelativePath(path));
             values.put(FileColumns.DISPLAY_NAME, extractDisplayName(path));
         }
@@ -6748,6 +6775,11 @@
                     ensureThumbnailsValid(volume, db);
                     return null;
                 });
+
+                // We just finished the database operation above, we know that
+                // it's ready to answer queries, so notify our DocumentProvider
+                // so it can answer queries without risking ANR
+                MediaDocumentsProvider.onMediaStoreReady(getContext(), volume);
             });
         }
         return uri;
diff --git a/src/com/android/providers/media/PermissionActivity.java b/src/com/android/providers/media/PermissionActivity.java
index 0696ef5..3f97464 100644
--- a/src/com/android/providers/media/PermissionActivity.java
+++ b/src/com/android/providers/media/PermissionActivity.java
@@ -182,7 +182,7 @@
             protected Void doInBackground(Void... params) {
                 Log.d(TAG, "User allowed grant for " + uris);
                 Metrics.logPermissionGranted(volumeName, appInfo.uid,
-                        getCallingPackage(), 1);
+                        getCallingPackage(), uris.size());
                 try {
                     switch (getIntent().getAction()) {
                         case MediaStore.CREATE_WRITE_REQUEST_CALL: {
diff --git a/tests/src/com/android/providers/media/DatabaseHelperTest.java b/tests/src/com/android/providers/media/DatabaseHelperTest.java
index a83cc13..cfc9eeb 100644
--- a/tests/src/com/android/providers/media/DatabaseHelperTest.java
+++ b/tests/src/com/android/providers/media/DatabaseHelperTest.java
@@ -25,6 +25,7 @@
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
+import android.content.ContentResolver;
 import android.content.ContentValues;
 import android.content.Context;
 import android.database.Cursor;
@@ -39,7 +40,8 @@
 import androidx.test.InstrumentationRegistry;
 import androidx.test.runner.AndroidJUnit4;
 
-import org.junit.After;
+import com.android.providers.media.scan.MediaScannerTest.IsolatedContext;
+
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -58,22 +60,19 @@
 
     private static final String SQLITE_MASTER_ORDER_BY = "type,name,tbl_name";
 
-    private Context getContext() {
-        return InstrumentationRegistry.getTargetContext();
-    }
+    private static Context sIsolatedContext;
+    private static ContentResolver sIsolatedResolver;
 
     @Before
-    @After
-    public void deleteDatabase() throws Exception {
-        getContext().deleteDatabase(TEST_RECOMPUTE_DB);
-        getContext().deleteDatabase(TEST_UPGRADE_DB);
-        getContext().deleteDatabase(TEST_DOWNGRADE_DB);
-        getContext().deleteDatabase(TEST_CLEAN_DB);
+    public void setUp() {
+        final Context context = InstrumentationRegistry.getTargetContext();
+        sIsolatedContext = new IsolatedContext(context, TAG);
+        sIsolatedResolver = sIsolatedContext.getContentResolver();
     }
 
     @Test
     public void testFilterVolumeNames() throws Exception {
-        try (DatabaseHelper helper = new DatabaseHelperR(getContext(), TEST_CLEAN_DB)) {
+        try (DatabaseHelper helper = new DatabaseHelperR(sIsolatedContext, TEST_CLEAN_DB)) {
             SQLiteDatabase db = helper.getWritableDatabaseForTest();
             {
                 final ContentValues values = new ContentValues();
@@ -155,7 +154,7 @@
 
     @Test
     public void testTransactions() throws Exception {
-        try (DatabaseHelper helper = new DatabaseHelperR(getContext(), TEST_CLEAN_DB)) {
+        try (DatabaseHelper helper = new DatabaseHelperR(sIsolatedContext, TEST_CLEAN_DB)) {
             helper.beginTransaction();
             try {
                 helper.setTransactionSuccessful();
@@ -187,7 +186,7 @@
     private void assertDowngrade(Class<? extends DatabaseHelper> before,
             Class<? extends DatabaseHelper> after) throws Exception {
         try (DatabaseHelper helper = before.getConstructor(Context.class, String.class)
-                .newInstance(getContext(), TEST_DOWNGRADE_DB)) {
+                .newInstance(sIsolatedContext, TEST_DOWNGRADE_DB)) {
             SQLiteDatabase db = helper.getWritableDatabaseForTest();
             {
                 final ContentValues values = new ContentValues();
@@ -206,7 +205,7 @@
 
         // Downgrade will wipe data, but at least we don't crash
         try (DatabaseHelper helper = after.getConstructor(Context.class, String.class)
-                .newInstance(getContext(), TEST_DOWNGRADE_DB)) {
+                .newInstance(sIsolatedContext, TEST_DOWNGRADE_DB)) {
             SQLiteDatabase db = helper.getWritableDatabaseForTest();
             try (Cursor c = db.query("files", null, null, null, null, null, null, null)) {
                 assertEquals(0, c.getCount());
@@ -234,7 +233,7 @@
     private void assertRecompute(Class<? extends DatabaseHelper> before,
             Class<? extends DatabaseHelper> after) throws Exception {
         try (DatabaseHelper helper = before.getConstructor(Context.class, String.class)
-                .newInstance(getContext(), TEST_RECOMPUTE_DB)) {
+                .newInstance(sIsolatedContext, TEST_RECOMPUTE_DB)) {
             SQLiteDatabase db = helper.getWritableDatabaseForTest();
             {
                 final ContentValues values = new ContentValues();
@@ -298,7 +297,7 @@
         }
 
         try (DatabaseHelper helper = after.getConstructor(Context.class, String.class)
-                .newInstance(getContext(), TEST_RECOMPUTE_DB)) {
+                .newInstance(sIsolatedContext, TEST_RECOMPUTE_DB)) {
             SQLiteDatabase db = helper.getWritableDatabaseForTest();
             try (Cursor c = db.query("files", null, FileColumns.DISPLAY_NAME + "='global.jpg'",
                     null, null, null, null)) {
@@ -368,18 +367,18 @@
     private void assertUpgrade(Class<? extends DatabaseHelper> before,
             Class<? extends DatabaseHelper> after) throws Exception {
         try (DatabaseHelper helper = before.getConstructor(Context.class, String.class)
-                .newInstance(getContext(), TEST_UPGRADE_DB)) {
+                .newInstance(sIsolatedContext, TEST_UPGRADE_DB)) {
             SQLiteDatabase db = helper.getWritableDatabaseForTest();
         }
 
         try (DatabaseHelper helper = after.getConstructor(Context.class, String.class)
-                .newInstance(getContext(), TEST_UPGRADE_DB)) {
+                .newInstance(sIsolatedContext, TEST_UPGRADE_DB)) {
             SQLiteDatabase db = helper.getWritableDatabaseForTest();
 
             // Create a second isolated instance from scratch and assert that
             // upgraded schema is identical
             try (DatabaseHelper helper2 = after.getConstructor(Context.class, String.class)
-                    .newInstance(getContext(), TEST_CLEAN_DB)) {
+                    .newInstance(sIsolatedContext, TEST_CLEAN_DB)) {
                 SQLiteDatabase db2 = helper2.getWritableDatabaseForTest();
 
                 try (Cursor c1 = db.query("sqlite_master",
@@ -426,7 +425,7 @@
     private static class DatabaseHelperO extends DatabaseHelper {
         public DatabaseHelperO(Context context, String name) {
             super(context, name, DatabaseHelper.VERSION_O,
-                    false, false, true, Column.class, null, null, null, null);
+                    false, false, false, Column.class, null, null, null, null);
         }
 
         @Override
@@ -438,7 +437,7 @@
     private static class DatabaseHelperP extends DatabaseHelper {
         public DatabaseHelperP(Context context, String name) {
             super(context, name, DatabaseHelper.VERSION_P,
-                    false, false, true, Column.class, null, null, null, null);
+                    false, false, false, Column.class, null, null, null, null);
         }
 
         @Override
@@ -450,7 +449,7 @@
     private static class DatabaseHelperQ extends DatabaseHelper {
         public DatabaseHelperQ(Context context, String name) {
             super(context, name, DatabaseHelper.VERSION_Q,
-                    false, false, true, Column.class, null, null, null, null);
+                    false, false, false, Column.class, null, null, null, null);
         }
 
         @Override
@@ -462,7 +461,7 @@
     private static class DatabaseHelperR extends DatabaseHelper {
         public DatabaseHelperR(Context context, String name) {
             super(context, name, DatabaseHelper.VERSION_R,
-                    false, false, true, Column.class, null, null, null, null);
+                    false, false, false, Column.class, null, null, null, null);
         }
     }