summaryrefslogtreecommitdiff
path: root/hostsidetests/scopedstorage/general/src/android/scopedstorage/cts/general/ScopedStorageDeviceTest.java
diff options
context:
space:
mode:
Diffstat (limited to 'hostsidetests/scopedstorage/general/src/android/scopedstorage/cts/general/ScopedStorageDeviceTest.java')
-rw-r--r--hostsidetests/scopedstorage/general/src/android/scopedstorage/cts/general/ScopedStorageDeviceTest.java3590
1 files changed, 3590 insertions, 0 deletions
diff --git a/hostsidetests/scopedstorage/general/src/android/scopedstorage/cts/general/ScopedStorageDeviceTest.java b/hostsidetests/scopedstorage/general/src/android/scopedstorage/cts/general/ScopedStorageDeviceTest.java
new file mode 100644
index 00000000000..d620b17bdd2
--- /dev/null
+++ b/hostsidetests/scopedstorage/general/src/android/scopedstorage/cts/general/ScopedStorageDeviceTest.java
@@ -0,0 +1,3590 @@
+/*
+ * 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 android.scopedstorage.cts.general;
+
+import static android.app.AppOpsManager.permissionToOp;
+import static android.os.ParcelFileDescriptor.MODE_CREATE;
+import static android.os.ParcelFileDescriptor.MODE_READ_WRITE;
+import static android.scopedstorage.cts.lib.RedactionTestHelper.assertExifMetadataMatch;
+import static android.scopedstorage.cts.lib.RedactionTestHelper.assertExifMetadataMismatch;
+import static android.scopedstorage.cts.lib.RedactionTestHelper.getExifMetadataFromFile;
+import static android.scopedstorage.cts.lib.RedactionTestHelper.getExifMetadataFromRawResource;
+import static android.scopedstorage.cts.lib.TestUtils.BYTES_DATA2;
+import static android.scopedstorage.cts.lib.TestUtils.STR_DATA2;
+import static android.scopedstorage.cts.lib.TestUtils.addressStoragePermissions;
+import static android.scopedstorage.cts.lib.TestUtils.allowAppOpsToUid;
+import static android.scopedstorage.cts.lib.TestUtils.assertCanRenameDirectory;
+import static android.scopedstorage.cts.lib.TestUtils.assertCanRenameFile;
+import static android.scopedstorage.cts.lib.TestUtils.assertCantInsertToOtherPrivateAppDirectories;
+import static android.scopedstorage.cts.lib.TestUtils.assertCantRenameDirectory;
+import static android.scopedstorage.cts.lib.TestUtils.assertCantRenameFile;
+import static android.scopedstorage.cts.lib.TestUtils.assertCantUpdateToOtherPrivateAppDirectories;
+import static android.scopedstorage.cts.lib.TestUtils.assertDirectoryContains;
+import static android.scopedstorage.cts.lib.TestUtils.assertFileContent;
+import static android.scopedstorage.cts.lib.TestUtils.assertMountMode;
+import static android.scopedstorage.cts.lib.TestUtils.assertThrows;
+import static android.scopedstorage.cts.lib.TestUtils.canOpen;
+import static android.scopedstorage.cts.lib.TestUtils.canOpenFileAs;
+import static android.scopedstorage.cts.lib.TestUtils.canQueryOnUri;
+import static android.scopedstorage.cts.lib.TestUtils.checkPermission;
+import static android.scopedstorage.cts.lib.TestUtils.createFileAs;
+import static android.scopedstorage.cts.lib.TestUtils.deleteFileAs;
+import static android.scopedstorage.cts.lib.TestUtils.deleteFileAsNoThrow;
+import static android.scopedstorage.cts.lib.TestUtils.deleteRecursively;
+import static android.scopedstorage.cts.lib.TestUtils.deleteRecursivelyAs;
+import static android.scopedstorage.cts.lib.TestUtils.deleteWithMediaProvider;
+import static android.scopedstorage.cts.lib.TestUtils.deleteWithMediaProviderNoThrow;
+import static android.scopedstorage.cts.lib.TestUtils.denyAppOpsToUid;
+import static android.scopedstorage.cts.lib.TestUtils.executeShellCommand;
+import static android.scopedstorage.cts.lib.TestUtils.fileExistsAs;
+import static android.scopedstorage.cts.lib.TestUtils.getAlarmsDir;
+import static android.scopedstorage.cts.lib.TestUtils.getAndroidDataDir;
+import static android.scopedstorage.cts.lib.TestUtils.getAndroidMediaDir;
+import static android.scopedstorage.cts.lib.TestUtils.getAudiobooksDir;
+import static android.scopedstorage.cts.lib.TestUtils.getContentResolver;
+import static android.scopedstorage.cts.lib.TestUtils.getDcimDir;
+import static android.scopedstorage.cts.lib.TestUtils.getDocumentsDir;
+import static android.scopedstorage.cts.lib.TestUtils.getDownloadDir;
+import static android.scopedstorage.cts.lib.TestUtils.getExternalFilesDir;
+import static android.scopedstorage.cts.lib.TestUtils.getExternalMediaDir;
+import static android.scopedstorage.cts.lib.TestUtils.getExternalObbDir;
+import static android.scopedstorage.cts.lib.TestUtils.getExternalStorageDir;
+import static android.scopedstorage.cts.lib.TestUtils.getFileMimeTypeFromDatabase;
+import static android.scopedstorage.cts.lib.TestUtils.getFileOwnerPackageFromDatabase;
+import static android.scopedstorage.cts.lib.TestUtils.getFileRowIdFromDatabase;
+import static android.scopedstorage.cts.lib.TestUtils.getFileSizeFromDatabase;
+import static android.scopedstorage.cts.lib.TestUtils.getFileUri;
+import static android.scopedstorage.cts.lib.TestUtils.getImageContentUri;
+import static android.scopedstorage.cts.lib.TestUtils.getMoviesDir;
+import static android.scopedstorage.cts.lib.TestUtils.getMusicDir;
+import static android.scopedstorage.cts.lib.TestUtils.getNotificationsDir;
+import static android.scopedstorage.cts.lib.TestUtils.getPicturesDir;
+import static android.scopedstorage.cts.lib.TestUtils.getPodcastsDir;
+import static android.scopedstorage.cts.lib.TestUtils.getRecordingsDir;
+import static android.scopedstorage.cts.lib.TestUtils.getRingtonesDir;
+import static android.scopedstorage.cts.lib.TestUtils.grantPermission;
+import static android.scopedstorage.cts.lib.TestUtils.installApp;
+import static android.scopedstorage.cts.lib.TestUtils.installAppWithStoragePermissions;
+import static android.scopedstorage.cts.lib.TestUtils.listAs;
+import static android.scopedstorage.cts.lib.TestUtils.openWithMediaProvider;
+import static android.scopedstorage.cts.lib.TestUtils.pollForPermission;
+import static android.scopedstorage.cts.lib.TestUtils.queryAudioFile;
+import static android.scopedstorage.cts.lib.TestUtils.queryFile;
+import static android.scopedstorage.cts.lib.TestUtils.queryFileExcludingPending;
+import static android.scopedstorage.cts.lib.TestUtils.queryImageFile;
+import static android.scopedstorage.cts.lib.TestUtils.queryVideoFile;
+import static android.scopedstorage.cts.lib.TestUtils.readExifMetadataFromTestApp;
+import static android.scopedstorage.cts.lib.TestUtils.revokePermission;
+import static android.scopedstorage.cts.lib.TestUtils.setAppOpsModeForUid;
+import static android.scopedstorage.cts.lib.TestUtils.setAttrAs;
+import static android.scopedstorage.cts.lib.TestUtils.trashFileAndAssert;
+import static android.scopedstorage.cts.lib.TestUtils.uninstallAppNoThrow;
+import static android.scopedstorage.cts.lib.TestUtils.untrashFileAndAssert;
+import static android.scopedstorage.cts.lib.TestUtils.updateDisplayNameWithMediaProvider;
+import static android.scopedstorage.cts.lib.TestUtils.verifyInsertFromExternalMediaDirViaRelativePath_allowed;
+import static android.scopedstorage.cts.lib.TestUtils.verifyInsertFromExternalPrivateDirViaRelativePath_denied;
+import static android.scopedstorage.cts.lib.TestUtils.verifyUpdateToExternalMediaDirViaRelativePath_allowed;
+import static android.scopedstorage.cts.lib.TestUtils.verifyUpdateToExternalPrivateDirsViaRelativePath_denied;
+import static android.system.OsConstants.F_OK;
+import static android.system.OsConstants.O_APPEND;
+import static android.system.OsConstants.O_CREAT;
+import static android.system.OsConstants.O_EXCL;
+import static android.system.OsConstants.O_RDWR;
+import static android.system.OsConstants.O_TRUNC;
+import static android.system.OsConstants.R_OK;
+import static android.system.OsConstants.S_IRWXU;
+import static android.system.OsConstants.W_OK;
+
+import static androidx.test.InstrumentationRegistry.getContext;
+import static androidx.test.InstrumentationRegistry.getTargetContext;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+
+import android.Manifest;
+import android.app.AppOpsManager;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.pm.ProviderInfo;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.FileUtils;
+import android.os.ParcelFileDescriptor;
+import android.os.Process;
+import android.os.storage.StorageManager;
+import android.provider.DocumentsContract;
+import android.provider.MediaStore;
+import android.scopedstorage.cts.lib.RedactionTestHelper;
+import android.scopedstorage.cts.lib.ScopedStorageBaseDeviceTest;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.StructStat;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.test.filters.SdkSuppress;
+
+import com.android.compatibility.common.util.FeatureUtil;
+import com.android.cts.install.lib.TestApp;
+import com.android.modules.utils.build.SdkLevel;
+
+import com.google.common.io.Files;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+
+/**
+ * Device-side test suite to verify scoped storage business logic.
+ */
+@RunWith(Parameterized.class)
+public class ScopedStorageDeviceTest extends ScopedStorageBaseDeviceTest {
+ public static final String STR_DATA1 = "Just some random text";
+
+ public static final byte[] BYTES_DATA1 = STR_DATA1.getBytes();
+
+ static final String TAG = "ScopedStorageDeviceTest";
+ static final String THIS_PACKAGE_NAME = getContext().getPackageName();
+
+ /**
+ * To help avoid flaky tests, give ourselves a unique nonce to be used for
+ * all filesystem paths, so that we don't risk conflicting with previous
+ * test runs.
+ */
+ static final String NONCE = String.valueOf(System.nanoTime());
+
+ static final String TEST_DIRECTORY_NAME = "ScopedStorageDeviceTestDirectory" + NONCE;
+
+ static final String AUDIO_FILE_NAME = "ScopedStorageDeviceTest_file_" + NONCE + ".mp3";
+ static final String PLAYLIST_FILE_NAME = "ScopedStorageDeviceTest_file_" + NONCE + ".m3u";
+ static final String SUBTITLE_FILE_NAME = "ScopedStorageDeviceTest_file_" + NONCE + ".srt";
+ static final String VIDEO_FILE_NAME = "ScopedStorageDeviceTest_file_" + NONCE + ".mp4";
+ static final String IMAGE_FILE_NAME = "ScopedStorageDeviceTest_file_" + NONCE + ".jpg";
+ static final String NONMEDIA_FILE_NAME = "ScopedStorageDeviceTest_file_" + NONCE + ".pdf";
+
+ static final String FILE_CREATION_ERROR_MESSAGE = "No such file or directory";
+
+ // The following apps are installed before the tests are run via a target_preparer.
+ // See test config for details.
+ // An app with READ_EXTERNAL_STORAGE and READ_MEDIA_* permissions
+ private static final TestApp APP_A_HAS_RES =
+ new TestApp(
+ "TestAppA",
+ "android.scopedstorage.cts.testapp.A.withres",
+ 1,
+ false,
+ "CtsScopedStorageTestAppA.apk");
+ // An app with no permissions
+ private static final TestApp APP_B_NO_PERMS = new TestApp("TestAppB",
+ "android.scopedstorage.cts.testapp.B.noperms", 1, false,
+ "CtsScopedStorageTestAppB.apk");
+ // An app that has file manager (MANAGE_EXTERNAL_STORAGE) permission.
+ private static final TestApp APP_FM = new TestApp("TestAppFileManager",
+ "android.scopedstorage.cts.testapp.filemanager", 1, false,
+ "CtsScopedStorageTestAppFileManager.apk");
+ // A legacy targeting app with RES and WES permissions
+ private static final TestApp APP_D_LEGACY_HAS_RW = new TestApp("TestAppDLegacy",
+ "android.scopedstorage.cts.testapp.D", 1, false, "CtsScopedStorageTestAppDLegacy.apk");
+ private static final TestApp APP_E = new TestApp("TestAppE",
+ "android.scopedstorage.cts.testapp.E", 1, false, "CtsScopedStorageTestAppE.apk");
+ private static final TestApp APP_E_LEGACY = new TestApp("TestAppELegacy",
+ "android.scopedstorage.cts.testapp.E.legacy", 1, false,
+ "CtsScopedStorageTestAppELegacy.apk");
+ // APP_GENERAL_ONLY is not installed at test startup - please install before using.
+ private static final TestApp APP_GENERAL_ONLY = new TestApp("TestAppGeneralOnly",
+ "android.scopedstorage.cts.testapp.general.only", 1, false,
+ "CtsScopedStorageGeneralTestOnlyApp.apk");
+
+ private static final String[] SYSTEM_GALERY_APPOPS = {
+ AppOpsManager.OPSTR_WRITE_MEDIA_IMAGES, AppOpsManager.OPSTR_WRITE_MEDIA_VIDEO};
+ private static final String OPSTR_MANAGE_EXTERNAL_STORAGE =
+ permissionToOp(Manifest.permission.MANAGE_EXTERNAL_STORAGE);
+
+ private static final String TRANSFORMS_DIR = ".transforms";
+ private static final String TRANSFORMS_TRANSCODE_DIR = TRANSFORMS_DIR + "/" + "transcode";
+ private static final String TRANSFORMS_SYNTHETIC_DIR = TRANSFORMS_DIR + "/" + "synthetic";
+
+ @Parameter(0)
+ public String mVolumeName;
+
+ /** Parameters data. */
+ @Parameters(name = "volume={0}")
+ public static Iterable<? extends Object> data() {
+ return ScopedStorageDeviceTest.getTestParameters();
+ }
+
+ @BeforeClass
+ public static void setupApps() throws Exception {
+ // File manager needs to be explicitly granted MES app op.
+ final int fmUid =
+ getContext().getPackageManager().getPackageUid(APP_FM.getPackageName(),
+ 0);
+ allowAppOpsToUid(fmUid, OPSTR_MANAGE_EXTERNAL_STORAGE);
+
+ // Others are installed by target preparer with runtime permissions.
+ // Verify.
+ assertThat(checkPermission(APP_A_HAS_RES,
+ Manifest.permission.READ_EXTERNAL_STORAGE)).isTrue();
+ assertThat(checkPermission(APP_B_NO_PERMS,
+ Manifest.permission.READ_EXTERNAL_STORAGE)).isFalse();
+ assertThat(checkPermission(APP_D_LEGACY_HAS_RW,
+ Manifest.permission.READ_EXTERNAL_STORAGE)).isTrue();
+ assertThat(checkPermission(APP_D_LEGACY_HAS_RW,
+ Manifest.permission.WRITE_EXTERNAL_STORAGE)).isTrue();
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ executeShellCommand("rm -r /sdcard/Android/data/com.android.shell");
+ }
+
+ @Before
+ public void setupExternalStorage() throws Exception {
+ super.setupExternalStorage(mVolumeName);
+ Log.i(TAG, "Using volume : " + mVolumeName);
+ }
+
+ /**
+ * Test that we enforce certain media types can only be created in certain directories.
+ */
+ @Test
+ public void testTypePathConformity() throws Exception {
+ final File dcimDir = getDcimDir();
+ final File documentsDir = getDocumentsDir();
+ final File downloadDir = getDownloadDir();
+ final File moviesDir = getMoviesDir();
+ final File musicDir = getMusicDir();
+ final File picturesDir = getPicturesDir();
+ // Only audio files can be created in Music
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(musicDir, NONMEDIA_FILE_NAME).createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(musicDir, VIDEO_FILE_NAME).createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(musicDir, IMAGE_FILE_NAME).createNewFile();
+ });
+ // Only video files can be created in Movies
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(moviesDir, NONMEDIA_FILE_NAME).createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(moviesDir, AUDIO_FILE_NAME).createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(moviesDir, IMAGE_FILE_NAME).createNewFile();
+ });
+ // Only image and video files can be created in DCIM
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(dcimDir, NONMEDIA_FILE_NAME).createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(dcimDir, AUDIO_FILE_NAME).createNewFile();
+ });
+ // Only image and video files can be created in Pictures
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(picturesDir, NONMEDIA_FILE_NAME).createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(picturesDir, AUDIO_FILE_NAME).createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(picturesDir, PLAYLIST_FILE_NAME).createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(dcimDir, SUBTITLE_FILE_NAME).createNewFile();
+ });
+
+ assertCanCreateFile(new File(getAlarmsDir(), AUDIO_FILE_NAME));
+ assertCanCreateFile(new File(getAudiobooksDir(), AUDIO_FILE_NAME));
+ assertCanCreateFile(new File(dcimDir, IMAGE_FILE_NAME));
+ assertCanCreateFile(new File(dcimDir, VIDEO_FILE_NAME));
+ assertCanCreateFile(new File(documentsDir, AUDIO_FILE_NAME));
+ assertCanCreateFile(new File(documentsDir, IMAGE_FILE_NAME));
+ assertCanCreateFile(new File(documentsDir, NONMEDIA_FILE_NAME));
+ assertCanCreateFile(new File(documentsDir, PLAYLIST_FILE_NAME));
+ assertCanCreateFile(new File(documentsDir, SUBTITLE_FILE_NAME));
+ assertCanCreateFile(new File(documentsDir, VIDEO_FILE_NAME));
+ assertCanCreateFile(new File(downloadDir, AUDIO_FILE_NAME));
+ assertCanCreateFile(new File(downloadDir, IMAGE_FILE_NAME));
+ assertCanCreateFile(new File(downloadDir, NONMEDIA_FILE_NAME));
+ assertCanCreateFile(new File(downloadDir, PLAYLIST_FILE_NAME));
+ assertCanCreateFile(new File(downloadDir, SUBTITLE_FILE_NAME));
+ assertCanCreateFile(new File(downloadDir, VIDEO_FILE_NAME));
+ assertCanCreateFile(new File(moviesDir, VIDEO_FILE_NAME));
+ assertCanCreateFile(new File(moviesDir, SUBTITLE_FILE_NAME));
+ assertCanCreateFile(new File(musicDir, AUDIO_FILE_NAME));
+ assertCanCreateFile(new File(musicDir, PLAYLIST_FILE_NAME));
+ assertCanCreateFile(new File(getNotificationsDir(), AUDIO_FILE_NAME));
+ assertCanCreateFile(new File(picturesDir, IMAGE_FILE_NAME));
+ assertCanCreateFile(new File(picturesDir, VIDEO_FILE_NAME));
+ assertCanCreateFile(new File(getPodcastsDir(), AUDIO_FILE_NAME));
+ assertCanCreateFile(new File(getRingtonesDir(), AUDIO_FILE_NAME));
+
+ // No file whatsoever can be created in the top level directory
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(getExternalStorageDir(), NONMEDIA_FILE_NAME).createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(getExternalStorageDir(), AUDIO_FILE_NAME).createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(getExternalStorageDir(), IMAGE_FILE_NAME).createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(getExternalStorageDir(), VIDEO_FILE_NAME).createNewFile();
+ });
+ }
+
+ /**
+ * Test that we enforce certain media types can only be created in certain directories.
+ */
+ @Test
+ @SdkSuppress(minSdkVersion = 31, codeName = "S")
+ public void testTypePathConformity_recordingsDir() throws Exception {
+ final File recordingsDir = getRecordingsDir();
+
+ // Only audio files can be created in Recordings
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(recordingsDir, NONMEDIA_FILE_NAME).createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(recordingsDir, VIDEO_FILE_NAME).createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ new File(recordingsDir, IMAGE_FILE_NAME).createNewFile();
+ });
+
+ assertCanCreateFile(new File(recordingsDir, AUDIO_FILE_NAME));
+ }
+
+ /**
+ * Test that we can create a file in app's external files directory,
+ * and that we can write and read to/from the file.
+ */
+ @Test
+ public void testCreateFileInAppExternalDir() throws Exception {
+ final File file = new File(getExternalFilesDir(), "text.txt");
+ try {
+ assertThat(file.createNewFile()).isTrue();
+ assertThat(file.delete()).isTrue();
+ // Ensure the file is properly deleted and can be created again
+ assertThat(file.createNewFile()).isTrue();
+
+ // Write to file
+ try (FileOutputStream fos = new FileOutputStream(file)) {
+ fos.write(BYTES_DATA1);
+ }
+
+ // Read the same data from file
+ assertFileContent(file, BYTES_DATA1);
+ } finally {
+ file.delete();
+ }
+ }
+
+ /**
+ * Test that we can't create a file in another app's external files directory,
+ * and that we'll get the same error regardless of whether the app exists or not.
+ */
+ @Test
+ public void testCreateFileInOtherAppExternalDir() throws Exception {
+ // Creating a file in a non existent package dir should return ENOENT, as expected
+ final File nonexistentPackageFileDir = new File(
+ getExternalFilesDir().getPath().replace(THIS_PACKAGE_NAME, "no.such.package"));
+ final File file1 = new File(nonexistentPackageFileDir, NONMEDIA_FILE_NAME);
+ assertThrows(
+ IOException.class, FILE_CREATION_ERROR_MESSAGE, () -> {
+ file1.createNewFile();
+ });
+
+ // Creating a file in an existent package dir should give the same error string to avoid
+ // leaking installed app names, and we know the following directory exists because shell
+ // mkdirs it in test setup
+ final File shellPackageFileDir = new File(
+ getExternalFilesDir().getPath().replace(THIS_PACKAGE_NAME, "com.android.shell"));
+ final File file2 = new File(shellPackageFileDir, NONMEDIA_FILE_NAME);
+ assertThrows(
+ IOException.class, FILE_CREATION_ERROR_MESSAGE, () -> {
+ file1.createNewFile();
+ });
+ }
+
+ /**
+ * Test that apps can't read/write files in another app's external files directory,
+ * and can do so in their own app's external file directory.
+ */
+ @Test
+ public void testReadWriteFilesInOtherAppExternalDir() throws Exception {
+ final File videoFile = new File(getExternalFilesDir(), VIDEO_FILE_NAME);
+
+ try {
+ // Create a file in app's external files directory
+ if (!videoFile.exists()) {
+ assertThat(videoFile.createNewFile()).isTrue();
+ }
+
+ // App A should not be able to read/write to other app's external files directory.
+ assertThat(canOpenFileAs(APP_A_HAS_RES, videoFile, false /* forWrite */)).isFalse();
+ assertThat(canOpenFileAs(APP_A_HAS_RES, videoFile, true /* forWrite */)).isFalse();
+ // App A should not be able to delete files in other app's external files
+ // directory.
+ assertThat(deleteFileAs(APP_A_HAS_RES, videoFile.getPath())).isFalse();
+
+ // Apps should have read/write access in their own app's external files directory.
+ assertThat(canOpen(videoFile, false /* forWrite */)).isTrue();
+ assertThat(canOpen(videoFile, true /* forWrite */)).isTrue();
+ // Apps should be able to delete files in their own app's external files directory.
+ assertThat(videoFile.delete()).isTrue();
+ } finally {
+ videoFile.delete();
+ }
+ }
+
+ /**
+ * Test that we can contribute media without any permissions.
+ */
+ @Test
+ public void testContributeMediaFile() throws Exception {
+ final File imageFile = new File(getDcimDir(), IMAGE_FILE_NAME);
+
+ try {
+ assertThat(imageFile.createNewFile()).isTrue();
+
+ // Ensure that the file was successfully added to the MediaProvider database
+ assertThat(getFileOwnerPackageFromDatabase(imageFile)).isEqualTo(THIS_PACKAGE_NAME);
+
+ // Try to write random data to the file
+ try (FileOutputStream fos = new FileOutputStream(imageFile)) {
+ fos.write(BYTES_DATA1);
+ fos.write(BYTES_DATA2);
+ }
+
+ final byte[] expected = (STR_DATA1 + STR_DATA2).getBytes();
+ assertFileContent(imageFile, expected);
+
+ // Closing the file after writing will not trigger a MediaScan. Call scanFile to update
+ // file's entry in MediaProvider's database.
+ assertThat(MediaStore.scanFile(getContentResolver(), imageFile)).isNotNull();
+
+ // Ensure that the scan was completed and the file's size was updated.
+ assertThat(getFileSizeFromDatabase(imageFile)).isEqualTo(
+ BYTES_DATA1.length + BYTES_DATA2.length);
+ } finally {
+ imageFile.delete();
+ }
+ // Ensure that delete makes a call to MediaProvider to remove the file from its database.
+ assertThat(getFileRowIdFromDatabase(imageFile)).isEqualTo(-1);
+ }
+
+ @Test
+ public void testCreateAndDeleteEmptyDir() throws Exception {
+ final File externalFilesDir = getExternalFilesDir();
+ // Remove directory in order to create it again
+ deleteRecursively(externalFilesDir);
+
+ // Can create own external files dir
+ assertThat(externalFilesDir.mkdir()).isTrue();
+
+ final File dir1 = new File(externalFilesDir, "random_dir");
+ // Can create dirs inside it
+ assertThat(dir1.mkdir()).isTrue();
+
+ final File dir2 = new File(dir1, "random_dir_inside_random_dir");
+ // And create a dir inside the new dir
+ assertThat(dir2.mkdir()).isTrue();
+
+ // And can delete them all
+ assertThat(deleteRecursively(dir2)).isTrue();
+ assertThat(deleteRecursively(dir1)).isTrue();
+ assertThat(deleteRecursively(externalFilesDir)).isTrue();
+
+ // Can't create external dir for other apps
+ final File nonexistentPackageFileDir = new File(
+ externalFilesDir.getPath().replace(THIS_PACKAGE_NAME, "no.such.package"));
+ final File shellPackageFileDir = new File(
+ externalFilesDir.getPath().replace(THIS_PACKAGE_NAME, "com.android.shell"));
+
+ assertThat(nonexistentPackageFileDir.mkdir()).isFalse();
+ assertThat(shellPackageFileDir.mkdir()).isFalse();
+ }
+
+ @Test
+ public void testCantAccessOtherAppsContents() throws Exception {
+ final File mediaFile = new File(getPicturesDir(), IMAGE_FILE_NAME);
+ final File nonMediaFile = new File(getDownloadDir(), NONMEDIA_FILE_NAME);
+ try {
+ assertThat(createFileAs(APP_B_NO_PERMS, mediaFile.getPath())).isTrue();
+ assertThat(createFileAs(APP_B_NO_PERMS, nonMediaFile.getPath())).isTrue();
+
+ // We can still see that the files exist
+ assertThat(mediaFile.exists()).isTrue();
+ assertThat(nonMediaFile.exists()).isTrue();
+
+ // But we can't access their content
+ assertThat(canOpen(mediaFile, /* forWrite */ false)).isFalse();
+ assertThat(canOpen(nonMediaFile, /* forWrite */ true)).isFalse();
+ assertThat(canOpen(mediaFile, /* forWrite */ false)).isFalse();
+ assertThat(canOpen(nonMediaFile, /* forWrite */ true)).isFalse();
+ } finally {
+ deleteFileAsNoThrow(APP_B_NO_PERMS, nonMediaFile.getPath());
+ deleteFileAsNoThrow(APP_B_NO_PERMS, mediaFile.getPath());
+ }
+ }
+
+ @Test
+ public void testCantDeleteOtherAppsContents() throws Exception {
+ final File dirInDownload = new File(getDownloadDir(), TEST_DIRECTORY_NAME);
+ final File mediaFile = new File(dirInDownload, IMAGE_FILE_NAME);
+ final File nonMediaFile = new File(dirInDownload, NONMEDIA_FILE_NAME);
+ try {
+ assertThat(dirInDownload.mkdir()).isTrue();
+ // Have another app create a media file in the directory
+ assertThat(createFileAs(APP_B_NO_PERMS, mediaFile.getPath())).isTrue();
+
+ // Can't delete the directory since it contains another app's content
+ assertThat(dirInDownload.delete()).isFalse();
+ // Can't delete another app's content
+ assertThat(deleteRecursively(dirInDownload)).isFalse();
+
+ // Have another app create a non-media file in the directory
+ assertThat(createFileAs(APP_B_NO_PERMS, nonMediaFile.getPath())).isTrue();
+
+ // Can't delete the directory since it contains another app's content
+ assertThat(dirInDownload.delete()).isFalse();
+ // Can't delete another app's content
+ assertThat(deleteRecursively(dirInDownload)).isFalse();
+
+ // Delete only the media file and keep the non-media file
+ assertThat(deleteFileAs(APP_B_NO_PERMS, mediaFile.getPath())).isTrue();
+ // Directory now has only the non-media file contributed by another app, so we still
+ // can't delete it nor its content
+ assertThat(dirInDownload.delete()).isFalse();
+ assertThat(deleteRecursively(dirInDownload)).isFalse();
+
+ // Delete the last file belonging to another app
+ assertThat(deleteFileAs(APP_B_NO_PERMS, nonMediaFile.getPath())).isTrue();
+ // Create our own file
+ assertThat(nonMediaFile.createNewFile()).isTrue();
+
+ // Now that the directory only has content that was contributed by us, we can delete it
+ assertThat(deleteRecursively(dirInDownload)).isTrue();
+ } finally {
+ deleteFileAsNoThrow(APP_B_NO_PERMS, nonMediaFile.getPath());
+ deleteFileAsNoThrow(APP_B_NO_PERMS, mediaFile.getPath());
+ // At this point, we're not sure who created this file, so we'll have both apps
+ // deleting it
+ mediaFile.delete();
+ deleteRecursively(dirInDownload);
+ }
+ }
+
+ /**
+ * Test that deleting uri corresponding to a file which was already deleted via filePath
+ * doesn't result in a security exception.
+ */
+ @Test
+ public void testDeleteAlreadyUnlinkedFile() throws Exception {
+ final File nonMediaFile = new File(getDownloadDir(), NONMEDIA_FILE_NAME);
+ try {
+ assertTrue(nonMediaFile.createNewFile());
+ final Uri uri = MediaStore.scanFile(getContentResolver(), nonMediaFile);
+ assertNotNull(uri);
+
+ // Delete the file via filePath
+ assertTrue(nonMediaFile.delete());
+
+ // If we delete nonMediaFile with ContentResolver#delete, it shouldn't result in a
+ // security exception.
+ assertThat(getContentResolver().delete(uri, Bundle.EMPTY)).isEqualTo(0);
+ } finally {
+ nonMediaFile.delete();
+ }
+ }
+
+ /**
+ * This test relies on the fact that {@link File#list} uses opendir internally, and that it
+ * returns {@code null} if opendir fails.
+ */
+ @Test
+ public void testOpendirRestrictions() throws Exception {
+ // Opening a non existent package directory should fail, as expected
+ final File nonexistentPackageFileDir = new File(
+ getExternalFilesDir().getPath().replace(THIS_PACKAGE_NAME, "no.such.package"));
+ assertThat(nonexistentPackageFileDir.list()).isNull();
+
+ // Opening another package's external directory should fail as well, even if it exists
+ final File shellPackageFileDir = new File(
+ getExternalFilesDir().getPath().replace(THIS_PACKAGE_NAME, "com.android.shell"));
+ assertThat(shellPackageFileDir.list()).isNull();
+
+ // We can open our own external files directory
+ final String[] filesList = getExternalFilesDir().list();
+ assertThat(filesList).isNotNull();
+
+ // We can open any public directory in external storage
+ assertThat(getDcimDir().list()).isNotNull();
+ assertThat(getDownloadDir().list()).isNotNull();
+ assertThat(getMoviesDir().list()).isNotNull();
+ assertThat(getMusicDir().list()).isNotNull();
+
+ // We can open the root directory of external storage
+ final String[] topLevelDirs = getExternalStorageDir().list();
+ assertThat(topLevelDirs).isNotNull();
+ // TODO(b/145287327): This check fails on a device with no visible files.
+ // This can be fixed if we display default directories.
+ // assertThat(topLevelDirs).isNotEmpty();
+ }
+
+ @Test
+ public void testLowLevelFileIO() throws Exception {
+ String filePath = new File(getDownloadDir(), NONMEDIA_FILE_NAME).toString();
+ try {
+ int createFlags = O_CREAT | O_RDWR;
+ int createExclFlags = createFlags | O_EXCL;
+
+ FileDescriptor fd = Os.open(filePath, createExclFlags, S_IRWXU);
+ Os.close(fd);
+ assertThrows(
+ ErrnoException.class, () -> {
+ Os.open(filePath, createExclFlags, S_IRWXU);
+ });
+
+ fd = Os.open(filePath, createFlags, S_IRWXU);
+ try {
+ assertThat(Os.write(fd,
+ ByteBuffer.wrap(BYTES_DATA1))).isEqualTo(BYTES_DATA1.length);
+ assertFileContent(fd, BYTES_DATA1);
+ } finally {
+ Os.close(fd);
+ }
+ // should just append the data
+ fd = Os.open(filePath, createFlags | O_APPEND, S_IRWXU);
+ try {
+ assertThat(Os.write(fd,
+ ByteBuffer.wrap(BYTES_DATA2))).isEqualTo(BYTES_DATA2.length);
+ final byte[] expected = (STR_DATA1 + STR_DATA2).getBytes();
+ assertFileContent(fd, expected);
+ } finally {
+ Os.close(fd);
+ }
+ // should overwrite everything
+ fd = Os.open(filePath, createFlags | O_TRUNC, S_IRWXU);
+ try {
+ final byte[] otherData = "this is different data".getBytes();
+ assertThat(Os.write(fd, ByteBuffer.wrap(otherData))).isEqualTo(otherData.length);
+ assertFileContent(fd, otherData);
+ } finally {
+ Os.close(fd);
+ }
+ } finally {
+ new File(filePath).delete();
+ }
+ }
+
+ /**
+ * Test that media files from other packages are only visible to apps with storage permission.
+ */
+ @Test
+ public void testListDirectoriesWithMediaFiles() throws Exception {
+ final File dcimDir = getDcimDir();
+ final File dir = new File(dcimDir, TEST_DIRECTORY_NAME);
+ final File videoFile = new File(dir, VIDEO_FILE_NAME);
+ final String videoFileName = videoFile.getName();
+ try {
+ if (!dir.exists()) {
+ assertThat(dir.mkdir()).isTrue();
+ }
+
+ assertThat(createFileAs(APP_B_NO_PERMS, videoFile.getPath())).isTrue();
+ // App B should see TEST_DIRECTORY in DCIM and new file in TEST_DIRECTORY.
+ assertThat(listAs(APP_B_NO_PERMS, dcimDir.getPath())).contains(TEST_DIRECTORY_NAME);
+ assertThat(listAs(APP_B_NO_PERMS, dir.getPath())).containsExactly(videoFileName);
+
+ // App A has storage permission, so should see TEST_DIRECTORY in DCIM and new file
+ // in TEST_DIRECTORY.
+ assertThat(listAs(APP_A_HAS_RES, dcimDir.getPath())).contains(TEST_DIRECTORY_NAME);
+ assertThat(listAs(APP_A_HAS_RES, dir.getPath())).containsExactly(videoFileName);
+
+ // We are an app without storage permission; should see TEST_DIRECTORY in DCIM and
+ // should not see new file in new TEST_DIRECTORY.
+ assertThat(dcimDir.list()).asList().contains(TEST_DIRECTORY_NAME);
+ assertThat(dir.list()).asList().doesNotContain(videoFileName);
+ } finally {
+ deleteFileAsNoThrow(APP_B_NO_PERMS, videoFile.getPath());
+ deleteRecursively(dir);
+ }
+ }
+
+ /**
+ * Test that app can't see non-media files created by other packages
+ */
+ @Test
+ public void testListDirectoriesWithNonMediaFiles() throws Exception {
+ final File downloadDir = getDownloadDir();
+ final File dir = new File(downloadDir, TEST_DIRECTORY_NAME);
+ final File pdfFile = new File(dir, NONMEDIA_FILE_NAME);
+ final String pdfFileName = pdfFile.getName();
+ try {
+ if (!dir.exists()) {
+ assertThat(dir.mkdir()).isTrue();
+ }
+
+ // Have App B create non media file in the new directory.
+ assertThat(createFileAs(APP_B_NO_PERMS, pdfFile.getPath())).isTrue();
+
+ // App B should see TEST_DIRECTORY in downloadDir and new non media file in
+ // TEST_DIRECTORY.
+ assertThat(listAs(APP_B_NO_PERMS, downloadDir.getPath())).contains(TEST_DIRECTORY_NAME);
+ assertThat(listAs(APP_B_NO_PERMS, dir.getPath())).containsExactly(pdfFileName);
+
+ // APP A with storage permission should see TEST_DIRECTORY in downloadDir
+ // and should not see non media file in TEST_DIRECTORY.
+ assertThat(listAs(APP_A_HAS_RES, downloadDir.getPath())).contains(TEST_DIRECTORY_NAME);
+ assertThat(listAs(APP_A_HAS_RES, dir.getPath())).doesNotContain(pdfFileName);
+ } finally {
+ deleteFileAsNoThrow(APP_B_NO_PERMS, pdfFile.getPath());
+ deleteRecursively(dir);
+ }
+ }
+
+ /**
+ * Test that app can only see its directory in Android/data.
+ */
+ @Test
+ public void testListFilesFromExternalFilesDirectory() throws Exception {
+ final String packageName = THIS_PACKAGE_NAME;
+ final File nonmediaFile = new File(getExternalFilesDir(), NONMEDIA_FILE_NAME);
+
+ try {
+ // Create a file in app's external files directory
+ if (!nonmediaFile.exists()) {
+ assertThat(nonmediaFile.createNewFile()).isTrue();
+ }
+ // App should see its directory and directories of shared packages. App should see all
+ // files and directories in its external directory.
+ assertDirectoryContains(nonmediaFile.getParentFile(), nonmediaFile);
+
+ // App A should not see other app's external files directory despite RES.
+ assertThrows(IOException.class,
+ () -> listAs(APP_A_HAS_RES, getAndroidDataDir().getPath()));
+ assertThrows(IOException.class,
+ () -> listAs(APP_A_HAS_RES, getExternalFilesDir().getPath()));
+ } finally {
+ nonmediaFile.delete();
+ }
+ }
+
+ /**
+ * Test that app can see files and directories in Android/media.
+ */
+ @Test
+ public void testListFilesFromExternalMediaDirectory() throws Exception {
+ final File videoFile = new File(getExternalMediaDir(), VIDEO_FILE_NAME);
+
+ try {
+ // Create a file in app's external media directory
+ if (!videoFile.exists()) {
+ assertThat(videoFile.createNewFile()).isTrue();
+ }
+
+ // App should see its directory and other app's external media directories with media
+ // files.
+ assertDirectoryContains(videoFile.getParentFile(), videoFile);
+
+ // App A with storage permission should see other app's external media directory.
+ // Apps with READ_EXTERNAL_STORAGE can list files in other app's external media
+ // directory.
+ assertThat(listAs(APP_A_HAS_RES, getAndroidMediaDir().getPath()))
+ .contains(THIS_PACKAGE_NAME);
+ assertThat(listAs(APP_A_HAS_RES, getExternalMediaDir().getPath()))
+ .containsExactly(videoFile.getName());
+ } finally {
+ videoFile.delete();
+ }
+ }
+
+ @Test
+ public void testMetaDataRedaction() throws Exception {
+ File jpgFile = new File(getPicturesDir(), "img_metadata.jpg");
+ try {
+ if (jpgFile.exists()) {
+ assertThat(jpgFile.delete()).isTrue();
+ }
+
+ HashMap<String, String> originalExif =
+ getExifMetadataFromRawResource(R.raw.img_with_metadata);
+
+ try (InputStream in =
+ getContext().getResources().openRawResource(R.raw.img_with_metadata);
+ FileOutputStream out = new FileOutputStream(jpgFile)) {
+ // Dump the image we have to external storage
+ FileUtils.copy(in, out);
+ // Sync file to disk to ensure file is fully written to the lower fs attempting to
+ // open for redaction. Otherwise, the FUSE daemon might not accurately parse the
+ // EXIF tags and might misleadingly think there are not tags to redact
+ out.getFD().sync();
+
+ HashMap<String, String> exif = getExifMetadataFromFile(jpgFile);
+ assertExifMetadataMatch(exif, originalExif);
+
+ HashMap<String, String> exifFromTestApp =
+ readExifMetadataFromTestApp(APP_A_HAS_RES, jpgFile.getPath());
+ // App does not have AML; shouldn't have access to the same metadata.
+ assertExifMetadataMismatch(exifFromTestApp, originalExif);
+
+ // TODO(b/146346138): Test that if we give APP_A write URI permission,
+ // it would be able to access the metadata.
+ } // Intentionally keep the original streams open during the test so bytes are more
+ // likely to be in the VFS cache from both file opens
+ } finally {
+ jpgFile.delete();
+ }
+ }
+
+ @Test
+ public void testOpenFilePathFirstWriteContentResolver() throws Exception {
+ String displayName = "open_file_path_write_content_resolver.jpg";
+ File file = new File(getDcimDir(), displayName);
+
+ try {
+ assertThat(file.createNewFile()).isTrue();
+
+ try (ParcelFileDescriptor readPfd = ParcelFileDescriptor.open(file, MODE_READ_WRITE);
+ ParcelFileDescriptor writePfd = openWithMediaProvider(file, "rw")) {
+ assertRWR(readPfd, writePfd);
+ assertUpperFsFd(writePfd); // With cache
+ }
+ } finally {
+ file.delete();
+ }
+ }
+
+ @Test
+ public void testOpenContentResolverFirstWriteContentResolver() throws Exception {
+ String displayName = "open_content_resolver_write_content_resolver.jpg";
+ File file = new File(getDcimDir(), displayName);
+
+ try {
+ assertThat(file.createNewFile()).isTrue();
+
+ try (ParcelFileDescriptor writePfd = openWithMediaProvider(file, "rw");
+ ParcelFileDescriptor readPfd = ParcelFileDescriptor.open(file, MODE_READ_WRITE)) {
+ assertRWR(readPfd, writePfd);
+ assertLowerFsFdWithPassthrough(file.getPath(), writePfd);
+ }
+ } finally {
+ file.delete();
+ }
+ }
+
+ @Test
+ public void testOpenFilePathFirstWriteFilePath() throws Exception {
+ String displayName = "open_file_path_write_file_path.jpg";
+ File file = new File(getDcimDir(), displayName);
+
+ try {
+ assertThat(file.createNewFile()).isTrue();
+
+ try (ParcelFileDescriptor writePfd = ParcelFileDescriptor.open(file, MODE_READ_WRITE);
+ ParcelFileDescriptor readPfd = openWithMediaProvider(file, "rw")) {
+ assertRWR(readPfd, writePfd);
+ assertUpperFsFd(readPfd); // With cache
+ }
+ } finally {
+ file.delete();
+ }
+ }
+
+ @Test
+ public void testOpenContentResolverFirstWriteFilePath() throws Exception {
+ String displayName = "open_content_resolver_write_file_path.jpg";
+ File file = new File(getDcimDir(), displayName);
+
+ try {
+ assertThat(file.createNewFile()).isTrue();
+
+ try (ParcelFileDescriptor readPfd = openWithMediaProvider(file, "rw");
+ ParcelFileDescriptor writePfd = ParcelFileDescriptor.open(file, MODE_READ_WRITE)) {
+ assertRWR(readPfd, writePfd);
+ assertLowerFsFdWithPassthrough(file.getPath(), readPfd);
+ }
+ } finally {
+ file.delete();
+ }
+ }
+
+ @Test
+ public void testOpenContentResolverWriteOnly() throws Exception {
+ String displayName = "open_content_resolver_write_only.jpg";
+ File file = new File(getDcimDir(), displayName);
+
+ try {
+ assertThat(file.createNewFile()).isTrue();
+
+ // We upgrade 'w' only to 'rw'
+ try (ParcelFileDescriptor writePfd = openWithMediaProvider(file, "w");
+ ParcelFileDescriptor readPfd = openWithMediaProvider(file, "rw")) {
+ assertRWR(readPfd, writePfd);
+ assertRWR(writePfd, readPfd); // Can read on 'w' only pfd
+ assertLowerFsFdWithPassthrough(file.getPath(), writePfd);
+ assertLowerFsFdWithPassthrough(file.getPath(), readPfd);
+ }
+ } finally {
+ file.delete();
+ }
+ }
+
+ @Test
+ public void testOpenContentResolverDup() throws Exception {
+ String displayName = "open_content_resolver_dup.jpg";
+ File file = new File(getDcimDir(), displayName);
+
+ try {
+ file.delete();
+ assertThat(file.createNewFile()).isTrue();
+
+ // Even if we close the original fd, since we have a dup open
+ // the FUSE IO should still bypass the cache
+ try (ParcelFileDescriptor writePfd = openWithMediaProvider(file, "rw");
+ ParcelFileDescriptor writePfdDup = writePfd.dup();
+ ParcelFileDescriptor readPfd = ParcelFileDescriptor.open(file, MODE_READ_WRITE)) {
+ writePfd.close();
+
+ assertRWR(readPfd, writePfdDup);
+ assertLowerFsFdWithPassthrough(file.getPath(), writePfdDup);
+ }
+ } finally {
+ file.delete();
+ }
+ }
+
+ @Test
+ public void testOpenContentResolverClose() throws Exception {
+ String displayName = "open_content_resolver_close.jpg";
+ File file = new File(getDcimDir(), displayName);
+
+ try {
+ byte[] readBuffer = new byte[10];
+ byte[] writeBuffer = new byte[10];
+ Arrays.fill(writeBuffer, (byte) 1);
+
+ assertThat(file.createNewFile()).isTrue();
+
+ // Lower fs open and write
+ ParcelFileDescriptor writePfd = openWithMediaProvider(file, "rw");
+ Os.pwrite(writePfd.getFileDescriptor(), writeBuffer, 0, 10, 0);
+
+ // Close so upper fs open will not use direct_io
+ writePfd.close();
+
+ // Upper fs open and read without direct_io
+ try (ParcelFileDescriptor readPfd = ParcelFileDescriptor.open(file, MODE_READ_WRITE)) {
+ Os.pread(readPfd.getFileDescriptor(), readBuffer, 0, 10, 0);
+
+ // Last write on lower fs is visible via upper fs
+ assertThat(readBuffer).isEqualTo(writeBuffer);
+ assertThat(readPfd.getStatSize()).isEqualTo(writeBuffer.length);
+ }
+ } finally {
+ file.delete();
+ }
+ }
+
+ @Test
+ public void testContentResolverDelete() throws Exception {
+ String displayName = "content_resolver_delete.jpg";
+ File file = new File(getDcimDir(), displayName);
+
+ try {
+ assertThat(file.createNewFile()).isTrue();
+
+ deleteWithMediaProvider(file);
+
+ assertThat(file.exists()).isFalse();
+ assertThat(file.createNewFile()).isTrue();
+ } finally {
+ file.delete();
+ }
+ }
+
+ @Test
+ public void testContentResolverUpdate() throws Exception {
+ String oldDisplayName = "content_resolver_update_old.jpg";
+ String newDisplayName = "content_resolver_update_new.jpg";
+ File oldFile = new File(getDcimDir(), oldDisplayName);
+ File newFile = new File(getDcimDir(), newDisplayName);
+
+ try {
+ assertThat(oldFile.createNewFile()).isTrue();
+ // Publish the pending oldFile before updating with MediaProvider. Not publishing the
+ // file will make MP consider pending from FUSE as explicit IS_PENDING
+ final Uri uri = MediaStore.scanFile(getContentResolver(), oldFile);
+ assertNotNull(uri);
+
+ updateDisplayNameWithMediaProvider(uri,
+ Environment.DIRECTORY_DCIM, oldDisplayName, newDisplayName);
+
+ assertThat(oldFile.exists()).isFalse();
+ assertThat(oldFile.createNewFile()).isTrue();
+ assertThat(newFile.exists()).isTrue();
+ assertThat(newFile.createNewFile()).isFalse();
+ } finally {
+ oldFile.delete();
+ newFile.delete();
+ }
+ }
+
+ void writeAndCheckMtime(final boolean append) throws Exception {
+ File file = new File(getDcimDir(), "update_modifies_mtime.jpg");
+
+ try {
+ assertThat(file.createNewFile()).isTrue();
+ assertThat(file.exists()).isTrue();
+
+ final long creationTime = file.lastModified();
+
+ // File should exist
+ assertNotEquals(creationTime, 0L);
+
+ // Sleep a bit more than 1 second because although
+ // File::lastModified() represents the duration in milliseconds,
+ // has 1 second precision.
+ // With lower sleep durations the test results flakey...
+ Thread.sleep(2000);
+
+ // Modification time should be the same as long the file has not
+ // been modified
+ assertEquals(creationTime, file.lastModified());
+
+ // Sleep a bit more than 1 second because although
+ // File::lastModified() represents the duration in milliseconds,
+ // has 1 second precision.
+ // With lower sleep durations the test results flakey...
+ Thread.sleep(2000);
+
+ // Assert we can write to the file
+ try (FileOutputStream fos = new FileOutputStream(file, append)) {
+ fos.write(BYTES_DATA1);
+ fos.close();
+ }
+
+ final long modificationTime = file.lastModified();
+
+ // As the file has been written, modification time should have
+ // changed
+ assertNotEquals(modificationTime, 0L);
+ assertNotEquals(modificationTime, creationTime);
+ } finally {
+ file.delete();
+ }
+ }
+
+ @Test
+ // There is a minor bug which, alghough fixed in sc-dev (aosp/1834457),
+ // cannot be propagated to the already released sc-release branche
+ // (b/234145920), where mainline-modules are tested.
+ // Skip this test in S to avoid failures in outdated targets.
+ @SdkSuppress(minSdkVersion = 33, codeName = "T")
+ public void testAppendUpdatesMtime() throws Exception {
+ writeAndCheckMtime(true);
+ }
+
+ @Test
+ public void testWriteUpdatesMtime() throws Exception {
+ writeAndCheckMtime(false);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 31, codeName = "S")
+ public void testDefaultNoIsolatedStorageFlag() throws Exception {
+ assertThat(Environment.isExternalStorageLegacy()).isFalse();
+ }
+
+ @Test
+ public void testCreateLowerCaseDeleteUpperCase() throws Exception {
+ File upperCase = new File(getDownloadDir(), "CREATE_LOWER_DELETE_UPPER");
+ File lowerCase = new File(getDownloadDir(), "create_lower_delete_upper");
+
+ createDeleteCreate(lowerCase, upperCase);
+ }
+
+ @Test
+ public void testCreateUpperCaseDeleteLowerCase() throws Exception {
+ File upperCase = new File(getDownloadDir(), "CREATE_UPPER_DELETE_LOWER");
+ File lowerCase = new File(getDownloadDir(), "create_upper_delete_lower");
+
+ createDeleteCreate(upperCase, lowerCase);
+ }
+
+ @Test
+ public void testCreateMixedCaseDeleteDifferentMixedCase() throws Exception {
+ File mixedCase1 = new File(getDownloadDir(), "CrEaTe_MiXeD_dElEtE_mIxEd");
+ File mixedCase2 = new File(getDownloadDir(), "cReAtE_mIxEd_DeLeTe_MiXeD");
+
+ createDeleteCreate(mixedCase1, mixedCase2);
+ }
+
+ @Test
+ public void testAndroidDataObbDoesNotForgetMount() throws Exception {
+ File dataDir = getContext().getExternalFilesDir(null);
+ File upperCaseDataDir = new File(dataDir.getPath().replace("Android/data", "ANDROID/DATA"));
+
+ File obbDir = getContext().getObbDir();
+ File upperCaseObbDir = new File(obbDir.getPath().replace("Android/obb", "ANDROID/OBB"));
+
+
+ StructStat beforeDataStruct = Os.stat(dataDir.getPath());
+ StructStat beforeObbStruct = Os.stat(obbDir.getPath());
+
+ assertThat(dataDir.exists()).isTrue();
+ assertThat(upperCaseDataDir.exists()).isTrue();
+ assertThat(obbDir.exists()).isTrue();
+ assertThat(upperCaseObbDir.exists()).isTrue();
+
+ StructStat afterDataStruct = Os.stat(upperCaseDataDir.getPath());
+ StructStat afterObbStruct = Os.stat(upperCaseObbDir.getPath());
+
+ assertThat(beforeDataStruct.st_dev).isEqualTo(afterDataStruct.st_dev);
+ assertThat(beforeObbStruct.st_dev).isEqualTo(afterObbStruct.st_dev);
+ }
+
+ @Test
+ public void testCacheConsistencyForCaseInsensitivity() throws Exception {
+ File upperCaseFile = new File(getDownloadDir(), "CACHE_CONSISTENCY_FOR_CASE_INSENSITIVITY");
+ File lowerCaseFile = new File(getDownloadDir(), "cache_consistency_for_case_insensitivity");
+
+ try (ParcelFileDescriptor upperCasePfd =
+ ParcelFileDescriptor.open(upperCaseFile, MODE_READ_WRITE | MODE_CREATE);
+ ParcelFileDescriptor lowerCasePfd =
+ ParcelFileDescriptor.open(lowerCaseFile, MODE_READ_WRITE | MODE_CREATE)) {
+
+ assertRWR(upperCasePfd, lowerCasePfd);
+ assertRWR(lowerCasePfd, upperCasePfd);
+ } finally {
+ upperCaseFile.delete();
+ lowerCaseFile.delete();
+ }
+ }
+
+ @Test
+ public void testInsertDefaultPrimaryCaseInsensitiveCheck() throws Exception {
+ final File podcastsDir = getPodcastsDir();
+ final File podcastsDirLowerCase =
+ new File(getExternalStorageDir(), Environment.DIRECTORY_PODCASTS.toLowerCase());
+ final File fileInPodcastsDirLowerCase = new File(podcastsDirLowerCase, AUDIO_FILE_NAME);
+ try {
+ // Delete the directory if it already exists
+ if (podcastsDir.exists()) {
+ deleteRecursivelyAsLegacyApp(podcastsDir);
+ }
+ assertThat(podcastsDir.exists()).isFalse();
+ assertThat(podcastsDirLowerCase.exists()).isFalse();
+
+ // Create the directory with lower case
+ assertThat(podcastsDirLowerCase.mkdir()).isTrue();
+ // Because of case-insensitivity, even though directory is created
+ // with lower case, we should be able to see both directory names.
+ assertThat(podcastsDirLowerCase.exists()).isTrue();
+ assertThat(podcastsDir.exists()).isTrue();
+
+ // File creation with lower case path of podcasts directory should not fail
+ assertThat(fileInPodcastsDirLowerCase.createNewFile()).isTrue();
+ } finally {
+ fileInPodcastsDirLowerCase.delete();
+ deleteRecursivelyAsLegacyApp(podcastsDirLowerCase);
+ podcastsDir.mkdirs();
+ }
+ }
+
+ private void createDeleteCreate(File create, File delete) throws Exception {
+ try {
+ assertThat(create.createNewFile()).isTrue();
+ // Wait for the kernel to update the dentry cache.
+ Thread.sleep(100);
+
+ assertThat(delete.delete()).isTrue();
+ // Wait for the kernel to clean up the dentry cache.
+ Thread.sleep(100);
+
+ assertThat(create.createNewFile()).isTrue();
+ // Wait for the kernel to update the dentry cache.
+ Thread.sleep(100);
+ } finally {
+ create.delete();
+ delete.delete();
+ }
+ }
+
+ @Test
+ public void testReadStorageInvalidation() throws Exception {
+ if (SdkLevel.isAtLeastT()) {
+ testAppOpInvalidation(
+ APP_E,
+ new File(getDcimDir(), "read_storage.jpg"),
+ Manifest.permission.READ_MEDIA_IMAGES,
+ AppOpsManager.OPSTR_READ_MEDIA_IMAGES,
+ /* forWrite */ false);
+ } else {
+ testAppOpInvalidation(APP_E, new File(getDcimDir(), "read_storage.jpg"),
+ Manifest.permission.READ_EXTERNAL_STORAGE,
+ AppOpsManager.OPSTR_READ_EXTERNAL_STORAGE, /* forWrite */ false);
+ }
+ }
+
+ @Test
+ public void testWriteStorageInvalidation() throws Exception {
+ testAppOpInvalidation(APP_E_LEGACY, new File(getDcimDir(), "write_storage.jpg"),
+ Manifest.permission.WRITE_EXTERNAL_STORAGE,
+ AppOpsManager.OPSTR_WRITE_EXTERNAL_STORAGE, /* forWrite */ true);
+ }
+
+ @Test
+ public void testManageStorageInvalidation() throws Exception {
+ testAppOpInvalidation(APP_E, new File(getDownloadDir(), "manage_storage.pdf"),
+ /* permission */ null, OPSTR_MANAGE_EXTERNAL_STORAGE, /* forWrite */ true);
+ }
+
+ @Test
+ public void testWriteImagesInvalidation() throws Exception {
+ testAppOpInvalidation(APP_E, new File(getDcimDir(), "write_images.jpg"),
+ /* permission */ null, AppOpsManager.OPSTR_WRITE_MEDIA_IMAGES, /* forWrite */ true);
+ }
+
+ @Test
+ public void testWriteVideoInvalidation() throws Exception {
+ testAppOpInvalidation(APP_E, new File(getDcimDir(), "write_video.mp4"),
+ /* permission */ null, AppOpsManager.OPSTR_WRITE_MEDIA_VIDEO, /* forWrite */ true);
+ }
+
+ @Test
+ public void testAccessMediaLocationInvalidation() throws Exception {
+ File imgFile = new File(getDcimDir(), "access_media_location.jpg");
+
+ try {
+ // Setup image with sensitive data on external storage
+ HashMap<String, String> originalExif =
+ getExifMetadataFromRawResource(R.raw.img_with_metadata);
+ try (InputStream in =
+ getContext().getResources().openRawResource(R.raw.img_with_metadata);
+ FileOutputStream out = new FileOutputStream(imgFile)) {
+ // Dump the image we have to external storage
+ FileUtils.copy(in, out);
+ // Sync file to disk to ensure file is fully written to the lower fs.
+ out.getFD().sync();
+ }
+ HashMap<String, String> exif = getExifMetadataFromFile(imgFile);
+ assertExifMetadataMatch(exif, originalExif);
+
+ // Install test app
+ installAppWithStoragePermissions(APP_GENERAL_ONLY);
+
+ // Grant A_M_L and verify access to sensitive data
+ grantPermission(APP_GENERAL_ONLY.getPackageName(), Manifest.permission.ACCESS_MEDIA_LOCATION);
+ pollForPermission(APP_GENERAL_ONLY.getPackageName(),
+ Manifest.permission.ACCESS_MEDIA_LOCATION, /* granted */ true);
+ HashMap<String, String> exifFromTestApp =
+ readExifMetadataFromTestApp(APP_GENERAL_ONLY, imgFile.getPath());
+ assertExifMetadataMatch(exifFromTestApp, originalExif);
+
+ // Revoke A_M_L and verify sensitive data redaction
+ revokePermission(
+ APP_GENERAL_ONLY.getPackageName(), Manifest.permission.ACCESS_MEDIA_LOCATION);
+ // revokePermission waits for permission status to be updated, but MediaProvider still
+ // needs to get permission change callback and clear its permission cache.
+ pollForPermission(APP_GENERAL_ONLY.getPackageName(),
+ Manifest.permission.ACCESS_MEDIA_LOCATION, /* granted */ false);
+ Thread.sleep(500);
+ exifFromTestApp = readExifMetadataFromTestApp(APP_GENERAL_ONLY, imgFile.getPath());
+ assertExifMetadataMismatch(exifFromTestApp, originalExif);
+
+ // Re-grant A_M_L and verify access to sensitive data
+ grantPermission(APP_GENERAL_ONLY.getPackageName(), Manifest.permission.ACCESS_MEDIA_LOCATION);
+ // grantPermission waits for permission status to be updated, but MediaProvider still
+ // needs to get permission change callback and clear its permission cache.
+ pollForPermission(APP_GENERAL_ONLY.getPackageName(),
+ Manifest.permission.ACCESS_MEDIA_LOCATION, /* granted */ true);
+ Thread.sleep(500);
+ exifFromTestApp = readExifMetadataFromTestApp(APP_GENERAL_ONLY, imgFile.getPath());
+ assertExifMetadataMatch(exifFromTestApp, originalExif);
+ } finally {
+ imgFile.delete();
+ uninstallAppNoThrow(APP_GENERAL_ONLY);
+ }
+ }
+
+ @Test
+ public void testAppUpdateInvalidation() throws Exception {
+ File file = new File(getDcimDir(), "app_update.jpg");
+ try {
+ assertThat(file.createNewFile()).isTrue();
+
+ addressStoragePermissions(APP_E_LEGACY.getPackageName(), true);
+ grantPermission(APP_E_LEGACY.getPackageName(),
+ Manifest.permission.WRITE_EXTERNAL_STORAGE); // Grants write access for legacy
+
+ // Legacy app can read and write media files contributed by others
+ assertThat(canOpenFileAs(APP_E_LEGACY, file, /* forWrite */ false)).isTrue();
+ assertThat(canOpenFileAs(APP_E_LEGACY, file, /* forWrite */ true)).isTrue();
+
+ // Update to non-legacy
+ addressStoragePermissions(APP_E.getPackageName(), true);
+ grantPermission(APP_E_LEGACY.getPackageName(),
+ Manifest.permission.WRITE_EXTERNAL_STORAGE); // No effect for non-legacy
+
+ // Non-legacy app can read media files contributed by others
+ assertThat(canOpenFileAs(APP_E, file, /* forWrite */ false)).isTrue();
+ // But cannot write
+ assertThat(canOpenFileAs(APP_E, file, /* forWrite */ true)).isFalse();
+ } finally {
+ file.delete();
+ revokePermission(APP_E_LEGACY.getPackageName(),
+ Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ }
+ }
+
+ @Test
+ public void testAppReinstallInvalidation() throws Exception {
+ File file = new File(getDcimDir(), "app_reinstall.jpg");
+
+ try {
+ assertThat(file.createNewFile()).isTrue();
+
+ // Install
+ installAppWithStoragePermissions(APP_GENERAL_ONLY);
+ assertThat(canOpenFileAs(APP_GENERAL_ONLY, file, /* forWrite */ false)).isTrue();
+
+ // Re-install
+ uninstallAppNoThrow(APP_GENERAL_ONLY);
+ installApp(APP_GENERAL_ONLY);
+ assertThat(canOpenFileAs(APP_GENERAL_ONLY, file, /* forWrite */ false)).isFalse();
+ } finally {
+ file.delete();
+ uninstallAppNoThrow(APP_GENERAL_ONLY);
+ }
+ }
+
+ private void testAppOpInvalidation(TestApp app, File file, @Nullable String permission,
+ String opstr, boolean forWrite) throws Exception {
+ try {
+ addressStoragePermissions(app.getPackageName(), false);
+ assertThat(file.createNewFile()).isTrue();
+ assertAppOpInvalidation(app, file, permission, opstr, forWrite);
+ } finally {
+ file.delete();
+ }
+ }
+
+ /** If {@code permission} is null, appops are flipped, otherwise permissions are flipped */
+ private void assertAppOpInvalidation(TestApp app, File file, @Nullable String permission,
+ String opstr, boolean forWrite) throws Exception {
+ String packageName = app.getPackageName();
+ int uid = getContext().getPackageManager().getPackageUid(packageName, 0);
+
+ // Deny
+ if (permission != null) {
+ revokePermission(packageName, permission);
+ } else {
+ denyAppOpsToUid(uid, opstr);
+ // TODO(191724755): Poll for AppOp state change instead
+ Thread.sleep(200);
+ }
+ assertThat(canOpenFileAs(app, file, forWrite)).isFalse();
+
+ // Grant
+ if (permission != null) {
+ grantPermission(packageName, permission);
+ } else {
+ allowAppOpsToUid(uid, opstr);
+ // TODO(191724755): Poll for AppOp state change instead
+ Thread.sleep(200);
+ }
+ assertThat(canOpenFileAs(app, file, forWrite)).isTrue();
+ // Deny
+ if (permission != null) {
+ revokePermission(packageName, permission);
+ } else {
+ denyAppOpsToUid(uid, opstr);
+ // TODO(191724755): Poll for AppOp state change instead
+ Thread.sleep(200);
+ }
+ assertThat(canOpenFileAs(app, file, forWrite)).isFalse();
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 31, codeName = "S")
+ public void testDisableOpResetForSystemGallery() throws Exception {
+ final File otherAppImageFile = new File(getDcimDir(), "other_" + IMAGE_FILE_NAME);
+ final File otherAppVideoFile = new File(getDcimDir(), "other_" + VIDEO_FILE_NAME);
+
+ try {
+ allowAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+
+ // Have another app create an image file
+ assertThat(createFileAs(APP_B_NO_PERMS, otherAppImageFile.getPath())).isTrue();
+ assertThat(otherAppImageFile.exists()).isTrue();
+
+ // Have another app create a video file
+ assertThat(createFileAs(APP_B_NO_PERMS, otherAppVideoFile.getPath())).isTrue();
+ assertThat(otherAppVideoFile.exists()).isTrue();
+
+ assertCanWriteAndRead(otherAppImageFile, BYTES_DATA1);
+ assertCanWriteAndRead(otherAppVideoFile, BYTES_DATA1);
+
+ // Reset app op should not reset System Gallery privileges
+ executeShellCommand("appops reset " + THIS_PACKAGE_NAME);
+
+ // Assert we can still write to images/videos
+ assertCanWriteAndRead(otherAppImageFile, BYTES_DATA2);
+ assertCanWriteAndRead(otherAppVideoFile, BYTES_DATA2);
+
+ } finally {
+ deleteFileAsNoThrow(APP_B_NO_PERMS, otherAppImageFile.getAbsolutePath());
+ deleteFileAsNoThrow(APP_B_NO_PERMS, otherAppVideoFile.getAbsolutePath());
+ denyAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+ }
+ }
+
+ @Test
+ public void testSystemGalleryAppHasFullAccessToImages() throws Exception {
+ final File otherAppImageFile = new File(getDcimDir(), "other_" + IMAGE_FILE_NAME);
+ final File topLevelImageFile = new File(getExternalStorageDir(), IMAGE_FILE_NAME);
+ final File imageInAnObviouslyWrongPlace = new File(getMusicDir(), IMAGE_FILE_NAME);
+
+ try {
+ allowAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+
+ // Have another app create an image file
+ assertThat(createFileAs(APP_B_NO_PERMS, otherAppImageFile.getPath())).isTrue();
+ assertThat(otherAppImageFile.exists()).isTrue();
+
+ // Assert we can write to the file
+ try (FileOutputStream fos = new FileOutputStream(otherAppImageFile)) {
+ fos.write(BYTES_DATA1);
+ }
+
+ // Assert we can read from the file
+ assertFileContent(otherAppImageFile, BYTES_DATA1);
+
+ // Assert has access to redacted information
+ RedactionTestHelper.assertConsistentNonRedactedAccess(otherAppImageFile,
+ R.raw.img_with_metadata);
+
+ // Assert we can delete the file
+ assertThat(otherAppImageFile.delete()).isTrue();
+ assertThat(otherAppImageFile.exists()).isFalse();
+
+ // Can create an image anywhere
+ assertCanCreateFile(topLevelImageFile);
+ assertCanCreateFile(imageInAnObviouslyWrongPlace);
+
+ // Put the file back in its place and let APP B delete it
+ assertThat(otherAppImageFile.createNewFile()).isTrue();
+ } finally {
+ deleteFileAsNoThrow(APP_B_NO_PERMS, otherAppImageFile.getAbsolutePath());
+ otherAppImageFile.delete();
+ denyAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+ }
+ }
+
+ @Test
+ public void testSystemGalleryAppHasNoFullAccessToAudio() throws Exception {
+ final File otherAppAudioFile = new File(getMusicDir(), "other_" + AUDIO_FILE_NAME);
+ final File topLevelAudioFile = new File(getExternalStorageDir(), AUDIO_FILE_NAME);
+ final File audioInAnObviouslyWrongPlace = new File(getPicturesDir(), AUDIO_FILE_NAME);
+
+ try {
+ allowAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+
+ // Have another app create an audio file
+ assertThat(createFileAs(APP_B_NO_PERMS, otherAppAudioFile.getPath())).isTrue();
+ assertThat(otherAppAudioFile.exists()).isTrue();
+
+ // Assert we can't access the file
+ assertThat(canOpen(otherAppAudioFile, /* forWrite */ false)).isFalse();
+ assertThat(canOpen(otherAppAudioFile, /* forWrite */ true)).isFalse();
+
+ // Assert we can't delete the file
+ assertThat(otherAppAudioFile.delete()).isFalse();
+
+ // Can't create an audio file where it doesn't belong
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ topLevelAudioFile.createNewFile();
+ });
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ audioInAnObviouslyWrongPlace.createNewFile();
+ });
+ } finally {
+ deleteFileAs(APP_B_NO_PERMS, otherAppAudioFile.getPath());
+ topLevelAudioFile.delete();
+ audioInAnObviouslyWrongPlace.delete();
+ denyAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+ }
+ }
+
+ @Test
+ public void testSystemGalleryCanRenameImagesAndVideos() throws Exception {
+ final File otherAppVideoFile = new File(getDcimDir(), "other_" + VIDEO_FILE_NAME);
+ final File imageFile = new File(getPicturesDir(), IMAGE_FILE_NAME);
+ final File videoFile = new File(getPicturesDir(), VIDEO_FILE_NAME);
+ final File topLevelVideoFile = new File(getExternalStorageDir(), VIDEO_FILE_NAME);
+ final File musicFile = new File(getMusicDir(), AUDIO_FILE_NAME);
+ try {
+ allowAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+
+ // Have another app create a video file
+ assertThat(createFileAs(APP_B_NO_PERMS, otherAppVideoFile.getPath())).isTrue();
+ assertThat(otherAppVideoFile.exists()).isTrue();
+
+ // Write some data to the file
+ try (FileOutputStream fos = new FileOutputStream(otherAppVideoFile)) {
+ fos.write(BYTES_DATA1);
+ }
+ assertFileContent(otherAppVideoFile, BYTES_DATA1);
+
+ // Assert we can rename the file and ensure the file has the same content
+ assertCanRenameFile(otherAppVideoFile, videoFile);
+ assertFileContent(videoFile, BYTES_DATA1);
+ // We can even move it to the top level directory
+ assertCanRenameFile(videoFile, topLevelVideoFile);
+ assertFileContent(topLevelVideoFile, BYTES_DATA1);
+ // And we can even convert it into an image file, because why not?
+ assertCanRenameFile(topLevelVideoFile, imageFile);
+ assertFileContent(imageFile, BYTES_DATA1);
+
+ // We can convert it to a music file, but we won't have access to music file after
+ // renaming.
+ assertThat(imageFile.renameTo(musicFile)).isTrue();
+ assertThat(getFileRowIdFromDatabase(musicFile)).isEqualTo(-1);
+ } finally {
+ deleteFileAsNoThrow(APP_B_NO_PERMS, otherAppVideoFile.getAbsolutePath());
+ imageFile.delete();
+ videoFile.delete();
+ topLevelVideoFile.delete();
+ executeShellCommand("rm " + musicFile.getAbsolutePath());
+ MediaStore.scanFile(getContentResolver(), musicFile);
+ denyAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+ }
+ }
+
+ /**
+ * Test that basic file path restrictions are enforced on file rename.
+ */
+ @Test
+ public void testRenameFile() throws Exception {
+ final File downloadDir = getDownloadDir();
+ final File nonMediaDir = new File(downloadDir, TEST_DIRECTORY_NAME);
+ final File pdfFile1 = new File(downloadDir, NONMEDIA_FILE_NAME);
+ final File pdfFile2 = new File(nonMediaDir, NONMEDIA_FILE_NAME);
+ final File videoFile1 = new File(getDcimDir(), VIDEO_FILE_NAME);
+ final File videoFile2 = new File(getMoviesDir(), VIDEO_FILE_NAME);
+ final File videoFile3 = new File(downloadDir, VIDEO_FILE_NAME);
+
+ try {
+ // Renaming non media file to media directory is not allowed.
+ assertThat(pdfFile1.createNewFile()).isTrue();
+ assertCantRenameFile(pdfFile1, new File(getDcimDir(), NONMEDIA_FILE_NAME));
+ assertCantRenameFile(pdfFile1, new File(getMusicDir(), NONMEDIA_FILE_NAME));
+ assertCantRenameFile(pdfFile1, new File(getMoviesDir(), NONMEDIA_FILE_NAME));
+
+ // Renaming non media files to non media directories is allowed.
+ if (!nonMediaDir.exists()) {
+ assertThat(nonMediaDir.mkdirs()).isTrue();
+ }
+ // App can rename pdfFile to non media directory.
+ assertCanRenameFile(pdfFile1, pdfFile2);
+
+ assertThat(videoFile1.createNewFile()).isTrue();
+ // App can rename video file to Movies directory
+ assertCanRenameFile(videoFile1, videoFile2);
+ // App can rename video file to Download directory
+ assertCanRenameFile(videoFile2, videoFile3);
+ } finally {
+ pdfFile1.delete();
+ pdfFile2.delete();
+ videoFile1.delete();
+ videoFile2.delete();
+ videoFile3.delete();
+ deleteRecursively(nonMediaDir);
+ }
+ }
+
+ /**
+ * Test that renaming file to different mime type is allowed.
+ */
+ @Test
+ public void testRenameFileType() throws Exception {
+ final File pdfFile = new File(getDownloadDir(), NONMEDIA_FILE_NAME);
+ final File videoFile = new File(getDcimDir(), VIDEO_FILE_NAME);
+ try {
+ assertThat(pdfFile.createNewFile()).isTrue();
+ assertThat(videoFile.exists()).isFalse();
+ // Moving pdfFile to DCIM directory is not allowed.
+ assertCantRenameFile(pdfFile, new File(getDcimDir(), NONMEDIA_FILE_NAME));
+ // However, moving pdfFile to DCIM directory with changing the mime type to video is
+ // allowed.
+ assertCanRenameFile(pdfFile, videoFile);
+
+ // On rename, MediaProvider database entry for pdfFile should be updated with new
+ // videoFile path and mime type should be updated to video/mp4.
+ assertThat(getFileMimeTypeFromDatabase(videoFile)).isEqualTo("video/mp4");
+ } finally {
+ pdfFile.delete();
+ videoFile.delete();
+ }
+ }
+
+ /**
+ * Test that renaming files overwrites files in newPath.
+ */
+ @Test
+ public void testRenameAndReplaceFile() throws Exception {
+ final File videoFile1 = new File(getDcimDir(), VIDEO_FILE_NAME);
+ final File videoFile2 = new File(getMoviesDir(), VIDEO_FILE_NAME);
+ final ContentResolver cr = getContentResolver();
+ try {
+ assertThat(videoFile1.createNewFile()).isTrue();
+ assertThat(videoFile2.createNewFile()).isTrue();
+ final Uri uriVideoFile1 = MediaStore.scanFile(cr, videoFile1);
+ final Uri uriVideoFile2 = MediaStore.scanFile(cr, videoFile2);
+
+ // Renaming a file which replaces file in newPath videoFile2 is allowed.
+ assertCanRenameFile(videoFile1, videoFile2);
+
+ // Uri of videoFile2 should be accessible after rename.
+ try (ParcelFileDescriptor pfd = cr.openFileDescriptor(uriVideoFile2, "rw")) {
+ assertThat(pfd).isNotNull();
+ }
+
+ // Uri of videoFile1 should not be accessible after rename.
+ assertThrows(FileNotFoundException.class,
+ () -> {
+ cr.openFileDescriptor(uriVideoFile1, "rw");
+ });
+ } finally {
+ videoFile1.delete();
+ videoFile2.delete();
+ }
+ }
+
+ /**
+ * Test that ScanFile() after renaming file extension updates the right
+ * MIME type from the file metadata.
+ */
+ @Test
+ public void testScanUpdatesMimeTypeForRenameFileExtension() throws Exception {
+ final String audioFileName = "ScopedStorageDeviceTest_" + NONCE;
+ final File mpegFile = new File(getMusicDir(), audioFileName + ".mp3");
+ final File nonMpegFile = new File(getMusicDir(), audioFileName + ".snd");
+ try {
+ // Copy audio content to mpegFile
+ try (InputStream in =
+ getContext().getResources().openRawResource(R.raw.test_audio);
+ FileOutputStream out = new FileOutputStream(mpegFile)) {
+ FileUtils.copy(in, out);
+ out.getFD().sync();
+ }
+ assertThat(MediaStore.scanFile(getContentResolver(), mpegFile)).isNotNull();
+ assertThat(getFileMimeTypeFromDatabase(mpegFile)).isEqualTo("audio/mpeg");
+
+ // This rename changes MIME type from audio/mpeg to audio/basic
+ assertCanRenameFile(mpegFile, nonMpegFile);
+ assertThat(getFileMimeTypeFromDatabase(nonMpegFile)).isNotEqualTo("audio/mpeg");
+
+ assertThat(MediaStore.scanFile(getContentResolver(), nonMpegFile)).isNotNull();
+ // Above scan should read file metadata and update the MIME type to audio/mpeg
+ assertThat(getFileMimeTypeFromDatabase(nonMpegFile)).isEqualTo("audio/mpeg");
+ } finally {
+ mpegFile.delete();
+ nonMpegFile.delete();
+ }
+ }
+
+ /**
+ * Test that app without write permission for file can't update the file.
+ */
+ @Test
+ public void testRenameFileNotOwned() throws Exception {
+ final File videoFile1 = new File(getDcimDir(), VIDEO_FILE_NAME);
+ final File videoFile2 = new File(getMoviesDir(), VIDEO_FILE_NAME);
+ try {
+ assertThat(createFileAs(APP_B_NO_PERMS, videoFile1.getAbsolutePath())).isTrue();
+ // App can't rename a file owned by APP B.
+ assertCantRenameFile(videoFile1, videoFile2);
+
+ assertThat(videoFile2.createNewFile()).isTrue();
+ // App can't rename a file to videoFile1 which is owned by APP B.
+ assertCantRenameFile(videoFile2, videoFile1);
+ // TODO(b/146346138): Test that app with right URI permission should be able to rename
+ // the corresponding file
+ } finally {
+ deleteFileAsNoThrow(APP_B_NO_PERMS, videoFile1.getAbsolutePath());
+ videoFile2.delete();
+ }
+ }
+
+ /**
+ * Test that renaming file paths to an external directory such as Android/* and Android/* /*
+ * except Android/media/* /* is not allowed.
+ */
+ @Test
+ public void testRenameFileToAppSpecificDir() throws Exception {
+ final File testFile = new File(getExternalMediaDir(), IMAGE_FILE_NAME);
+ final File testFileNew = new File(getExternalMediaDir(), NONMEDIA_FILE_NAME);
+
+ try {
+ // Create a file in app's external media directory
+ if (!testFile.exists()) {
+ assertThat(testFile.createNewFile()).isTrue();
+ }
+
+ final String androidDirPath = getExternalStorageDir().getPath() + "/Android";
+
+ // Verify that we can't rename a file to Android/ or Android/data or
+ // Android/media directory
+ assertCantRenameFile(testFile, new File(androidDirPath, IMAGE_FILE_NAME));
+ assertCantRenameFile(testFile, new File(androidDirPath + "/data", IMAGE_FILE_NAME));
+ assertCantRenameFile(testFile, new File(androidDirPath + "/media", IMAGE_FILE_NAME));
+
+ // Verify that we can rename a file to app specific media directory.
+ assertCanRenameFile(testFile, testFileNew);
+ } finally {
+ testFile.delete();
+ testFileNew.delete();
+ }
+ }
+
+ /**
+ * Test that renaming directories is allowed and aligns to default directory restrictions.
+ */
+ @Test
+ public void testRenameDirectory() throws Exception {
+ final File dcimDir = getDcimDir();
+ final File downloadDir = getDownloadDir();
+ final String nonMediaDirectoryName = TEST_DIRECTORY_NAME + "NonMedia";
+ final File nonMediaDirectory = new File(downloadDir, nonMediaDirectoryName);
+ final File pdfFile = new File(nonMediaDirectory, NONMEDIA_FILE_NAME);
+
+ final String mediaDirectoryName = TEST_DIRECTORY_NAME + "Media";
+ final File mediaDirectory1 = new File(dcimDir, mediaDirectoryName);
+ final File videoFile1 = new File(mediaDirectory1, VIDEO_FILE_NAME);
+ final File mediaDirectory2 = new File(downloadDir, mediaDirectoryName);
+ final File videoFile2 = new File(mediaDirectory2, VIDEO_FILE_NAME);
+ final File mediaDirectory3 = new File(getMoviesDir(), TEST_DIRECTORY_NAME);
+ final File videoFile3 = new File(mediaDirectory3, VIDEO_FILE_NAME);
+ final File mediaDirectory4 = new File(mediaDirectory3, mediaDirectoryName);
+
+ try {
+ if (!nonMediaDirectory.exists()) {
+ assertThat(nonMediaDirectory.mkdirs()).isTrue();
+ }
+ assertThat(pdfFile.createNewFile()).isTrue();
+ // Move directory with pdf file to DCIM directory is not allowed.
+ assertThat(nonMediaDirectory.renameTo(new File(dcimDir, nonMediaDirectoryName)))
+ .isFalse();
+
+ if (!mediaDirectory1.exists()) {
+ assertThat(mediaDirectory1.mkdirs()).isTrue();
+ }
+ assertThat(videoFile1.createNewFile()).isTrue();
+ // Renaming to and from default directories is not allowed.
+ assertThat(mediaDirectory1.renameTo(dcimDir)).isFalse();
+ // Moving top level default directories is not allowed.
+ assertCantRenameDirectory(downloadDir, new File(dcimDir, TEST_DIRECTORY_NAME), null);
+
+ // Moving media directory to Download directory is allowed.
+ assertCanRenameDirectory(mediaDirectory1, mediaDirectory2, new File[] {videoFile1},
+ new File[] {videoFile2});
+
+ // Moving media directory to Movies directory and renaming directory in new path is
+ // allowed.
+ assertCanRenameDirectory(mediaDirectory2, mediaDirectory3, new File[] {videoFile2},
+ new File[] {videoFile3});
+
+ // Can't rename a mediaDirectory to non empty non Media directory.
+ assertCantRenameDirectory(mediaDirectory3, nonMediaDirectory, new File[] {videoFile3});
+ // Can't rename a file to a directory.
+ assertCantRenameFile(videoFile3, mediaDirectory3);
+ // Can't rename a directory to file.
+ assertCantRenameDirectory(mediaDirectory3, pdfFile, null);
+ if (!mediaDirectory4.exists()) {
+ assertThat(mediaDirectory4.mkdir()).isTrue();
+ }
+ // Can't rename a directory to subdirectory of itself.
+ assertCantRenameDirectory(mediaDirectory3, mediaDirectory4, new File[] {videoFile3});
+
+ } finally {
+ pdfFile.delete();
+ deleteRecursively(nonMediaDirectory);
+
+ videoFile1.delete();
+ videoFile2.delete();
+ videoFile3.delete();
+ deleteRecursively(mediaDirectory1);
+ deleteRecursively(mediaDirectory2);
+ deleteRecursively(mediaDirectory3);
+ deleteRecursively(mediaDirectory4);
+ }
+ }
+
+ /**
+ * Test that renaming directory checks file ownership permissions.
+ */
+ @Test
+ public void testRenameDirectoryNotOwned() throws Exception {
+ final String mediaDirectoryName = TEST_DIRECTORY_NAME + "Media";
+ File mediaDirectory1 = new File(getDcimDir(), mediaDirectoryName);
+ File mediaDirectory2 = new File(getMoviesDir(), mediaDirectoryName);
+ File videoFile = new File(mediaDirectory1, VIDEO_FILE_NAME);
+
+ try {
+ if (!mediaDirectory1.exists()) {
+ assertThat(mediaDirectory1.mkdirs()).isTrue();
+ }
+ assertThat(createFileAs(APP_B_NO_PERMS, videoFile.getAbsolutePath())).isTrue();
+ // App doesn't have access to videoFile1, can't rename mediaDirectory1.
+ assertThat(mediaDirectory1.renameTo(mediaDirectory2)).isFalse();
+ assertThat(videoFile.exists()).isTrue();
+ // Test app can delete the file since the file is not moved to new directory.
+ assertThat(deleteFileAs(APP_B_NO_PERMS, videoFile.getAbsolutePath())).isTrue();
+ } finally {
+ deleteFileAsNoThrow(APP_B_NO_PERMS, videoFile.getAbsolutePath());
+ deleteRecursively(mediaDirectory1);
+ deleteRecursively(mediaDirectory2);
+ }
+ }
+
+ /**
+ * Test renaming empty directory is allowed
+ */
+ @Test
+ public void testRenameEmptyDirectory() throws Exception {
+ final String emptyDirectoryName = TEST_DIRECTORY_NAME + "Media";
+ File emptyDirectoryOldPath = new File(getDcimDir(), emptyDirectoryName);
+ File emptyDirectoryNewPath = new File(getMoviesDir(), TEST_DIRECTORY_NAME + "23456");
+ try {
+ if (emptyDirectoryOldPath.exists()) {
+ executeShellCommand("rm -r " + emptyDirectoryOldPath.getPath());
+ }
+ assertThat(emptyDirectoryOldPath.mkdirs()).isTrue();
+ assertCanRenameDirectory(emptyDirectoryOldPath, emptyDirectoryNewPath, null, null);
+ } finally {
+ deleteRecursively(emptyDirectoryOldPath);
+ deleteRecursively(emptyDirectoryNewPath);
+ }
+ }
+
+ /**
+ * Test that apps can create and delete hidden file.
+ */
+ @Test
+ public void testCanCreateHiddenFile() throws Exception {
+ final File hiddenImageFile = new File(getDownloadDir(), ".hiddenFile" + IMAGE_FILE_NAME);
+ try {
+ assertThat(hiddenImageFile.createNewFile()).isTrue();
+ // Write to hidden file is allowed.
+ try (FileOutputStream fos = new FileOutputStream(hiddenImageFile)) {
+ fos.write(BYTES_DATA1);
+ }
+ assertFileContent(hiddenImageFile, BYTES_DATA1);
+
+ assertNotMediaTypeImage(hiddenImageFile);
+
+ assertDirectoryContains(getDownloadDir(), hiddenImageFile);
+ assertThat(getFileRowIdFromDatabase(hiddenImageFile)).isNotEqualTo(-1);
+
+ // We can delete hidden file
+ assertThat(hiddenImageFile.delete()).isTrue();
+ assertThat(hiddenImageFile.exists()).isFalse();
+ } finally {
+ hiddenImageFile.delete();
+ }
+ }
+
+ /**
+ * Test that FUSE upper-fs is consistent with lower-fs after the lower-fs fd is closed.
+ */
+ @Test
+ public void testInodeStatConsistency() throws Exception {
+ File file = new File(getDcimDir(), IMAGE_FILE_NAME);
+
+ try {
+ byte[] writeBuffer = new byte[10];
+ Arrays.fill(writeBuffer, (byte) 1);
+
+ assertThat(file.createNewFile()).isTrue();
+ // Scanning a file is essential as files created via filepath will be marked
+ // as isPending, and we do not set listener for pending files as it can lead to
+ // performance overhead. See: I34611f0ee897dc676e7653beb7943aa6de58c55a.
+ MediaStore.scanFile(getContentResolver(), file);
+
+ // File operation #1 (to lower-fs)
+ ParcelFileDescriptor writePfd = openWithMediaProvider(file, "rw");
+
+ // File operation #2 (to fuse). This caches the inode for the file.
+ file.exists();
+
+ // Write bytes directly to lower-fs
+ Os.pwrite(writePfd.getFileDescriptor(), writeBuffer, 0, 10, 0);
+
+ // Close should invalidate inode cache for this file.
+ writePfd.close();
+ Thread.sleep(1000);
+
+ long fuseFileSize = file.length();
+ assertThat(writeBuffer.length).isEqualTo(fuseFileSize);
+ } finally {
+ file.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(getDcimDir(), hiddenFileName);
+ final File hiddenImageFile2 = new File(getDownloadDir(), hiddenFileName);
+ final File imageFile = new File(getDownloadDir(), 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(getDownloadDir(), ".hidden" + TEST_DIRECTORY_NAME);
+ final File hiddenImageFile = new File(hiddenDir, IMAGE_FILE_NAME);
+ final File nonHiddenDir = new File(getDownloadDir(), 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();
+ deleteRecursively(hiddenDir);
+ deleteRecursively(nonHiddenDir);
+ }
+ }
+
+ /**
+ * 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(getDownloadDir(), "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();
+ deleteRecursively(directoryNoMedia);
+ }
+ }
+
+ /**
+ * Test that only file manager and app that created the hidden file can list it.
+ */
+ @Test
+ public void testListHiddenFile() throws Exception {
+ final File dcimDir = getDcimDir();
+ final String hiddenImageFileName = ".hidden" + IMAGE_FILE_NAME;
+ final File hiddenImageFile = new File(dcimDir, hiddenImageFileName);
+ try {
+ assertThat(hiddenImageFile.createNewFile()).isTrue();
+ assertNotMediaTypeImage(hiddenImageFile);
+
+ assertDirectoryContains(dcimDir, hiddenImageFile);
+
+ // TestApp with read permissions can't see the hidden image file created by other app
+ assertThat(listAs(APP_A_HAS_RES, dcimDir.getAbsolutePath()))
+ .doesNotContain(hiddenImageFileName);
+
+ // But file manager can
+ assertThat(listAs(APP_FM, dcimDir.getAbsolutePath()))
+ .contains(hiddenImageFileName);
+
+ // Gallery cannot see the hidden image file created by other app
+ final int resAppUid =
+ getContext().getPackageManager().getPackageUid(APP_A_HAS_RES.getPackageName(),
+ 0);
+ try {
+ allowAppOpsToUid(resAppUid, SYSTEM_GALERY_APPOPS);
+ assertThat(listAs(APP_A_HAS_RES, dcimDir.getAbsolutePath()))
+ .doesNotContain(hiddenImageFileName);
+ } finally {
+ denyAppOpsToUid(resAppUid, SYSTEM_GALERY_APPOPS);
+ }
+ } finally {
+ hiddenImageFile.delete();
+ }
+ }
+
+ @Test
+ public void testOpenPendingAndTrashed() throws Exception {
+ final File pendingImageFile = new File(getDcimDir(), IMAGE_FILE_NAME);
+ final File trashedVideoFile = new File(getPicturesDir(), VIDEO_FILE_NAME);
+ final File pendingPdfFile = new File(getDocumentsDir(), NONMEDIA_FILE_NAME);
+ final File trashedPdfFile = new File(getDownloadDir(), NONMEDIA_FILE_NAME);
+ Uri pendingImgaeFileUri = null;
+ Uri trashedVideoFileUri = null;
+ Uri pendingPdfFileUri = null;
+ Uri trashedPdfFileUri = null;
+ try {
+ pendingImgaeFileUri = createPendingFile(pendingImageFile);
+ assertOpenPendingOrTrashed(pendingImgaeFileUri, /*isImageOrVideo*/ true);
+
+ pendingPdfFileUri = createPendingFile(pendingPdfFile);
+ assertOpenPendingOrTrashed(pendingPdfFileUri, /*isImageOrVideo*/ false);
+
+ trashedVideoFileUri = createTrashedFile(trashedVideoFile);
+ assertOpenPendingOrTrashed(trashedVideoFileUri, /*isImageOrVideo*/ true);
+
+ trashedPdfFileUri = createTrashedFile(trashedPdfFile);
+ assertOpenPendingOrTrashed(trashedPdfFileUri, /*isImageOrVideo*/ false);
+
+ } finally {
+ deleteFiles(pendingImageFile, pendingImageFile, trashedVideoFile,
+ trashedPdfFile);
+ deleteWithMediaProviderNoThrow(pendingImgaeFileUri, trashedVideoFileUri,
+ pendingPdfFileUri, trashedPdfFileUri);
+ }
+ }
+
+ @Test
+ public void testListPendingAndTrashed() throws Exception {
+ final File imageFile = new File(getDcimDir(), IMAGE_FILE_NAME);
+ final File pdfFile = new File(getDownloadDir(), NONMEDIA_FILE_NAME);
+ Uri imageFileUri = null;
+ Uri pdfFileUri = null;
+ try {
+ imageFileUri = createPendingFile(imageFile);
+ // Check that only owner package, file manager and system gallery can list pending image
+ // file.
+ assertListPendingOrTrashed(imageFileUri, imageFile, /*isImageOrVideo*/ true);
+
+ trashFileAndAssert(imageFileUri);
+ // Check that only owner package, file manager and system gallery can list trashed image
+ // file.
+ assertListPendingOrTrashed(imageFileUri, imageFile, /*isImageOrVideo*/ true);
+
+ pdfFileUri = createPendingFile(pdfFile);
+ // Check that only owner package, file manager can list pending non media file.
+ assertListPendingOrTrashed(pdfFileUri, pdfFile, /*isImageOrVideo*/ false);
+
+ trashFileAndAssert(pdfFileUri);
+ // Check that only owner package, file manager can list trashed non media file.
+ assertListPendingOrTrashed(pdfFileUri, pdfFile, /*isImageOrVideo*/ false);
+ } finally {
+ deleteWithMediaProviderNoThrow(imageFileUri, pdfFileUri);
+ deleteFiles(imageFile, pdfFile);
+ }
+ }
+
+ @Test
+ public void testDeletePendingAndTrashed_ownerCanDelete() throws Exception {
+ final File pendingVideoFile = new File(getDcimDir(), VIDEO_FILE_NAME);
+ final File trashedImageFile = new File(getPicturesDir(), IMAGE_FILE_NAME);
+ final File pendingPdfFile = new File(getDownloadDir(), NONMEDIA_FILE_NAME);
+ final File trashedPdfFile = new File(getDocumentsDir(), NONMEDIA_FILE_NAME);
+ // Actual path of the file gets rewritten for pending and trashed files.
+ String pendingVideoFilePath = null;
+ String trashedImageFilePath = null;
+ String pendingPdfFilePath = null;
+ String trashedPdfFilePath = null;
+ try {
+ pendingVideoFilePath = getFilePathFromUri(createPendingFile(pendingVideoFile));
+ trashedImageFilePath = getFilePathFromUri(createTrashedFile(trashedImageFile));
+ pendingPdfFilePath = getFilePathFromUri(createPendingFile(pendingPdfFile));
+ trashedPdfFilePath = getFilePathFromUri(createTrashedFile(trashedPdfFile));
+
+ // App can delete its own pending and trashed file.
+ assertCanDeletePaths(pendingVideoFilePath, trashedImageFilePath, pendingPdfFilePath,
+ trashedPdfFilePath);
+ } finally {
+ deletePaths(pendingVideoFilePath, trashedImageFilePath, pendingPdfFilePath,
+ trashedPdfFilePath);
+ deleteFiles(pendingVideoFile, trashedImageFile, pendingPdfFile, trashedPdfFile);
+ }
+ }
+
+ @Test
+ public void testDeletePendingAndTrashed_otherAppCantDelete() throws Exception {
+ final File pendingVideoFile = new File(getDcimDir(), VIDEO_FILE_NAME);
+ final File trashedImageFile = new File(getPicturesDir(), IMAGE_FILE_NAME);
+ final File pendingPdfFile = new File(getDownloadDir(), NONMEDIA_FILE_NAME);
+ final File trashedPdfFile = new File(getDocumentsDir(), NONMEDIA_FILE_NAME);
+ // Actual path of the file gets rewritten for pending and trashed files.
+ String pendingVideoFilePath = null;
+ String trashedImageFilePath = null;
+ String pendingPdfFilePath = null;
+ String trashedPdfFilePath = null;
+ try {
+ pendingVideoFilePath = getFilePathFromUri(createPendingFile(pendingVideoFile));
+ trashedImageFilePath = getFilePathFromUri(createTrashedFile(trashedImageFile));
+ pendingPdfFilePath = getFilePathFromUri(createPendingFile(pendingPdfFile));
+ trashedPdfFilePath = getFilePathFromUri(createTrashedFile(trashedPdfFile));
+
+ // App can't delete other app's pending and trashed file.
+ assertCantDeletePathsAs(APP_A_HAS_RES, pendingVideoFilePath, trashedImageFilePath,
+ pendingPdfFilePath, trashedPdfFilePath);
+ } finally {
+ deletePaths(pendingVideoFilePath, trashedImageFilePath, pendingPdfFilePath,
+ trashedPdfFilePath);
+ deleteFiles(pendingVideoFile, trashedImageFile, pendingPdfFile, trashedPdfFile);
+ }
+ }
+
+ @Test
+ public void testDeletePendingAndTrashed_fileManagerCanDelete() throws Exception {
+ final File pendingVideoFile = new File(getDcimDir(), VIDEO_FILE_NAME);
+ final File trashedImageFile = new File(getPicturesDir(), IMAGE_FILE_NAME);
+ final File pendingPdfFile = new File(getDownloadDir(), NONMEDIA_FILE_NAME);
+ final File trashedPdfFile = new File(getDocumentsDir(), NONMEDIA_FILE_NAME);
+ // Actual path of the file gets rewritten for pending and trashed files.
+ String pendingVideoFilePath = null;
+ String trashedImageFilePath = null;
+ String pendingPdfFilePath = null;
+ String trashedPdfFilePath = null;
+ try {
+ pendingVideoFilePath = getFilePathFromUri(createPendingFile(pendingVideoFile));
+ trashedImageFilePath = getFilePathFromUri(createTrashedFile(trashedImageFile));
+ pendingPdfFilePath = getFilePathFromUri(createPendingFile(pendingPdfFile));
+ trashedPdfFilePath = getFilePathFromUri(createTrashedFile(trashedPdfFile));
+
+ // File Manager can delete any pending and trashed file
+ assertCanDeletePathsAs(APP_FM, pendingVideoFilePath, trashedImageFilePath,
+ pendingPdfFilePath, trashedPdfFilePath);
+ } finally {
+ deletePaths(pendingVideoFilePath, trashedImageFilePath, pendingPdfFilePath,
+ trashedPdfFilePath);
+ deleteFiles(pendingVideoFile, trashedImageFile, pendingPdfFile, trashedPdfFile);
+ }
+ }
+
+ @Test
+ public void testDeletePendingAndTrashed_systemGalleryCanDeleteMedia() throws Exception {
+ final File pendingVideoFile = new File(getDcimDir(), VIDEO_FILE_NAME);
+ final File trashedImageFile = new File(getPicturesDir(), IMAGE_FILE_NAME);
+ final File pendingPdfFile = new File(getDownloadDir(), NONMEDIA_FILE_NAME);
+ final File trashedPdfFile = new File(getDocumentsDir(), NONMEDIA_FILE_NAME);
+ // Actual path of the file gets rewritten for pending and trashed files.
+ String pendingVideoFilePath = null;
+ String trashedImageFilePath = null;
+ String pendingPdfFilePath = null;
+ String trashedPdfFilePath = null;
+ try {
+ pendingVideoFilePath = getFilePathFromUri(createPendingFile(pendingVideoFile));
+ trashedImageFilePath = getFilePathFromUri(createTrashedFile(trashedImageFile));
+ pendingPdfFilePath = getFilePathFromUri(createPendingFile(pendingPdfFile));
+ trashedPdfFilePath = getFilePathFromUri(createTrashedFile(trashedPdfFile));
+
+ // System Gallery can delete any pending and trashed image or video file.
+ final int resAppUid =
+ getContext().getPackageManager().getPackageUid(APP_A_HAS_RES.getPackageName(),
+ 0);
+ try {
+ allowAppOpsToUid(resAppUid, SYSTEM_GALERY_APPOPS);
+ assertTrue(isMediaTypeImageOrVideo(new File(pendingVideoFilePath)));
+ assertTrue(isMediaTypeImageOrVideo(new File(trashedImageFilePath)));
+ assertCanDeletePathsAs(APP_A_HAS_RES, pendingVideoFilePath, trashedImageFilePath);
+
+ // System Gallery can't delete other app's pending and trashed pdf file.
+ assertFalse(isMediaTypeImageOrVideo(new File(pendingPdfFilePath)));
+ assertFalse(isMediaTypeImageOrVideo(new File(trashedPdfFilePath)));
+ assertCantDeletePathsAs(APP_A_HAS_RES, pendingPdfFilePath, trashedPdfFilePath);
+ } finally {
+ denyAppOpsToUid(resAppUid, SYSTEM_GALERY_APPOPS);
+ }
+ } finally {
+ deletePaths(pendingVideoFilePath, trashedImageFilePath, pendingPdfFilePath,
+ trashedPdfFilePath);
+ deleteFiles(pendingVideoFile, trashedImageFile, pendingPdfFile, trashedPdfFile);
+ }
+ }
+
+ @Test
+ public void testSystemGalleryCanTrashOtherAndroidMediaFiles() throws Exception {
+ final File otherVideoFile = new File(getAndroidMediaDir(),
+ String.format("%s/%s", APP_B_NO_PERMS.getPackageName(), VIDEO_FILE_NAME));
+ try {
+ allowAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+
+ assertThat(createFileAs(APP_B_NO_PERMS, otherVideoFile.getAbsolutePath())).isTrue();
+
+ final Uri otherVideoUri = MediaStore.scanFile(getContentResolver(), otherVideoFile);
+ assertNotNull(otherVideoUri);
+
+ trashFileAndAssert(otherVideoUri);
+ untrashFileAndAssert(otherVideoUri);
+ } finally {
+ otherVideoFile.delete();
+ denyAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+ }
+ }
+
+ @Test
+ public void testSystemGalleryCanUpdateOtherAndroidMediaFiles() throws Exception {
+ final File otherImageFile = new File(getAndroidMediaDir(),
+ String.format("%s/%s", APP_B_NO_PERMS.getPackageName(), IMAGE_FILE_NAME));
+ final File updatedImageFileInDcim = new File(getDcimDir(), IMAGE_FILE_NAME);
+ try {
+ allowAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+
+ assertThat(createFileAs(APP_B_NO_PERMS, otherImageFile.getAbsolutePath())).isTrue();
+
+ final Uri otherImageUri = MediaStore.scanFile(getContentResolver(), otherImageFile);
+ assertNotNull(otherImageUri);
+
+ final ContentValues values = new ContentValues();
+ values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM);
+ // Test that we can move the file to "DCIM/"
+ assertWithMessage("Result of ContentResolver#update for " + otherImageUri
+ + " with values " + values)
+ .that(getContentResolver().update(otherImageUri, values, Bundle.EMPTY))
+ .isEqualTo(1);
+ assertThat(updatedImageFileInDcim.exists()).isTrue();
+ assertThat(otherImageFile.exists()).isFalse();
+
+ values.clear();
+ values.put(MediaStore.MediaColumns.RELATIVE_PATH,
+ "Android/media/" + APP_B_NO_PERMS.getPackageName());
+ // Test that we can move the file back to other app's owned path
+ assertWithMessage("Result of ContentResolver#update for " + otherImageUri
+ + " with values " + values)
+ .that(getContentResolver().update(otherImageUri, values, Bundle.EMPTY))
+ .isEqualTo(1);
+ assertThat(otherImageFile.exists()).isTrue();
+ } finally {
+ otherImageFile.delete();
+ updatedImageFileInDcim.delete();
+ denyAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+ }
+ }
+
+ @Test
+ public void testQueryOtherAppsFiles() throws Exception {
+ final File otherAppPdf = new File(getDownloadDir(), "other" + NONMEDIA_FILE_NAME);
+ final File otherAppImg = new File(getDcimDir(), "other" + IMAGE_FILE_NAME);
+ final File otherAppMusic = new File(getMusicDir(), "other" + AUDIO_FILE_NAME);
+ final File otherHiddenFile = new File(getPicturesDir(), ".otherHiddenFile.jpg");
+ try {
+ // Apps can't query other app's pending file, hence create file and publish it.
+ assertCreatePublishedFilesAs(
+ APP_B_NO_PERMS, otherAppImg, otherAppMusic, otherAppPdf, otherHiddenFile);
+
+ // Since the test doesn't have READ_EXTERNAL_STORAGE nor any other special permissions,
+ // it can't query for another app's contents.
+ assertCantQueryFile(otherAppImg);
+ assertCantQueryFile(otherAppMusic);
+ assertCantQueryFile(otherAppPdf);
+ assertCantQueryFile(otherHiddenFile);
+ } finally {
+ deleteFilesAs(APP_B_NO_PERMS, otherAppImg, otherAppMusic, otherAppPdf, otherHiddenFile);
+ }
+ }
+
+ @Test
+ public void testSystemGalleryQueryOtherAppsFiles() throws Exception {
+ final File otherAppPdf = new File(getDownloadDir(), "other" + NONMEDIA_FILE_NAME);
+ final File otherAppImg = new File(getDcimDir(), "other" + IMAGE_FILE_NAME);
+ final File otherAppMusic = new File(getMusicDir(), "other" + AUDIO_FILE_NAME);
+ final File otherHiddenFile = new File(getPicturesDir(), ".otherHiddenFile.jpg");
+ try {
+ // Apps can't query other app's pending file, hence create file and publish it.
+ assertCreatePublishedFilesAs(
+ APP_B_NO_PERMS, otherAppImg, otherAppMusic, otherAppPdf, otherHiddenFile);
+
+ // System gallery apps have access to video and image files
+ allowAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+
+ assertCanQueryAndOpenFile(otherAppImg, "rw");
+ // System gallery doesn't have access to hidden image files of other app
+ assertCantQueryFile(otherHiddenFile);
+ // But no access to PDFs or music files
+ assertCantQueryFile(otherAppMusic);
+ assertCantQueryFile(otherAppPdf);
+ } finally {
+ denyAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+ deleteFilesAs(APP_B_NO_PERMS, otherAppImg, otherAppMusic, otherAppPdf, otherHiddenFile);
+ }
+ }
+
+ /**
+ * Test that System Gallery app can rename any directory under the default directories
+ * designated for images and videos, even if they contain other apps' contents that
+ * System Gallery doesn't have read access to.
+ */
+ @Test
+ public void testSystemGalleryCanRenameImageAndVideoDirs() throws Exception {
+ final File dirInDcim = new File(getDcimDir(), TEST_DIRECTORY_NAME);
+ final File dirInPictures = new File(getPicturesDir(), TEST_DIRECTORY_NAME);
+ final File dirInPodcasts = new File(getPodcastsDir(), TEST_DIRECTORY_NAME);
+ final File otherAppImageFile1 = new File(dirInDcim, "other_" + IMAGE_FILE_NAME);
+ final File otherAppVideoFile1 = new File(dirInDcim, "other_" + VIDEO_FILE_NAME);
+ final File otherAppPdfFile1 = new File(dirInDcim, "other_" + NONMEDIA_FILE_NAME);
+ final File otherAppImageFile2 = new File(dirInPictures, "other_" + IMAGE_FILE_NAME);
+ final File otherAppVideoFile2 = new File(dirInPictures, "other_" + VIDEO_FILE_NAME);
+ final File otherAppPdfFile2 = new File(dirInPictures, "other_" + NONMEDIA_FILE_NAME);
+ try {
+ assertThat(dirInDcim.exists() || dirInDcim.mkdir()).isTrue();
+
+ executeShellCommand("touch " + otherAppPdfFile1);
+ MediaStore.scanFile(getContentResolver(), otherAppPdfFile1);
+
+ allowAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+
+ assertCreateFilesAs(APP_A_HAS_RES, otherAppImageFile1, otherAppVideoFile1);
+
+ // System gallery privileges don't go beyond DCIM, Movies and Pictures boundaries.
+ assertCantRenameDirectory(dirInDcim, dirInPodcasts, /*oldFilesList*/ null);
+
+ // Rename should succeed, but System Gallery still can't access that PDF file!
+ assertCanRenameDirectory(dirInDcim, dirInPictures,
+ new File[] {otherAppImageFile1, otherAppVideoFile1},
+ new File[] {otherAppImageFile2, otherAppVideoFile2});
+ assertThat(getFileRowIdFromDatabase(otherAppPdfFile1)).isEqualTo(-1);
+ assertThat(getFileRowIdFromDatabase(otherAppPdfFile2)).isEqualTo(-1);
+ } finally {
+ executeShellCommand("rm " + otherAppPdfFile1);
+ executeShellCommand("rm " + otherAppPdfFile2);
+ MediaStore.scanFile(getContentResolver(), otherAppPdfFile1);
+ MediaStore.scanFile(getContentResolver(), otherAppPdfFile2);
+ otherAppImageFile1.delete();
+ otherAppImageFile2.delete();
+ otherAppVideoFile1.delete();
+ otherAppVideoFile2.delete();
+ otherAppPdfFile1.delete();
+ otherAppPdfFile2.delete();
+ deleteRecursively(dirInDcim);
+ deleteRecursively(dirInPictures);
+ denyAppOpsToUid(Process.myUid(), SYSTEM_GALERY_APPOPS);
+ }
+ }
+
+ /**
+ * Test that row ID corresponding to deleted path is restored on subsequent create.
+ */
+ @Test
+ public void testCreateCanRestoreDeletedRowId() throws Exception {
+ final File imageFile = new File(getDcimDir(), IMAGE_FILE_NAME);
+ final ContentResolver cr = getContentResolver();
+
+ try {
+ assertThat(imageFile.createNewFile()).isTrue();
+ final long oldRowId = getFileRowIdFromDatabase(imageFile);
+ assertThat(oldRowId).isNotEqualTo(-1);
+ final Uri uriOfOldFile = MediaStore.scanFile(cr, imageFile);
+ assertThat(uriOfOldFile).isNotNull();
+
+ assertThat(imageFile.delete()).isTrue();
+ // We should restore old row Id corresponding to deleted imageFile.
+ assertThat(imageFile.createNewFile()).isTrue();
+ assertThat(getFileRowIdFromDatabase(imageFile)).isEqualTo(oldRowId);
+ try (ParcelFileDescriptor pfd = cr.openFileDescriptor(uriOfOldFile, "rw")) {
+ assertThat(pfd).isNotNull();
+ }
+
+
+ assertThat(imageFile.delete()).isTrue();
+ assertThat(createFileAs(APP_B_NO_PERMS, imageFile.getAbsolutePath())).isTrue();
+
+ final Uri uriOfNewFile = MediaStore.scanFile(getContentResolver(), imageFile);
+ assertThat(uriOfNewFile).isNotNull();
+ // We shouldn't restore deleted row Id if delete & create are called from different apps
+ assertThat(Integer.getInteger(uriOfNewFile.getLastPathSegment()))
+ .isNotEqualTo(oldRowId);
+ } finally {
+ imageFile.delete();
+ deleteFileAsNoThrow(APP_B_NO_PERMS, imageFile.getAbsolutePath());
+ }
+ }
+
+ /**
+ * Test that row ID corresponding to deleted path is restored on subsequent rename.
+ */
+ @Test
+ public void testRenameCanRestoreDeletedRowId() throws Exception {
+ final File imageFile = new File(getDcimDir(), IMAGE_FILE_NAME);
+ final File temporaryFile = new File(getDownloadDir(), IMAGE_FILE_NAME + "_.tmp");
+ final ContentResolver cr = getContentResolver();
+
+ try {
+ assertThat(imageFile.createNewFile()).isTrue();
+ final Uri oldUri = MediaStore.scanFile(cr, imageFile);
+ assertThat(oldUri).isNotNull();
+
+ Files.copy(imageFile, temporaryFile);
+ assertThat(imageFile.delete()).isTrue();
+ assertCanRenameFile(temporaryFile, imageFile);
+
+ final Uri newUri = MediaStore.scanFile(cr, imageFile);
+ assertThat(newUri).isNotNull();
+ assertThat(newUri.getLastPathSegment()).isEqualTo(oldUri.getLastPathSegment());
+ // oldUri of imageFile is still accessible after delete and rename.
+ try (ParcelFileDescriptor pfd = cr.openFileDescriptor(oldUri, "rw")) {
+ assertThat(pfd).isNotNull();
+ }
+ } finally {
+ imageFile.delete();
+ temporaryFile.delete();
+ }
+ }
+
+ @Test
+ public void testCantCreateOrRenameFileWithInvalidName() throws Exception {
+ File invalidFile = new File(getDownloadDir(), "<>");
+ File validFile = new File(getDownloadDir(), NONMEDIA_FILE_NAME);
+ try {
+ assertThrows(IOException.class, "Operation not permitted",
+ () -> {
+ invalidFile.createNewFile();
+ });
+
+ assertThat(validFile.createNewFile()).isTrue();
+ // We can't rename a file to a file name with invalid FAT characters.
+ assertCantRenameFile(validFile, invalidFile);
+ } finally {
+ invalidFile.delete();
+ validFile.delete();
+ }
+ }
+
+ @Test
+ public void testRenameWithSpecialChars() throws Exception {
+ final String specialCharsSuffix = "'`~!@#$%^& ()_+-={}[];'.)";
+
+ final File fileSpecialChars =
+ new File(getDownloadDir(), NONMEDIA_FILE_NAME + specialCharsSuffix);
+
+ final File dirSpecialChars =
+ new File(getDownloadDir(), TEST_DIRECTORY_NAME + specialCharsSuffix);
+ final File file1 = new File(dirSpecialChars, NONMEDIA_FILE_NAME);
+ final File fileSpecialChars1 =
+ new File(dirSpecialChars, NONMEDIA_FILE_NAME + specialCharsSuffix);
+
+ final File renamedDir = new File(getDocumentsDir(), TEST_DIRECTORY_NAME);
+ final File file2 = new File(renamedDir, NONMEDIA_FILE_NAME);
+ final File fileSpecialChars2 =
+ new File(renamedDir, NONMEDIA_FILE_NAME + specialCharsSuffix);
+ try {
+ assertTrue(fileSpecialChars.createNewFile());
+ if (!dirSpecialChars.exists()) {
+ assertTrue(dirSpecialChars.mkdir());
+ }
+ assertTrue(file1.createNewFile());
+
+ // We can rename file name with special characters
+ assertCanRenameFile(fileSpecialChars, fileSpecialChars1);
+
+ // We can rename directory name with special characters
+ assertCanRenameDirectory(dirSpecialChars, renamedDir,
+ new File[] {file1, fileSpecialChars1}, new File[] {file2, fileSpecialChars2});
+ } finally {
+ file1.delete();
+ file2.delete();
+ fileSpecialChars.delete();
+ fileSpecialChars1.delete();
+ fileSpecialChars2.delete();
+ deleteRecursively(dirSpecialChars);
+ deleteRecursively(renamedDir);
+ }
+ }
+
+ /**
+ * Test that IS_PENDING is set for files created via filepath
+ */
+ @Test
+ public void testPendingFromFuse() throws Exception {
+ final File pendingFile = new File(getDcimDir(), IMAGE_FILE_NAME);
+ final File otherPendingFile = new File(getDcimDir(), VIDEO_FILE_NAME);
+ try {
+ assertTrue(pendingFile.createNewFile());
+ // Newly created file should have IS_PENDING set
+ try (Cursor c = queryFile(pendingFile, MediaStore.MediaColumns.IS_PENDING)) {
+ assertTrue(c.moveToFirst());
+ assertThat(c.getInt(0)).isEqualTo(1);
+ }
+
+ // If we query with MATCH_EXCLUDE, we should still see this pendingFile
+ try (Cursor c = queryFileExcludingPending(pendingFile,
+ MediaStore.MediaColumns.IS_PENDING)) {
+ assertThat(c.getCount()).isEqualTo(1);
+ assertTrue(c.moveToFirst());
+ assertThat(c.getInt(0)).isEqualTo(1);
+ }
+
+ assertNotNull(MediaStore.scanFile(getContentResolver(), pendingFile));
+
+ // IS_PENDING should be unset after the scan
+ try (Cursor c = queryFile(pendingFile, MediaStore.MediaColumns.IS_PENDING)) {
+ assertTrue(c.moveToFirst());
+ assertThat(c.getInt(0)).isEqualTo(0);
+ }
+
+ assertCreateFilesAs(APP_A_HAS_RES, otherPendingFile);
+ // We can't query other apps pending file from FUSE with MATCH_EXCLUDE
+ try (Cursor c = queryFileExcludingPending(otherPendingFile,
+ MediaStore.MediaColumns.IS_PENDING)) {
+ assertThat(c.getCount()).isEqualTo(0);
+ }
+ } finally {
+ pendingFile.delete();
+ deleteFileAsNoThrow(APP_A_HAS_RES, otherPendingFile.getAbsolutePath());
+ }
+ }
+
+ /**
+ * Test that we don't allow renaming to top level directory
+ */
+ @Test
+ public void testCantRenameToTopLevelDirectory() throws Exception {
+ final File topLevelDir1 = new File(getExternalStorageDir(), TEST_DIRECTORY_NAME + "_1");
+ final File topLevelDir2 = new File(getExternalStorageDir(), TEST_DIRECTORY_NAME + "_2");
+ final File nonTopLevelDir = new File(getDcimDir(), TEST_DIRECTORY_NAME);
+ try {
+ createDirectoryAsLegacyApp(topLevelDir1);
+ assertTrue(topLevelDir1.exists());
+
+ // We can't rename a top level directory to a top level directory
+ assertCantRenameDirectory(topLevelDir1, topLevelDir2, null);
+
+ // However, we can rename a top level directory to non-top level directory.
+ assertCanRenameDirectory(topLevelDir1, nonTopLevelDir, null, null);
+
+ // We can't rename a non-top level directory to a top level directory.
+ assertCantRenameDirectory(nonTopLevelDir, topLevelDir2, null);
+ } finally {
+ deleteRecursivelyAsLegacyApp(topLevelDir1);
+ deleteRecursivelyAsLegacyApp(topLevelDir2);
+ deleteRecursively(nonTopLevelDir);
+ }
+ }
+
+ @Test
+ public void testCanCreateDefaultDirectory() throws Exception {
+ final File podcastsDir = getPodcastsDir();
+ try {
+ if (podcastsDir.exists()) {
+ deleteRecursivelyAsLegacyApp(podcastsDir);
+ }
+ assertThat(podcastsDir.mkdir()).isTrue();
+ } finally {
+ createDirectoryAsLegacyApp(podcastsDir);
+ }
+ }
+
+ /**
+ * b/168830497: Test that app can write to file in DCIM/Camera even with .nomedia presence
+ */
+ @Test
+ public void testCanWriteToDCIMCameraWithNomedia() throws Exception {
+ final File cameraDir = new File(getDcimDir(), "Camera");
+ final File nomediaFile = new File(cameraDir, ".nomedia");
+ Uri targetUri = null;
+
+ try {
+ // Recreate required file and directory
+ if (cameraDir.exists()) {
+ // This is a work around to address a known inode cache inconsistency issue
+ // that occurs when test runs for the second time.
+ deleteRecursivelyAsLegacyApp(cameraDir);
+ }
+
+ createDirectoryAsLegacyApp(cameraDir);
+ assertTrue(cameraDir.exists());
+
+ createFileAsLegacyApp(nomediaFile);
+ assertTrue(nomediaFile.exists());
+
+ ContentValues values = new ContentValues();
+ values.put(MediaStore.MediaColumns.RELATIVE_PATH, "DCIM/Camera");
+ targetUri = getContentResolver().insert(getImageContentUri(), values, Bundle.EMPTY);
+ assertNotNull(targetUri);
+
+ try (ParcelFileDescriptor pfd =
+ getContentResolver().openFileDescriptor(targetUri, "w")) {
+ assertThat(pfd).isNotNull();
+ Os.write(pfd.getFileDescriptor(), ByteBuffer.wrap(BYTES_DATA1));
+ }
+
+ assertFileContent(new File(getFilePathFromUri(targetUri)), BYTES_DATA1);
+ } finally {
+ deleteWithMediaProviderNoThrow(targetUri);
+ deleteAsLegacyApp(nomediaFile);
+ deleteRecursivelyAsLegacyApp(cameraDir);
+ }
+ }
+
+ /**
+ * b/182479650: Test that Screenshots directory is not hidden because of .nomedia presence
+ */
+ @Test
+ public void testNoMediaDoesntHideSpecialDirectories() throws Exception {
+ for (File directory : new File [] {
+ getDcimDir(),
+ getDownloadDir(),
+ new File(getDcimDir(), "Camera"),
+ new File(getPicturesDir(), Environment.DIRECTORY_SCREENSHOTS),
+ new File(getMoviesDir(), Environment.DIRECTORY_SCREENSHOTS),
+ new File(getExternalStorageDir(), Environment.DIRECTORY_SCREENSHOTS)
+ }) {
+ assertNoMediaDoesntHideSpecialDirectories(directory);
+ }
+ }
+
+ private void assertNoMediaDoesntHideSpecialDirectories(File directory) throws Exception {
+ final File nomediaFile = new File(directory, ".nomedia");
+ final File videoFile = new File(directory, VIDEO_FILE_NAME);
+ Log.d(TAG, "Directory " + directory);
+
+ try {
+ // Recreate required file and directory
+ if (!directory.exists()) {
+ Log.d(TAG, "mkdir directory " + directory);
+ createDirectoryAsLegacyApp(directory);
+ }
+ assertWithMessage("Exists " + directory).that(directory.exists()).isTrue();
+
+ Log.d(TAG, "CreateFileAs " + nomediaFile);
+ createFileAsLegacyApp(nomediaFile);
+ assertWithMessage("Exists " + nomediaFile).that(nomediaFile.exists()).isTrue();
+
+ createFileAsLegacyApp(videoFile);
+ assertWithMessage("Exists " + videoFile).that(videoFile.exists()).isTrue();
+ final Uri targetUri = MediaStore.scanFile(getContentResolver(), videoFile);
+ assertWithMessage("Scan result for " + videoFile).that(targetUri)
+ .isNotNull();
+
+ assertWithMessage("Uri path segment for " + targetUri)
+ .that(targetUri.getPathSegments()).contains("video");
+
+ // Verify that the imageFile is not hidden because of .nomedia presence
+ assertWithMessage("Query as other app ")
+ .that(canQueryOnUri(APP_A_HAS_RES, targetUri)).isTrue();
+ } finally {
+ deleteAsLegacyApp(videoFile);
+ deleteAsLegacyApp(nomediaFile);
+ deleteRecursivelyAsLegacyApp(directory);
+ }
+ }
+
+ /**
+ * Test that readdir lists unsupported file types in default directories.
+ */
+ @Test
+ public void testListUnsupportedFileType() throws Exception {
+ final File pdfFile = new File(getDcimDir(), NONMEDIA_FILE_NAME);
+ final File videoFile = new File(getMusicDir(), VIDEO_FILE_NAME);
+ try {
+ // TEST_APP_A with storage permission should not see pdf file in DCIM
+ createFileAsLegacyApp(pdfFile);
+ assertThat(pdfFile.exists()).isTrue();
+ assertThat(MediaStore.scanFile(getContentResolver(), pdfFile)).isNotNull();
+
+ assertThat(listAs(APP_A_HAS_RES, getDcimDir().getPath()))
+ .doesNotContain(NONMEDIA_FILE_NAME);
+
+ createFileAsLegacyApp(videoFile);
+ // We don't insert files to db for files created by shell.
+ assertThat(MediaStore.scanFile(getContentResolver(), videoFile)).isNotNull();
+ // TEST_APP_A with storage permission should see video file in Music directory.
+ assertThat(listAs(APP_A_HAS_RES, getMusicDir().getPath())).contains(VIDEO_FILE_NAME);
+ } finally {
+ deleteAsLegacyApp(pdfFile);
+ deleteAsLegacyApp(videoFile);
+ MediaStore.scanFile(getContentResolver(), pdfFile);
+ MediaStore.scanFile(getContentResolver(), videoFile);
+ }
+ }
+
+ /**
+ * Test that normal apps cannot access Android/data and Android/obb dirs of other apps
+ */
+ @Test
+ public void testCantAccessOtherAppsExternalDirs() throws Exception {
+ File[] obbDirs = getContext().getObbDirs();
+ File[] dataDirs = getContext().getExternalFilesDirs(null);
+ for (File obbDir : obbDirs) {
+ final File otherAppExternalObbDir = new File(obbDir.getPath().replace(
+ THIS_PACKAGE_NAME, APP_B_NO_PERMS.getPackageName()));
+ final File file = new File(otherAppExternalObbDir, NONMEDIA_FILE_NAME);
+ try {
+ assertThat(createFileAs(APP_B_NO_PERMS, file.getPath())).isTrue();
+ assertCannotReadOrWrite(file);
+ } finally {
+ deleteFileAsNoThrow(APP_B_NO_PERMS, file.getAbsolutePath());
+ }
+ }
+ for (File dataDir : dataDirs) {
+ final File otherAppExternalDataDir = new File(dataDir.getPath().replace(
+ THIS_PACKAGE_NAME, APP_B_NO_PERMS.getPackageName()));
+ final File file = new File(otherAppExternalDataDir, NONMEDIA_FILE_NAME);
+ try {
+ assertThat(createFileAs(APP_B_NO_PERMS, file.getPath())).isTrue();
+ assertCannotReadOrWrite(file);
+ } finally {
+ deleteFileAsNoThrow(APP_B_NO_PERMS, file.getAbsolutePath());
+ }
+ }
+ }
+
+ /**
+ * Test that apps can't set attributes on another app's files.
+ */
+ @Test
+ public void testCantSetAttrOtherAppsFile() throws Exception {
+ // This path's permission is checked in MediaProvider (directory/external media dir)
+ final File externalMediaPath = new File(getExternalMediaDir(), VIDEO_FILE_NAME);
+
+ try {
+ // Create the files
+ if (!externalMediaPath.exists()) {
+ assertThat(externalMediaPath.createNewFile()).isTrue();
+ }
+
+ // APP A should not be able to setattr to other app's files.
+ assertWithMessage(
+ "setattr on directory/external media path [%s]", externalMediaPath.getPath())
+ .that(setAttrAs(APP_A_HAS_RES, externalMediaPath.getPath()))
+ .isFalse();
+ } finally {
+ externalMediaPath.delete();
+ }
+ }
+
+ /**
+ * b/171768780: Test that scan doesn't skip scanning renamed hidden file.
+ */
+ @Test
+ public void testScanUpdatesMetadataForRenamedHiddenFile() throws Exception {
+ final File hiddenFile = new File(getPicturesDir(), ".hidden_" + IMAGE_FILE_NAME);
+ final File jpgFile = new File(getPicturesDir(), IMAGE_FILE_NAME);
+ try {
+ // Copy the image content to hidden file
+ try (InputStream in =
+ getContext().getResources().openRawResource(R.raw.img_with_metadata);
+ FileOutputStream out = new FileOutputStream(hiddenFile)) {
+ FileUtils.copy(in, out);
+ out.getFD().sync();
+ }
+ Uri scanUri = MediaStore.scanFile(getContentResolver(), hiddenFile);
+ assertNotNull(scanUri);
+
+ // Rename hidden file to non-hidden
+ assertCanRenameFile(hiddenFile, jpgFile);
+
+ try (Cursor c = queryFile(jpgFile, MediaStore.MediaColumns.DATE_TAKEN)) {
+ assertTrue(c.moveToFirst());
+ // The file is not scanned yet, hence the metadata is not updated yet.
+ assertThat(c.getString(0)).isNull();
+ }
+
+ // Scan the file to update the metadata for renamed hidden file.
+ scanUri = MediaStore.scanFile(getContentResolver(), jpgFile);
+ assertNotNull(scanUri);
+
+ // Scan should be able to update metadata even if File.lastModifiedTime hasn't changed.
+ try (Cursor c = queryFile(jpgFile, MediaStore.MediaColumns.DATE_TAKEN)) {
+ assertTrue(c.moveToFirst());
+ assertThat(c.getString(0)).isNotNull();
+ }
+ } finally {
+ hiddenFile.delete();
+ jpgFile.delete();
+ }
+ }
+
+ /**
+ * Tests that System Gallery apps cannot insert files in other app's private directories.
+ */
+ @Test
+ public void testCantInsertFilesInOtherAppPrivateDir_hasSystemGallery() throws Exception {
+ int uid = Process.myUid();
+ try {
+ setAppOpsModeForUid(uid, AppOpsManager.MODE_ALLOWED, SYSTEM_GALERY_APPOPS);
+ assertCantInsertToOtherPrivateAppDirectories(IMAGE_FILE_NAME,
+ /* throwsExceptionForDataValue */ false, APP_B_NO_PERMS, THIS_PACKAGE_NAME);
+ } finally {
+ setAppOpsModeForUid(uid, AppOpsManager.MODE_ERRORED, SYSTEM_GALERY_APPOPS);
+ }
+ }
+
+ /**
+ * Tests that System Gallery apps cannot update files in other app's private directories.
+ */
+ @Test
+ public void testCantUpdateFilesInOtherAppPrivateDir_hasSystemGallery() throws Exception {
+ int uid = Process.myUid();
+ try {
+ setAppOpsModeForUid(uid, AppOpsManager.MODE_ALLOWED, SYSTEM_GALERY_APPOPS);
+ assertCantUpdateToOtherPrivateAppDirectories(IMAGE_FILE_NAME,
+ /* throwsExceptionForDataValue */ false, APP_B_NO_PERMS, THIS_PACKAGE_NAME);
+ } finally {
+ setAppOpsModeForUid(uid, AppOpsManager.MODE_ERRORED, SYSTEM_GALERY_APPOPS);
+ }
+ }
+
+ /**
+ * This test is for operations to the calling app's own private packages.
+ */
+ @Test
+ public void testInsertFromExternalDirsViaRelativePath() throws Exception {
+ verifyInsertFromExternalMediaDirViaRelativePath_allowed();
+ verifyInsertFromExternalPrivateDirViaRelativePath_denied();
+ }
+
+ /**
+ * This test is for operations to the calling app's own private packages.
+ */
+ @Test
+ public void testUpdateToExternalDirsViaRelativePath() throws Exception {
+ verifyUpdateToExternalMediaDirViaRelativePath_allowed();
+ verifyUpdateToExternalPrivateDirsViaRelativePath_denied();
+ }
+
+ /**
+ * This test is for operations to the calling app's own private packages.
+ */
+ @Test
+ public void testInsertFromExternalDirsViaRelativePathAsSystemGallery() throws Exception {
+ int uid = Process.myUid();
+ try {
+ setAppOpsModeForUid(uid, AppOpsManager.MODE_ALLOWED, SYSTEM_GALERY_APPOPS);
+ verifyInsertFromExternalMediaDirViaRelativePath_allowed();
+ verifyInsertFromExternalPrivateDirViaRelativePath_denied();
+ } finally {
+ setAppOpsModeForUid(uid, AppOpsManager.MODE_ERRORED, SYSTEM_GALERY_APPOPS);
+ }
+ }
+
+ /**
+ * This test is for operations to the calling app's own private packages.
+ */
+ @Test
+ public void testUpdateToExternalDirsViaRelativePathAsSystemGallery() throws Exception {
+ int uid = Process.myUid();
+ try {
+ setAppOpsModeForUid(uid, AppOpsManager.MODE_ALLOWED, SYSTEM_GALERY_APPOPS);
+ verifyUpdateToExternalMediaDirViaRelativePath_allowed();
+ verifyUpdateToExternalPrivateDirsViaRelativePath_denied();
+ } finally {
+ setAppOpsModeForUid(uid, AppOpsManager.MODE_ERRORED, SYSTEM_GALERY_APPOPS);
+ }
+ }
+
+ @Test
+ public void testDeferredScanHidesPartialDatabaseRows() throws Exception {
+ ContentValues values = new ContentValues();
+ values.put(MediaStore.MediaColumns.IS_PENDING, 1);
+ // Insert a pending row
+ final Uri targetUri = getContentResolver().insert(getImageContentUri(), values, null);
+ try (InputStream in =
+ getContext().getResources().openRawResource(R.raw.img_with_metadata)) {
+ try (ParcelFileDescriptor pfd =
+ getContentResolver().openFileDescriptor(targetUri, "w")) {
+ // Write image content to the file
+ FileUtils.copy(in, new ParcelFileDescriptor.AutoCloseOutputStream(pfd));
+ }
+ }
+
+ // Verify that metadata is not updated yet.
+ try (Cursor c = getContentResolver().query(targetUri, new String[] {
+ MediaStore.Images.ImageColumns.DATE_TAKEN}, null, null)) {
+ assertThat(c.moveToFirst()).isTrue();
+ assertThat(c.getString(0)).isNull();
+ }
+ // Get file path to use in the next query().
+ final String imageFilePath = getFilePathFromUri(targetUri);
+
+ values.put(MediaStore.MediaColumns.IS_PENDING, 0);
+ Bundle extras = new Bundle();
+ extras.putBoolean(MediaStore.QUERY_ARG_DEFER_SCAN, true);
+ // Publish the file, but, defer the scan on update().
+ assertThat(getContentResolver().update(targetUri, values, extras)).isEqualTo(1);
+
+ // The update() above can return before scanning is complete. Verify that either we don't
+ // see the file in published files or if the file appears in the collection, it means that
+ // deferred scan is now complete, hence verify metadata is intact.
+ try (Cursor c = getContentResolver().query(getImageContentUri(),
+ new String[] {MediaStore.Images.ImageColumns.DATE_TAKEN},
+ MediaStore.Files.FileColumns.DATA + "=?", new String[] {imageFilePath}, null)) {
+ if (c.getCount() == 1) {
+ // If the file appears in media collection as published file, verify that metadata
+ // is correct.
+ assertThat(c.moveToFirst()).isTrue();
+ assertThat(c.getString(0)).isNotNull();
+ Log.i(TAG, "Verified that deferred scan on " + imageFilePath + " is complete"
+ + " and hence metadata is updated");
+
+ } else {
+ assertThat(c.getCount()).isEqualTo(0);
+ Log.i(TAG, "Verified that " + imageFilePath + " was excluded in default query");
+ }
+ }
+ }
+
+ /**
+ * Test that renaming a file to {@link Environment#DIRECTORY_RINGTONES} sets
+ * {@link MediaStore.Audio.AudioColumns#IS_RINGTONE}
+ */
+
+ @Test
+ public void testRenameToRingtoneDirectory() throws Exception {
+ final File fileInDownloads = new File(getDownloadDir(), AUDIO_FILE_NAME);
+ final File fileInRingtones = new File(getRingtonesDir(), AUDIO_FILE_NAME);
+
+ try {
+ assertThat(fileInDownloads.createNewFile()).isTrue();
+ assertThat(MediaStore.scanFile(getContentResolver(), fileInDownloads)).isNotNull();
+
+ assertCanRenameFile(fileInDownloads, fileInRingtones);
+
+ try (Cursor c = queryAudioFile(fileInRingtones,
+ MediaStore.Audio.AudioColumns.IS_RINGTONE)) {
+ assertTrue(c.moveToFirst());
+ assertWithMessage("Expected " + MediaStore.Audio.AudioColumns.IS_RINGTONE
+ + " to be set after renaming to " + fileInRingtones)
+ .that(c.getInt(0)).isEqualTo(1);
+ }
+
+ assertCanRenameFile(fileInRingtones, fileInDownloads);
+
+ try (Cursor c = queryAudioFile(fileInDownloads,
+ MediaStore.Audio.AudioColumns.IS_RINGTONE)) {
+ assertTrue(c.moveToFirst());
+ assertWithMessage("Expected " + MediaStore.Audio.AudioColumns.IS_RINGTONE
+ + " to be unset after renaming to " + fileInDownloads)
+ .that(c.getInt(0)).isEqualTo(0);
+ }
+ } finally {
+ fileInDownloads.delete();
+ fileInRingtones.delete();
+ }
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 31, codeName = "S")
+ public void testTransformsDirFileOperations() throws Exception {
+ final String path = Environment.getExternalStorageDirectory() + "/" + TRANSFORMS_DIR;
+ final File file = new File(path);
+ assertThat(file.exists()).isTrue();
+ testTransformsDirCommon(file);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 31, codeName = "S")
+ public void testTransformsSyntheticDirFileOperations() throws Exception {
+ final String path =
+ Environment.getExternalStorageDirectory() + "/" + TRANSFORMS_SYNTHETIC_DIR;
+ final File file = new File(path);
+ assertThat(file.exists()).isTrue();
+ testTransformsDirCommon(file);
+ }
+
+ @Test
+ @SdkSuppress(minSdkVersion = 31, codeName = "S")
+ public void testTransformsTranscodeDirFileOperations() throws Exception {
+ final String path =
+ Environment.getExternalStorageDirectory() + "/" + TRANSFORMS_TRANSCODE_DIR;
+ final File file = new File(path);
+ assertThat(file.exists()).isFalse();
+ testTransformsDirCommon(file);
+ }
+
+
+ /**
+ * Test mount modes for a platform signed app with ACCESS_MTP permission.
+ */
+ @Test
+ @SdkSuppress(minSdkVersion = 31, codeName = "S")
+ public void testMTPAppWithPlatformSignatureMountMode() throws Exception {
+ final String shellPackageName = "com.android.shell";
+ final int uid = getContext().getPackageManager().getPackageUid(shellPackageName, 0);
+ assertMountMode(shellPackageName, uid, StorageManager.MOUNT_MODE_EXTERNAL_ANDROID_WRITABLE);
+ }
+
+ /**
+ * Test mount modes for ExternalStorageProvider and DownloadsProvider.
+ */
+ @Test
+ @SdkSuppress(minSdkVersion = 31, codeName = "S")
+ public void testExternalStorageProviderAndDownloadsProvider() throws Exception {
+ // External Storage Provider and Downloads Provider are not supported on Wear OS
+ if (FeatureUtil.isWatch()) {
+ return;
+ }
+ assertWritableMountModeForProvider(DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY);
+ assertWritableMountModeForProvider(DocumentsContract.DOWNLOADS_PROVIDER_AUTHORITY);
+ }
+
+ /**
+ * Test that normal apps cannot access Android/data and Android/obb dirs of other apps
+ */
+ @Test
+ public void testCantProbeOtherAppsExternalDirs() throws Exception {
+ // Before fuse-bpf, apps could see other app's external storage
+ boolean expectToSee = !isFuseBpfEnabled()
+ && mVolumeName.equals(MediaStore.VOLUME_EXTERNAL);
+ String message = expectToSee
+ ? "Expected to see other app's private dirs"
+ : "Expected not to see other app's private dirs";
+
+ assertWithMessage(message)
+ .that(fileExistsAs(APP_B_NO_PERMS, new File(getExternalFilesDir().getParent())))
+ .isEqualTo(expectToSee);
+
+ assertWithMessage(message)
+ .that(fileExistsAs(APP_B_NO_PERMS, getExternalObbDir()))
+ .isEqualTo(expectToSee);
+ }
+
+ private boolean isFuseBpfEnabled() throws Exception {
+ return executeShellCommand("getprop ro.fuse.bpf.is_running").trim().equals("true");
+ }
+
+ private void assertWritableMountModeForProvider(String auth) {
+ final ProviderInfo provider = getContext().getPackageManager()
+ .resolveContentProvider(auth, 0);
+ int uid = provider.applicationInfo.uid;
+ final String packageName = provider.applicationInfo.packageName;
+
+ assertMountMode(packageName, uid, StorageManager.MOUNT_MODE_EXTERNAL_ANDROID_WRITABLE);
+ }
+
+ private boolean canRenameFile(File file) {
+ return file.renameTo(new File(file.getAbsolutePath() + "test"));
+ }
+
+ private void testTransformsDirCommon(File file) throws Exception {
+ assertThat(file.delete()).isFalse();
+ assertThat(canRenameFile(file)).isFalse();
+
+ final File newFile = new File(file.getAbsolutePath(), "test");
+ assertThat(newFile.mkdir()).isFalse();
+ assertThrows(IOException.class, () -> newFile.createNewFile());
+ }
+
+ private void assertCanWriteAndRead(File file, byte[] data) throws Exception {
+ // Assert we can write to images/videos
+ try (FileOutputStream fos = new FileOutputStream(file)) {
+ fos.write(data);
+ }
+ assertFileContent(file, data);
+ }
+
+ /**
+ * Checks restrictions for opening pending and trashed files by different apps. Assumes that
+ * given {@code testApp} is already installed and has READ_EXTERNAL_STORAGE permission. This
+ * method doesn't uninstall given {@code testApp} at the end.
+ */
+ private void assertOpenPendingOrTrashed(Uri uri, boolean isImageOrVideo)
+ throws Exception {
+ final File pendingOrTrashedFile = new File(getFilePathFromUri(uri));
+
+ // App can open its pending or trashed file for read or write
+ assertTrue(canOpen(pendingOrTrashedFile, /*forWrite*/ false));
+ assertTrue(canOpen(pendingOrTrashedFile, /*forWrite*/ true));
+
+ // App with READ_EXTERNAL_STORAGE can't open other app's pending or trashed file for read or
+ // write
+ assertFalse(canOpenFileAs(APP_A_HAS_RES, pendingOrTrashedFile, /*forWrite*/ false));
+ assertFalse(canOpenFileAs(APP_A_HAS_RES, pendingOrTrashedFile, /*forWrite*/ true));
+
+ assertTrue(canOpenFileAs(APP_FM, pendingOrTrashedFile, /*forWrite*/ false));
+ assertTrue(canOpenFileAs(APP_FM, pendingOrTrashedFile, /*forWrite*/ true));
+
+ final int resAppUid =
+ getContext().getPackageManager().getPackageUid(APP_A_HAS_RES.getPackageName(), 0);
+ try {
+ allowAppOpsToUid(resAppUid, SYSTEM_GALERY_APPOPS);
+ if (isImageOrVideo) {
+ // System Gallery can open any pending or trashed image/video file for read or write
+ assertTrue(isMediaTypeImageOrVideo(pendingOrTrashedFile));
+ assertTrue(canOpenFileAs(APP_A_HAS_RES, pendingOrTrashedFile, /*forWrite*/ false));
+ assertTrue(canOpenFileAs(APP_A_HAS_RES, pendingOrTrashedFile, /*forWrite*/ true));
+ } else {
+ // System Gallery can't open other app's pending or trashed non-media file for read
+ // or write
+ assertFalse(isMediaTypeImageOrVideo(pendingOrTrashedFile));
+ assertFalse(canOpenFileAs(APP_A_HAS_RES, pendingOrTrashedFile, /*forWrite*/ false));
+ assertFalse(canOpenFileAs(APP_A_HAS_RES, pendingOrTrashedFile, /*forWrite*/ true));
+ }
+ } finally {
+ denyAppOpsToUid(resAppUid, SYSTEM_GALERY_APPOPS);
+ }
+ }
+
+ /**
+ * Checks restrictions for listing pending and trashed files by different apps.
+ */
+ private void assertListPendingOrTrashed(Uri uri, File file, boolean isImageOrVideo)
+ throws Exception {
+ final String parentDirPath = file.getParent();
+ assertTrue(new File(parentDirPath).isDirectory());
+
+ final List<String> listedFileNames = Arrays.asList(new File(parentDirPath).list());
+ assertThat(listedFileNames).doesNotContain(file);
+
+ final File pendingOrTrashedFile = new File(getFilePathFromUri(uri));
+
+ assertThat(listedFileNames).contains(pendingOrTrashedFile.getName());
+
+ // App with READ_EXTERNAL_STORAGE can't see other app's pending or trashed file.
+ assertThat(listAs(APP_A_HAS_RES, parentDirPath)).doesNotContain(
+ pendingOrTrashedFile.getName());
+
+ final int resAppUid =
+ getContext().getPackageManager().getPackageUid(APP_A_HAS_RES.getPackageName(), 0);
+ // File Manager can see any pending or trashed file.
+ assertThat(listAs(APP_FM, parentDirPath)).contains(pendingOrTrashedFile.getName());
+
+
+ try {
+ allowAppOpsToUid(resAppUid, SYSTEM_GALERY_APPOPS);
+ if (isImageOrVideo) {
+ // System Gallery can see any pending or trashed image/video file.
+ assertTrue(isMediaTypeImageOrVideo(pendingOrTrashedFile));
+ assertThat(listAs(APP_A_HAS_RES, parentDirPath)).contains(
+ pendingOrTrashedFile.getName());
+ } else {
+ // System Gallery can't see other app's pending or trashed non media file.
+ assertFalse(isMediaTypeImageOrVideo(pendingOrTrashedFile));
+ assertThat(listAs(APP_A_HAS_RES, parentDirPath))
+ .doesNotContain(pendingOrTrashedFile.getName());
+ }
+ } finally {
+ denyAppOpsToUid(resAppUid, SYSTEM_GALERY_APPOPS);
+ }
+ }
+
+ private Uri createPendingFile(File pendingFile) throws Exception {
+ assertTrue(pendingFile.createNewFile());
+
+ final ContentResolver cr = getContentResolver();
+ final Uri trashedFileUri = MediaStore.scanFile(cr, pendingFile);
+ assertNotNull(trashedFileUri);
+
+ final ContentValues values = new ContentValues();
+ values.put(MediaStore.MediaColumns.IS_PENDING, 1);
+ assertEquals(1, cr.update(trashedFileUri, values, Bundle.EMPTY));
+
+ return trashedFileUri;
+ }
+
+ private Uri createTrashedFile(File trashedFile) throws Exception {
+ assertTrue(trashedFile.createNewFile());
+
+ final ContentResolver cr = getContentResolver();
+ final Uri trashedFileUri = MediaStore.scanFile(cr, trashedFile);
+ assertNotNull(trashedFileUri);
+
+ trashFileAndAssert(trashedFileUri);
+ return trashedFileUri;
+ }
+
+ /**
+ * Gets file path corresponding to the db row pointed by {@code uri}. If {@code uri} points to
+ * multiple db rows, file path is extracted from the first db row of the database query result.
+ */
+ private String getFilePathFromUri(Uri uri) {
+ final String[] projection = new String[] {MediaStore.MediaColumns.DATA};
+ try (Cursor c = getContentResolver().query(uri, projection, null, null)) {
+ assertTrue(c.moveToFirst());
+ return c.getString(0);
+ }
+ }
+
+ private boolean isMediaTypeImageOrVideo(File file) {
+ return queryImageFile(file).getCount() == 1 || queryVideoFile(file).getCount() == 1;
+ }
+
+ 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();
+ // Confirm that file exists in the database.
+ assertNotNull(MediaStore.scanFile(getContentResolver(), file));
+ }
+
+ private static void assertCreateFilesAs(TestApp testApp, File... files) throws Exception {
+ for (File file : files) {
+ assertFalse("File already exists: " + file, file.exists());
+ assertTrue("Failed to create file " + file + " on behalf of "
+ + testApp.getPackageName(), createFileAs(testApp, file.getPath()));
+ }
+ }
+
+ /**
+ * Makes {@code testApp} create {@code files}. Publishes {@code files} by scanning the file.
+ * Pending files from FUSE are not visible to other apps via MediaStore APIs. We have to publish
+ * the file or make the file non-pending to make the file visible to other apps.
+ * <p>
+ * Note that this method can only be used for scannable files.
+ */
+ private static void assertCreatePublishedFilesAs(TestApp testApp, File... files)
+ throws Exception {
+ for (File file : files) {
+ assertTrue("Failed to create published file " + file + " on behalf of "
+ + testApp.getPackageName(), createFileAs(testApp, file.getPath()));
+ assertNotNull("Failed to scan " + file,
+ MediaStore.scanFile(getContentResolver(), file));
+ }
+ }
+
+
+ private static void deleteFilesAs(TestApp testApp, File... files) throws Exception {
+ for (File file : files) {
+ deleteFileAs(testApp, file.getPath());
+ }
+ }
+ private static void assertCanDeletePathsAs(TestApp testApp, String... filePaths)
+ throws Exception {
+ for (String path: filePaths) {
+ assertTrue("Failed to delete file " + path + " on behalf of "
+ + testApp.getPackageName(), deleteFileAs(testApp, path));
+ }
+ }
+
+ private static void assertCantDeletePathsAs(TestApp testApp, String... filePaths)
+ throws Exception {
+ for (String path: filePaths) {
+ assertFalse("Deleting " + path + " on behalf of " + testApp.getPackageName()
+ + " was expected to fail", deleteFileAs(testApp, path));
+ }
+ }
+
+ private void deleteFiles(File... files) {
+ for (File file: files) {
+ if (file == null) continue;
+ file.delete();
+ }
+ }
+
+ private void deletePaths(String... paths) {
+ for (String path: paths) {
+ if (path == null) continue;
+ new File(path).delete();
+ }
+ }
+
+ private static void assertCanDeletePaths(String... filePaths) {
+ for (String filePath : filePaths) {
+ assertTrue("Failed to delete " + filePath,
+ new File(filePath).delete());
+ }
+ }
+
+ /**
+ * For possible values of {@code mode}, look at {@link android.content.ContentProvider#openFile}
+ */
+ private static void assertCanQueryAndOpenFile(File file, String mode) throws IOException {
+ // This call performs the query
+ final Uri fileUri = getFileUri(file);
+ // The query succeeds iff it didn't return null
+ assertThat(fileUri).isNotNull();
+ // Now we assert that we can open the file through ContentResolver
+ try (ParcelFileDescriptor pfd =
+ getContentResolver().openFileDescriptor(fileUri, mode)) {
+ assertThat(pfd).isNotNull();
+ }
+ }
+
+ /**
+ * Assert that the last read in: read - write - read using {@code readFd} and {@code writeFd}
+ * see the last write. {@code readFd} and {@code writeFd} are fds pointing to the same
+ * underlying file on disk but may be derived from different mount points and in that case
+ * have separate VFS caches.
+ */
+ private void assertRWR(ParcelFileDescriptor readPfd, ParcelFileDescriptor writePfd)
+ throws Exception {
+ FileDescriptor readFd = readPfd.getFileDescriptor();
+ FileDescriptor writeFd = writePfd.getFileDescriptor();
+
+ byte[] readBuffer = new byte[10];
+ byte[] writeBuffer = new byte[10];
+ Arrays.fill(writeBuffer, (byte) 1);
+
+ // Write so readFd has content to read from next
+ Os.pwrite(readFd, readBuffer, 0, 10, 0);
+ // Read so readBuffer is in readFd's mount VFS cache
+ Os.pread(readFd, readBuffer, 0, 10, 0);
+
+ // Assert that readBuffer is zeroes
+ assertThat(readBuffer).isEqualTo(new byte[10]);
+
+ // Write so writeFd and readFd should now see writeBuffer
+ Os.pwrite(writeFd, writeBuffer, 0, 10, 0);
+
+ // Read so the last write can be verified on readFd
+ Os.pread(readFd, readBuffer, 0, 10, 0);
+
+ // Assert that the last write is indeed visible via readFd
+ assertThat(readBuffer).isEqualTo(writeBuffer);
+ assertThat(readPfd.getStatSize()).isEqualTo(writePfd.getStatSize());
+ }
+
+ private void assertStartsWith(String actual, String prefix) throws Exception {
+ String message = "String \"" + actual + "\" should start with \"" + prefix + "\"";
+
+ assertWithMessage(message).that(actual).startsWith(prefix);
+ }
+
+ private void assertLowerFsFd(ParcelFileDescriptor pfd) throws Exception {
+ String path = Os.readlink("/proc/self/fd/" + pfd.getFd());
+ String prefix = "/storage";
+
+ assertStartsWith(path, prefix);
+ }
+
+ private void assertUpperFsFd(ParcelFileDescriptor pfd) throws Exception {
+ String path = Os.readlink("/proc/self/fd/" + pfd.getFd());
+ String prefix = "/mnt/user";
+
+ assertStartsWith(path, prefix);
+ }
+
+ private void assertLowerFsFdWithPassthrough(final String path, ParcelFileDescriptor pfd)
+ throws Exception {
+ final ContentResolver resolver = getTargetContext().getContentResolver();
+ final Bundle res = resolver.call(MediaStore.AUTHORITY, "uses_fuse_passthrough", path, null);
+ boolean passthroughEnabled = res.getBoolean("uses_fuse_passthrough_result");
+
+ if (passthroughEnabled) {
+ assertUpperFsFd(pfd);
+ } else {
+ assertLowerFsFd(pfd);
+ }
+ }
+
+ private static void assertCanCreateFile(File file) throws IOException {
+ // If the file somehow managed to survive a previous run, then the test app was uninstalled
+ // and MediaProvider will remove our its ownership of the file, so it's not guaranteed that
+ // we can create nor delete it.
+ if (!file.exists()) {
+ assertThat(file.createNewFile()).isTrue();
+ assertThat(file.delete()).isTrue();
+ } else {
+ Log.w(TAG,
+ "Couldn't assertCanCreateFile(" + file + ") because file existed prior to "
+ + "running the test!");
+ }
+ }
+
+ private static void assertCannotReadOrWrite(File file)
+ throws Exception {
+ // App data directories have different 'x' bits on upgrading vs new devices. Let's not
+ // check 'exists', by passing checkExists=false. But assert this app cannot read or write
+ // the other app's file.
+ assertAccess(file, false /* value is moot */, false /* canRead */,
+ false /* canWrite */, false /* checkExists */);
+ }
+
+ private static void assertAccess(File file, boolean exists, boolean canRead, boolean canWrite)
+ throws Exception {
+ assertAccess(file, exists, canRead, canWrite, true /* checkExists */);
+ }
+
+ private static void assertAccess(File file, boolean exists, boolean canRead, boolean canWrite,
+ boolean checkExists) throws Exception {
+ if (checkExists) {
+ assertThat(file.exists()).isEqualTo(exists);
+ }
+ assertThat(file.canRead()).isEqualTo(canRead);
+ assertThat(file.canWrite()).isEqualTo(canWrite);
+ if (file.isDirectory()) {
+ if (checkExists) {
+ assertThat(file.canExecute()).isEqualTo(exists);
+ }
+ } else {
+ assertThat(file.canExecute()).isFalse(); // Filesytem is mounted with MS_NOEXEC
+ }
+
+ // Test some combinations of mask.
+ assertAccess(file, R_OK, canRead);
+ assertAccess(file, W_OK, canWrite);
+ assertAccess(file, R_OK | W_OK, canRead && canWrite);
+ assertAccess(file, W_OK | F_OK, canWrite);
+
+ if (checkExists) {
+ assertAccess(file, F_OK, exists);
+ }
+ }
+
+ private static void assertAccess(File file, int mask, boolean expected) throws Exception {
+ if (expected) {
+ assertThat(Os.access(file.getAbsolutePath(), mask)).isTrue();
+ } else {
+ assertThrows(ErrnoException.class, () -> {
+ Os.access(file.getAbsolutePath(), mask);
+ });
+ }
+ }
+
+ /**
+ * Creates a file at any location on storage (except external app data directory).
+ * The owner of the file is not the caller app.
+ */
+ private void createFileAsLegacyApp(File file) throws Exception {
+ // Use a legacy app to create this file, since it could be outside shared storage.
+ Log.d(TAG, "Creating file " + file);
+ assertThat(createFileAs(APP_D_LEGACY_HAS_RW, file.getAbsolutePath())).isTrue();
+ }
+
+ /**
+ * Creates a file at any location on storage (except external app data directory).
+ * The owner of the file is not the caller app.
+ */
+ private void createDirectoryAsLegacyApp(File file) throws Exception {
+ // Use a legacy app to create this file, since it could be outside shared storage.
+ Log.d(TAG, "Creating directory " + file);
+ // Create a tmp file in the target directory, this would also create the required
+ // directory, then delete the tmp file. It would leave only new directory.
+ assertThat(createFileAs(APP_D_LEGACY_HAS_RW, file.getAbsolutePath() + "/tmp.txt")).isTrue();
+ assertThat(deleteFileAs(APP_D_LEGACY_HAS_RW, file.getAbsolutePath() + "/tmp.txt")).isTrue();
+ }
+
+ /**
+ * Deletes a file or directory at any location on storage (except external app data directory).
+ */
+ private void deleteAsLegacyApp(File file) throws Exception {
+ // Use a legacy app to delete this file, since it could be outside shared storage.
+ Log.d(TAG, "Deleting file " + file);
+ deleteFileAs(APP_D_LEGACY_HAS_RW, file.getAbsolutePath());
+ }
+
+ /**
+ * Deletes the given file/directory recursively. If the file is a directory, then deletes all
+ * of its children (files or directories) recursively.
+ */
+ private void deleteRecursivelyAsLegacyApp(File dir) throws Exception {
+ // Use a legacy app to delete this directory, since it could be outside shared storage.
+ Log.d(TAG, "Deleting directory " + dir);
+ deleteRecursivelyAs(APP_D_LEGACY_HAS_RW, dir.getAbsolutePath());
+ }
+}