Ensure default folders and Camera folders are always visible

".nomedia" presence in a directory makes MediaProvider treat that
directory as a hidden directory. MEDIA_TYPE for all files in the hidden
directory will be set to MEDIA_TYPE_NONE hence apps can't use content
uris of media(like images or video) for files in the hidden directory.

Top level default directories and Camera directories are well-known
directories and should always be visible despite ".nomedia" presense.
Any unintentional/intentional ".nomedia" file addition to these
directories shouldn't hurt other apps that are dependent on these
directories to be always visible.

Changed isDirectoryHidden to ignore any ".nomedia" presence in top level
default directories and "DCIM/Camera", and treat these directories as
non-hidden.

Bug: 168830497
Test: atest
android.scopedstorage.cts.host.ScopedStorageHostTest#testCanWriteToDCIMCameraWithNomedia
Test: atest
com.android.providers.media.scan.ModernMediaScannerTest#testVisibleDefaultFolders

Change-Id: I6a529a1d123134de238f4c494d04037137ee252c
Merged-In: I6a529a1d123134de238f4c494d04037137ee252c
(cherry picked from commit e44080f2790dd80c796d17e41eba8040801700dc)
diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java
index c7598db..626bbb8 100644
--- a/src/com/android/providers/media/MediaProvider.java
+++ b/src/com/android/providers/media/MediaProvider.java
@@ -54,6 +54,7 @@
 import static com.android.providers.media.scan.MediaScanner.REASON_DEMAND;
 import static com.android.providers.media.scan.MediaScanner.REASON_IDLE;
 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.extractFileName;
@@ -749,29 +750,6 @@
         }
     }
 
