blob: 067063982580e4471b2050abeb276b4989ddeb96 [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.lib;
import static android.provider.MediaStore.VOLUME_EXTERNAL;
import static android.scopedstorage.cts.lib.RedactionTestHelper.EXIF_METADATA_QUERY;
import static androidx.test.InstrumentationRegistry.getContext;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static junit.framework.Assert.assertEquals;
import static junit.framework.TestCase.assertNotNull;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.fail;
import android.Manifest;
import android.app.ActivityManager;
import android.app.AppOpsManager;
import android.app.UiAutomation;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.os.storage.StorageManager;
import android.provider.MediaStore;
import android.system.ErrnoException;
import android.system.Os;
import android.system.OsConstants;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.os.BuildCompat;
import androidx.test.InstrumentationRegistry;
import com.android.cts.install.lib.Install;
import com.android.cts.install.lib.InstallUtils;
import com.android.cts.install.lib.TestApp;
import com.android.cts.install.lib.Uninstall;
import com.android.modules.utils.build.SdkLevel;
import com.google.common.io.ByteStreams;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
/**
* General helper functions for ScopedStorageTest tests.
*/
public class TestUtils {
static final String TAG = "ScopedStorageTest";
public static final String QUERY_TYPE = "android.scopedstorage.cts.queryType";
public static final String INTENT_EXTRA_PATH = "android.scopedstorage.cts.path";
public static final String INTENT_EXTRA_URI = "android.scopedstorage.cts.uri";
public static final String INTENT_EXTRA_CALLING_PKG = "android.scopedstorage.cts.calling_pkg";
public static final String INTENT_EXCEPTION = "android.scopedstorage.cts.exception";
public static final String CREATE_FILE_QUERY = "android.scopedstorage.cts.createfile";
public static final String CREATE_IMAGE_ENTRY_QUERY =
"android.scopedstorage.cts.createimageentry";
public static final String DELETE_FILE_QUERY = "android.scopedstorage.cts.deletefile";
public static final String DELETE_RECURSIVE_QUERY = "android.scopedstorage.cts.deleteRecursive";
public static final String CAN_OPEN_FILE_FOR_READ_QUERY =
"android.scopedstorage.cts.can_openfile_read";
public static final String CAN_OPEN_FILE_FOR_WRITE_QUERY =
"android.scopedstorage.cts.can_openfile_write";
public static final String IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_READ =
"android.scopedstorage.cts.is_uri_redacted_via_file_descriptor_for_read";
public static final String IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_WRITE =
"android.scopedstorage.cts.is_uri_redacted_via_file_descriptor_for_write";
public static final String IS_URI_REDACTED_VIA_FILEPATH =
"android.scopedstorage.cts.is_uri_redacted_via_filepath";
public static final String QUERY_URI = "android.scopedstorage.cts.query_uri";
public static final String QUERY_MAX_ROW_ID = "android.scopedstorage.cts.query_max_row_id";
public static final String QUERY_MIN_ROW_ID = "android.scopedstorage.cts.query_min_row_id";
public static final String OPEN_FILE_FOR_READ_QUERY =
"android.scopedstorage.cts.openfile_read";
public static final String OPEN_FILE_FOR_WRITE_QUERY =
"android.scopedstorage.cts.openfile_write";
public static final String CAN_READ_WRITE_QUERY =
"android.scopedstorage.cts.can_read_and_write";
public static final String READDIR_QUERY = "android.scopedstorage.cts.readdir";
public static final String SETATTR_QUERY = "android.scopedstorage.cts.setattr";
public static final String CHECK_DATABASE_ROW_EXISTS_QUERY =
"android.scopedstorage.cts.check_database_row_exists";
public static final String RENAME_FILE_QUERY = "android.scopedstorage.cts.renamefile";
public static final String STR_DATA1 = "Just some random text";
public static final String STR_DATA2 = "More arbitrary stuff";
public static final byte[] BYTES_DATA1 = STR_DATA1.getBytes();
public static final byte[] BYTES_DATA2 = STR_DATA2.getBytes();
public static final String RENAME_FILE_PARAMS_SEPARATOR = ";";
// Root of external storage
private static File sExternalStorageDirectory = Environment.getExternalStorageDirectory();
private static String sStorageVolumeName = MediaStore.VOLUME_EXTERNAL;
/**
* Set this to {@code false} if the test is verifying uri grants on testApp. Force stopping the
* app will kill the app and it will lose uri grants.
*/
private static boolean sShouldForceStopTestApp = true;
private static final long POLLING_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(20);
private static final long POLLING_SLEEP_MILLIS = 100;
/**
* Creates the top level default directories.
*
* <p>Those are usually created by MediaProvider, but some naughty tests might delete them
* and not restore them afterwards, so we make sure we create them before we make any
* assumptions about their existence.
*/
public static void setupDefaultDirectories() {
for (File dir : getDefaultTopLevelDirs()) {
dir.mkdirs();
assertWithMessage("Could not setup default dir [%s]", dir.toString())
.that(dir.exists())
.isTrue();
}
}
/**
* Grants {@link Manifest.permission#GRANT_RUNTIME_PERMISSIONS} to the given package.
*/
public static void grantPermission(String packageName, String permission) {
UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
uiAutomation.adoptShellPermissionIdentity("android.permission.GRANT_RUNTIME_PERMISSIONS");
try {
uiAutomation.grantRuntimePermission(packageName, permission);
} finally {
uiAutomation.dropShellPermissionIdentity();
}
try {
pollForPermission(packageName, permission, true);
} catch (Exception e) {
fail("Exception on polling for permission grant for " + packageName + " for "
+ permission + ": " + e.getMessage());
}
}
/**
* Revokes permissions from the given package.
*/
public static void revokePermission(String packageName, String permission) {
UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
uiAutomation.adoptShellPermissionIdentity("android.permission.REVOKE_RUNTIME_PERMISSIONS");
try {
uiAutomation.revokeRuntimePermission(packageName, permission);
} finally {
uiAutomation.dropShellPermissionIdentity();
}
try {
pollForPermission(packageName, permission, false);
} catch (Exception e) {
fail("Exception on polling for permission revoke for " + packageName + " for "
+ permission + ": " + e.getMessage());
}
}
/**
* Adopts shell permission identity for the given permissions.
*/
public static void adoptShellPermissionIdentity(String... permissions) {
InstrumentationRegistry.getInstrumentation().getUiAutomation().adoptShellPermissionIdentity(
permissions);
}
/**
* Drops shell permission identity for all permissions.
*/
public static void dropShellPermissionIdentity() {
InstrumentationRegistry.getInstrumentation().getUiAutomation()
.dropShellPermissionIdentity();
}
/**
* Executes a shell command.
*/
public static String executeShellCommand(String pattern, Object...args) throws IOException {
String command = String.format(pattern, args);
int attempt = 0;
while (attempt++ < 5) {
try {
return executeShellCommandInternal(command);
} catch (InterruptedIOException e) {
// Hmm, we had trouble executing the shell command; the best we
// can do is try again a few more times
Log.v(TAG, "Trouble executing " + command + "; trying again", e);
}
}
throw new IOException("Failed to execute " + command);
}
private static String executeShellCommandInternal(String cmd) throws IOException {
UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
try (FileInputStream output = new FileInputStream(
uiAutomation.executeShellCommand(cmd).getFileDescriptor())) {
return new String(ByteStreams.toByteArray(output));
}
}
/**
* Makes the given {@code testApp} list the content of the given directory and returns the
* result as an {@link ArrayList}
*/
public static ArrayList<String> listAs(TestApp testApp, String dirPath) throws Exception {
return getContentsFromTestApp(testApp, dirPath, READDIR_QUERY);
}
/**
* Returns {@code true} iff the given {@code path} exists and is readable and
* writable for for {@code testApp}.
*/
public static boolean canReadAndWriteAs(TestApp testApp, String path) throws Exception {
return getResultFromTestApp(testApp, path, CAN_READ_WRITE_QUERY);
}
/**
* Makes the given {@code testApp} read the EXIF metadata from the given file and returns the
* result as an {@link HashMap}
*/
public static HashMap<String, String> readExifMetadataFromTestApp(
TestApp testApp, String filePath) throws Exception {
HashMap<String, String> res =
getMetadataFromTestApp(testApp, filePath, EXIF_METADATA_QUERY);
return res;
}
/**
* Makes the given {@code testApp} create a file.
*
* <p>This method drops shell permission identity.
*/
public static boolean createFileAs(TestApp testApp, String path) throws Exception {
return getResultFromTestApp(testApp, path, CREATE_FILE_QUERY);
}
/**
* Makes the given {@code testApp} create a mediastore DB entry under
* {@code MediaStore.Media.Images}.
*
* The {@code path} argument is treated as a relative path and a name separated
* by an {@code '/'}.
*/
public static boolean createImageEntryAs(TestApp testApp, String path) throws Exception {
return getResultFromTestApp(testApp, path, CREATE_IMAGE_ENTRY_QUERY);
}
/**
* Makes the given {@code testApp} delete a file.
*
* <p>This method drops shell permission identity.
*/
public static boolean deleteFileAs(TestApp testApp, String path) throws Exception {
return getResultFromTestApp(testApp, path, DELETE_FILE_QUERY);
}
/**
* Makes the given {@code testApp} delete a file or directory.
* If the file is a directory, then deletes all of its children (file or directories)
* recursively.
*
* <p>This method drops shell permission identity.
*/
public static boolean deleteRecursivelyAs(TestApp testApp, String path) throws Exception {
return getResultFromTestApp(testApp, path, DELETE_RECURSIVE_QUERY);
}
/**
* Makes the given {@code testApp} delete a file. Doesn't throw in case of failure.
*/
public static boolean deleteFileAsNoThrow(TestApp testApp, String path) {
try {
return deleteFileAs(testApp, path);
} catch (Exception e) {
Log.e(TAG,
"Error occurred while deleting file: " + path + " on behalf of app: " + testApp,
e);
return false;
}
}
/**
* Makes the given {@code testApp} open {@code file} for read or write.
*
* <p>This method drops shell permission identity.
*/
public static boolean canOpenFileAs(TestApp testApp, File file, boolean forWrite)
throws Exception {
String actionName = forWrite ? CAN_OPEN_FILE_FOR_WRITE_QUERY : CAN_OPEN_FILE_FOR_READ_QUERY;
return getResultFromTestApp(testApp, file.getPath(), actionName);
}
/**
* Makes the given {@code testApp} rename give {@code src} to {@code dst}.
*
* The method concatenates source and destination paths while sending the request to
* {@code testApp}. Hence, {@link TestUtils#RENAME_FILE_PARAMS_SEPARATOR} shouldn't be used
* in path names.
*
* <p>This method drops shell permission identity.
*/
public static boolean renameFileAs(TestApp testApp, File src, File dst) throws Exception {
final String paths = String.format("%s%s%s",
src.getAbsolutePath(), RENAME_FILE_PARAMS_SEPARATOR, dst.getAbsolutePath());
return getResultFromTestApp(testApp, paths, RENAME_FILE_QUERY);
}
/**
* Makes the given {@code testApp} check if a database row exists for given {@code file}
*
* <p>This method drops shell permission identity.
*/
public static boolean checkDatabaseRowExistsAs(TestApp testApp, File file) throws Exception {
return getResultFromTestApp(testApp, file.getPath(), CHECK_DATABASE_ROW_EXISTS_QUERY);
}
/**
* Makes the given {@code testApp} open file descriptor on {@code uri} and verifies that the fd
* redacts EXIF metadata.
*
* <p> This method drops shell permission identity.
*/
public static boolean isFileDescriptorRedacted(TestApp testApp, Uri uri)
throws Exception {
String actionName = IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_READ;
return getFromTestApp(testApp, uri, actionName).getBoolean(actionName, false);
}
/**
* Makes the given {@code testApp} open file descriptor on {@code uri} and verifies that the fd
* redacts EXIF metadata.
*
* <p> This method drops shell permission identity.
*/
public static boolean canOpenRedactedUriForWrite(TestApp testApp, Uri uri)
throws Exception {
String actionName = IS_URI_REDACTED_VIA_FILE_DESCRIPTOR_FOR_WRITE;
return getFromTestApp(testApp, uri, actionName).getBoolean(actionName, false);
}
/**
* Makes the given {@code testApp} open file path associated with {@code uri} and verifies that
* the path redacts EXIF metadata.
*
* <p>This method drops shell permission identity.
*/
public static boolean isFileOpenRedacted(TestApp testApp, Uri uri)
throws Exception {
final String actionName = IS_URI_REDACTED_VIA_FILEPATH;
return getFromTestApp(testApp, uri, actionName).getBoolean(actionName, false);
}
/**
* Makes the given {@code testApp} query on {@code uri}.
*
* <p>This method drops shell permission identity.
*/
public static boolean canQueryOnUri(TestApp testApp, Uri uri) throws Exception {
final String actionName = QUERY_URI;
return getFromTestApp(testApp, uri, actionName).getBoolean(actionName, false);
}
public static Uri insertFileFromExternalMedia(boolean useRelative) throws IOException {
ContentValues values = new ContentValues();
String filePath =
getAndroidMediaDir().toString() + "/" + getContext().getPackageName() + "/"
+ System.currentTimeMillis();
if (useRelative) {
values.put(MediaStore.MediaColumns.RELATIVE_PATH,
"Android/media/" + getContext().getPackageName());
values.put(MediaStore.MediaColumns.DISPLAY_NAME, System.currentTimeMillis());
} else {
values.put(MediaStore.MediaColumns.DATA, filePath);
}
return getContentResolver().insert(
MediaStore.Files.getContentUri(sStorageVolumeName), values);
}
public static void insertFile(ContentValues values) {
assertNotNull(getContentResolver().insert(
MediaStore.Files.getContentUri(sStorageVolumeName), values));
}
public static int updateFile(Uri uri, ContentValues values) {
return getContentResolver().update(uri, values, new Bundle());
}
public static void verifyInsertFromExternalPrivateDirViaRelativePath_denied() throws Exception {
// Test that inserting files from Android/obb/.. is not allowed.
final String androidObbDir = getExternalObbDir().toString();
ContentValues values = new ContentValues();
values.put(
MediaStore.MediaColumns.RELATIVE_PATH,
androidObbDir.substring(androidObbDir.indexOf("Android")));
assertThrows(IllegalArgumentException.class, () -> insertFile(values));
// Test that inserting files from Android/data/.. is not allowed.
final String androidDataDir = getExternalFilesDir().toString();
values.put(
MediaStore.MediaColumns.RELATIVE_PATH,
androidDataDir.substring(androidDataDir.indexOf("Android")));
assertThrows(IllegalArgumentException.class, () -> insertFile(values));
}
public static void verifyInsertFromExternalMediaDirViaRelativePath_allowed() throws Exception {
// Test that inserting files from Android/media/.. is allowed.
final String androidMediaDir = getExternalMediaDir().toString();
final ContentValues values = new ContentValues();
values.put(
MediaStore.MediaColumns.RELATIVE_PATH,
androidMediaDir.substring(androidMediaDir.indexOf("Android")));
insertFile(values);
}
public static void verifyInsertFromExternalPrivateDirViaData_denied() throws Exception {
ContentValues values = new ContentValues();
// Test that inserting files from Android/obb/.. is not allowed.
final String androidObbDir =
getExternalObbDir().toString() + "/" + System.currentTimeMillis();
values.put(MediaStore.MediaColumns.DATA, androidObbDir);
assertThrows(IllegalArgumentException.class, () -> insertFile(values));
// Test that inserting files from Android/data/.. is not allowed.
final String androidDataDir = getExternalFilesDir().toString();
values.put(MediaStore.MediaColumns.DATA, androidDataDir);
assertThrows(IllegalArgumentException.class, () -> insertFile(values));
}
public static void verifyInsertFromExternalMediaDirViaData_allowed() throws Exception {
// Test that inserting files from Android/media/.. is allowed.
ContentValues values = new ContentValues();
final String androidMediaDirFile =
getExternalMediaDir().toString() + "/" + System.currentTimeMillis();
values.put(MediaStore.MediaColumns.DATA, androidMediaDirFile);
insertFile(values);
}
// NOTE: While updating, DATA field should be ignored for all the apps including file manager.
public static void verifyUpdateToExternalDirsViaData_denied() throws Exception {
Uri uri = insertFileFromExternalMedia(false);
final String androidMediaDirFile =
getExternalMediaDir().toString() + "/" + System.currentTimeMillis();
ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.DATA, androidMediaDirFile);
assertEquals(0, updateFile(uri, values));
final String androidObbDir =
getExternalObbDir().toString() + "/" + System.currentTimeMillis();
values.put(MediaStore.MediaColumns.DATA, androidObbDir);
assertEquals(0, updateFile(uri, values));
final String androidDataDir = getExternalFilesDir().toString();
values.put(MediaStore.MediaColumns.DATA, androidDataDir);
assertEquals(0, updateFile(uri, values));
}
public static void verifyUpdateToExternalMediaDirViaRelativePath_allowed()
throws IOException {
Uri uri = insertFileFromExternalMedia(true);
// Test that update to files from Android/media/.. is allowed.
final String androidMediaDir = getExternalMediaDir().toString();
ContentValues values = new ContentValues();
values.put(
MediaStore.MediaColumns.RELATIVE_PATH,
androidMediaDir.substring(androidMediaDir.indexOf("Android")));
assertNotEquals(0, updateFile(uri, values));
}
public static void verifyUpdateToExternalPrivateDirsViaRelativePath_denied()
throws Exception {
Uri uri = insertFileFromExternalMedia(true);
// Test that update to files from Android/obb/.. is not allowed.
final String androidObbDir = getExternalObbDir().toString();
ContentValues values = new ContentValues();
values.put(
MediaStore.MediaColumns.RELATIVE_PATH,
androidObbDir.substring(androidObbDir.indexOf("Android")));
assertThrows(IllegalArgumentException.class, () -> updateFile(uri, values));
// Test that update to files from Android/data/.. is not allowed.
final String androidDataDir = getExternalFilesDir().toString();
values.put(
MediaStore.MediaColumns.RELATIVE_PATH,
androidDataDir.substring(androidDataDir.indexOf("Android")));
assertThrows(IllegalArgumentException.class, () -> updateFile(uri, values));
}
/**
* Makes the given {@code testApp} open a file for read or write.
*
* <p>This method drops shell permission identity.
*/
public static ParcelFileDescriptor openFileAs(TestApp testApp, File file, boolean forWrite)
throws Exception {
String actionName = forWrite ? OPEN_FILE_FOR_WRITE_QUERY : OPEN_FILE_FOR_READ_QUERY;
String mode = forWrite ? "rw" : "r";
return getPfdFromTestApp(testApp, file, actionName, mode);
}
/**
* Makes the given {@code testApp} setattr for given file path.
*
* <p>This method drops shell permission identity.
*/
public static boolean setAttrAs(TestApp testApp, String path)
throws Exception {
return getResultFromTestApp(testApp, path, SETATTR_QUERY);
}
/**
* Installs a {@link TestApp} without storage permissions.
*/
public static void installApp(TestApp testApp) throws Exception {
installApp(testApp, /* grantStoragePermission */ false);
}
/**
* Installs a {@link TestApp} with storage permissions.
*/
public static void installAppWithStoragePermissions(TestApp testApp) throws Exception {
installApp(testApp, /* grantStoragePermission */ true);
}
/**
* Installs a {@link TestApp} and may grant it storage permissions.
*/
public static void installApp(TestApp testApp, boolean grantStoragePermission)
throws Exception {
UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
try {
final String packageName = testApp.getPackageName();
uiAutomation.adoptShellPermissionIdentity(
Manifest.permission.INSTALL_PACKAGES, Manifest.permission.DELETE_PACKAGES);
if (isAppInstalled(testApp)) {
Uninstall.packages(packageName);
}
Install.single(testApp).commit();
assertThat(InstallUtils.getInstalledVersion(packageName)).isEqualTo(1);
if (grantStoragePermission) {
grantPermission(packageName, Manifest.permission.READ_EXTERNAL_STORAGE);
if (SdkLevel.isAtLeastT()) {
grantPermission(packageName, Manifest.permission.READ_MEDIA_IMAGES);
grantPermission(packageName, Manifest.permission.READ_MEDIA_AUDIO);
grantPermission(packageName, Manifest.permission.READ_MEDIA_VIDEO);
}
}
} finally {
uiAutomation.dropShellPermissionIdentity();
}
}
public static boolean isAppInstalled(TestApp testApp) {
return InstallUtils.getInstalledVersion(testApp.getPackageName()) != -1;
}
/**
* Uninstalls a {@link TestApp}.
*/
public static void uninstallApp(TestApp testApp) throws Exception {
UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
try {
final String packageName = testApp.getPackageName();
uiAutomation.adoptShellPermissionIdentity(Manifest.permission.DELETE_PACKAGES);
Uninstall.packages(packageName);
assertThat(InstallUtils.getInstalledVersion(packageName)).isEqualTo(-1);
} finally {
uiAutomation.dropShellPermissionIdentity();
}
}
/**
* Uninstalls a {@link TestApp}. Doesn't throw in case of failure.
*/
public static void uninstallAppNoThrow(TestApp testApp) {
try {
uninstallApp(testApp);
} catch (Exception e) {
Log.e(TAG, "Exception occurred while uninstalling app: " + testApp, e);
}
}
public static ContentResolver getContentResolver() {
return getContext().getContentResolver();
}
/**
* Inserts a file into the database using {@link MediaStore.MediaColumns#DATA}.
*/
public static Uri insertFileUsingDataColumn(@NonNull File file) {
final ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.DATA, file.getPath());
return getContentResolver().insert(MediaStore.Files.getContentUri(sStorageVolumeName),
values);
}
/**
* Returns the content URI for images based on the current storage volume.
*/
public static Uri getImageContentUri() {
return MediaStore.Images.Media.getContentUri(sStorageVolumeName);
}
/**
* Renames the given file using {@link ContentResolver} and {@link MediaStore} and APIs.
* This method uses the data column, and not all apps can use it.
* @see MediaStore.MediaColumns#DATA
*/
public static int renameWithMediaProvider(@NonNull File oldPath, @NonNull File newPath) {
ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.DATA, newPath.getPath());
return getContentResolver().update(MediaStore.Files.getContentUri(sStorageVolumeName),
values, /*where*/ MediaStore.MediaColumns.DATA + "=?",
/*whereArgs*/ new String[] {oldPath.getPath()});
}
/**
* Queries {@link ContentResolver} for a file and returns the corresponding {@link Uri} for its
* entry in the database. Returns {@code null} if file doesn't exist in the database.
*/
@Nullable
public static Uri getFileUri(@NonNull File file) {
final Uri contentUri = MediaStore.Files.getContentUri(sStorageVolumeName);
final int id = getFileRowIdFromDatabase(file);
return id == -1 ? null : ContentUris.withAppendedId(contentUri, id);
}
/**
* Queries {@link ContentResolver} for a file and returns the corresponding row ID for its
* entry in the database. Returns {@code -1} if file is not found.
*/
public static int getFileRowIdFromDatabase(@NonNull File file) {
return getFileRowIdFromDatabase(getContentResolver(), file);
}
/**
* Queries given {@link ContentResolver} for a file and returns the corresponding row ID for
* its entry in the database. Returns {@code -1} if file is not found.
*/
public static int getFileRowIdFromDatabase(ContentResolver cr, @NonNull File file) {
int id = -1;
try (Cursor c = queryFile(cr, file, MediaStore.MediaColumns._ID)) {
if (c.moveToFirst()) {
id = c.getInt(0);
}
}
return id;
}
/**
* Queries {@link ContentResolver} for a file and returns the corresponding owner package name
* for its entry in the database.
*/
@Nullable
public static String getFileOwnerPackageFromDatabase(@NonNull File file) {
String ownerPackage = null;
try (Cursor c = queryFile(file, MediaStore.MediaColumns.OWNER_PACKAGE_NAME)) {
if (c.moveToFirst()) {
ownerPackage = c.getString(0);
}
}
return ownerPackage;
}
/**
* Queries {@link ContentResolver} for a file and returns the corresponding file size for its
* entry in the database. Returns {@code -1} if file is not found.
*/
@Nullable
public static int getFileSizeFromDatabase(@NonNull File file) {
int size = -1;
try (Cursor c = queryFile(file, MediaStore.MediaColumns.SIZE)) {
if (c.moveToFirst()) {
size = c.getInt(0);
}
}
return size;
}
/**
* Queries {@link ContentResolver} for a video file and returns a {@link Cursor} with the given
* columns.
*/
@NonNull
public static Cursor queryVideoFile(File file, String... projection) {
return queryFile(getContentResolver(),
MediaStore.Video.Media.getContentUri(sStorageVolumeName), file,
/*includePending*/ true, projection);
}
/**
* Queries {@link ContentResolver} for an image file and returns a {@link Cursor} with the given
* columns.
*/
@NonNull
public static Cursor queryImageFile(File file, String... projection) {
return queryFile(getContentResolver(),
MediaStore.Images.Media.getContentUri(sStorageVolumeName), file,
/*includePending*/ true, projection);
}
/**
* Queries {@link ContentResolver} for a file and returns the corresponding mime type for its
* entry in the database.
*/
@NonNull
public static String getFileMimeTypeFromDatabase(@NonNull File file) {
String mimeType = "";
try (Cursor c = queryFile(file, MediaStore.MediaColumns.MIME_TYPE)) {
if (c.moveToFirst()) {
mimeType = c.getString(0);
}
}
return mimeType;
}
/**
* Sets {@link AppOpsManager#MODE_ALLOWED} for the given {@code ops} and the given {@code uid}.
*
* <p>This method drops shell permission identity.
*/
public static void allowAppOpsToUid(int uid, @NonNull String... ops) {
setAppOpsModeForUid(uid, AppOpsManager.MODE_ALLOWED, ops);
}
/**
* Sets {@link AppOpsManager#MODE_ERRORED} for the given {@code ops} and the given {@code uid}.
*
* <p>This method drops shell permission identity.
*/
public static void denyAppOpsToUid(int uid, @NonNull String... ops) {
setAppOpsModeForUid(uid, AppOpsManager.MODE_ERRORED, ops);
}
/**
* Deletes the given file through {@link ContentResolver} and {@link MediaStore} APIs,
* and asserts that the file was successfully deleted from the database.
*/
public static void deleteWithMediaProvider(@NonNull File file) {
Bundle extras = new Bundle();
extras.putString(ContentResolver.QUERY_ARG_SQL_SELECTION,
MediaStore.MediaColumns.DATA + " = ?");
extras.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS,
new String[] {file.getPath()});
extras.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
extras.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
assertThat(getContentResolver().delete(
MediaStore.Files.getContentUri(sStorageVolumeName), extras)).isEqualTo(1);
}
/**
* Deletes db rows and files corresponding to uri through {@link ContentResolver} and
* {@link MediaStore} APIs.
*/
public static void deleteWithMediaProviderNoThrow(Uri... uris) {
for (Uri uri : uris) {
if (uri == null) continue;
try {
getContentResolver().delete(uri, Bundle.EMPTY);
} catch (Exception ignored) {
}
}
}
/**
* Renames the given file through {@link ContentResolver} and {@link MediaStore} APIs,
* and asserts that the file was updated in the database.
*/
public static void updateDisplayNameWithMediaProvider(Uri uri, String relativePath,
String oldDisplayName, String newDisplayName) {
String selection = MediaStore.MediaColumns.RELATIVE_PATH + " = ? AND "
+ MediaStore.MediaColumns.DISPLAY_NAME + " = ?";
String[] selectionArgs = {relativePath + '/', oldDisplayName};
Bundle extras = new Bundle();
extras.putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection);
extras.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs);
extras.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
extras.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.DISPLAY_NAME, newDisplayName);
assertThat(getContentResolver().update(uri, values, extras)).isEqualTo(1);
}
/**
* Opens the given file through {@link ContentResolver} and {@link MediaStore} APIs.
*/
@NonNull
public static ParcelFileDescriptor openWithMediaProvider(@NonNull File file, String mode)
throws Exception {
final Uri fileUri = getFileUri(file);
assertThat(fileUri).isNotNull();
Log.i(TAG, "Uri: " + fileUri + ". Data: " + file.getPath());
ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(fileUri, mode);
assertThat(pfd).isNotNull();
return pfd;
}
/**
* Opens the given file via file path
*/
@NonNull
public static ParcelFileDescriptor openWithFilePath(File file, boolean forWrite)
throws IOException {
return ParcelFileDescriptor.open(file,
forWrite
? ParcelFileDescriptor.MODE_READ_WRITE : ParcelFileDescriptor.MODE_READ_ONLY);
}
/**
* Returns whether we can open the file.
*/
public static boolean canOpen(File file, boolean forWrite) {
try (ParcelFileDescriptor ignore = openWithFilePath(file, forWrite)) {
return true;
} catch (IOException expected) {
return false;
}
}
/**
* Asserts the given operation throws an exception of type {@code T}.
*/
public static <T extends Exception> void assertThrows(Class<T> clazz, Operation<Exception> r)
throws Exception {
assertThrows(clazz, "", r);
}
/**
* Asserts the given operation throws an exception of type {@code T}.
*/
public static <T extends Exception> void assertThrows(
Class<T> clazz, String errMsg, Operation<Exception> r) throws Exception {
try {
r.run();
fail("Expected " + clazz + " to be thrown");
} catch (Exception e) {
if (!clazz.isAssignableFrom(e.getClass()) || !e.getMessage().contains(errMsg)) {
Log.e(TAG, "Expected " + clazz + " exception with error message: " + errMsg, e);
throw e;
}
}
}
public static void setShouldForceStopTestApp(boolean value) {
sShouldForceStopTestApp = value;
}
public static long readMaximumRowIdFromDatabaseAs(TestApp app, Uri uri) throws Exception {
final String actionName = QUERY_MAX_ROW_ID;
return getFromTestApp(app, uri, actionName).getLong(actionName, Long.MIN_VALUE);
}
public static long readMinimumRowIdFromDatabaseAs(TestApp app, Uri uri) throws Exception {
final String actionName = QUERY_MIN_ROW_ID;
return getFromTestApp(app, uri, actionName).getLong(actionName, Long.MAX_VALUE);
}
/**
* A functional interface representing an operation that takes no arguments,
* returns no arguments and might throw an {@link Exception} of any kind.
*
* @param T the subclass of {@link java.lang.Exception} that this operation might throw.
*/
@FunctionalInterface
public interface Operation<T extends Exception> {
/**
* This is the method that gets called for any object that implements this interface.
*/
void run() throws T;
}
/**
* Deletes the given file. If the file is a directory, then deletes all of its children (files
* or directories) recursively.
*/
public static boolean deleteRecursively(@NonNull File path) {
if (path.isDirectory()) {
for (File child : path.listFiles()) {
if (!deleteRecursively(child)) {
return false;
}
}
}
return path.delete();
}
/**
* Asserts can rename file.
*/
public static void assertCanRenameFile(File oldFile, File newFile) {
assertCanRenameFile(oldFile, newFile, /* checkDB */ true);
}
/**
* Asserts can rename file and optionally checks if the database is updated after rename.
*/
public static void assertCanRenameFile(File oldFile, File newFile, boolean checkDatabase) {
assertThat(oldFile.renameTo(newFile)).isTrue();
assertThat(oldFile.exists()).isFalse();
assertThat(newFile.exists()).isTrue();
if (checkDatabase) {
assertThat(getFileRowIdFromDatabase(oldFile)).isEqualTo(-1);
assertThat(getFileRowIdFromDatabase(newFile)).isNotEqualTo(-1);
}
}
/**
* Asserts cannot rename file.
*/
public static void assertCantRenameFile(File oldFile, File newFile) {
final int rowId = getFileRowIdFromDatabase(oldFile);
assertThat(oldFile.renameTo(newFile)).isFalse();
assertThat(oldFile.exists()).isTrue();
assertThat(getFileRowIdFromDatabase(oldFile)).isEqualTo(rowId);
}
/**
* Assert that app cannot insert files in other app's private directories
*
* @param fileName name of the file
* @param throwsExceptionForDataValue Apps like System Gallery for which Data column is not
* respected, will not throw an Exception as the Data value is ignored.
* @param otherApp Other test app in whose external private directory we will attempt to insert
* @param callingPackageName Calling package name
*/
public static void assertCantInsertToOtherPrivateAppDirectories(String fileName,
boolean throwsExceptionForDataValue, TestApp otherApp, String callingPackageName)
throws Exception {
// Create directory in which the device test will try to insert file to
final File otherAppExternalDataDir = new File(getExternalFilesDir().getPath().replace(
callingPackageName, otherApp.getPackageName()));
final File file = new File(otherAppExternalDataDir, fileName);
try {
assertThat(createFileAs(otherApp, file.getPath())).isTrue();
final ContentValues valuesWithData = new ContentValues();
valuesWithData.put(MediaStore.MediaColumns.DATA, file.getAbsolutePath());
try {
Uri uri = getContentResolver().insert(
MediaStore.Files.getContentUri(VOLUME_EXTERNAL),
valuesWithData);
if (throwsExceptionForDataValue) {
fail("File insert expected to fail: " + file);
} else {
try (Cursor c = getContentResolver().query(uri, new String[]{
MediaStore.MediaColumns.DATA}, null, null)) {
assertThat(c.moveToFirst()).isTrue();
assertThat(c.getString(0)).isNotEqualTo(file.getAbsolutePath());
}
}
} catch (IllegalArgumentException expected) {
}
final ContentValues valuesWithRelativePath = new ContentValues();
final String path = file.getAbsolutePath();
valuesWithRelativePath.put(MediaStore.MediaColumns.RELATIVE_PATH,
path.substring(path.indexOf("Android")));
valuesWithRelativePath.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName);
try {
getContentResolver().insert(MediaStore.Files.getContentUri(VOLUME_EXTERNAL),
valuesWithRelativePath);
fail("File insert expected to fail: " + file);
} catch (IllegalArgumentException expected) {
}
} finally {
deleteFileAsNoThrow(otherApp, file.getPath());
}
}
/**
* Assert that app cannot update files in other app's private directories
*
* @param fileName name of the file
* @param throwsExceptionForDataValue Apps like non-legacy System Gallery/MES for which
* Data column is not respected, will not throw an Exception as the Data value is ignored.
* @param otherApp Other test app in whose external private directory we will attempt to insert
* @param callingPackageName Calling package name
*/
public static void assertCantUpdateToOtherPrivateAppDirectories(String fileName,
boolean throwsExceptionForDataValue, TestApp otherApp, String callingPackageName)
throws Exception {
// Create priv-app file and add to the database that we will try to update
final File otherAppExternalDataDir = new File(getExternalFilesDir().getPath().replace(
callingPackageName, otherApp.getPackageName()));
final File file = new File(otherAppExternalDataDir, fileName);
try {
assertThat(createFileAs(otherApp, file.getPath())).isTrue();
MediaStore.scanFile(getContentResolver(), file);
final ContentValues valuesWithData = new ContentValues();
valuesWithData.put(MediaStore.MediaColumns.DATA, file.getAbsolutePath());
try {
int res = getContentResolver().update(
MediaStore.Files.getContentUri(VOLUME_EXTERNAL),
valuesWithData, Bundle.EMPTY);
if (throwsExceptionForDataValue) {
fail("File update expected to fail: " + file);
} else {
assertThat(res).isEqualTo(0);
}
} catch (IllegalArgumentException expected) {
}
final ContentValues valuesWithRelativePath = new ContentValues();
final String path = file.getAbsolutePath();
valuesWithRelativePath.put(MediaStore.MediaColumns.RELATIVE_PATH,
path.substring(path.indexOf("Android")));
valuesWithRelativePath.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName);
try {
getContentResolver().update(MediaStore.Files.getContentUri(VOLUME_EXTERNAL),
valuesWithRelativePath, Bundle.EMPTY);
fail("File update expected to fail: " + file);
} catch (IllegalArgumentException expected) {
}
} finally {
deleteFileAsNoThrow(otherApp, file.getPath());
}
}
/**
* Asserts can rename directory.
*/
public static void assertCanRenameDirectory(File oldDirectory, File newDirectory,
@Nullable File[] oldFilesList, @Nullable File[] newFilesList) {
assertThat(oldDirectory.renameTo(newDirectory)).isTrue();
assertThat(oldDirectory.exists()).isFalse();
assertThat(newDirectory.exists()).isTrue();
for (File file : oldFilesList != null ? oldFilesList : new File[0]) {
assertThat(file.exists()).isFalse();
assertThat(getFileRowIdFromDatabase(file)).isEqualTo(-1);
}
for (File file : newFilesList != null ? newFilesList : new File[0]) {
assertThat(file.exists()).isTrue();
assertThat(getFileRowIdFromDatabase(file)).isNotEqualTo(-1);
}
}
/**
* Asserts cannot rename directory.
*/
public static void assertCantRenameDirectory(
File oldDirectory, File newDirectory, @Nullable File[] oldFilesList) {
assertThat(oldDirectory.renameTo(newDirectory)).isFalse();
assertThat(oldDirectory.exists()).isTrue();
for (File file : oldFilesList != null ? oldFilesList : new File[0]) {
assertThat(file.exists()).isTrue();
assertThat(getFileRowIdFromDatabase(file)).isNotEqualTo(-1);
}
}
public static void assertMountMode(String packageName, int uid, int expectedMountMode) {
adoptShellPermissionIdentity("android.permission.WRITE_MEDIA_STORAGE");
try {
final StorageManager storageManager = getContext().getSystemService(
StorageManager.class);
final int actualMountMode = storageManager.getExternalStorageMountMode(uid,
packageName);
assertWithMessage("mount mode (%s=%s, %s=%s) for package %s and uid %s",
expectedMountMode, mountModeToString(expectedMountMode),
actualMountMode, mountModeToString(actualMountMode),
packageName, uid).that(actualMountMode).isEqualTo(expectedMountMode);
} finally {
dropShellPermissionIdentity();
}
}
public static String mountModeToString(int mountMode) {
switch (mountMode) {
case 0:
return "EXTERNAL_NONE";
case 1:
return "DEFAULT";
case 2:
return "INSTALLER";
case 3:
return "PASS_THROUGH";
case 4:
return "ANDROID_WRITABLE";
default:
return "INVALID(" + mountMode + ")";
}
}
public static void assertCanAccessPrivateAppAndroidDataDir(boolean canAccess,
TestApp testApp, String callingPackage, String fileName) throws Exception {
File[] dataDirs = getContext().getExternalFilesDirs(null);
canReadWriteFilesInDirs(dataDirs, canAccess, testApp, callingPackage, fileName);
}
public static void assertCanAccessPrivateAppAndroidObbDir(boolean canAccess,
TestApp testApp, String callingPackage, String fileName) throws Exception {
File[] obbDirs = getContext().getObbDirs();
canReadWriteFilesInDirs(obbDirs, canAccess, testApp, callingPackage, fileName);
}
private static void canReadWriteFilesInDirs(File[] dirs, boolean canAccess, TestApp testApp,
String callingPackage, String fileName) throws Exception {
for (File dir : dirs) {
final File otherAppExternalDataDir = new File(dir.getPath().replace(
callingPackage, testApp.getPackageName()));
final File file = new File(otherAppExternalDataDir, fileName);
try {
assertThat(file.exists()).isFalse();
assertThat(createFileAs(testApp, file.getPath())).isTrue();
if (canAccess) {
assertThat(file.canRead()).isTrue();
assertThat(file.canWrite()).isTrue();
} else {
assertThat(file.canRead()).isFalse();
assertThat(file.canWrite()).isFalse();
}
} finally {
deleteFileAsNoThrow(testApp, file.getAbsolutePath());
}
}
}
/**
* Polls for external storage to be mounted.
*/
public static void pollForExternalStorageState() throws Exception {
pollForCondition(
() -> Environment.getExternalStorageState(getExternalStorageDir())
.equals(Environment.MEDIA_MOUNTED),
"Timed out while waiting for ExternalStorageState to be MEDIA_MOUNTED");
}
/**
* Polls until we're granted or denied a given permission.
*/
public static void pollForPermission(String perm, boolean granted) throws Exception {
pollForCondition(() -> granted == checkPermissionAndAppOp(perm),
"Timed out while waiting for permission " + perm + " to be "
+ (granted ? "granted" : "revoked"));
}
/**
* Polls until {@code app} is granted or denied the given permission.
*/
public static void pollForPermission(TestApp app, String perm, boolean granted)
throws Exception {
pollForPermission(app.getPackageName(), perm, granted);
}
/**
* Polls until {@code packageName} is granted or denied the given permission.
*/
public static void pollForPermission(String packageName, String perm, boolean granted)
throws Exception {
pollForCondition(
() -> granted == checkPermission(packageName, perm),
"Timed out while waiting for permission " + perm + " to be "
+ (granted ? "granted" : "revoked"));
}
/**
* Returns true iff {@code packageName} is granted a given permission.
*/
public static boolean checkPermission(String packageName, String perm) {
try {
int uid = getContext().getPackageManager().getPackageUid(packageName, 0);
Optional<ActivityManager.RunningAppProcessInfo> process = getAppProcessInfo(
packageName);
int pid = process.isPresent() ? process.get().pid : -1;
return checkPermissionAndAppOp(perm, packageName, pid, uid);
} catch (PackageManager.NameNotFoundException e) {
return false;
}
}
/**
* Returns true iff {@code app} is granted a given permission.
*/
public static boolean checkPermission(TestApp app, String perm) {
return checkPermission(app.getPackageName(), perm);
}
/**
* Asserts the entire content of the file equals exactly {@code expectedContent}.
*/
public static void assertFileContent(File file, byte[] expectedContent) throws IOException {
try (FileInputStream fis = new FileInputStream(file)) {
assertInputStreamContent(fis, expectedContent);
}
}
/**
* Asserts the entire content of the file equals exactly {@code expectedContent}.
* <p>Sets {@code fd} to beginning of file first.
*/
public static void assertFileContent(FileDescriptor fd, byte[] expectedContent)
throws IOException, ErrnoException {
Os.lseek(fd, 0, OsConstants.SEEK_SET);
try (FileInputStream fis = new FileInputStream(fd)) {
assertInputStreamContent(fis, expectedContent);
}
}
/**
* Asserts that {@code dir} is a directory and that it doesn't contain any of
* {@code unexpectedContent}
*/
public static void assertDirectoryDoesNotContain(@NonNull File dir, File... unexpectedContent) {
assertThat(dir.isDirectory()).isTrue();
assertThat(Arrays.asList(dir.listFiles())).containsNoneIn(unexpectedContent);
}
/**
* Asserts that {@code dir} is a directory and that it contains all of {@code expectedContent}
*/
public static void assertDirectoryContains(@NonNull File dir, File... expectedContent) {
assertThat(dir.isDirectory()).isTrue();
assertThat(Arrays.asList(dir.listFiles())).containsAtLeastElementsIn(expectedContent);
}
public static File getExternalStorageDir() {
return sExternalStorageDirectory;
}
public static void setExternalStorageVolume(@NonNull String volName) {
sStorageVolumeName = volName.toLowerCase(Locale.ROOT);
sExternalStorageDirectory = new File("/storage/" + volName);
}
/**
* Resets the root directory of external storage to the default.
*
* @see Environment#getExternalStorageDirectory()
*/
public static void resetDefaultExternalStorageVolume() {
sStorageVolumeName = MediaStore.VOLUME_EXTERNAL;
sExternalStorageDirectory = Environment.getExternalStorageDirectory();
}
/**
* Asserts the default volume used in helper methods is the primary volume.
*/
public static void assertDefaultVolumeIsPrimary() {
assertVolumeType(true /* isPrimary */);
}
/**
* Asserts the default volume used in helper methods is a public volume.
*/
public static void assertDefaultVolumeIsPublic() {
assertVolumeType(false /* isPrimary */);
}
/**
* Creates and returns the Android data sub-directory belonging to the calling package.
*/
public static File getExternalFilesDir() {
final String packageName = getContext().getPackageName();
final File res = new File(getAndroidDataDir(), packageName + "/files");
if (!res.equals(getContext().getExternalFilesDir(null))) {
res.mkdirs();
}
return res;
}
/**
* Creates and returns the Android obb sub-directory belonging to the calling package.
*/
public static File getExternalObbDir() {
final String packageName = getContext().getPackageName();
final File res = new File(getAndroidObbDir(), packageName);
if (!res.equals(getContext().getObbDirs()[0])) {
res.mkdirs();
}
return res;
}
/**
* Creates and returns the Android media sub-directory belonging to the calling package.
*/
public static File getExternalMediaDir() {
final String packageName = getContext().getPackageName();
final File res = new File(getAndroidMediaDir(), packageName);
if (!res.equals(getContext().getExternalMediaDirs()[0])) {
res.mkdirs();
}
return res;
}
public static File getAlarmsDir() {
return new File(getExternalStorageDir(),
Environment.DIRECTORY_ALARMS);
}
public static File getAndroidDir() {
return new File(getExternalStorageDir(),
"Android");
}
public static File getAudiobooksDir() {
return new File(getExternalStorageDir(),
Environment.DIRECTORY_AUDIOBOOKS);
}
public static File getDcimDir() {
return new File(getExternalStorageDir(), Environment.DIRECTORY_DCIM);
}
public static File getDocumentsDir() {
return new File(getExternalStorageDir(),
Environment.DIRECTORY_DOCUMENTS);
}
public static File getDownloadDir() {
return new File(getExternalStorageDir(),
Environment.DIRECTORY_DOWNLOADS);
}
public static File getMusicDir() {
return new File(getExternalStorageDir(),
Environment.DIRECTORY_MUSIC);
}
public static File getMoviesDir() {
return new File(getExternalStorageDir(),
Environment.DIRECTORY_MOVIES);
}
public static File getNotificationsDir() {
return new File(getExternalStorageDir(),
Environment.DIRECTORY_NOTIFICATIONS);
}
public static File getPicturesDir() {
return new File(getExternalStorageDir(),
Environment.DIRECTORY_PICTURES);
}
public static File getPodcastsDir() {
return new File(getExternalStorageDir(),
Environment.DIRECTORY_PODCASTS);
}
public static File getRecordingsDir() {
return new File(getExternalStorageDir(),
Environment.DIRECTORY_RECORDINGS);
}
public static File getRingtonesDir() {
return new File(getExternalStorageDir(),
Environment.DIRECTORY_RINGTONES);
}
public static File getAndroidDataDir() {
return new File(getAndroidDir(), "data");
}
public static File getAndroidObbDir() {
return new File(getAndroidDir(), "obb");
}
public static File getAndroidMediaDir() {
return new File(getAndroidDir(), "media");
}
public static File[] getDefaultTopLevelDirs() {
if (BuildCompat.isAtLeastS()) {
return new File[]{getAlarmsDir(), getAndroidDir(), getAudiobooksDir(), getDcimDir(),
getDocumentsDir(), getDownloadDir(), getMusicDir(), getMoviesDir(),
getNotificationsDir(), getPicturesDir(), getPodcastsDir(), getRecordingsDir(),
getRingtonesDir()};
}
return new File[]{getAlarmsDir(), getAndroidDir(), getAudiobooksDir(), getDcimDir(),
getDocumentsDir(), getDownloadDir(), getMusicDir(), getMoviesDir(),
getNotificationsDir(), getPicturesDir(), getPodcastsDir(),
getRingtonesDir()};
}
private static void assertInputStreamContent(InputStream in, byte[] expectedContent)
throws IOException {
assertThat(ByteStreams.toByteArray(in)).isEqualTo(expectedContent);
}
/**
* Checks if the given {@code permission} is granted and corresponding AppOp is MODE_ALLOWED.
*/
private static boolean checkPermissionAndAppOp(String permission) {
final int pid = Os.getpid();
final int uid = Os.getuid();
final String packageName = getContext().getPackageName();
return checkPermissionAndAppOp(permission, packageName, pid, uid);
}
/**
* Checks if the given {@code permission} is granted and corresponding AppOp is MODE_ALLOWED.
*/
private static boolean checkPermissionAndAppOp(String permission, String packageName, int pid,
int uid) {
final Context context = getContext();
if (context.checkPermission(permission, pid, uid) != PackageManager.PERMISSION_GRANTED) {
return false;
}
final String op = AppOpsManager.permissionToOp(permission);
// No AppOp associated with the given permission, skip AppOp check.
if (op == null) {
return true;
}
final AppOpsManager appOps = context.getSystemService(AppOpsManager.class);
try {
appOps.checkPackage(uid, packageName);
} catch (SecurityException e) {
return false;
}
return appOps.unsafeCheckOpNoThrow(op, uid, packageName) == AppOpsManager.MODE_ALLOWED;
}
/**
* <p>This method drops shell permission identity.
*/
public static void forceStopApp(String packageName) throws Exception {
UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
try {
uiAutomation.adoptShellPermissionIdentity(Manifest.permission.FORCE_STOP_PACKAGES);
getContext().getSystemService(ActivityManager.class).forceStopPackage(packageName);
pollForCondition(() -> {
return !isProcessRunning(packageName);
}, "Timed out while waiting for " + packageName + " to be stopped");
} finally {
uiAutomation.dropShellPermissionIdentity();
}
}
private static void launchTestApp(TestApp testApp, String actionName,
BroadcastReceiver broadcastReceiver, CountDownLatch latch, Intent intent)
throws InterruptedException, TimeoutException {
// Register broadcast receiver
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(actionName);
intentFilter.addCategory(Intent.CATEGORY_DEFAULT);
getContext().registerReceiver(broadcastReceiver, intentFilter,
Context.RECEIVER_EXPORTED_UNAUDITED);
// Launch the test app.
intent.setPackage(testApp.getPackageName());
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra(QUERY_TYPE, actionName);
intent.putExtra(INTENT_EXTRA_CALLING_PKG, getContext().getPackageName());
intent.addCategory(Intent.CATEGORY_LAUNCHER);
getContext().startActivity(intent);
if (!latch.await(POLLING_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) {
final String errorMessage = "Timed out while waiting to receive " + actionName
+ " intent from " + testApp.getPackageName();
throw new TimeoutException(errorMessage);
}
getContext().unregisterReceiver(broadcastReceiver);
}
/**
* Sends intent to {@code testApp} for actions on {@code dirPath}
*
* <p>This method drops shell permission identity.
*/
private static void sendIntentToTestApp(TestApp testApp, String dirPath, String actionName,
BroadcastReceiver broadcastReceiver, CountDownLatch latch) throws Exception {
if (sShouldForceStopTestApp) {
final String packageName = testApp.getPackageName();
forceStopApp(packageName);
}
// Launch the test app.
final Intent intent = new Intent(Intent.ACTION_MAIN);
intent.putExtra(INTENT_EXTRA_PATH, dirPath);
launchTestApp(testApp, actionName, broadcastReceiver, latch, intent);
}
/**
* Sends intent to {@code testApp} for actions on {@code uri}
*
* <p>This method drops shell permission identity.
*/
private static void sendIntentToTestApp(TestApp testApp, Uri uri, String actionName,
BroadcastReceiver broadcastReceiver, CountDownLatch latch) throws Exception {
if (sShouldForceStopTestApp) {
final String packageName = testApp.getPackageName();
forceStopApp(packageName);
}
final Intent intent = new Intent(Intent.ACTION_MAIN);
intent.putExtra(INTENT_EXTRA_URI, uri);
launchTestApp(testApp, actionName, broadcastReceiver, latch, intent);
}
/**
* Gets images/video metadata from a test app.
*
* <p>This method drops shell permission identity.
*/
private static HashMap<String, String> getMetadataFromTestApp(
TestApp testApp, String dirPath, String actionName) throws Exception {
Bundle bundle = getFromTestApp(testApp, dirPath, actionName);
return (HashMap<String, String>) bundle.get(actionName);
}
/**
* <p>This method drops shell permission identity.
*/
private static ArrayList<String> getContentsFromTestApp(
TestApp testApp, String dirPath, String actionName) throws Exception {
Bundle bundle = getFromTestApp(testApp, dirPath, actionName);
return bundle.getStringArrayList(actionName);
}
/**
* <p>This method drops shell permission identity.
*/
private static boolean getResultFromTestApp(TestApp testApp, String dirPath, String actionName)
throws Exception {
Bundle bundle = getFromTestApp(testApp, dirPath, actionName);
return bundle.getBoolean(actionName, false);
}
private static ParcelFileDescriptor getPfdFromTestApp(TestApp testApp, File dirPath,
String actionName, String mode) throws Exception {
Bundle bundle = getFromTestApp(testApp, dirPath.getPath(), actionName);
return getContentResolver().openFileDescriptor(bundle.getParcelable(actionName), mode);
}
/**
* <p>This method drops shell permission identity.
*/
private static Bundle getFromTestApp(TestApp testApp, String dirPath, String actionName)
throws Exception {
final CountDownLatch latch = new CountDownLatch(1);
final Bundle[] bundle = new Bundle[1];
final Exception[] exception = new Exception[1];
exception[0] = null;
BroadcastReceiver broadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (intent.hasExtra(INTENT_EXCEPTION)) {
exception[0] = (Exception) (intent.getSerializableExtra(INTENT_EXCEPTION));
} else {
bundle[0] = intent.getExtras();
}
latch.countDown();
}
};
sendIntentToTestApp(testApp, dirPath, actionName, broadcastReceiver, latch);
if (exception[0] != null) {
throw exception[0];
}
return bundle[0];
}
/**
* <p>This method drops shell permission identity.
*/
private static Bundle getFromTestApp(TestApp testApp, Uri uri, String actionName)
throws Exception {
final CountDownLatch latch = new CountDownLatch(1);
final Bundle[] bundle = new Bundle[1];
final Exception[] exception = new Exception[1];
exception[0] = null;
BroadcastReceiver broadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (intent.hasExtra(INTENT_EXCEPTION)) {
exception[0] = (Exception) (intent.getSerializableExtra(INTENT_EXCEPTION));
} else {
bundle[0] = intent.getExtras();
}
latch.countDown();
}
};
sendIntentToTestApp(testApp, uri, actionName, broadcastReceiver, latch);
if (exception[0] != null) {
throw exception[0];
}
return bundle[0];
}
/**
* Sets {@code mode} for the given {@code ops} and the given {@code uid}.
*
* <p>This method drops shell permission identity.
*/
public static void setAppOpsModeForUid(int uid, int mode, @NonNull String... ops) {
adoptShellPermissionIdentity(null);
try {
for (String op : ops) {
getContext().getSystemService(AppOpsManager.class).setUidMode(op, uid, mode);
}
} finally {
dropShellPermissionIdentity();
}
}
/**
* Queries {@link ContentResolver} for a file IS_PENDING=0 and returns a {@link Cursor} with the
* given columns.
*/
@NonNull
public static Cursor queryFileExcludingPending(@NonNull File file, String... projection) {
return queryFile(getContentResolver(), MediaStore.Files.getContentUri(sStorageVolumeName),
file, /*includePending*/ false, projection);
}
@NonNull
public static Cursor queryFile(ContentResolver cr, @NonNull File file, String... projection) {
return queryFile(cr, MediaStore.Files.getContentUri(sStorageVolumeName),
file, /*includePending*/ true, projection);
}
@NonNull
public static Cursor queryFile(@NonNull File file, String... projection) {
return queryFile(getContentResolver(), MediaStore.Files.getContentUri(sStorageVolumeName),
file, /*includePending*/ true, projection);
}
@NonNull
private static Cursor queryFile(ContentResolver cr, @NonNull Uri uri, @NonNull File file,
boolean includePending, String... projection) {
Bundle queryArgs = new Bundle();
queryArgs.putString(ContentResolver.QUERY_ARG_SQL_SELECTION,
MediaStore.MediaColumns.DATA + " = ?");
queryArgs.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS,
new String[] { file.getAbsolutePath() });
queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
if (includePending) {
queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
} else {
queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_EXCLUDE);
}
final Cursor c = cr.query(uri, projection, queryArgs, null);
assertThat(c).isNotNull();
return c;
}
private static boolean isObbDirUnmounted() {
List<String> mounts = new ArrayList<>();
try {
for (String line : executeShellCommand("cat /proc/mounts").split("\n")) {
String[] split = line.split(" ");
// Only check obb dirs with tmpfs, as if it's mounted for app data
// isolation, it will be tmpfs only.
if (split[0].equals("tmpfs") && split[1].startsWith("/storage/")
&& split[1].endsWith("/obb")) {
return false;
}
}
} catch (IOException e) {
Log.e(TAG, "Failed to execute shell command", e);
}
return true;
}
private static boolean isVolumeMounted(String type) {
try {
final String volume = executeShellCommand("sm list-volumes " + type).trim();
return volume != null && volume.contains(" mounted");
} catch (Exception e) {
return false;
}
}
private static boolean isPublicVolumeMounted() {
return isVolumeMounted("public");
}
private static boolean isEmulatedVolumeMounted() {
return isVolumeMounted("emulated");
}
private static boolean isFuseReady() {
for (String volumeName : MediaStore.getExternalVolumeNames(getContext())) {
final Uri uri = MediaStore.Files.getContentUri(volumeName);
try (Cursor c = getContentResolver().query(uri, null, null, null)) {
assertThat(c).isNotNull();
} catch (IllegalArgumentException e) {
return false;
}
}
return true;
}
/**
* Prepare or create a public volume for testing
*/
public static void preparePublicVolume() throws Exception {
if (getCurrentPublicVolumeName() == null) {
createNewPublicVolume();
return;
}
if (!Boolean.parseBoolean(executeShellCommand("sm has-adoptable").trim())) {
unmountAppDirs();
// ensure the volume is visible
executeShellCommand("sm set-force-adoptable on");
Thread.sleep(2000);
pollForCondition(TestUtils::isPublicVolumeMounted,
"Timed out while waiting for public volume");
pollForCondition(TestUtils::isEmulatedVolumeMounted,
"Timed out while waiting for emulated volume");
pollForCondition(TestUtils::isFuseReady,
"Timed out while waiting for fuse");
}
}
/**
* Unmount app's obb and data dirs.
*/
public static void unmountAppDirs() throws Exception {
if (TestUtils.isObbDirUnmounted()) {
return;
}
executeShellCommand("sm unmount-app-data-dirs " + getContext().getPackageName() + " "
+ android.os.Process.myPid() + " " + android.os.UserHandle.myUserId());
pollForCondition(TestUtils::isObbDirUnmounted,
"Timed out while waiting for unmounting obb dir");
}
/**
* Creates a new virtual public volume and returns the volume's name.
*/
public static void createNewPublicVolume() throws Exception {
// Unmount data and obb dirs for test app first so test app won't be killed during
// volume unmount.
unmountAppDirs();
executeShellCommand("sm set-force-adoptable on");
executeShellCommand("sm set-virtual-disk true");
Thread.sleep(2000);
pollForCondition(TestUtils::partitionDisk, "Timed out while waiting for disk partitioning");
}
private static boolean partitionDisk() {
try {
final String listDisks = executeShellCommand("sm list-disks").trim();
if (TextUtils.isEmpty(listDisks)) {
return false;
}
executeShellCommand("sm partition " + listDisks + " public");
return true;
} catch (Exception e) {
return false;
}
}
/**
* Gets the name of the public volume, waiting for a bit for it to be available.
*/
public static String getPublicVolumeName() throws Exception {
final String[] volName = new String[1];
pollForCondition(() -> {
volName[0] = getCurrentPublicVolumeName();
return volName[0] != null;
}, "Timed out while waiting for public volume to be ready");
return volName[0];
}
/**
* @return the currently mounted public volume, if any.
*/
public static String getCurrentPublicVolumeName() {
final String[] allVolumeDetails;
try {
allVolumeDetails = executeShellCommand("sm list-volumes")
.trim().split("\n");
} catch (Exception e) {
Log.e(TAG, "Failed to execute shell command", e);
return null;
}
for (String volDetails : allVolumeDetails) {
if (volDetails.startsWith("public")) {
final String[] publicVolumeDetails = volDetails.trim().split(" ");
String res = publicVolumeDetails[publicVolumeDetails.length - 1];
if ("null".equals(res)) {
continue;
}
return res;
}
}
return null;
}
/**
* Returns the content URI of the volume on which the test is running.
*/
public static Uri getTestVolumeFileUri() {
return MediaStore.Files.getContentUri(sStorageVolumeName);
}
private static void pollForCondition(Supplier<Boolean> condition, String errorMessage)
throws Exception {
for (int i = 0; i < POLLING_TIMEOUT_MILLIS / POLLING_SLEEP_MILLIS; i++) {
if (condition.get()) {
return;
}
Thread.sleep(POLLING_SLEEP_MILLIS);
}
throw new TimeoutException(errorMessage);
}
/**
* Polls for all files access to be allowed.
*/
public static void pollForManageExternalStorageAllowed() throws Exception {
pollForCondition(
() -> Environment.isExternalStorageManager(),
"Timed out while waiting for MANAGE_EXTERNAL_STORAGE");
}
private static void assertVolumeType(boolean isPrimary) {
String[] parts = getExternalFilesDir().getAbsolutePath().split("/");
assertThat(parts.length).isAtLeast(3);
assertThat(parts[1]).isEqualTo("storage");
if (isPrimary) {
assertThat(parts[2]).isEqualTo("emulated");
} else {
assertThat(parts[2]).isNotEqualTo("emulated");
}
}
private static boolean isProcessRunning(String packageName) {
return getAppProcessInfo(packageName).isPresent();
}
private static Optional<ActivityManager.RunningAppProcessInfo> getAppProcessInfo(
String packageName) {
return getContext().getSystemService(
ActivityManager.class).getRunningAppProcesses().stream().filter(
p -> packageName.equals(p.processName)).findFirst();
}
public static void trashFileAndAssert(Uri uri) {
final ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.IS_TRASHED, 1);
assertWithMessage("Result of ContentResolver#update for " + uri + " with values to trash "
+ "file " + values)
.that(getContentResolver().update(uri, values, Bundle.EMPTY)).isEqualTo(1);
}
public static void untrashFileAndAssert(Uri uri) {
final ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.IS_TRASHED, 0);
assertWithMessage("Result of ContentResolver#update for " + uri + " with values to untrash "
+ "file " + values)
.that(getContentResolver().update(uri, values, Bundle.EMPTY)).isEqualTo(1);
}
public static void waitForMountedAndIdleState(ContentResolver resolver) throws Exception {
// We purposefully perform these operations twice in this specific
// order, since clearing the data on a package can asynchronously
// perform a vold reset, which can make us think storage is ready and
// mounted when it's moments away from being torn down.
pollForExternalStorageMountedState();
MediaStore.waitForIdle(resolver);
pollForExternalStorageMountedState();
MediaStore.waitForIdle(resolver);
}
private static void pollForExternalStorageMountedState() throws Exception {
final File target = Environment.getExternalStorageDirectory();
pollForCondition(() -> isExternalStorageDirectoryMounted(target),
"Timed out while waiting for ExternalStorageState to be MEDIA_MOUNTED");
}
private static boolean isExternalStorageDirectoryMounted(File target) {
boolean isMounted = Environment.MEDIA_MOUNTED.equals(
Environment.getExternalStorageState(target));
if (isMounted) {
try {
return Os.statvfs(target.getAbsolutePath()).f_blocks > 0;
} catch (Exception e) {
// Waiting for external storage to be mounted
}
}
return false;
}
}