| /** |
| * 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; |
| } |
| } |