Update media_type as NONE for hidden files created via filepath.

Hidden files and files inside hidden directory or directory containing
.nomedia file are consider hidden, and should be updated with media type
as MEDIA_TYPE_NONE.

When a file created or renamed, the new file might be a hidden file or
inside a hidden directory, and this hidden file must be updated with
media_type as MEDIA_TYPE_NONE. We achieve the same in this CL by
scanning the newly created or renamed file.

Added tests for creating, renaming and deleting hidden file.

Test: atest FuseDaemonHostTest
Test: atest packages/providers/MediaProvider
Bug: 148585977, 150927291
Change-Id: If6fd1b950a46ea7b43953661e7fbdaaf126bf3ee
diff --git a/jni/FuseDaemon.cpp b/jni/FuseDaemon.cpp
index 342662a..444d521 100644
--- a/jni/FuseDaemon.cpp
+++ b/jni/FuseDaemon.cpp
@@ -1484,6 +1484,10 @@
         return;
     }
 
+    // File was inserted to MP database with default mime type/media type values. ScanFile will
+    // update the db columns with appropriate values. This is used for hidden file handling.
+    fuse->mp->ScanFile(child_path.c_str());
+
     int error_code = 0;
     struct fuse_entry_param e;
     node* node = make_node_entry(req, parent_node, name, child_path, &e, &error_code);
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index e875cc3..6aa2862 100644
--- a/src/com/android/providers/media/MediaProvider.java
+++ b/src/com/android/providers/media/MediaProvider.java
@@ -1350,6 +1350,40 @@
     }
 
     /**
+     * Scan files during renames for the following reasons:
+     * <ul>
+     * <li>When a file or directory is renamed, media type for the corresponding db row will be
+     * updated with media type resolved based on the mime type of the file. This updated media type
+     * doesn't consider hidden file/hidden directory, so we must scan the new path to update the
+     * media type based on the path of the file.
+     * <li>When a .nomedia file is moved to new path, old parent of the .nomedia file is no more
+     * hidden. We should scan old parent directory to ensure all files in that directory will be
+     * updated with appropriate media type.
+     * <li>When a file is renamed to .nomedia, the new parent will be a hidden directory. We should
+     * scan new parent directory to ensure all files in that directory are updated with
+     * MEDIA_TYPE_NONE.
+     * </ul>
+     */
+    private void scanRenamedPathForFuse(@NonNull String oldPath, @NonNull String newPath) {
+        final LocalCallingIdentity token = clearLocalCallingIdentity();
+        try {
+            if (extractDisplayName(oldPath).equals(".nomedia")) {
+                // .nomedia file is moved to a new directory. Old directory may not be treated as
+                // hidden anymore.
+                scanFile(new File(oldPath).getParentFile(), REASON_DEMAND);
+            }
+
+            // We should always scan new path to update the media type, but if new file is .nomedia
+            // we should scan new parent as well
+            File newPathToScan = extractDisplayName(newPath).equals(".nomedia") ?
+                    new File(newPath).getParentFile() : new File(newPath);
+            scanFile(newPathToScan, REASON_DEMAND);
+        } finally {
+            restoreLocalCallingIdentity(token);
+        }
+    }
+
+    /**
      * Checks if given {@code mimeType} is supported in {@code path}.
      */
     private boolean isMimeTypeSupportedInPath(String path, String mimeType) {
@@ -1676,6 +1710,8 @@
         } finally {
             helper.endTransaction();
         }
+        // File or directory movement might have made new/old path hidden.
+        scanRenamedPathForFuse(oldPath, newPath);
         return 0;
     }
 
@@ -1742,6 +1778,8 @@
         } finally {
             helper.endTransaction();
         }
+        // File or directory movement might have made new/old path hidden.
+        scanRenamedPathForFuse(oldPath, newPath);
         return 0;
     }
 
