1 /* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package android.provider.cts.media; 18 19 import static android.provider.cts.ProviderTestUtils.executeShellCommand; 20 21 import static com.google.common.truth.Truth.assertWithMessage; 22 23 import static org.junit.Assert.assertFalse; 24 import static org.junit.Assert.assertTrue; 25 import static org.junit.Assert.fail; 26 27 import android.app.AppOpsManager; 28 import android.content.Context; 29 import android.content.pm.PackageManager; 30 import android.content.res.AssetFileDescriptor; 31 import android.database.Cursor; 32 import android.graphics.Bitmap; 33 import android.graphics.Canvas; 34 import android.graphics.Color; 35 import android.graphics.Rect; 36 import android.net.Uri; 37 import android.os.Build; 38 import android.os.Bundle; 39 import android.os.Environment; 40 import android.os.FileUtils; 41 import android.os.ParcelFileDescriptor; 42 import android.os.Process; 43 import android.os.SystemProperties; 44 import android.os.UserManager; 45 import android.os.storage.StorageManager; 46 import android.os.storage.StorageVolume; 47 import android.provider.MediaStore; 48 import android.system.ErrnoException; 49 import android.system.Os; 50 import android.system.OsConstants; 51 import android.util.Log; 52 53 import androidx.test.platform.app.InstrumentationRegistry; 54 55 import com.android.compatibility.common.util.Timeout; 56 import com.android.modules.utils.build.SdkLevel; 57 58 import com.google.common.io.BaseEncoding; 59 60 import java.io.BufferedInputStream; 61 import java.io.File; 62 import java.io.FileNotFoundException; 63 import java.io.FileOutputStream; 64 import java.io.IOException; 65 import java.io.InputStream; 66 import java.io.OutputStream; 67 import java.nio.file.Files; 68 import java.security.DigestInputStream; 69 import java.security.MessageDigest; 70 import java.util.HashSet; 71 import java.util.Set; 72 import java.util.regex.Matcher; 73 import java.util.regex.Pattern; 74 75 public class MediaProviderTestUtils { 76 77 private static final String TAG = "MediaProviderTestUtils"; 78 private static final Timeout IO_TIMEOUT = new Timeout("IO_TIMEOUT", 2_000, 2, 2_000); 79 private static final Pattern PATTERN_STORAGE_PATH = Pattern.compile( 80 "(?i)^/storage/[^/]+/(?:[0-9]+/)?"); 81 MediaProviderTestUtils()82 private MediaProviderTestUtils() { 83 // Utility class 84 } 85 getSharedVolumeNames()86 public static Iterable<String> getSharedVolumeNames() { 87 // We test both new and legacy volume names 88 final HashSet<String> testVolumes = new HashSet<>(); 89 final Set<String> volumeNames = MediaStore.getExternalVolumeNames( 90 InstrumentationRegistry.getInstrumentation().getTargetContext()); 91 // Run tests only on VISIBLE volumes which are FUSE mounted and indexed by MediaProvider 92 for (String vol : volumeNames) { 93 final File mountedPath = getVolumePath(vol); 94 if (mountedPath == null || mountedPath.getAbsolutePath() == null) continue; 95 if (mountedPath.getAbsolutePath().startsWith("/storage/")) { 96 testVolumes.add(vol); 97 } 98 } 99 testVolumes.add(MediaStore.VOLUME_EXTERNAL); 100 return testVolumes; 101 } 102 resolveVolumeName(String volumeName)103 public static String resolveVolumeName(String volumeName) { 104 if (MediaStore.VOLUME_EXTERNAL.equals(volumeName)) { 105 return MediaStore.VOLUME_EXTERNAL_PRIMARY; 106 } else { 107 return volumeName; 108 } 109 } 110 waitForIdle()111 public static void waitForIdle() { 112 MediaStore.waitForIdle(InstrumentationRegistry.getInstrumentation().getTargetContext() 113 .getContentResolver()); 114 } 115 116 /** 117 * Waits until a file exists, or fails. 118 * 119 * @return existing file. 120 */ waitUntilExists(File file)121 public static File waitUntilExists(File file) throws IOException { 122 try { 123 return IO_TIMEOUT.run("file '" + file + "' doesn't exist yet", () -> { 124 return file.exists() ? file : null; // will retry if it returns null 125 }); 126 } catch (Exception e) { 127 throw new IOException(e); 128 } 129 } 130 getVolumePath(String volumeName)131 public static File getVolumePath(String volumeName) { 132 final Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); 133 return context.getSystemService(StorageManager.class) 134 .getStorageVolume(MediaStore.Files.getContentUri(volumeName)).getDirectory(); 135 } 136 stageDir(String volumeName)137 public static File stageDir(String volumeName) throws IOException { 138 if (MediaStore.VOLUME_EXTERNAL.equals(volumeName)) { 139 volumeName = MediaStore.VOLUME_EXTERNAL_PRIMARY; 140 } 141 final StorageVolume vol = InstrumentationRegistry.getInstrumentation().getTargetContext() 142 .getSystemService(StorageManager.class) 143 .getStorageVolume(MediaStore.Files.getContentUri(volumeName)); 144 File dir = Environment.buildPath(vol.getDirectory(), "Android", "media", 145 "android.provider.cts"); 146 Log.d(TAG, "stageDir(" + volumeName + "): returning " + dir); 147 return dir; 148 } 149 stageDownloadDir(String volumeName)150 public static File stageDownloadDir(String volumeName) throws IOException { 151 if (MediaStore.VOLUME_EXTERNAL.equals(volumeName)) { 152 volumeName = MediaStore.VOLUME_EXTERNAL_PRIMARY; 153 } 154 final StorageVolume vol = InstrumentationRegistry.getInstrumentation().getTargetContext() 155 .getSystemService(StorageManager.class) 156 .getStorageVolume(MediaStore.Files.getContentUri(volumeName)); 157 return Environment.buildPath(vol.getDirectory(), 158 Environment.DIRECTORY_DOWNLOADS, "android.provider.cts"); 159 } 160 stageFile(int resId, File file)161 public static File stageFile(int resId, File file) throws IOException { 162 // The caller may be trying to stage into a location only available to 163 // the shell user, so we need to perform the entire copy as the shell 164 final Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); 165 UserManager userManager = context.getSystemService(UserManager.class); 166 if (userManager.isSystemUser() 167 && FileUtils.contains(Environment.getStorageDirectory(), file)) { 168 executeShellCommand("mkdir -p " + file.getParent()); 169 waitUntilExists(file.getParentFile()); 170 try (AssetFileDescriptor afd = context.getResources().openRawResourceFd(resId)) { 171 final File source = ParcelFileDescriptor.getFile(afd.getFileDescriptor()); 172 final long skip = afd.getStartOffset(); 173 final long count = afd.getLength(); 174 175 try { 176 // Try to create the file as calling package so that calling package remains 177 // as owner of the file. 178 file.createNewFile(); 179 } catch (IOException ignored) { 180 // Apps can't create files in other app's private directories, but shell can. If 181 // file creation fails, we ignore and let `dd` command create it instead. 182 } 183 184 executeShellCommand(String.format( 185 "dd bs=4K if=%s iflag=skip_bytes,count_bytes skip=%d count=%d of=%s", 186 source.getAbsolutePath(), skip, count, file.getAbsolutePath())); 187 188 // Force sync to try updating other views 189 executeShellCommand("sync"); 190 } 191 } else { 192 final File dir = file.getParentFile(); 193 dir.mkdirs(); 194 if (!dir.exists()) { 195 throw new FileNotFoundException("Failed to create parent for " + file); 196 } 197 try (InputStream source = context.getResources().openRawResource(resId); 198 OutputStream target = new FileOutputStream(file)) { 199 FileUtils.copy(source, target); 200 } 201 } 202 return waitUntilExists(file); 203 } 204 stageMedia(int resId, Uri collectionUri)205 public static Uri stageMedia(int resId, Uri collectionUri) throws IOException { 206 return stageMedia(resId, collectionUri, "image/png"); 207 } 208 stageMedia(int resId, Uri collectionUri, String mimeType)209 public static Uri stageMedia(int resId, Uri collectionUri, String mimeType) throws IOException { 210 final Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); 211 final String displayName = "cts" + System.nanoTime(); 212 final MediaStoreUtils.PendingParams params = new MediaStoreUtils.PendingParams( 213 collectionUri, displayName, mimeType); 214 final Uri pendingUri = MediaStoreUtils.createPending(context, params); 215 try (MediaStoreUtils.PendingSession session = MediaStoreUtils.openPending(context, 216 pendingUri)) { 217 try (InputStream source = context.getResources().openRawResource(resId); 218 OutputStream target = session.openOutputStream()) { 219 FileUtils.copy(source, target); 220 } 221 return session.publish(); 222 } 223 } 224 scanFile(File file)225 public static Uri scanFile(File file) throws Exception { 226 final Uri uri = MediaStore.scanFile(InstrumentationRegistry.getInstrumentation() 227 .getTargetContext().getContentResolver(), file); 228 assertWithMessage("no URI for '%s'", file).that(uri).isNotNull(); 229 return uri; 230 } 231 scanFileFromShell(File file)232 public static Uri scanFileFromShell(File file) throws Exception { 233 return scanFile(file); 234 } 235 setOwner(Uri uri, String packageName)236 public static void setOwner(Uri uri, String packageName) throws Exception { 237 executeShellCommand("content update" 238 + " --user " + androidx.test.InstrumentationRegistry.getTargetContext().getUserId() 239 + " --uri " + uri 240 + " --bind owner_package_name:s:" + packageName); 241 } 242 clearOwner(Uri uri)243 public static void clearOwner(Uri uri) throws Exception { 244 executeShellCommand("content update" 245 + " --user " + androidx.test.InstrumentationRegistry.getTargetContext().getUserId() 246 + " --uri " + uri 247 + " --bind owner_package_name:n:"); 248 } 249 hash(InputStream in)250 public static byte[] hash(InputStream in) throws Exception { 251 try (DigestInputStream digestIn = new DigestInputStream(in, 252 MessageDigest.getInstance("SHA-1")); 253 OutputStream out = new FileOutputStream(new File("/dev/null"))) { 254 FileUtils.copy(digestIn, out); 255 return digestIn.getMessageDigest().digest(); 256 } 257 } 258 259 /** 260 * Extract the average overall color of the given bitmap. 261 * <p> 262 * Internally takes advantage of gaussian blurring that is naturally applied 263 * when downscaling an image. 264 */ extractAverageColor(Bitmap bitmap)265 public static int extractAverageColor(Bitmap bitmap) { 266 final Bitmap res = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); 267 final Canvas canvas = new Canvas(res); 268 final Rect src = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); 269 final Rect dst = new Rect(0, 0, 1, 1); 270 canvas.drawBitmap(bitmap, src, dst, null); 271 return res.getPixel(0, 0); 272 } 273 assertColorMostlyEquals(int expected, int actual)274 public static void assertColorMostlyEquals(int expected, int actual) { 275 assertTrue("Expected " + Integer.toHexString(expected) + " but was " 276 + Integer.toHexString(actual), isColorMostlyEquals(expected, actual)); 277 } 278 assertColorMostlyNotEquals(int expected, int actual)279 public static void assertColorMostlyNotEquals(int expected, int actual) { 280 assertFalse("Expected " + Integer.toHexString(expected) + " but was " 281 + Integer.toHexString(actual), isColorMostlyEquals(expected, actual)); 282 } 283 isColorMostlyEquals(int expected, int actual)284 private static boolean isColorMostlyEquals(int expected, int actual) { 285 final float[] expectedHSV = new float[3]; 286 final float[] actualHSV = new float[3]; 287 Color.colorToHSV(expected, expectedHSV); 288 Color.colorToHSV(actual, actualHSV); 289 290 // Fail if more than a 10% difference in any component 291 if (Math.abs(expectedHSV[0] - actualHSV[0]) > 36) return false; 292 if (Math.abs(expectedHSV[1] - actualHSV[1]) > 0.1f) return false; 293 if (Math.abs(expectedHSV[2] - actualHSV[2]) > 0.1f) return false; 294 return true; 295 } 296 assertExists(String path)297 public static void assertExists(String path) throws IOException { 298 assertExists(null, path); 299 } 300 assertExists(File file)301 public static void assertExists(File file) throws IOException { 302 assertExists(null, file.getAbsolutePath()); 303 } 304 assertExists(String msg, String path)305 public static void assertExists(String msg, String path) throws IOException { 306 if (!access(path)) { 307 if (msg != null) { 308 fail(path + ": " + msg); 309 } else { 310 fail("File " + path + " does not exist"); 311 } 312 } 313 } 314 assertNotExists(String path)315 public static void assertNotExists(String path) throws IOException { 316 assertNotExists(null, path); 317 } 318 assertNotExists(File file)319 public static void assertNotExists(File file) throws IOException { 320 assertNotExists(null, file.getAbsolutePath()); 321 } 322 assertNotExists(String msg, String path)323 public static void assertNotExists(String msg, String path) throws IOException { 324 if (access(path)) { 325 fail(msg); 326 } 327 } 328 access(String path)329 private static boolean access(String path) throws IOException { 330 // The caller may be trying to stage into a location only available to 331 // the shell user, so we need to perform the entire copy as the shell 332 if (FileUtils.contains(Environment.getStorageDirectory(), new File(path))) { 333 return executeShellCommand("ls -la " + path).contains(path); 334 } else { 335 try { 336 Os.access(path, OsConstants.F_OK); 337 return true; 338 } catch (ErrnoException e) { 339 if (e.errno == OsConstants.ENOENT) { 340 return false; 341 } else { 342 throw new IOException(e.getMessage()); 343 } 344 } 345 } 346 } 347 containsId(Uri uri, long id)348 public static boolean containsId(Uri uri, long id) { 349 return containsId(uri, null, id); 350 } 351 containsId(Uri uri, Bundle extras, long id)352 public static boolean containsId(Uri uri, Bundle extras, long id) { 353 try (Cursor c = InstrumentationRegistry.getInstrumentation().getTargetContext() 354 .getContentResolver().query(uri, 355 new String[] { MediaStore.MediaColumns._ID }, extras, null)) { 356 while (c.moveToNext()) { 357 if (c.getLong(0) == id) return true; 358 } 359 } 360 return false; 361 } 362 363 /** 364 * Gets File corresponding to the uri. 365 * This function assumes that the caller has access to the uri 366 * @param uri uri to get File for 367 * @return File file corresponding to the uri 368 * @throws FileNotFoundException if either the file does not exist or the caller does not have 369 * read access to the file 370 */ getRawFile(Uri uri)371 public static File getRawFile(Uri uri) throws Exception { 372 String filePath; 373 try (Cursor c = InstrumentationRegistry.getInstrumentation().getTargetContext() 374 .getContentResolver() 375 .query(uri, new String[] { MediaStore.MediaColumns.DATA }, null, null)) { 376 assertTrue(c.moveToFirst()); 377 filePath = c.getString(0); 378 } 379 if (filePath != null) { 380 return new File(filePath); 381 } else { 382 throw new FileNotFoundException("Failed to find _data for " + uri); 383 } 384 } 385 getRawFileHash(File file)386 public static String getRawFileHash(File file) throws Exception { 387 MessageDigest digest = MessageDigest.getInstance("SHA-1"); 388 try (InputStream in = new BufferedInputStream(Files.newInputStream(file.toPath()))) { 389 byte[] buf = new byte[4096]; 390 int n; 391 while ((n = in.read(buf)) >= 0) { 392 digest.update(buf, 0, n); 393 } 394 } 395 396 byte[] hash = digest.digest(); 397 return BaseEncoding.base16().encode(hash); 398 } 399 getRelativeFile(Uri uri)400 public static File getRelativeFile(Uri uri) throws Exception { 401 final String path = getRawFile(uri).getAbsolutePath(); 402 final Matcher matcher = PATTERN_STORAGE_PATH.matcher(path); 403 if (matcher.find()) { 404 return new File(path.substring(matcher.end())); 405 } else { 406 throw new IllegalArgumentException(); 407 } 408 } 409 410 /** Revokes ACCESS_MEDIA_LOCATION from the test app */ revokeMediaLocationPermission(Context context)411 public static void revokeMediaLocationPermission(Context context) throws Exception { 412 try { 413 InstrumentationRegistry.getInstrumentation().getUiAutomation() 414 .adoptShellPermissionIdentity("android.permission.MANAGE_APP_OPS_MODES", 415 "android.permission.REVOKE_RUNTIME_PERMISSIONS"); 416 417 // Revoking ACCESS_MEDIA_LOCATION permission will kill the test app. 418 // Deny access_media_permission App op to revoke this permission. 419 PackageManager packageManager = context.getPackageManager(); 420 String packageName = context.getPackageName(); 421 if (packageManager.checkPermission(android.Manifest.permission.ACCESS_MEDIA_LOCATION, 422 packageName) == PackageManager.PERMISSION_GRANTED) { 423 context.getPackageManager().updatePermissionFlags( 424 android.Manifest.permission.ACCESS_MEDIA_LOCATION, packageName, 425 PackageManager.FLAG_PERMISSION_REVOKED_COMPAT, 426 PackageManager.FLAG_PERMISSION_REVOKED_COMPAT, context.getUser()); 427 context.getSystemService(AppOpsManager.class).setUidMode( 428 "android:access_media_location", Process.myUid(), 429 AppOpsManager.MODE_IGNORED); 430 } 431 } finally { 432 InstrumentationRegistry.getInstrumentation().getUiAutomation() 433 .dropShellPermissionIdentity(); 434 } 435 } 436 437 /** 438 * @return {@code true} if initial sdk version of the device is at least Android R 439 */ isDeviceInitialSdkIntR()440 public static boolean isDeviceInitialSdkIntR() { 441 // Build.VERSION.DEVICE_INITIAL_SDK_INT is available only Android S onwards 442 int deviceInitialSdkInt = SdkLevel.isAtLeastS() ? Build.VERSION.DEVICE_INITIAL_SDK_INT : 443 SystemProperties.getInt("ro.product.first_api_level", 0); 444 return deviceInitialSdkInt >= Build.VERSION_CODES.R; 445 } 446 } 447