blob: 099a0ab727697cc5d426a1079576f05f589c8ed1 [file] [log] [blame]
/*
* 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());
}
}