diff --git a/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppA.xml b/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppA.xml
index f5d4493..82b69da 100644
--- a/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppA.xml
+++ b/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppA.xml
@@ -20,6 +20,7 @@
     android:versionName="1.0" >
 
     <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
 
     <application android:label="TestAppA">
         <activity android:name="com.android.tests.fused.FilePathAccessTestHelper">
diff --git a/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppB.xml b/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppB.xml
index 6e35e90..5c0b244 100644
--- a/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppB.xml
+++ b/tests/jni/FuseDaemonTest/FilePathAccessTestHelper/TestAppB.xml
@@ -20,6 +20,7 @@
     android:versionName="1.0" >
 
     <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
 
     <application android:label="TestAppB">
         <activity android:name="com.android.tests.fused.FilePathAccessTestHelper">
diff --git a/tests/jni/FuseDaemonTest/host/src/com/android/tests/fused/host/FuseDaemonHostTest.java b/tests/jni/FuseDaemonTest/host/src/com/android/tests/fused/host/FuseDaemonHostTest.java
index 273f200..0ae9d1d 100644
--- a/tests/jni/FuseDaemonTest/host/src/com/android/tests/fused/host/FuseDaemonHostTest.java
+++ b/tests/jni/FuseDaemonTest/host/src/com/android/tests/fused/host/FuseDaemonHostTest.java
@@ -272,6 +272,27 @@
     }
 
     @Test
+    public void testCanRenameHiddenFile() throws Exception {
+        runDeviceTest("testCanRenameHiddenFile");
+
+    }
+
+    @Test
+    public void testHiddenDirectory() throws Exception {
+        runDeviceTest("testHiddenDirectory");
+    }
+
+    @Test
+    public void testHiddenDirectory_nomedia() throws Exception {
+        runDeviceTest("testHiddenDirectory_nomedia");
+    }
+
+    @Test
+    public void testListHiddenFile() throws Exception {
+        runDeviceTest("testListHiddenFile");
+    }
+
+    @Test
     public void testCanCreateDefaultDirectory() throws Exception {
         runDeviceTest("testCanCreateDefaultDirectory");
     }
diff --git a/tests/jni/FuseDaemonTest/legacy/src/com/android/tests/fused/legacy/LegacyFileAccessTest.java b/tests/jni/FuseDaemonTest/legacy/src/com/android/tests/fused/legacy/LegacyFileAccessTest.java
index 18c240d..de36f68 100644
--- a/tests/jni/FuseDaemonTest/legacy/src/com/android/tests/fused/legacy/LegacyFileAccessTest.java
+++ b/tests/jni/FuseDaemonTest/legacy/src/com/android/tests/fused/legacy/LegacyFileAccessTest.java
@@ -581,8 +581,8 @@
             imageInNoMediaDir.delete();
             renamedImageInDCIM.delete();
             noMediaFile.delete();
+            directoryNoMedia.delete();
         }
-
     }
 
     private static void assertCanCreateFile(File file) throws IOException {
diff --git a/tests/jni/FuseDaemonTest/libs/FuseDaemonTestLib/src/com/android/tests/fused/lib/TestUtils.java b/tests/jni/FuseDaemonTest/libs/FuseDaemonTestLib/src/com/android/tests/fused/lib/TestUtils.java
index 37a7f67..22485e9 100644
--- a/tests/jni/FuseDaemonTest/libs/FuseDaemonTestLib/src/com/android/tests/fused/lib/TestUtils.java
+++ b/tests/jni/FuseDaemonTest/libs/FuseDaemonTestLib/src/com/android/tests/fused/lib/TestUtils.java
@@ -368,6 +368,11 @@
         return ownerPackage;
     }
 
