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