1 /*
2  * Copyright (C) 2019 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 com.android.providers.media.client;
18 
19 import static android.provider.MediaStore.rewriteToLegacy;
20 
21 import static com.google.common.truth.Truth.assertWithMessage;
22 
23 import static org.junit.Assert.assertNotNull;
24 import static org.junit.Assert.assertEquals;
25 import static org.junit.Assert.assertFalse;
26 import static org.junit.Assert.assertTrue;
27 import static org.junit.Assert.fail;
28 
29 import android.app.UiAutomation;
30 import android.content.ContentProviderClient;
31 import android.content.ContentProviderOperation;
32 import android.content.ContentResolver;
33 import android.content.ContentUris;
34 import android.content.ContentValues;
35 import android.content.Context;
36 import android.content.pm.ProviderInfo;
37 import android.database.Cursor;
38 import android.net.Uri;
39 import android.os.Bundle;
40 import android.os.Environment;
41 import android.os.FileUtils;
42 import android.os.ParcelFileDescriptor;
43 import android.os.SystemClock;
44 import android.os.storage.StorageManager;
45 import android.provider.BaseColumns;
46 import android.provider.MediaStore;
47 import android.provider.MediaStore.Audio.AudioColumns;
48 import android.provider.MediaStore.DownloadColumns;
49 import android.provider.MediaStore.Files.FileColumns;
50 import android.provider.MediaStore.Images.ImageColumns;
51 import android.provider.MediaStore.MediaColumns;
52 import android.provider.MediaStore.Video.VideoColumns;
53 import android.system.ErrnoException;
54 import android.system.Os;
55 import android.util.Log;
56 import android.webkit.MimeTypeMap;
57 
58 import androidx.annotation.NonNull;
59 import androidx.test.filters.FlakyTest;
60 import androidx.test.InstrumentationRegistry;
61 import androidx.test.runner.AndroidJUnit4;
62 
63 import com.google.common.truth.Truth;
64 
65 import org.junit.Assume;
66 import org.junit.Before;
67 import org.junit.Test;
68 import org.junit.runner.RunWith;
69 
70 import java.io.BufferedReader;
71 import java.io.File;
72 import java.io.FileInputStream;
73 import java.io.FileOutputStream;
74 import java.io.IOException;
75 import java.io.InputStream;
76 import java.io.InputStreamReader;
77 import java.io.InterruptedIOException;
78 import java.io.OutputStream;
79 import java.nio.charset.StandardCharsets;
80 import java.util.ArrayList;
81 import java.util.concurrent.TimeUnit;
82 
83 /**
84  * Verify that we preserve information from the old "legacy" provider from
85  * before we migrated into a Mainline module.
86  * <p>
87  * Specifically, values like {@link BaseColumns#_ID} and user edits like
88  * {@link MediaColumns#IS_FAVORITE} should be retained.
89  */
90 @RunWith(AndroidJUnit4.class)
91 @FlakyTest(bugId = 176977253)
92 public class LegacyProviderMigrationTest {
93     private static final String TAG = "LegacyTest";
94 
95     // TODO: expand test to cover secondary storage devices
96     private String mVolumeName = MediaStore.VOLUME_EXTERNAL_PRIMARY;
97 
98     private static final long POLLING_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(10);
99     private static final long POLLING_SLEEP_MILLIS = 100;
100 
101     /**
102      * Number of media items to insert for {@link #testLegacy_Extreme()}.
103      */
104     private static final int EXTREME_COUNT = 10_000;
105 
106     private Uri mExternalAudio;
107     private Uri mExternalVideo;
108     private Uri mExternalImages;
109     private Uri mExternalDownloads;
110     private Uri mExternalPlaylists;
111 
112     @Before
setUp()113     public void setUp() throws Exception {
114         Log.d(TAG, "Using volume " + mVolumeName);
115         mExternalAudio = MediaStore.Audio.Media.getContentUri(mVolumeName);
116         mExternalVideo = MediaStore.Video.Media.getContentUri(mVolumeName);
117         mExternalImages = MediaStore.Images.Media.getContentUri(mVolumeName);
118         mExternalDownloads = MediaStore.Downloads.getContentUri(mVolumeName);
119 
120         Uri playlists = MediaStore.Audio.Playlists.getContentUri(mVolumeName);
121         mExternalPlaylists = playlists.buildUpon()
122                 .appendQueryParameter("silent", "true").build();
123     }
124 
generateValues(int mediaType, String mimeType, String dirName)125     private ContentValues generateValues(int mediaType, String mimeType, String dirName)
126             throws Exception {
127         return generateValues(mediaType, mimeType, dirName, 0);
128     }
129 
generateValues(int mediaType, String mimeType, String dirName, int resId)130     private ContentValues generateValues(int mediaType, String mimeType, String dirName, int resId)
131             throws Exception {
132         final Context context = InstrumentationRegistry.getContext();
133 
134         final File dir = context.getSystemService(StorageManager.class)
135                 .getStorageVolume(MediaStore.Files.getContentUri(mVolumeName)).getDirectory();
136         final File subDir = new File(dir, dirName);
137         File file = new File(subDir, "legacy" + System.nanoTime() + "."
138                 + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType));
139 
140         if (resId != 0) {
141             file = stageFile(resId, file.getAbsolutePath());
142         }
143 
144         final ContentValues values = new ContentValues();
145         values.put(FileColumns.MEDIA_TYPE, mediaType);
146         values.put(MediaColumns.DATA, file.getAbsolutePath());
147         values.put(MediaColumns.DISPLAY_NAME, file.getName());
148         values.put(MediaColumns.MIME_TYPE, mimeType);
149         values.put(MediaColumns.VOLUME_NAME, mVolumeName);
150         values.put(MediaColumns.DATE_ADDED, String.valueOf(System.currentTimeMillis() / 1_000));
151         values.put(MediaColumns.OWNER_PACKAGE_NAME,
152                 InstrumentationRegistry.getContext().getPackageName());
153         return values;
154     }
155 
stageFile(int resId, String path)156     private static File stageFile(int resId, String path) throws Exception {
157         final Context context = InstrumentationRegistry.getContext();
158         final File file = new File(path);
159         try (InputStream in = context.getResources().openRawResource(resId);
160              OutputStream out = new FileOutputStream(file)) {
161             FileUtils.copy(in, out);
162         }
163         return file;
164     }
165 
166     @Test
testLegacy_Orientation()167     public void testLegacy_Orientation() throws Exception {
168         // Use an image file with orientation of 90 degrees
169         final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_IMAGE,
170                 "image/jpeg", Environment.DIRECTORY_PICTURES, R.raw.orientation_90);
171         values.put(MediaColumns.ORIENTATION, String.valueOf(90));
172         doLegacy(mExternalImages, values);
173     }
174 
175     @Test
testLegacy_Pending()176     public void testLegacy_Pending() throws Exception {
177         final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_IMAGE,
178                 "image/png", Environment.DIRECTORY_PICTURES);
179         values.put(MediaColumns.IS_PENDING, String.valueOf(1));
180         values.put(MediaColumns.DATE_EXPIRES, String.valueOf(System.currentTimeMillis() / 1_000));
181         doLegacy(mExternalImages, values);
182     }
183 
184     @Test
testLegacy_Trashed()185     public void testLegacy_Trashed() throws Exception {
186         final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_IMAGE,
187                 "image/png", Environment.DIRECTORY_PICTURES);
188         values.put(MediaColumns.IS_TRASHED, String.valueOf(1));
189         doLegacy(mExternalImages, values);
190     }
191 
192     @Test
testLegacy_Favorite()193     public void testLegacy_Favorite() throws Exception {
194         final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_IMAGE,
195                 "image/png", Environment.DIRECTORY_PICTURES);
196         values.put(MediaColumns.IS_FAVORITE, String.valueOf(1));
197         doLegacy(mExternalImages, values);
198     }
199 
200     @Test
testLegacy_Orphaned()201     public void testLegacy_Orphaned() throws Exception {
202         final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_IMAGE,
203                 "image/png", Environment.DIRECTORY_PICTURES);
204         values.putNull(MediaColumns.OWNER_PACKAGE_NAME);
205         doLegacy(mExternalImages, values);
206     }
207 
208     @Test
testLegacy_Audio()209     public void testLegacy_Audio() throws Exception {
210         final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_AUDIO,
211                 "audio/mpeg", Environment.DIRECTORY_MUSIC);
212         values.put(AudioColumns.BOOKMARK, String.valueOf(42));
213         doLegacy(mExternalAudio, values);
214     }
215 
216     @Test
testLegacy_Video()217     public void testLegacy_Video() throws Exception {
218         final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_VIDEO,
219                 "video/mpeg", Environment.DIRECTORY_MOVIES);
220         values.put(VideoColumns.BOOKMARK, String.valueOf(42));
221         values.put(VideoColumns.TAGS, "My Tags");
222         values.put(VideoColumns.CATEGORY, "My Category");
223         values.put(VideoColumns.IS_PRIVATE, String.valueOf(1));
224         doLegacy(mExternalVideo, values);
225     }
226 
227     @Test
testLegacy_Image()228     public void testLegacy_Image() throws Exception {
229         final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_IMAGE,
230                 "image/png", Environment.DIRECTORY_PICTURES);
231         values.put(ImageColumns.IS_PRIVATE, String.valueOf(1));
232         doLegacy(mExternalImages, values);
233     }
234 
235     @Test
testLegacy_Download()236     public void testLegacy_Download() throws Exception {
237         final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_NONE,
238                 "application/x-iso9660-image", Environment.DIRECTORY_DOWNLOADS);
239         values.put(DownloadColumns.DOWNLOAD_URI, "http://example.com/download");
240         values.put(DownloadColumns.REFERER_URI, "http://example.com/referer");
241         doLegacy(mExternalDownloads, values);
242     }
243 
244     /**
245      * Test that migrating from legacy database with volume_name=NULL doesn't
246      * result in empty cursor when queried.
247      */
248     @Test
testMigrateNullVolumeName()249     public void testMigrateNullVolumeName() throws Exception {
250         final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_IMAGE,
251                 "image/png", Environment.DIRECTORY_PICTURES);
252         values.remove(MediaColumns.VOLUME_NAME);
253         doLegacy(mExternalImages, values);
254     }
255 
256     @Test
testLegacy_PlaylistMap()257     public void testLegacy_PlaylistMap() throws Exception {
258         final Context context = InstrumentationRegistry.getTargetContext();
259         final UiAutomation ui = InstrumentationRegistry.getInstrumentation().getUiAutomation();
260 
261         final ContentValues audios[] = new ContentValues[] {
262                 generateValues(FileColumns.MEDIA_TYPE_AUDIO, "audio/mpeg",
263                         Environment.DIRECTORY_MUSIC),
264                 generateValues(FileColumns.MEDIA_TYPE_AUDIO, "audio/mpeg",
265                         Environment.DIRECTORY_MUSIC),
266         };
267 
268         final String playlistMimeType = "audio/mpegurl";
269         final ContentValues playlist = generateValues(FileColumns.MEDIA_TYPE_PLAYLIST,
270                 playlistMimeType, "Playlists");
271         final String playlistName = "LegacyPlaylistName_" + System.nanoTime();
272         playlist.put(MediaStore.Audio.PlaylistsColumns.NAME, playlistName);
273         File playlistFile = new File(playlist.getAsString(MediaColumns.DATA));
274 
275         playlistFile.delete();
276 
277         final ContentValues playlistMap = new ContentValues();
278         playlistMap.put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, 1);
279 
280         prepareProviders(context, ui);
281 
282         try (ContentProviderClient legacy = context.getContentResolver()
283                 .acquireContentProviderClient(MediaStore.AUTHORITY_LEGACY)) {
284 
285             // Step 1: Insert the playlist entry into the playlists table.
286             final Uri playlistUri = rewriteToLegacy(legacy.insert(
287                     rewriteToLegacy(mExternalPlaylists), playlist));
288             long playlistId = ContentUris.parseId(playlistUri);
289             final Uri playlistMemberUri = MediaStore.rewriteToLegacy(
290                     MediaStore.Audio.Playlists.Members.getContentUri(mVolumeName, playlistId)
291                             .buildUpon()
292                             .appendQueryParameter("silent", "true").build());
293 
294 
295             for (ContentValues values : audios) {
296                 // Step 2: Write the audio file to the legacy mediastore.
297                 final Uri audioUri =
298                         rewriteToLegacy(legacy.insert(rewriteToLegacy(mExternalAudio), values));
299                 // Remember our ID to check it later
300                 values.put(MediaColumns._ID, audioUri.getLastPathSegment());
301 
302 
303                 long audioId = ContentUris.parseId(audioUri);
304                 playlistMap.put(MediaStore.Audio.Playlists.Members.PLAYLIST_ID, playlistId);
305                 playlistMap.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, audioId);
306 
307                 // Step 3: Add a mapping to playlist members.
308                 legacy.insert(playlistMemberUri, playlistMap);
309             }
310 
311             // Insert a stale row, We only have 3 items in the database. #4 is a stale row
312             // and will be skipped from the playlist during the migration.
313             playlistMap.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, 4);
314             legacy.insert(playlistMemberUri, playlistMap);
315 
316         }
317 
318         // This will delete MediaProvider data and restarts MediaProvider, and mounts storage.
319         clearProviders(context, ui);
320 
321         // Verify scan on DEMAND doesn't delete any virtual playlist files.
322         MediaStore.scanFile(context.getContentResolver(),
323                 Environment.getExternalStorageDirectory());
324 
325         // Playlist files are created from playlist NAME
326         final File musicDir = new File(context.getSystemService(StorageManager.class)
327                 .getStorageVolume(MediaStore.Files.getContentUri(mVolumeName)).getDirectory(),
328                 Environment.DIRECTORY_MUSIC);
329         playlistFile = new File(musicDir, playlistName + "."
330                 + MimeTypeMap.getSingleton().getExtensionFromMimeType(playlistMimeType));
331         // Wait for scan on MEDIA_MOUNTED to create "real" playlist files.
332         pollForFile(playlistFile);
333 
334         // Scan again to verify updated playlist metadata
335         MediaStore.scanFile(context.getContentResolver(), playlistFile);
336 
337         try (ContentProviderClient modern = context.getContentResolver()
338                 .acquireContentProviderClient(MediaStore.AUTHORITY)) {
339             long legacyPlaylistId =
340                     playlistMap.getAsLong(MediaStore.Audio.Playlists.Members.PLAYLIST_ID);
341             long legacyAudioId1 = audios[0].getAsLong(MediaColumns._ID);
342             long legacyAudioId2 = audios[1].getAsLong(MediaColumns._ID);
343 
344             // Verify that playlist_id matches with legacy playlist_id
345             {
346                 Uri playlists = MediaStore.Audio.Playlists.getContentUri(mVolumeName);
347                 final String[] project = {FileColumns._ID, MediaStore.Audio.PlaylistsColumns.NAME};
348 
349                 try (Cursor cursor = modern.query(playlists, project, null, null, null)) {
350                     boolean found = false;
351                     while(cursor.moveToNext()) {
352                         if (cursor.getLong(0) == legacyPlaylistId) {
353                             found = true;
354                             assertEquals(playlistName, cursor.getString(1));
355                             break;
356                         }
357                     }
358                     assertTrue(found);
359                 }
360             }
361 
362             // Verify that playlist_members map matches legacy playlist_members map.
363             {
364                  Uri members = MediaStore.Audio.Playlists.Members.getContentUri(
365                         mVolumeName, legacyPlaylistId);
366                  final String[] project = { MediaStore.Audio.Playlists.Members.AUDIO_ID };
367 
368                  try (Cursor cursor = modern.query(members, project, null, null,
369                          MediaStore.Audio.Playlists.Members.DEFAULT_SORT_ORDER)) {
370                      assertTrue(cursor.moveToNext());
371                      assertEquals(legacyAudioId1, cursor.getLong(0));
372                      assertTrue(cursor.moveToNext());
373                      assertEquals(legacyAudioId2, cursor.getLong(0));
374                      assertFalse(cursor.moveToNext());
375                  }
376             }
377 
378             // Verify that migrated playlist audio_id refers to legacy audio file.
379             {
380                 Uri modernAudioUri = ContentUris.withAppendedId(
381                         MediaStore.Audio.Media.getContentUri(mVolumeName), legacyAudioId1);
382                 final String[] project = {FileColumns.DATA};
383 
384                 try (Cursor cursor = modern.query(modernAudioUri, project, null, null, null)) {
385                     assertTrue(cursor.moveToFirst());
386                     assertEquals(audios[0].getAsString(MediaColumns.DATA), cursor.getString(0));
387                 }
388             }
389         }
390     }
391 
prepareProviders(Context context, UiAutomation ui)392     private static void prepareProviders(Context context, UiAutomation ui) throws Exception {
393         final ProviderInfo legacyProvider = context.getPackageManager()
394                 .resolveContentProvider(MediaStore.AUTHORITY_LEGACY, 0);
395         final ProviderInfo modernProvider = context.getPackageManager()
396                 .resolveContentProvider(MediaStore.AUTHORITY, 0);
397 
398         // Only continue if we have both providers to test against
399         Assume.assumeNotNull(legacyProvider);
400         Assume.assumeNotNull(modernProvider);
401 
402         // Clear data on the legacy provider so that we create a database
403         waitForMountedAndIdle(context.getContentResolver());
404         executeShellCommand("sync", ui);
405         executeShellCommand("pm clear " + legacyProvider.applicationInfo.packageName, ui);
406         waitForMountedAndIdle(context.getContentResolver());
407     }
408 
clearProviders(Context context, UiAutomation ui)409     private static void clearProviders(Context context, UiAutomation ui) throws Exception {
410         final ProviderInfo modernProvider = context.getPackageManager()
411                 .resolveContentProvider(MediaStore.AUTHORITY, 0);
412 
413         // Clear data on the modern provider so that the initial scan recovers
414         // metadata from the legacy provider
415         waitForMountedAndIdle(context.getContentResolver());
416         executeShellCommand("sync", ui);
417         executeShellCommand("pm clear " + modernProvider.applicationInfo.packageName, ui);
418         waitForMountedAndIdle(context.getContentResolver());
419     }
420 
421     /**
422      * Verify that a legacy database with thousands of media entries can be
423      * successfully migrated.
424      */
425     @Test
testLegacy_Extreme()426     public void testLegacy_Extreme() throws Exception {
427         final Context context = InstrumentationRegistry.getTargetContext();
428         final UiAutomation ui = InstrumentationRegistry.getInstrumentation().getUiAutomation();
429 
430         prepareProviders(context, ui);
431 
432         // Create thousands of items in the legacy provider
433         try (ContentProviderClient legacy = context.getContentResolver()
434                 .acquireContentProviderClient(MediaStore.AUTHORITY_LEGACY)) {
435             // We're purposefully "silent" to avoid creating the raw file on
436             // disk, since otherwise this test would take several minutes
437             final Uri insertTarget = rewriteToLegacy(
438                     mExternalImages.buildUpon().appendQueryParameter("silent", "true").build());
439 
440             final ArrayList<ContentProviderOperation> ops = new ArrayList<>();
441             for (int i = 0; i < EXTREME_COUNT; i++) {
442                 ops.add(ContentProviderOperation.newInsert(insertTarget)
443                         .withValues(generateValues(FileColumns.MEDIA_TYPE_IMAGE, "image/png",
444                                 Environment.DIRECTORY_PICTURES))
445                         .build());
446 
447                 if ((ops.size() > 1_000) || (i == (EXTREME_COUNT - 1))) {
448                     Log.v(TAG, "Inserting items...");
449                     legacy.applyBatch(MediaStore.AUTHORITY_LEGACY, ops);
450                     ops.clear();
451                 }
452             }
453         }
454 
455         clearProviders(context, ui);
456 
457         // Confirm that details from legacy provider have migrated
458         try (ContentProviderClient modern = context.getContentResolver()
459                 .acquireContentProviderClient(MediaStore.AUTHORITY)) {
460             try (Cursor cursor = modern.query(mExternalImages, null, null, null)) {
461                 Truth.assertThat(cursor.getCount()).isAtLeast(EXTREME_COUNT);
462             }
463         }
464     }
465 
doLegacy(Uri collectionUri, ContentValues values)466     private void doLegacy(Uri collectionUri, ContentValues values) throws Exception {
467         final Context context = InstrumentationRegistry.getTargetContext();
468         final UiAutomation ui = InstrumentationRegistry.getInstrumentation().getUiAutomation();
469 
470         prepareProviders(context, ui);
471 
472         // Create a well-known entry in legacy provider, and write data into
473         // place to ensure the file is created on disk
474         final Uri legacyUri;
475         final File legacyFile;
476         try (ContentProviderClient legacy = context.getContentResolver()
477                 .acquireContentProviderClient(MediaStore.AUTHORITY_LEGACY)) {
478             legacyUri = rewriteToLegacy(legacy.insert(rewriteToLegacy(collectionUri), values));
479             legacyFile = new File(values.getAsString(MediaColumns.DATA));
480 
481             // Remember our ID to check it later
482             values.put(MediaColumns._ID, legacyUri.getLastPathSegment());
483 
484             // Drop media type from the columns we check, since it's implicitly
485             // verified via the collection Uri
486             values.remove(FileColumns.MEDIA_TYPE);
487 
488             // Drop raw path, since we may rename pending or trashed files
489             values.remove(FileColumns.DATA);
490         }
491 
492         // This will delete MediaProvider data and restarts MediaProvider, and mounts storage.
493         clearProviders(context, ui);
494 
495         // Make sure we do not lose the ORIENTATION column after database migration
496         // We check this column again after the scan
497         if (values.getAsString(MediaColumns.ORIENTATION) != null) {
498             assertOrientationColumn(collectionUri, values, context, legacyFile);
499         }
500 
501         // And force a scan to confirm upgraded data survives
502         MediaStore.scanVolume(context.getContentResolver(),
503                 MediaStore.getVolumeName(collectionUri));
504         assertColumnsHaveExpectedValues(collectionUri, values, context, legacyFile);
505 
506     }
507 
assertOrientationColumn(Uri collectionUri, ContentValues originalValues, Context context, File legacyFile)508     private void assertOrientationColumn(Uri collectionUri, ContentValues originalValues,
509             Context context, File legacyFile) throws Exception {
510         final ContentValues values = new ContentValues();
511         values.put(MediaColumns.ORIENTATION, (String) originalValues.get(MediaColumns.ORIENTATION));
512         assertColumnsHaveExpectedValues(collectionUri, values, context, legacyFile);
513     }
514 
assertColumnsHaveExpectedValues(Uri collectionUri, ContentValues values, Context context, File legacyFile)515     private void assertColumnsHaveExpectedValues(Uri collectionUri, ContentValues values,
516             Context context, File legacyFile) throws Exception {
517         // Confirm that details from legacy provider have migrated
518         try (ContentProviderClient modern = context.getContentResolver()
519                 .acquireContentProviderClient(MediaStore.AUTHORITY)) {
520             final Bundle extras = new Bundle();
521             extras.putString(ContentResolver.QUERY_ARG_SQL_SELECTION,
522                     MediaColumns.DISPLAY_NAME + "=?");
523             extras.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS,
524                     new String[] { legacyFile.getName() });
525             extras.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
526             extras.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
527             extras.putInt(MediaStore.QUERY_ARG_MATCH_FAVORITE, MediaStore.MATCH_INCLUDE);
528 
529             try (Cursor cursor = pollForCursor(modern, collectionUri, extras)) {
530                 assertNotNull(cursor);
531                 assertTrue(cursor.moveToFirst());
532                 for (String key : values.keySet()) {
533                     assertWithMessage("Checking key %s", key)
534                             .that(cursor.getString(cursor.getColumnIndexOrThrow(key)))
535                             .isEqualTo(values.get(key));
536                 }
537             }
538         }
539     }
540 
waitForMountedAndIdle(ContentResolver resolver)541     private static void waitForMountedAndIdle(ContentResolver resolver) {
542         // We purposefully perform these operations twice in this specific
543         // order, since clearing the data on a package can asynchronously
544         // perform a vold reset, which can make us think storage is ready and
545         // mounted when it's moments away from being torn down.
546         pollForExternalStorageState();
547         MediaStore.waitForIdle(resolver);
548         pollForExternalStorageState();
549         MediaStore.waitForIdle(resolver);
550     }
551 
pollForCursor(ContentProviderClient modern, Uri collectionUri, Bundle extras)552     private static Cursor pollForCursor(ContentProviderClient modern, Uri collectionUri,
553             Bundle extras) throws Exception {
554         Cursor cursor = null;
555         for (int i = 0; i < POLLING_TIMEOUT_MILLIS / POLLING_SLEEP_MILLIS; i++) {
556            try {
557                cursor = modern.query(collectionUri, null, extras, null);
558                return cursor;
559            } catch (IllegalArgumentException e) {
560                // try again
561            }
562             Log.v(TAG, "Waiting for..." + collectionUri);
563             SystemClock.sleep(POLLING_SLEEP_MILLIS);
564         }
565         fail("Timed out while waiting for uri " + collectionUri);
566         return cursor;
567     }
568 
pollForFile(File file)569     private static void pollForFile(File file) {
570         for (int i = 0; i < POLLING_TIMEOUT_MILLIS / POLLING_SLEEP_MILLIS; i++) {
571             if (file.exists()) return;
572 
573             Log.v(TAG, "Waiting for..." + file);
574             SystemClock.sleep(POLLING_SLEEP_MILLIS);
575         }
576         fail("Timed out while waiting for file " + file);
577     }
578 
pollForExternalStorageState()579     private static void pollForExternalStorageState() {
580         final File target = Environment.getExternalStorageDirectory();
581         for (int i = 0; i < POLLING_TIMEOUT_MILLIS / POLLING_SLEEP_MILLIS; i++) {
582             try {
583                 if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState(target))
584                         && Os.statvfs(target.getAbsolutePath()).f_blocks > 0) {
585                     return;
586                 }
587             } catch (ErrnoException ignored) {
588             }
589 
590             Log.v(TAG, "Waiting for external storage...");
591             SystemClock.sleep(POLLING_SLEEP_MILLIS);
592         }
593         fail("Timed out while waiting for ExternalStorageState to be MEDIA_MOUNTED");
594     }
595 
executeShellCommand(String command, UiAutomation uiAutomation)596     public static String executeShellCommand(String command, UiAutomation uiAutomation)
597             throws IOException {
598         int attempt = 0;
599         while (attempt++ < 5) {
600             try {
601                 return executeShellCommandInternal(command, uiAutomation);
602             } catch (InterruptedIOException e) {
603                 // Hmm, we had trouble executing the shell command; the best we
604                 // can do is try again a few more times
605                 Log.v(TAG, "Trouble executing " + command + "; trying again", e);
606             }
607         }
608         throw new IOException("Failed to execute " + command);
609     }
610 
executeShellCommandInternal(String command, UiAutomation uiAutomation)611     public static String executeShellCommandInternal(String command, UiAutomation uiAutomation)
612             throws IOException {
613         Log.v(TAG, "$ " + command);
614         ParcelFileDescriptor pfd = uiAutomation.executeShellCommand(command.toString());
615         BufferedReader br = null;
616         try (InputStream in = new FileInputStream(pfd.getFileDescriptor());) {
617             br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
618             String str = null;
619             StringBuilder out = new StringBuilder();
620             while ((str = br.readLine()) != null) {
621                 Log.v(TAG, "> " + str);
622                 out.append(str);
623             }
624             return out.toString();
625         } finally {
626             if (br != null) {
627                 br.close();
628             }
629         }
630     }
631 
copyFromCursorToContentValues(@onNull String column, @NonNull Cursor cursor, @NonNull ContentValues values)632     public static void copyFromCursorToContentValues(@NonNull String column, @NonNull Cursor cursor,
633             @NonNull ContentValues values) {
634         final int index = cursor.getColumnIndex(column);
635         if (index != -1) {
636             if (cursor.isNull(index)) {
637                 values.putNull(column);
638             } else {
639                 values.put(column, cursor.getString(index));
640             }
641         }
642     }
643 }
644