+    @NonNull
+    public static Cursor queryImageFile(File file, String... projection) {
+        return queryFile(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, file, projection);
+    }
+
     /**
      * Queries {@link ContentResolver} for a file and returns the corresponding mime type for its
      * entry in the database.
@@ -772,9 +777,14 @@
     @NonNull
     private static Cursor queryFile(@NonNull File file,
             String... projection) {
-        final Cursor c = getContentResolver().query(
-                MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
-                projection,
+        return queryFile(MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
+                file, projection);
+    }
+
+    @NonNull
+    private static Cursor queryFile(@NonNull Uri uri, @NonNull File file,
+            String... projection) {
+        final Cursor c = getContentResolver().query(uri, projection,
                 /*selection*/ MediaStore.MediaColumns.DATA + " = ?",
                 /*selectionArgs*/ new String[] { file.getAbsolutePath() },
                 /*sortOrder*/ null);
diff --git a/tests/jni/FuseDaemonTest/src/com/android/tests/fused/FilePathAccessTest.java b/tests/jni/FuseDaemonTest/src/com/android/tests/fused/FilePathAccessTest.java
index 8d81c98..c0bf1e1 100644
--- a/tests/jni/FuseDaemonTest/src/com/android/tests/fused/FilePathAccessTest.java
+++ b/tests/jni/FuseDaemonTest/src/com/android/tests/fused/FilePathAccessTest.java
@@ -81,6 +81,7 @@
 import static com.android.tests.fused.lib.TestUtils.openWithMediaProvider;
 import static com.android.tests.fused.lib.TestUtils.pollForExternalStorageState;
 import static com.android.tests.fused.lib.TestUtils.pollForPermission;
+import static com.android.tests.fused.lib.TestUtils.queryImageFile;
 import static com.android.tests.fused.lib.TestUtils.readExifMetadataFromTestApp;
 import static com.android.tests.fused.lib.TestUtils.revokePermission;
 import static com.android.tests.fused.lib.TestUtils.setupDefaultDirectories;
@@ -92,6 +93,7 @@
 
 import static org.junit.Assume.assumeFalse;
 import static org.junit.Assume.assumeTrue;
+import static org.junit.Assert.assertEquals;
 
 import android.Manifest;
 import android.app.AppOpsManager;