-    private static final String[] sDefaultFolderNames = {
-            Environment.DIRECTORY_MUSIC,
-            Environment.DIRECTORY_PODCASTS,
-            Environment.DIRECTORY_RINGTONES,
-            Environment.DIRECTORY_ALARMS,
-            Environment.DIRECTORY_NOTIFICATIONS,
-            Environment.DIRECTORY_PICTURES,
-            Environment.DIRECTORY_MOVIES,
-            Environment.DIRECTORY_DOWNLOADS,
-            Environment.DIRECTORY_DCIM,
-            Environment.DIRECTORY_AUDIOBOOKS,
-            Environment.DIRECTORY_DOCUMENTS,
-    };
-
-    private static boolean isDefaultDirectoryName(@Nullable String dirName) {
-        for (String defaultDirName : sDefaultFolderNames) {
-            if (defaultDirName.equals(dirName)) {
-                return true;
-            }
-        }
-        return false;
-    }
-
     /**
      * Ensure that default folders are created on mounted primary storage
      * devices. We only do this once per volume so we don't annoy the user if
@@ -796,7 +774,7 @@
             final SharedPreferences prefs = PreferenceManager
                     .getDefaultSharedPreferences(getContext());
             if (prefs.getInt(key, 0) == 0) {
-                for (String folderName : sDefaultFolderNames) {
+                for (String folderName : DEFAULT_FOLDER_NAMES) {
                     final File folder = new File(vol.getDirectory(), folderName);
                     if (!folder.exists()) {
                         folder.mkdirs();
@@ -2078,7 +2056,7 @@
             if (oldRelativePath.length == 1 && TextUtils.isEmpty(oldRelativePath[0])) {
                 // Allow rename of files/folders other than default directories.
                 final String displayName = extractDisplayName(oldPath);
-                for (String defaultFolder : sDefaultFolderNames) {
+                for (String defaultFolder : DEFAULT_FOLDER_NAMES) {
                     if (displayName.equals(defaultFolder)) {
                         Log.e(TAG, errorMessage + oldPath + " is a default folder."
                                 + " Renaming a default folder is not allowed.");
@@ -3054,7 +3032,11 @@
             if (isCallingPackageSelf() && values.containsKey(FileColumns.MEDIA_TYPE)) {
                 // Leave FileColumns.MEDIA_TYPE untouched if the caller is ModernMediaScanner and
                 // FileColumns.MEDIA_TYPE is already populated.
-            } else if (path != null && shouldFileBeHidden(new File(path))) {
+            } else if (isFuseThread() && path != null && shouldFileBeHidden(new File(path))) {
+                // We should only mark MEDIA_TYPE as MEDIA_TYPE_NONE for Fuse Thread.
+                // MediaProvider#insert() returns the uri by appending the "rowId" to the given
+                // uri, hence to ensure the correct working of the returned uri, we shouldn't
+                // change the MEDIA_TYPE in insert operation and let scan change it for us.
                 values.put(FileColumns.MEDIA_TYPE, FileColumns.MEDIA_TYPE_NONE);
             } else {
                 values.put(FileColumns.MEDIA_TYPE, MimeUtils.resolveMediaType(mimeType));
@@ -6952,7 +6934,7 @@
             if (isTopLevelDir) {
                 // We allow creating the default top level directories only, all other operations on
                 // top level directories are not allowed.
-                if (forCreate && isDefaultDirectoryName(extractDisplayName(path))) {
+                if (forCreate && FileUtils.isDefaultDirectoryName(extractDisplayName(path))) {
                     return 0;
                 }
                 Log.e(TAG,
@@ -7017,7 +6999,7 @@
                 final boolean isTopLevelDir =
                         relativePath.length == 1 && TextUtils.isEmpty(relativePath[0]);
                 if (isTopLevelDir) {
-                    if (isDefaultDirectoryName(extractDisplayName(path))) {
+                    if (FileUtils.isDefaultDirectoryName(extractDisplayName(path))) {
                         return 0;
                     } else {
                         Log.e(TAG,
diff --git a/src/com/android/providers/media/util/FileUtils.java b/src/com/android/providers/media/util/FileUtils.java
index 4515c6f..47c5bc1 100644
--- a/src/com/android/providers/media/util/FileUtils.java
+++ b/src/com/android/providers/media/util/FileUtils.java
@@ -892,6 +892,21 @@
     public static final Pattern PATTERN_DATA_OR_OBB_PATH = Pattern.compile(
             "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(?:data|obb)/?$");
 
+    @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,
+    };
+
     /**
      * Regex that matches paths for {@link MediaColumns#RELATIVE_PATH}; it
      * captures both top-level paths and sandboxed paths.
@@ -905,6 +920,9 @@
     private static final Pattern PATTERN_VOLUME_NAME = Pattern.compile(
             "(?i)^/storage/([^/]+)");
 
+    private static final String CAMERA_RELATIVE_PATH =
+            String.format("%s/%s/", Environment.DIRECTORY_DCIM, "Camera");
+
     private static @Nullable String normalizeUuid(@Nullable String fsUuid) {
         return fsUuid != null ? fsUuid.toLowerCase(Locale.ROOT) : null;
     }
@@ -1017,6 +1035,15 @@
         return relativePathSegments.length > 0 ? relativePathSegments[0] : null;
     }
 
+    public static boolean isDefaultDirectoryName(@Nullable String dirName) {
+        for (String defaultDirName : DEFAULT_FOLDER_NAMES) {
+            if (defaultDirName.equalsIgnoreCase(dirName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     /**
      * Compute the value of {@link MediaColumns#DATE_EXPIRES} based on other
      * columns being modified by this operation.
@@ -1214,12 +1241,32 @@
         }
 
         final File nomedia = new File(dir, ".nomedia");
+
         // check for .nomedia presence
-        if (nomedia.exists()) {
-            Logging.logPersistent("Observed non-standard " + nomedia);
-            return true;
+        if (!nomedia.exists()) {
+            return false;
         }
-        return false;
+
+        // Handle top-level default directories. These directories should always be visible,
+        // regardless of .nomedia presence.
+        final String[] relativePath = sanitizePath(extractRelativePath(dir.getAbsolutePath()));
+        final boolean isTopLevelDir =
+                relativePath.length == 1 && TextUtils.isEmpty(relativePath[0]);
+        if (isTopLevelDir && isDefaultDirectoryName(name)) {
+            nomedia.delete();
+            return false;
+        }
+
+        // DCIM/Camera should always be visible regardless of .nomedia presence.
+        if (CAMERA_RELATIVE_PATH.equalsIgnoreCase(
+                extractRelativePathForDirectory(dir.getAbsolutePath()))) {
+            nomedia.delete();
+            return false;
+        }
+
+        // .nomedia is present which makes this directory as hidden directory
+        Logging.logPersistent("Observed non-standard " + nomedia);
+        return true;
     }
 
     /**
diff --git a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
index ec6bad1..f7bb434 100644
--- a/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
+++ b/tests/src/com/android/providers/media/scan/ModernMediaScannerTest.java
@@ -45,6 +45,7 @@
 import static org.mockito.Mockito.when;
 
 import android.Manifest;
+import android.app.UiAutomation;
 import android.content.ContentResolver;
 import android.content.ContentUris;
 import android.content.ContentValues;
@@ -59,6 +60,7 @@
 import android.provider.MediaStore;
 import android.provider.MediaStore.Files.FileColumns;
 import android.provider.MediaStore.MediaColumns;
+import android.util.Log;
 import android.util.Pair;
 
 import androidx.test.InstrumentationRegistry;
@@ -68,20 +70,26 @@
 import com.android.providers.media.scan.MediaScannerTest.IsolatedContext;
 import com.android.providers.media.util.FileUtils;
 
+import com.google.common.io.ByteStreams;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InterruptedIOException;
 import java.util.Optional;
 
 @RunWith(AndroidJUnit4.class)
 public class ModernMediaScannerTest {
     // TODO: scan directory-vs-files and confirm identical results
 
+    private static final String TAG = "ModernMediaScannerTest";
     private File mDir;
 
     private Context mIsolatedContext;
@@ -402,6 +410,41 @@
         }
     }
 
+    private void assertVisibleFolder(File dir) throws Exception {
+        final File nomediaFile = new File(dir, ".nomedia");
+
+        if (!nomediaFile.getParentFile().exists()) {
+            assertTrue(nomediaFile.getParentFile().mkdirs());
+        }
+        try {
+            if (!nomediaFile.exists()) {
+                executeShellCommand("touch " + nomediaFile.getAbsolutePath());
+                assertTrue(nomediaFile.exists());
+            }
+            assertShouldScanPathAndIsPathHidden(true, false, dir);
+        } finally {
+            executeShellCommand("rm " + nomediaFile.getAbsolutePath());
+        }
+    }
+
+    /**
+     * b/168830497: Test that default folders and Camera folder are always visible
+     */
+    @Test
+    public void testVisibleDefaultFolders() throws Exception {
+        final File root = new File("storage/emulated/0");
+
+        // Top level directories should always be visible
+        for (String dirName : FileUtils.DEFAULT_FOLDER_NAMES) {
+            final File defaultFolder = new File(root, dirName);
+            assertVisibleFolder(defaultFolder);
+        }
+
+        // DCIM/Camera should always be visible
+        final File cameraDir = new File(root, Environment.DIRECTORY_DCIM + "/" + "Camera");
+        assertVisibleFolder(cameraDir);
+    }
+
     private static void assertShouldScanDirectory(File file) {
         assertTrue(file.getAbsolutePath(), shouldScanDirectory(file));
     }
@@ -881,4 +924,29 @@
             assertTrue(isFileAlbumArt(file));
         }
     }
+
+    /**
+     * Executes a shell command.
+     */
+    public static String executeShellCommand(String command) throws IOException {
+        int attempt = 0;
+        while (attempt++ < 5) {
+            try {
+                return executeShellCommandInternal(command);
+            } catch (InterruptedIOException e) {
+                // Hmm, we had trouble executing the shell command; the best we
+                // can do is try again a few more times
+                Log.v(TAG, "Trouble executing " + command + "; trying again", e);
+            }
+        }
+        throw new IOException("Failed to execute " + command);
+    }
+
+    private static String executeShellCommandInternal(String cmd) throws IOException {
+        UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
+        try (FileInputStream output = new FileInputStream(
+                uiAutomation.executeShellCommand(cmd).getFileDescriptor())) {
+            return new String(ByteStreams.toByteArray(output));
+        }
+    }
 }