| /* |
| * 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.device; |
| |
| 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.getExifMetadata; |
| 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.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.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.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.isAppInstalled; |
| import static android.scopedstorage.cts.lib.TestUtils.listAs; |
| import static android.scopedstorage.cts.lib.TestUtils.openWithMediaProvider; |
| 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.uninstallApp; |
| 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.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.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"); |
| |
| // The following apps are not installed at test startup - please install before using. |
| private static final TestApp APP_C = new TestApp("TestAppC", |
| "android.scopedstorage.cts.testapp.C", 1, false, "CtsScopedStorageTestAppC.apk"); |
| private static final TestApp APP_C_LEGACY = new TestApp("TestAppCLegacy", |
| "android.scopedstorage.cts.testapp.C", 1, false, "CtsScopedStorageTestAppCLegacy.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() { |
| 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 = getExifMetadata(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(); |
| deleteAsLegacyApp(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_C, |
| new File(getDcimDir(), "read_storage.jpg"), |
| Manifest.permission.READ_MEDIA_IMAGES, |
| AppOpsManager.OPSTR_READ_MEDIA_IMAGES, |
| /* forWrite */ false); |
| } else { |
| testAppOpInvalidation(APP_C, 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_C_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_C, new File(getDownloadDir(), "manage_storage.pdf"), |
| /* permission */ null, OPSTR_MANAGE_EXTERNAL_STORAGE, /* forWrite */ true); |
| } |
| |
| @Test |
| public void testWriteImagesInvalidation() throws Exception { |
| testAppOpInvalidation(APP_C, new File(getDcimDir(), "write_images.jpg"), |
| /* permission */ null, AppOpsManager.OPSTR_WRITE_MEDIA_IMAGES, /* forWrite */ true); |
| } |
| |
| @Test |
| public void testWriteVideoInvalidation() throws Exception { |
| testAppOpInvalidation(APP_C, 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 = getExifMetadata(imgFile); |
| assertExifMetadataMatch(exif, originalExif); |
| |
| // Install test app |
| installAppWithStoragePermissions(APP_C); |
| |
| // Grant A_M_L and verify access to sensitive data |
| grantPermission(APP_C.getPackageName(), Manifest.permission.ACCESS_MEDIA_LOCATION); |
| HashMap<String, String> exifFromTestApp = |
| readExifMetadataFromTestApp(APP_C, imgFile.getPath()); |
| assertExifMetadataMatch(exifFromTestApp, originalExif); |
| |
| // Revoke A_M_L and verify sensitive data redaction |
| revokePermission( |
| APP_C.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. |
| Thread.sleep(500); |
| exifFromTestApp = readExifMetadataFromTestApp(APP_C, imgFile.getPath()); |
| assertExifMetadataMismatch(exifFromTestApp, originalExif); |
| |
| // Re-grant A_M_L and verify access to sensitive data |
| grantPermission(APP_C.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. |
| Thread.sleep(500); |
| exifFromTestApp = readExifMetadataFromTestApp(APP_C, imgFile.getPath()); |
| assertExifMetadataMatch(exifFromTestApp, originalExif); |
| } finally { |
| imgFile.delete(); |
| uninstallAppNoThrow(APP_C); |
| } |
| } |
| |
| @Test |
| public void testAppUpdateInvalidation() throws Exception { |
| File file = new File(getDcimDir(), "app_update.jpg"); |
| try { |
| assertThat(file.createNewFile()).isTrue(); |
| |
| // Install legacy |
| installAppWithStoragePermissions(APP_C_LEGACY); |
| grantPermission(APP_C_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_C_LEGACY, file, /* forWrite */ false)).isTrue(); |
| assertThat(canOpenFileAs(APP_C_LEGACY, file, /* forWrite */ true)).isTrue(); |
| |
| // Update to non-legacy |
| installAppWithStoragePermissions(APP_C); |
| grantPermission(APP_C_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_C, file, /* forWrite */ false)).isTrue(); |
| // But cannot write |
| assertThat(canOpenFileAs(APP_C, file, /* forWrite */ true)).isFalse(); |
| } finally { |
| file.delete(); |
| uninstallAppNoThrow(APP_C); |
| } |
| } |
| |
| @Test |
| public void testAppReinstallInvalidation() throws Exception { |
| File file = new File(getDcimDir(), "app_reinstall.jpg"); |
| |
| try { |
| assertThat(file.createNewFile()).isTrue(); |
| |
| // Install |
| installAppWithStoragePermissions(APP_C); |
| assertThat(canOpenFileAs(APP_C, file, /* forWrite */ false)).isTrue(); |
| |
| // Re-install |
| uninstallAppNoThrow(APP_C); |
| installApp(APP_C); |
| assertThat(canOpenFileAs(APP_C, file, /* forWrite */ false)).isFalse(); |
| } finally { |
| file.delete(); |
| uninstallAppNoThrow(APP_C); |
| } |
| } |
| |
| private void testAppOpInvalidation(TestApp app, File file, @Nullable String permission, |
| String opstr, boolean forWrite) throws Exception { |
| boolean alreadyInstalled = true; |
| try { |
| if (!isAppInstalled(app)) { |
| alreadyInstalled = false; |
| installApp(app); |
| } |
| assertThat(file.createNewFile()).isTrue(); |
| assertAppOpInvalidation(app, file, permission, opstr, forWrite); |
| } finally { |
| file.delete(); |
| if (!alreadyInstalled) { |
| // only uninstall if we installed this app here |
| uninstallApp(app); |
| } |
| } |
| } |
| |
| /** 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 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. |
| assertThat(cr.openFileDescriptor(uriVideoFile2, "rw")).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 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); |
| assertThat(cr.openFileDescriptor(uriOfOldFile, "rw")).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. |
| assertThat(cr.openFileDescriptor(oldUri, "rw")).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 { |
| deleteAsLegacyApp(topLevelDir1); |
| deleteAsLegacyApp(topLevelDir2); |
| deleteRecursively(nonTopLevelDir); |
| } |
| } |
| |
| @Test |
| public void testCanCreateDefaultDirectory() throws Exception { |
| final File podcastsDir = getPodcastsDir(); |
| try { |
| if (podcastsDir.exists()) { |
| deleteAsLegacyApp(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. |
| deleteAsLegacyApp(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); |
| deleteAsLegacyApp(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); |
| deleteAsLegacyApp(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 |
| @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 { |
| assertWritableMountModeForProvider(DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY); |
| assertWritableMountModeForProvider(DocumentsContract.DOWNLOADS_PROVIDER_AUTHORITY); |
| } |
| |
| 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()); |
| } |
| } |