@@ -1542,28 +1544,172 @@
     }
 
     /**
-     * Test that apps can create hidden file
+     * Test that apps can create and delete hidden file.
      */
     @Test
     public void testCanCreateHiddenFile() throws Exception {
-        final String hiddenFileName = ".hiddenFile";
-        final File hiddenFile = new File(DOWNLOAD_DIR, hiddenFileName);
+        final File hiddenImageFile = new File(DOWNLOAD_DIR, ".hiddenFile" + IMAGE_FILE_NAME);
         try {
-            assertThat(hiddenFile.createNewFile()).isTrue();
+            assertThat(hiddenImageFile.createNewFile()).isTrue();
             // Write to hidden file is allowed.
-            try (final FileOutputStream fos = new FileOutputStream(hiddenFile)) {
+            try (final FileOutputStream fos = new FileOutputStream(hiddenImageFile)) {
                 fos.write(BYTES_DATA1);
             }
-            assertFileContent(hiddenFile, BYTES_DATA1);
-            assertThat(ReaddirTestHelper.readDirectory(hiddenFile.getParentFile()))
-                    .contains(hiddenFileName);
-            assertThat(getFileRowIdFromDatabase(hiddenFile)).isNotEqualTo(-1);
+            assertFileContent(hiddenImageFile, BYTES_DATA1);
+
+            assertNotMediaTypeImage(hiddenImageFile);
+
+            assertDirectoryContains(DOWNLOAD_DIR, hiddenImageFile);
+            assertThat(getFileRowIdFromDatabase(hiddenImageFile)).isNotEqualTo(-1);
 
             // We can delete hidden file
-            assertThat(hiddenFile.delete()).isTrue();
-            assertThat(hiddenFile.exists()).isFalse();
+            assertThat(hiddenImageFile.delete()).isTrue();
+            assertThat(hiddenImageFile.exists()).isFalse();
         } finally {
-            hiddenFile.delete();
+            hiddenImageFile.delete();
+        }
+    }
+
+    /**
+     * Test that apps can rename a hidden file.
+     */
+    @Test
+    public void testCanRenameHiddenFile() throws Exception {
+        final String hiddenFileName = ".hidden" + IMAGE_FILE_NAME;
+        final File hiddenImageFile1 = new File(DCIM_DIR, hiddenFileName);
+        final File hiddenImageFile2 = new File(DOWNLOAD_DIR, hiddenFileName);
+        final File imageFile = new File(DOWNLOAD_DIR, IMAGE_FILE_NAME);
+        try {
+            assertThat(hiddenImageFile1.createNewFile()).isTrue();
+            assertCanRenameFile(hiddenImageFile1, hiddenImageFile2);
+            assertNotMediaTypeImage(hiddenImageFile2);
+
+            // We can also rename hidden file to non-hidden
+            assertCanRenameFile(hiddenImageFile2, imageFile);
+            assertIsMediaTypeImage(imageFile);
+
+            // We can rename non-hidden file to hidden
+            assertCanRenameFile(imageFile, hiddenImageFile1);
+            assertNotMediaTypeImage(hiddenImageFile1);
+        } finally {
+            hiddenImageFile1.delete();
+            hiddenImageFile2.delete();
+            imageFile.delete();
+        }
+    }
+
+    /**
+     * Test that files in hidden directory have MEDIA_TYPE=MEDIA_TYPE_NONE
+     */
+    @Test
+    public void testHiddenDirectory() throws Exception {
+        final File hiddenDir = new File(DOWNLOAD_DIR, ".hidden" + TEST_DIRECTORY_NAME);
+        final File hiddenImageFile = new File(hiddenDir, IMAGE_FILE_NAME);
+        final File nonHiddenDir = new File(DOWNLOAD_DIR, TEST_DIRECTORY_NAME);
+        final File imageFile = new File(nonHiddenDir, IMAGE_FILE_NAME);
+        try {
+            if (!hiddenDir.exists()) {
+                assertThat(hiddenDir.mkdir()).isTrue();
+            }
+            assertThat(hiddenImageFile.createNewFile()).isTrue();
+
+            assertNotMediaTypeImage(hiddenImageFile);
+
+            // Renaming hiddenDir to nonHiddenDir makes the imageFile non-hidden and vice versa
+            assertCanRenameDirectory(hiddenDir, nonHiddenDir, new File[] {hiddenImageFile},
+                    new File[] {imageFile});
+            assertIsMediaTypeImage(imageFile);
+
+            assertCanRenameDirectory(nonHiddenDir, hiddenDir, new File[] {imageFile},
+                    new File[] {hiddenImageFile});
+            assertNotMediaTypeImage(hiddenImageFile);
+        } finally {
+            hiddenImageFile.delete();
+            imageFile.delete();
+            hiddenDir.delete();
+            nonHiddenDir.delete();
+        }
+    }
+
+    /**
+     * Test that files in directory with nomedia have MEDIA_TYPE=MEDIA_TYPE_NONE
+     */
+    @Test
+    public void testHiddenDirectory_nomedia() throws Exception {
+        final File directoryNoMedia = new File(DOWNLOAD_DIR, "nomedia" + TEST_DIRECTORY_NAME);
+        final File noMediaFile = new File(directoryNoMedia, ".nomedia");
+        final File imageFile = new File(directoryNoMedia, IMAGE_FILE_NAME);
+        final File videoFile = new File(directoryNoMedia, VIDEO_FILE_NAME);
+        try {
+            if (!directoryNoMedia.exists()) {
+                assertThat(directoryNoMedia.mkdir()).isTrue();
+            }
+            assertThat(noMediaFile.createNewFile()).isTrue();
+            assertThat(imageFile.createNewFile()).isTrue();
+
+            assertNotMediaTypeImage(imageFile);
+
+            // Deleting the .nomedia file makes the parent directory non hidden.
+            noMediaFile.delete();
+            MediaStore.scanFile(getContentResolver(), directoryNoMedia);
+            assertIsMediaTypeImage(imageFile);
+
+            // Creating the .nomedia file makes the parent directory hidden again
+            assertThat(noMediaFile.createNewFile()).isTrue();
+            MediaStore.scanFile(getContentResolver(), directoryNoMedia);
+            assertNotMediaTypeImage(imageFile);
+
+            // Renaming the .nomedia file to non hidden file makes the parent directory non hidden.
+            assertCanRenameFile(noMediaFile, videoFile);
+            assertIsMediaTypeImage(imageFile);
+        } finally {
+            noMediaFile.delete();
+            imageFile.delete();
+            videoFile.delete();
+            directoryNoMedia.delete();
+        }
+    }
+
+    /**
+     * Test that only file manager and app that created the hidden file can list it.
+     */
+    @Test
+    public void testListHiddenFile() throws Exception {
+        final String hiddenImageFileName = ".hidden" + IMAGE_FILE_NAME;
+        final File hiddenImageFile = new File(DCIM_DIR, hiddenImageFileName);
+        try {
+            assertThat(hiddenImageFile.createNewFile()).isTrue();
+            assertNotMediaTypeImage(hiddenImageFile);
+
+            assertDirectoryContains(DCIM_DIR, hiddenImageFile);
+
+            installApp(TEST_APP_A, true);
+            // TestApp with read permissions can't see the hidden image file created by other app
+            assertThat(listAs(TEST_APP_A, DCIM_DIR.getAbsolutePath()))
+                    .doesNotContain(hiddenImageFileName);
+
+            final int testAppUid =
+                    getContext().getPackageManager().getPackageUid(TEST_APP_A.getPackageName(), 0);
+            // FileManager can see the hidden image file created by other app
+            try {
+                allowAppOpsToUid(testAppUid, OPSTR_MANAGE_EXTERNAL_STORAGE);
+                assertThat(listAs(TEST_APP_A, DCIM_DIR.getAbsolutePath()))
+                        .contains(hiddenImageFileName);
+            } finally {
+                denyAppOpsToUid(testAppUid, OPSTR_MANAGE_EXTERNAL_STORAGE);
+            }
+
+            // Gallery can not see the hidden image file created by other app
+            try {
+                allowAppOpsToUid(testAppUid, SYSTEM_GALERY_APPOPS);
+                assertThat(listAs(TEST_APP_A, DCIM_DIR.getAbsolutePath()))
+                        .doesNotContain(hiddenImageFileName);
+            } finally {
+                denyAppOpsToUid(testAppUid, SYSTEM_GALERY_APPOPS);
+            }
+        } finally {
+            hiddenImageFile.delete();
+            uninstallAppNoThrow(TEST_APP_A);
         }
     }
 
@@ -1949,6 +2095,17 @@
             temporaryFile.delete();
         }
     }
+
+    private static void assertIsMediaTypeImage(File file) {
+        final Cursor c = queryImageFile(file);
+        assertEquals(1, c.getCount());
+    }
+
+    private static void assertNotMediaTypeImage(File file) {
+        final Cursor c = queryImageFile(file);
+        assertEquals(0, c.getCount());
+    }
+
     private static void assertCantQueryFile(File file) {
         assertThat(getFileUri(file)).isNull();
     }
diff --git a/tests/src/com/android/providers/media/MediaProviderForFuseTest.java b/tests/src/com/android/providers/media/MediaProviderForFuseTest.java
index 9ef97b1..994803d 100644
--- a/tests/src/com/android/providers/media/MediaProviderForFuseTest.java
+++ b/tests/src/com/android/providers/media/MediaProviderForFuseTest.java
@@ -124,6 +124,7 @@
         final File file = new File(sTestDir, "test" + System.nanoTime() + ".jpg");
         Truth.assertThat(sMediaProvider.insertFileIfNecessaryForFuse(
                 file.getPath(), sTestUid)).isEqualTo(0);
+        Truth.assertThat(file.createNewFile()).isTrue();
 
         // Rename directory should bring along files
         final File renamed = new File(sTestDir.getParentFile(), "renamed" + System.nanoTime());