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.scan;
18 
19 import static com.android.providers.media.scan.MediaScanner.REASON_UNKNOWN;
20 import static com.android.providers.media.scan.MediaScannerTest.stage;
21 import static com.android.providers.media.scan.ModernMediaScanner.MAX_EXCLUDE_DIRS;
22 import static com.android.providers.media.scan.ModernMediaScanner.isFileAlbumArt;
23 import static com.android.providers.media.scan.ModernMediaScanner.parseOptional;
24 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalDate;
25 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalDateTaken;
26 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalImageResolution;
27 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalMimeType;
28 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalNumerator;
29 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalOrZero;
30 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalOrientation;
31 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalResolution;
32 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalTrack;
33 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalVideoResolution;
34 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalYear;
35 import static com.android.providers.media.scan.ModernMediaScanner.shouldScanDirectory;
36 import static com.android.providers.media.scan.ModernMediaScanner.shouldScanPathAndIsPathHidden;
37 import static com.android.providers.media.util.FileUtils.isDirectoryHidden;
38 import static com.android.providers.media.util.FileUtils.isFileHidden;
39 
40 import static com.google.common.truth.Truth.assertThat;
41 
42 import static org.junit.Assert.assertEquals;
43 import static org.junit.Assert.assertFalse;
44 import static org.junit.Assert.assertNotNull;
45 import static org.junit.Assert.assertTrue;
46 import static org.mockito.ArgumentMatchers.eq;
47 import static org.mockito.Mockito.mock;
48 import static org.mockito.Mockito.when;
49 
50 import android.Manifest;
51 import android.app.UiAutomation;
52 import android.content.ContentResolver;
53 import android.content.ContentUris;
54 import android.content.ContentValues;
55 import android.content.Context;
56 import android.database.Cursor;
57 import android.graphics.Bitmap;
58 import android.media.ExifInterface;
59 import android.media.MediaMetadataRetriever;
60 import android.net.Uri;
61 import android.os.Bundle;
62 import android.os.Environment;
63 import android.os.ParcelFileDescriptor;
64 import android.provider.MediaStore;
65 import android.provider.MediaStore.Audio.AudioColumns;
66 import android.provider.MediaStore.MediaColumns;
67 import android.text.format.DateUtils;
68 import android.util.Log;
69 import android.util.Pair;
70 
71 import androidx.test.InstrumentationRegistry;
72 import androidx.test.filters.SdkSuppress;
73 import androidx.test.runner.AndroidJUnit4;
74 
75 import com.android.providers.media.R;
76 import com.android.providers.media.scan.MediaScannerTest.IsolatedContext;
77 import com.android.providers.media.tests.utils.Timer;
78 import com.android.providers.media.util.FileUtils;
79 
80 import com.google.common.io.ByteStreams;
81 
82 import org.junit.After;
83 import org.junit.Before;
84 import org.junit.Test;
85 import org.junit.runner.RunWith;
86 
87 import java.io.File;
88 import java.io.FileInputStream;
89 import java.io.FileNotFoundException;
90 import java.io.FileOutputStream;
91 import java.io.IOException;
92 import java.io.InterruptedIOException;
93 import java.util.Locale;
94 import java.util.Optional;
95 
96 @RunWith(AndroidJUnit4.class)
97 public class ModernMediaScannerTest {
98     // TODO: scan directory-vs-files and confirm identical results
99 
100     private static final String TAG = "ModernMediaScannerTest";
101     /**
102      * Number of times we should repeat an operation to get an average/max.
103      */
104     private static final int COUNT_REPEAT = 5;
105 
106     private File mDir;
107 
108     private Context mIsolatedContext;
109     private ContentResolver mIsolatedResolver;
110 
111     private ModernMediaScanner mModern;
112 
113     @Before
setUp()114     public void setUp() {
115         final Context context = InstrumentationRegistry.getTargetContext();
116         InstrumentationRegistry.getInstrumentation().getUiAutomation()
117                 .adoptShellPermissionIdentity(Manifest.permission.LOG_COMPAT_CHANGE,
118                         Manifest.permission.READ_COMPAT_CHANGE_CONFIG,
119                         Manifest.permission.INTERACT_ACROSS_USERS);
120 
121         mDir = new File(context.getExternalMediaDirs()[0], "test_" + System.nanoTime());
122         mDir.mkdirs();
123         FileUtils.deleteContents(mDir);
124 
125         mIsolatedContext = new IsolatedContext(context, "modern", /*asFuseThread*/ false);
126         mIsolatedResolver = mIsolatedContext.getContentResolver();
127 
128         mModern = new ModernMediaScanner(mIsolatedContext);
129     }
130 
131     @After
tearDown()132     public void tearDown() {
133         FileUtils.deleteContents(mDir);
134         InstrumentationRegistry.getInstrumentation()
135                 .getUiAutomation().dropShellPermissionIdentity();
136     }
137 
138     @Test
testSimple()139     public void testSimple() throws Exception {
140         assertNotNull(mModern.getContext());
141     }
142 
143     @Test
testOverrideMimeType()144     public void testOverrideMimeType() throws Exception {
145         assertFalse(parseOptionalMimeType("image/png", null).isPresent());
146         assertFalse(parseOptionalMimeType("image/png", "image").isPresent());
147         assertFalse(parseOptionalMimeType("image/png", "im/im").isPresent());
148         assertFalse(parseOptionalMimeType("image/png", "audio/x-shiny").isPresent());
149 
150         assertTrue(parseOptionalMimeType("image/png", "image/x-shiny").isPresent());
151         assertEquals("image/x-shiny",
152                 parseOptionalMimeType("image/png", "image/x-shiny").get());
153 
154         // Radical file type shifting isn't allowed
155         assertEquals(Optional.empty(),
156                 parseOptionalMimeType("video/mp4", "audio/mpeg"));
157     }
158 
159     @Test
testParseOptional()160     public void testParseOptional() throws Exception {
161         assertFalse(parseOptional(null).isPresent());
162         assertFalse(parseOptional("").isPresent());
163         assertFalse(parseOptional(" ").isPresent());
164         assertFalse(parseOptional("-1").isPresent());
165 
166         assertFalse(parseOptional(-1).isPresent());
167         assertTrue(parseOptional(0).isPresent());
168         assertTrue(parseOptional(1).isPresent());
169 
170         assertEquals("meow", parseOptional("meow").get());
171         assertEquals(42, (int) parseOptional(42).get());
172     }
173 
174     @Test
testParseOptionalOrZero()175     public void testParseOptionalOrZero() throws Exception {
176         assertFalse(parseOptionalOrZero(-1).isPresent());
177         assertFalse(parseOptionalOrZero(0).isPresent());
178         assertTrue(parseOptionalOrZero(1).isPresent());
179     }
180 
181     @Test
testParseOptionalNumerator()182     public void testParseOptionalNumerator() throws Exception {
183         assertEquals(12, (int) parseOptionalNumerator("12").get());
184         assertEquals(12, (int) parseOptionalNumerator("12/24").get());
185 
186         assertFalse(parseOptionalNumerator("/24").isPresent());
187     }
188 
189     @Test
testParseOptionalOrientation()190     public void testParseOptionalOrientation() throws Exception {
191         assertEquals(0,
192                 (int) parseOptionalOrientation(ExifInterface.ORIENTATION_NORMAL).get());
193         assertEquals(90,
194                 (int) parseOptionalOrientation(ExifInterface.ORIENTATION_ROTATE_90).get());
195         assertEquals(180,
196                 (int) parseOptionalOrientation(ExifInterface.ORIENTATION_ROTATE_180).get());
197         assertEquals(270,
198                 (int) parseOptionalOrientation(ExifInterface.ORIENTATION_ROTATE_270).get());
199 
200         // We can't represent this as an orientation
201         assertFalse(parseOptionalOrientation(ExifInterface.ORIENTATION_TRANSPOSE).isPresent());
202     }
203 
204     @Test
testParseOptionalImageResolution()205     public void testParseOptionalImageResolution() throws Exception {
206         final MediaMetadataRetriever mmr = mock(MediaMetadataRetriever.class);
207         when(mmr.extractMetadata(eq(MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH)))
208                 .thenReturn("640");
209         when(mmr.extractMetadata(eq(MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT)))
210                 .thenReturn("480");
211         assertEquals("640\u00d7480", parseOptionalImageResolution(mmr).get());
212     }
213 
214     @Test
testParseOptionalVideoResolution()215     public void testParseOptionalVideoResolution() throws Exception {
216         final MediaMetadataRetriever mmr = mock(MediaMetadataRetriever.class);
217         when(mmr.extractMetadata(eq(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)))
218                 .thenReturn("640");
219         when(mmr.extractMetadata(eq(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)))
220                 .thenReturn("480");
221         assertEquals("640\u00d7480", parseOptionalVideoResolution(mmr).get());
222     }
223 
224     @Test
testParseOptionalResolution()225     public void testParseOptionalResolution() throws Exception {
226         final ExifInterface exif = mock(ExifInterface.class);
227         when(exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)).thenReturn("640");
228         when(exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)).thenReturn("480");
229         assertEquals("640\u00d7480", parseOptionalResolution(exif).get());
230     }
231 
232     @Test
testParseOptionalDate()233     public void testParseOptionalDate() throws Exception {
234         assertThat(parseOptionalDate("20200101T000000")).isEqualTo(Optional.of(1577836800000L));
235         assertThat(parseOptionalDate("20200101T211205")).isEqualTo(Optional.of(1577913125000L));
236         assertThat(parseOptionalDate("20200101T211205.000Z"))
237                 .isEqualTo(Optional.of(1577913125000L));
238         assertThat(parseOptionalDate("20200101T211205.123Z"))
239                 .isEqualTo(Optional.of(1577913125123L));
240     }
241 
242     @Test
testParseOptionalTrack()243     public void testParseOptionalTrack() throws Exception {
244         final MediaMetadataRetriever mmr = mock(MediaMetadataRetriever.class);
245         when(mmr.extractMetadata(eq(MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER)))
246                 .thenReturn("1/2");
247         when(mmr.extractMetadata(eq(MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER)))
248                 .thenReturn("4/12");
249         assertEquals(1004, (int) parseOptionalTrack(mmr).get());
250     }
251 
252     @Test
testParseDateTaken_Complete()253     public void testParseDateTaken_Complete() throws Exception {
254         final File file = File.createTempFile("test", ".jpg");
255         final ExifInterface exif = new ExifInterface(file);
256         exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
257 
258         // Offset is recorded, test both zeros
259         exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "-00:00");
260         assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
261         exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "+00:00");
262         assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
263 
264         // Offset is recorded, test both directions
265         exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "-07:00");
266         assertEquals(1453972654000L + 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
267         exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "+07:00");
268         assertEquals(1453972654000L - 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
269     }
270 
271     @Test
testParseDateTaken_Gps()272     public void testParseDateTaken_Gps() throws Exception {
273         final File file = File.createTempFile("test", ".jpg");
274         final ExifInterface exif = new ExifInterface(file);
275         exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
276 
277         // GPS tells us we're in UTC
278         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
279         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:14:00");
280         assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
281         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
282         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:20:00");
283         assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
284 
285         // GPS tells us we're in -7
286         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
287         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "16:14:00");
288         assertEquals(1453972654000L + 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
289         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
290         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "16:20:00");
291         assertEquals(1453972654000L + 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
292 
293         // GPS tells us we're in +7
294         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
295         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "02:14:00");
296         assertEquals(1453972654000L - 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
297         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
298         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "02:20:00");
299         assertEquals(1453972654000L - 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
300 
301         // GPS beyond 24 hours isn't helpful
302         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:27");
303         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:17:34");
304         assertFalse(parseOptionalDateTaken(exif, 0L).isPresent());
305         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:29");
306         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:17:34");
307         assertFalse(parseOptionalDateTaken(exif, 0L).isPresent());
308     }
309 
310     @Test
testParseDateTaken_File()311     public void testParseDateTaken_File() throws Exception {
312         final File file = File.createTempFile("test", ".jpg");
313         final ExifInterface exif = new ExifInterface(file);
314         exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
315 
316         // Modified tells us we're in UTC
317         assertEquals(1453972654000L,
318                 (long) parseOptionalDateTaken(exif, 1453972654000L - 60000L).get());
319         assertEquals(1453972654000L,
320                 (long) parseOptionalDateTaken(exif, 1453972654000L + 60000L).get());
321 
322         // Modified tells us we're in -7
323         assertEquals(1453972654000L + 25200000L,
324                 (long) parseOptionalDateTaken(exif, 1453972654000L + 25200000L - 60000L).get());
325         assertEquals(1453972654000L + 25200000L,
326                 (long) parseOptionalDateTaken(exif, 1453972654000L + 25200000L + 60000L).get());
327 
328         // Modified tells us we're in +7
329         assertEquals(1453972654000L - 25200000L,
330                 (long) parseOptionalDateTaken(exif, 1453972654000L - 25200000L - 60000L).get());
331         assertEquals(1453972654000L - 25200000L,
332                 (long) parseOptionalDateTaken(exif, 1453972654000L - 25200000L + 60000L).get());
333 
334         // Modified beyond 24 hours isn't helpful
335         assertFalse(parseOptionalDateTaken(exif, 1453972654000L - 86400000L).isPresent());
336         assertFalse(parseOptionalDateTaken(exif, 1453972654000L + 86400000L).isPresent());
337     }
338 
339     @Test
testParseDateTaken_Hopeless()340     public void testParseDateTaken_Hopeless() throws Exception {
341         final File file = File.createTempFile("test", ".jpg");
342         final ExifInterface exif = new ExifInterface(file);
343         exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
344 
345         // Offset is completely missing, and no useful GPS or modified time
346         assertFalse(parseOptionalDateTaken(exif, 0L).isPresent());
347     }
348 
349     @Test
testParseYear_Invalid()350     public void testParseYear_Invalid() throws Exception {
351         assertEquals(Optional.empty(), parseOptionalYear(null));
352         assertEquals(Optional.empty(), parseOptionalYear(""));
353         assertEquals(Optional.empty(), parseOptionalYear(" "));
354         assertEquals(Optional.empty(), parseOptionalYear("meow"));
355 
356         assertEquals(Optional.empty(), parseOptionalYear("0"));
357         assertEquals(Optional.empty(), parseOptionalYear("00"));
358         assertEquals(Optional.empty(), parseOptionalYear("000"));
359         assertEquals(Optional.empty(), parseOptionalYear("0000"));
360 
361         assertEquals(Optional.empty(), parseOptionalYear("1"));
362         assertEquals(Optional.empty(), parseOptionalYear("01"));
363         assertEquals(Optional.empty(), parseOptionalYear("001"));
364         assertEquals(Optional.empty(), parseOptionalYear("0001"));
365 
366         // No sane way to determine year from two-digit date formats
367         assertEquals(Optional.empty(), parseOptionalYear("01-01-01"));
368 
369         // Specific example from partner
370         assertEquals(Optional.empty(), parseOptionalYear("000 "));
371     }
372 
373     @Test
testParseYear_Valid()374     public void testParseYear_Valid() throws Exception {
375         assertEquals(Optional.of(1900), parseOptionalYear("1900"));
376         assertEquals(Optional.of(2020), parseOptionalYear("2020"));
377         assertEquals(Optional.of(2020), parseOptionalYear(" 2020 "));
378         assertEquals(Optional.of(2020), parseOptionalYear("01-01-2020"));
379 
380         // Specific examples from partner
381         assertEquals(Optional.of(1984), parseOptionalYear("1984-06-26T07:00:00Z"));
382         assertEquals(Optional.of(2016), parseOptionalYear("Thu, 01 Sep 2016 10:11:12.123456 -0500"));
383     }
384 
assertShouldScanPathAndIsPathHidden(boolean isScannable, boolean isHidden, File dir)385     private static void assertShouldScanPathAndIsPathHidden(boolean isScannable, boolean isHidden,
386         File dir) {
387         assertEquals(Pair.create(isScannable, isHidden), shouldScanPathAndIsPathHidden(dir));
388     }
389 
390     @Test
testShouldScanPathAndIsPathHidden()391     public void testShouldScanPathAndIsPathHidden() {
392         for (String prefix : new String[] {
393                 "/storage/emulated/0",
394                 "/storage/0000-0000",
395         }) {
396             assertShouldScanPathAndIsPathHidden(true, false, new File(prefix));
397             assertShouldScanPathAndIsPathHidden(true, false, new File(prefix + "/meow"));
398             assertShouldScanPathAndIsPathHidden(true, false, new File(prefix + "/Android/meow"));
399 
400             assertShouldScanPathAndIsPathHidden(true, true, new File(prefix + "/.meow/dir"));
401 
402             assertShouldScanPathAndIsPathHidden(false, false,
403                     new File(prefix + "/Android/data/meow"));
404             assertShouldScanPathAndIsPathHidden(false, false,
405                     new File(prefix + "/Android/obb/meow"));
406 
407             // When the path is not scannable, we don't care if it's hidden or not.
408             assertShouldScanPathAndIsPathHidden(false, false,
409                     new File(prefix + "/Pictures/.thumbnails/meow"));
410             assertShouldScanPathAndIsPathHidden(false, false,
411                     new File(prefix + "/Movies/.thumbnails/meow"));
412             assertShouldScanPathAndIsPathHidden(false, false,
413                     new File(prefix + "/Music/.thumbnails/meow"));
414 
415             assertShouldScanPathAndIsPathHidden(false, false,
416                     new File(prefix + "/.transforms/transcode"));
417         }
418     }
419 
assertVisibleFolder(File dir)420     private void assertVisibleFolder(File dir) throws Exception {
421         final File nomediaFile = new File(dir, ".nomedia");
422 
423         if (!nomediaFile.getParentFile().exists()) {
424             assertTrue(nomediaFile.getParentFile().mkdirs());
425         }
426         try {
427             if (!nomediaFile.exists()) {
428                 executeShellCommand("touch " + nomediaFile.getAbsolutePath());
429                 assertTrue(nomediaFile.exists());
430             }
431             assertShouldScanPathAndIsPathHidden(true, false, dir);
432         } finally {
433             executeShellCommand("rm " + nomediaFile.getAbsolutePath());
434         }
435     }
436 
437     /**
438      * b/168830497: Test that default folders and Camera folder are always visible
439      */
440     @Test
testVisibleDefaultFolders()441     public void testVisibleDefaultFolders() throws Exception {
442         final File root = new File("storage/emulated/0");
443 
444         // Top level directories should always be visible
445         for (String dirName : FileUtils.DEFAULT_FOLDER_NAMES) {
446             final File defaultFolder = new File(root, dirName);
447             assertVisibleFolder(defaultFolder);
448         }
449 
450         // DCIM/Camera should always be visible
451         final File cameraDir = new File(root, Environment.DIRECTORY_DCIM + "/" + "Camera");
452         assertVisibleFolder(cameraDir);
453     }
454 
assertShouldScanDirectory(File file)455     private static void assertShouldScanDirectory(File file) {
456         assertTrue(file.getAbsolutePath(), shouldScanDirectory(file));
457     }
458 
assertShouldntScanDirectory(File file)459     private static void assertShouldntScanDirectory(File file) {
460         assertFalse(file.getAbsolutePath(), shouldScanDirectory(file));
461     }
462 
463     @Test
testShouldScanDirectory()464     public void testShouldScanDirectory() throws Exception {
465         for (String prefix : new String[] {
466                 "/storage/emulated/0",
467                 "/storage/0000-0000",
468         }) {
469             assertShouldScanDirectory(new File(prefix));
470             assertShouldScanDirectory(new File(prefix + "/meow"));
471             assertShouldScanDirectory(new File(prefix + "/Android"));
472             assertShouldScanDirectory(new File(prefix + "/Android/meow"));
473             assertShouldScanDirectory(new File(prefix + "/.meow"));
474 
475             assertShouldntScanDirectory(new File(prefix + "/Android/data"));
476             assertShouldntScanDirectory(new File(prefix + "/Android/obb"));
477             assertShouldntScanDirectory(new File(prefix + "/Android/sandbox"));
478 
479             assertShouldntScanDirectory(new File(prefix + "/Pictures/.thumbnails"));
480             assertShouldntScanDirectory(new File(prefix + "/Movies/.thumbnails"));
481             assertShouldntScanDirectory(new File(prefix + "/Music/.thumbnails"));
482 
483             assertShouldScanDirectory(new File(prefix + "/DCIM/.thumbnails"));
484             assertShouldntScanDirectory(new File(prefix + "/.transforms"));
485         }
486     }
487 
assertDirectoryHidden(File file)488     private static void assertDirectoryHidden(File file) {
489         assertTrue(file.getAbsolutePath(), isDirectoryHidden(file));
490     }
491 
assertDirectoryNotHidden(File file)492     private static void assertDirectoryNotHidden(File file) {
493         assertFalse(file.getAbsolutePath(), isDirectoryHidden(file));
494     }
495 
496     @Test
testIsDirectoryHidden()497     public void testIsDirectoryHidden() throws Exception {
498         for (String prefix : new String[] {
499                 "/storage/emulated/0",
500                 "/storage/0000-0000",
501         }) {
502             assertDirectoryNotHidden(new File(prefix));
503             assertDirectoryNotHidden(new File(prefix + "/meow"));
504 
505             assertDirectoryHidden(new File(prefix + "/.meow"));
506         }
507 
508 
509         final File nomediaFile = new File("storage/emulated/0/Download/meow", ".nomedia");
510         try {
511             assertTrue(nomediaFile.getParentFile().mkdirs());
512             assertTrue(nomediaFile.createNewFile());
513 
514             assertDirectoryHidden(nomediaFile.getParentFile());
515 
516             assertTrue(nomediaFile.delete());
517 
518             assertDirectoryNotHidden(nomediaFile.getParentFile());
519         } finally {
520             nomediaFile.delete();
521             nomediaFile.getParentFile().delete();
522         }
523     }
524 
525     @Test
testIsFileHidden()526     public void testIsFileHidden() throws Exception {
527         assertFalse(isFileHidden(
528                 new File("/storage/emulated/0/DCIM/IMG1024.JPG")));
529         assertFalse(isFileHidden(
530                 new File("/storage/emulated/0/DCIM/.pending-1577836800-IMG1024.JPG")));
531         assertFalse(isFileHidden(
532                 new File("/storage/emulated/0/DCIM/.trashed-1577836800-IMG1024.JPG")));
533         assertTrue(isFileHidden(
534                 new File("/storage/emulated/0/DCIM/.IMG1024.JPG")));
535     }
536 
537     @Test
testIsZero()538     public void testIsZero() throws Exception {
539         assertFalse(ModernMediaScanner.isZero(""));
540         assertFalse(ModernMediaScanner.isZero("meow"));
541         assertFalse(ModernMediaScanner.isZero("1"));
542         assertFalse(ModernMediaScanner.isZero("01"));
543         assertFalse(ModernMediaScanner.isZero("010"));
544 
545         assertTrue(ModernMediaScanner.isZero("0"));
546         assertTrue(ModernMediaScanner.isZero("00"));
547         assertTrue(ModernMediaScanner.isZero("000"));
548     }
549 
550     @Test
testFilter()551     public void testFilter() throws Exception {
552         final File music = new File(mDir, "Music");
553         music.mkdirs();
554         stage(R.raw.test_audio, new File(music, "example.mp3"));
555         mModern.scanDirectory(mDir, REASON_UNKNOWN);
556 
557         // Exact matches
558         assertQueryCount(1, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
559                 .buildUpon().appendQueryParameter("filter", "artist").build());
560         assertQueryCount(1, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
561                 .buildUpon().appendQueryParameter("filter", "album").build());
562         assertQueryCount(1, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
563                 .buildUpon().appendQueryParameter("filter", "title").build());
564 
565         // Partial matches mid-string
566         assertQueryCount(1, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
567                 .buildUpon().appendQueryParameter("filter", "ArT").build());
568 
569         // Filter should only apply to narrow collection type
570         assertQueryCount(0, MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI
571                 .buildUpon().appendQueryParameter("filter", "title").build());
572 
573         // Other unrelated search terms
574         assertQueryCount(0, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
575                 .buildUpon().appendQueryParameter("filter", "example").build());
576         assertQueryCount(0, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
577                 .buildUpon().appendQueryParameter("filter", "チ").build());
578     }
579 
580     @Test
testScan_Common()581     public void testScan_Common() throws Exception {
582         final File file = new File(mDir, "red.jpg");
583         stage(R.raw.test_image, file);
584 
585         mModern.scanDirectory(mDir, REASON_UNKNOWN);
586 
587         // Confirm that we found new image and scanned it
588         final Uri uri;
589         try (Cursor cursor = mIsolatedResolver
590                 .query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
591             assertEquals(1, cursor.getCount());
592             cursor.moveToFirst();
593             uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
594                     cursor.getLong(cursor.getColumnIndex(MediaColumns._ID)));
595             assertEquals(1280, cursor.getLong(cursor.getColumnIndex(MediaColumns.WIDTH)));
596             assertEquals(720, cursor.getLong(cursor.getColumnIndex(MediaColumns.HEIGHT)));
597         }
598 
599         // Write a totally different image and confirm that we automatically
600         // rescanned it
601         try (ParcelFileDescriptor pfd = mIsolatedResolver.openFile(uri, "wt", null)) {
602             final Bitmap bitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888);
603             bitmap.compress(Bitmap.CompressFormat.JPEG, 90,
604                     new FileOutputStream(pfd.getFileDescriptor()));
605         }
606 
607         // Make sure out pending scan has finished
608         MediaStore.waitForIdle(mIsolatedResolver);
609 
610         try (Cursor cursor = mIsolatedResolver
611                 .query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
612             assertEquals(1, cursor.getCount());
613             cursor.moveToFirst();
614             assertEquals(32, cursor.getLong(cursor.getColumnIndex(MediaColumns.WIDTH)));
615             assertEquals(32, cursor.getLong(cursor.getColumnIndex(MediaColumns.HEIGHT)));
616         }
617 
618         // Delete raw file and confirm it's cleaned up
619         file.delete();
620         mModern.scanDirectory(mDir, REASON_UNKNOWN);
621         assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
622     }
623 
624     /**
625      * All file formats are thoroughly tested by {@code CtsProviderTestCases},
626      * but to prove code coverage we also need to exercise manually here with a
627      * bare-bones scan operation.
628      */
629     @Test
testScan_Coverage()630     public void testScan_Coverage() throws Exception {
631         stage(R.raw.test_audio, new File(mDir, "audio.mp3"));
632         stage(R.raw.test_video, new File(mDir, "video.mp4"));
633         stage(R.raw.test_image, new File(mDir, "image.jpg"));
634         stage(R.raw.test_m3u, new File(mDir, "playlist.m3u"));
635         stage(R.raw.test_srt, new File(mDir, "subtitle.srt"));
636         stage(R.raw.test_txt, new File(mDir, "document.txt"));
637         stage(R.raw.test_bin, new File(mDir, "random.bin"));
638 
639         mModern.scanDirectory(mDir, REASON_UNKNOWN);
640     }
641 
642     @Test
testScan_missingDir()643     public void testScan_missingDir() throws Exception {
644         File newDir = new File(mDir, "new-dir");
645         // Below shouldn't crash
646         mModern.scanDirectory(newDir, REASON_UNKNOWN);
647 
648         newDir = new File(Environment.getStorageDirectory(), "new-dir");
649         // Below shouldn't crash
650         mModern.scanDirectory(newDir, REASON_UNKNOWN);
651     }
652 
653     @Test
testScan_Nomedia_Dir()654     public void testScan_Nomedia_Dir() throws Exception {
655         final File redDir = new File(mDir, "red");
656         final File blueDir = new File(mDir, "blue");
657         redDir.mkdirs();
658         blueDir.mkdirs();
659         stage(R.raw.test_image, new File(redDir, "red.jpg"));
660         stage(R.raw.test_image, new File(blueDir, "blue.jpg"));
661 
662         mModern.scanDirectory(mDir, REASON_UNKNOWN);
663 
664         // We should have found both images
665         assertQueryCount(2, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
666 
667         // Hide one directory, rescan, and confirm hidden
668         final File redNomedia = new File(redDir, ".nomedia");
669         redNomedia.createNewFile();
670         mModern.scanDirectory(mDir, REASON_UNKNOWN);
671         assertQueryCount(1, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
672 
673         // Unhide, rescan, and confirm visible again
674         redNomedia.delete();
675         mModern.scanDirectory(mDir, REASON_UNKNOWN);
676         assertQueryCount(2, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
677     }
678 
679     @Test
testScan_MaxExcludeNomediaDirs_DoesNotThrowException()680     public void testScan_MaxExcludeNomediaDirs_DoesNotThrowException() throws Exception {
681         // Create MAX_EXCLUDE_DIRS + 50 nomedia dirs in mDir
682         // (Need to add 50 as MAX_EXCLUDE_DIRS is a safe limit;
683         // 499 would have been too close to the exception limit)
684         // Mark them as non-dirty so that they are excluded from scans
685         for (int i = 0 ; i < (MAX_EXCLUDE_DIRS + 50) ; i++) {
686             createCleanNomediaDir(mDir);
687         }
688 
689         final File redDir = new File(mDir, "red");
690         redDir.mkdirs();
691         stage(R.raw.test_image, new File(redDir, "red.jpg"));
692 
693         assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
694         mModern.scanDirectory(mDir, REASON_UNKNOWN);
695         assertQueryCount(1, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
696     }
697 
createCleanNomediaDir(File dir)698     private void createCleanNomediaDir(File dir) throws Exception {
699         final File nomediaDir = new File(dir, "test_" + System.nanoTime());
700         nomediaDir.mkdirs();
701         final File nomedia = new File(nomediaDir, ".nomedia");
702         nomedia.createNewFile();
703 
704         FileUtils.setDirectoryDirty(nomediaDir, false);
705         assertThat(FileUtils.isDirectoryDirty(nomediaDir)).isFalse();
706     }
707 
708     @Test
testScan_Nomedia_File()709     public void testScan_Nomedia_File() throws Exception {
710         final File image = new File(mDir, "image.jpg");
711         final File nomedia = new File(mDir, ".nomedia");
712         stage(R.raw.test_image, image);
713         nomedia.createNewFile();
714 
715         // Direct scan with nomedia will change media type to MEDIA_TYPE_NONE
716         assertNotNull(mModern.scanFile(image, REASON_UNKNOWN));
717         assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
718 
719         // Direct scan without nomedia means image
720         nomedia.delete();
721         assertNotNull(mModern.scanFile(image, REASON_UNKNOWN));
722         assertQueryCount(1, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
723 
724         // Direct scan again changes the media type to MEDIA_TYPE_NONE
725         nomedia.createNewFile();
726         assertNotNull(mModern.scanFile(image, REASON_UNKNOWN));
727         assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
728     }
729 
730     @Test
testScan_missingFile()731     public void testScan_missingFile() throws Exception {
732         File image = new File(mDir, "image.jpg");
733         assertThat(mModern.scanFile(image, REASON_UNKNOWN)).isNull();
734 
735         image = new File(Environment.getStorageDirectory(), "image.jpg");
736         assertThat(mModern.scanFile(image, REASON_UNKNOWN)).isNull();
737     }
738 
739     @Test
testScanFileAndUpdateOwnerPackageName()740     public void testScanFileAndUpdateOwnerPackageName() throws Exception {
741         final File image = new File(mDir, "image.jpg");
742         final String thisPackageName = InstrumentationRegistry.getContext().getPackageName();
743         stage(R.raw.test_image, image);
744 
745         assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
746         // scanning the image file inserts new database entry with OWNER_PACKAGE_NAME as
747         // thisPackageName.
748         assertNotNull(mModern.scanFile(image, REASON_UNKNOWN, thisPackageName));
749         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
750                 new String[] {MediaColumns.OWNER_PACKAGE_NAME}, null, null, null)) {
751             assertEquals(1, cursor.getCount());
752             cursor.moveToNext();
753             assertEquals(thisPackageName, cursor.getString(0));
754         }
755     }
756 
757     /**
758      * Verify fix for obscure bug which would cause us to delete files outside a
759      * directory that share a common prefix.
760      */
761     @Test
testScan_Prefix()762     public void testScan_Prefix() throws Exception {
763         final File dir = new File(mDir, "test");
764         final File inside = new File(dir, "testfile.jpg");
765         final File outside = new File(mDir, "testfile.jpg");
766 
767         dir.mkdirs();
768         inside.createNewFile();
769         outside.createNewFile();
770 
771         // Scanning from top means we get both items
772         mModern.scanDirectory(mDir, REASON_UNKNOWN);
773         assertQueryCount(2, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
774 
775         // Scanning from middle means we still have both items
776         mModern.scanDirectory(dir, REASON_UNKNOWN);
777         assertQueryCount(2, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
778     }
779 
assertQueryCount(int expected, Uri actualUri)780     private void assertQueryCount(int expected, Uri actualUri) {
781         try (Cursor cursor = mIsolatedResolver.query(actualUri, null, null, null, null)) {
782             assertEquals(expected, cursor.getCount());
783         }
784     }
785 
786     @Test
testScan_audio_empty_title()787     public void testScan_audio_empty_title() throws Exception {
788         final File music = new File(mDir, "Music");
789         final File audio = new File(music, "audio.mp3");
790 
791         music.mkdirs();
792         stage(R.raw.test_audio_empty_title, audio);
793 
794         mModern.scanFile(audio, REASON_UNKNOWN);
795 
796         try (Cursor cursor = mIsolatedResolver
797                 .query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
798             assertEquals(1, cursor.getCount());
799             cursor.moveToFirst();
800             assertEquals("audio", cursor.getString(cursor.getColumnIndex(MediaColumns.TITLE)));
801         }
802     }
803 
804     @Test
805     @SdkSuppress(minSdkVersion = 31, codeName = "S")
testScan_audio_recording()806     public void testScan_audio_recording() throws Exception {
807         final File music = new File(mDir, "Recordings");
808         final File audio = new File(music, "audio.mp3");
809 
810         music.mkdirs();
811         stage(R.raw.test_audio, audio);
812 
813         mModern.scanFile(audio, REASON_UNKNOWN);
814 
815         try (Cursor cursor = mIsolatedResolver
816                 .query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
817             assertEquals(1, cursor.getCount());
818             cursor.moveToFirst();
819             assertEquals(1, cursor.getInt(cursor.getColumnIndex(AudioColumns.IS_RECORDING)));
820             assertEquals(0, cursor.getInt(cursor.getColumnIndex(AudioColumns.IS_MUSIC)));
821         }
822     }
823 
824     /**
825      * Verify a narrow exception where we allow an {@code mp4} video file on
826      * disk to be indexed as an {@code m4a} audio file.
827      */
828     @Test
testScan_148316354()829     public void testScan_148316354() throws Exception {
830         final File file = new File(mDir, "148316354.mp4");
831         stage(R.raw.test_m4a, file);
832 
833         final Uri uri = mModern.scanFile(file, REASON_UNKNOWN);
834         try (Cursor cursor = mIsolatedResolver
835                 .query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
836             assertEquals(1, cursor.getCount());
837             cursor.moveToFirst();
838             assertThat(cursor.getString(cursor.getColumnIndex(MediaColumns.MIME_TYPE)))
839                     .isEqualTo("audio/mp4");
840             assertThat(cursor.getString(cursor.getColumnIndex(AudioColumns.IS_MUSIC)))
841                     .isEqualTo("1");
842 
843         }
844     }
845 
846     @Test
testScan_audioMp4_notRescanIfUnchanged()847     public void testScan_audioMp4_notRescanIfUnchanged() throws Exception {
848         final File file = new File(mDir, "176522651.m4a");
849         stage(R.raw.test_m4a, file);
850 
851         // We trigger a scan twice, but we expect the second scan to be skipped since there were
852         // no changes.
853         mModern.scanFile(file, REASON_UNKNOWN);
854         mModern.scanFile(file, REASON_UNKNOWN);
855 
856         try (Cursor cursor =
857                      mIsolatedResolver.query(
858                              MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
859                              null, null, null, null)) {
860             assertThat(cursor.getCount()).isEqualTo(1);
861             cursor.moveToFirst();
862             String added = cursor.getString(cursor.getColumnIndex(MediaColumns.GENERATION_ADDED));
863             String modified =
864                     cursor.getString(cursor.getColumnIndex(MediaColumns.GENERATION_MODIFIED));
865             assertThat(modified).isEqualTo(added);
866         }
867     }
868 
869     /**
870      * If there is a scan action between invoking {@link ContentResolver#insert} and
871      * {@link ContentResolver#openFileDescriptor}, it should not raise
872      * (@link FileNotFoundException}.
873      */
874     @Test
testScan_166063754()875     public void testScan_166063754() throws Exception {
876         Uri collection = MediaStore.Images.Media
877                 .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
878 
879         ContentValues values = new ContentValues();
880         values.put(MediaStore.Images.Media.DISPLAY_NAME, mDir.getName() + "_166063754.jpg");
881         values.put(MediaStore.Images.Media.IS_PENDING, 1);
882 
883         Uri uri = mIsolatedResolver.insert(collection, values);
884 
885         File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
886         mModern.scanFile(dir, REASON_UNKNOWN);
887         try {
888             mIsolatedResolver.openFileDescriptor(uri, "w", null);
889         } catch (FileNotFoundException e) {
890             throw new AssertionError("Can't open uri " + uri, e);
891         }
892     }
893 
894     @Test
testAlbumArtPattern()895     public void testAlbumArtPattern() throws Exception {
896         for (String path : new String[] {
897                 "/storage/emulated/0/._abc",
898                 "/storage/emulated/0/a._abc",
899 
900                 "/storage/emulated/0/AlbumArtSmall.jpg",
901                 "/storage/emulated/0/albumartsmall.jpg",
902 
903                 "/storage/emulated/0/AlbumArt_{}_Small.jpg",
904                 "/storage/emulated/0/albumart_{a}_small.jpg",
905                 "/storage/emulated/0/AlbumArt_{}_Large.jpg",
906                 "/storage/emulated/0/albumart_{a}_large.jpg",
907 
908                 "/storage/emulated/0/Folder.jpg",
909                 "/storage/emulated/0/folder.jpg",
910 
911                 "/storage/emulated/0/AlbumArt.jpg",
912                 "/storage/emulated/0/albumart.jpg",
913                 "/storage/emulated/0/albumart1.jpg",
914         }) {
915             final File file = new File(path);
916             assertEquals(LegacyMediaScannerTest.isNonMediaFile(path), isFileAlbumArt(file));
917         }
918 
919         for (String path : new String[] {
920                 "/storage/emulated/0/AlbumArtLarge.jpg",
921                 "/storage/emulated/0/albumartlarge.jpg",
922         }) {
923             final File file = new File(path);
924             assertTrue(isFileAlbumArt(file));
925         }
926     }
927 
928     @Test
testScan_BitmapFile()929     public void testScan_BitmapFile() throws Exception {
930         final File bmp = new File(mDir, "image.bmp");
931         stage(R.raw.test_bmp, bmp);
932 
933         final Uri uri = mModern.scanFile(bmp, REASON_UNKNOWN);
934         try (Cursor cursor = mIsolatedResolver
935                 .query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
936             assertEquals(1, cursor.getCount());
937             cursor.moveToFirst();
938             assertEquals(1280,
939                     cursor.getInt(cursor.getColumnIndex(MediaColumns.WIDTH)));
940             assertEquals(720,
941                     cursor.getInt(cursor.getColumnIndex(MediaColumns.HEIGHT)));
942         }
943     }
944 
945     @Test
testScan_deleteStaleRowWithExpiredPendingFile()946     public void testScan_deleteStaleRowWithExpiredPendingFile() throws Exception {
947         final String displayName = "audio.mp3";
948         final long dateExpires = (System.currentTimeMillis() - 5 * DateUtils.DAY_IN_MILLIS) / 1000;
949         final String expiredName = String.format(
950                 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_PENDING, dateExpires, displayName);
951         final File audio = new File(mDir, expiredName);
952         stage(R.raw.test_audio, audio);
953 
954         // files should exist
955         assertThat(audio.exists()).isTrue();
956 
957         // scan file, row is added
958         mModern.scanFile(audio, REASON_UNKNOWN);
959         final Bundle queryArgs = new Bundle();
960         queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
961 
962         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
963                 null, queryArgs, null)) {
964             assertThat(cursor.getCount()).isEqualTo(1);
965         }
966 
967         // Delete the pending file to make the row is stale
968         executeShellCommand("rm " + audio.getAbsolutePath());
969         assertThat(audio.exists()).isFalse();
970 
971         // the row still exists
972         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
973                 null, queryArgs, null)) {
974             assertThat(cursor.getCount()).isEqualTo(1);
975         }
976 
977         mModern.scanFile(audio, REASON_UNKNOWN);
978 
979         // ScanFile above deleted stale expired pending row, hence we shouldn't see
980         // the pending row in query result
981         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
982                 null, queryArgs, null)) {
983             assertThat(cursor.getCount()).isEqualTo(0);
984         }
985     }
986 
987     @Test
testScan_keepStaleRowWithNonExpiredPendingFile()988     public void testScan_keepStaleRowWithNonExpiredPendingFile() throws Exception {
989         final String displayName = "audio.mp3";
990         final long dateExpires = (System.currentTimeMillis() + 2 * DateUtils.DAY_IN_MILLIS) / 1000;
991         final String expiredName = String.format(
992                 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_PENDING, dateExpires, displayName);
993         final File audio = new File(mDir, expiredName);
994         stage(R.raw.test_audio, audio);
995 
996         // file should exist
997         assertThat(audio.exists()).isTrue();
998 
999         // scan file, row is added
1000         mModern.scanFile(audio, REASON_UNKNOWN);
1001         final Bundle queryArgs = new Bundle();
1002         queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
1003 
1004         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
1005                 null, queryArgs, null)) {
1006             assertThat(cursor.getCount()).isEqualTo(1);
1007         }
1008 
1009         // Delete the pending file to make the row is stale
1010         executeShellCommand("rm " + audio.getAbsolutePath());
1011         assertThat(audio.exists()).isFalse();
1012 
1013         // the row still exists
1014         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
1015                 null, queryArgs, null)) {
1016             assertThat(cursor.getCount()).isEqualTo(1);
1017         }
1018 
1019         mModern.scanFile(audio, REASON_UNKNOWN);
1020 
1021         // ScanFile above didn't delete stale pending row which is not expired, hence
1022         // we still see the pending row in query result
1023         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
1024                 null, queryArgs, null)) {
1025             assertThat(cursor.getCount()).isEqualTo(1);
1026         }
1027     }
1028 
1029     @Test
testScan_deleteStaleRowWithExpiredTrashedFile()1030     public void testScan_deleteStaleRowWithExpiredTrashedFile() throws Exception {
1031         final String displayName = "audio.mp3";
1032         final long dateExpires = (System.currentTimeMillis() - 5 * DateUtils.DAY_IN_MILLIS) / 1000;
1033         final String expiredName = String.format(
1034                 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_TRASHED, dateExpires, displayName);
1035         final File audio = new File(mDir, expiredName);
1036         stage(R.raw.test_audio, audio);
1037 
1038         // file should exist
1039         assertThat(audio.exists()).isTrue();
1040 
1041         // scan file, row is added
1042         mModern.scanFile(audio, REASON_UNKNOWN);
1043         final Bundle queryArgs = new Bundle();
1044         queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
1045 
1046         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
1047                 null, queryArgs, null)) {
1048             assertThat(cursor.getCount()).isEqualTo(1);
1049         }
1050 
1051         // Delete the trashed file to make the row is stale
1052         executeShellCommand("rm " + audio.getAbsolutePath());
1053         assertThat(audio.exists()).isFalse();
1054 
1055         // the row still exists
1056         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
1057                 null, queryArgs, null)) {
1058             assertThat(cursor.getCount()).isEqualTo(1);
1059         }
1060 
1061         mModern.scanFile(audio, REASON_UNKNOWN);
1062 
1063         // ScanFile above deleted stale expired trashed row, hence we shouldn't see
1064         // the trashed row in query result
1065         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
1066                 null, queryArgs, null)) {
1067             assertThat(cursor.getCount()).isEqualTo(0);
1068         }
1069     }
1070 
1071     @Test
testScan_deleteStaleRowWithNonExpiredTrashedFile()1072     public void testScan_deleteStaleRowWithNonExpiredTrashedFile() throws Exception {
1073         final String displayName = "audio.mp3";
1074         final long dateExpires = (System.currentTimeMillis() + 2 * DateUtils.DAY_IN_MILLIS) / 1000;
1075         final String expiredName = String.format(
1076                 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_TRASHED, dateExpires, displayName);
1077         final File audio = new File(mDir, expiredName);
1078         stage(R.raw.test_audio, audio);
1079 
1080         // file should exist
1081         assertThat(audio.exists()).isTrue();
1082 
1083         // scan file, row is added
1084         mModern.scanFile(audio, REASON_UNKNOWN);
1085         final Bundle queryArgs = new Bundle();
1086         queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
1087 
1088         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
1089                 null, queryArgs, null)) {
1090             assertThat(cursor.getCount()).isEqualTo(1);
1091         }
1092 
1093         // Delete the trashed file to make the row is stale
1094         executeShellCommand("rm " + audio.getAbsolutePath());
1095         assertThat(audio.exists()).isFalse();
1096 
1097         // the row still exists
1098         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
1099                 null, queryArgs, null)) {
1100             assertThat(cursor.getCount()).isEqualTo(1);
1101         }
1102 
1103         mModern.scanFile(audio, REASON_UNKNOWN);
1104 
1105         // ScanFile above deleted stale trashed row that is not expired, hence we
1106         // shouldn't see the trashed row in query result
1107         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
1108                 null, queryArgs, null)) {
1109             assertThat(cursor.getCount()).isEqualTo(0);
1110         }
1111     }
1112 
1113     @Test
testScan_deleteStaleRow()1114     public void testScan_deleteStaleRow() throws Exception {
1115         final String displayName = "audio.mp3";
1116         final File audio = new File(mDir, displayName);
1117         stage(R.raw.test_audio, audio);
1118 
1119         // file should exist
1120         assertThat(audio.exists()).isTrue();
1121 
1122         // scan file, row is added
1123         mModern.scanFile(audio, REASON_UNKNOWN);
1124 
1125         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
1126                 null, null, null)) {
1127             assertThat(cursor.getCount()).isEqualTo(1);
1128         }
1129 
1130         // Delete the file to make the row is stale
1131         executeShellCommand("rm " + audio.getAbsolutePath());
1132         assertThat(audio.exists()).isFalse();
1133 
1134         // the row still exists
1135         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
1136                 null, null, null)) {
1137             assertThat(cursor.getCount()).isEqualTo(1);
1138         }
1139 
1140         mModern.scanFile(audio, REASON_UNKNOWN);
1141 
1142         // ScanFile above deleted stale row, hence we shouldn't see the row in query result
1143         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
1144                 null, null, null)) {
1145             assertThat(cursor.getCount()).isEqualTo(0);
1146         }
1147     }
1148 
1149     /**
1150      * Executes a shell command.
1151      */
executeShellCommand(String command)1152     public static String executeShellCommand(String command) throws IOException {
1153         int attempt = 0;
1154         while (attempt++ < 5) {
1155             try {
1156                 return executeShellCommandInternal(command);
1157             } catch (InterruptedIOException e) {
1158                 // Hmm, we had trouble executing the shell command; the best we
1159                 // can do is try again a few more times
1160                 Log.v(TAG, "Trouble executing " + command + "; trying again", e);
1161             }
1162         }
1163         throw new IOException("Failed to execute " + command);
1164     }
1165 
executeShellCommandInternal(String cmd)1166     private static String executeShellCommandInternal(String cmd) throws IOException {
1167         UiAutomation uiAutomation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
1168         try (FileInputStream output = new FileInputStream(
1169                 uiAutomation.executeShellCommand(cmd).getFileDescriptor())) {
1170             return new String(ByteStreams.toByteArray(output));
1171         }
1172     }
1173 
1174     @Test
testScan_largeXmpData()1175     public void testScan_largeXmpData() throws Exception {
1176         final File image = new File(mDir, "large_xmp.mp4");
1177         stage(R.raw.large_xmp, image);
1178         assertTrue(image.exists());
1179 
1180         mModern.scanDirectory(mDir, REASON_UNKNOWN);
1181 
1182         try (Cursor cursor = mIsolatedResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
1183                 new String[] { MediaColumns.XMP }, null, null, null)) {
1184             assertEquals(1, cursor.getCount());
1185             cursor.moveToFirst();
1186             assertEquals(0, cursor.getBlob(0).length);
1187         }
1188     }
1189 
1190     @Test
testNoOpScan_NoMediaDirs()1191     public void testNoOpScan_NoMediaDirs() throws Exception {
1192         File nomedia = new File(mDir, ".nomedia");
1193         assertThat(nomedia.createNewFile()).isTrue();
1194         for (int i = 0; i < 100; i++) {
1195             File file = new File(mDir, "file_" + System.nanoTime());
1196             assertThat(file.createNewFile()).isTrue();
1197         }
1198         Timer firstDirScan = new Timer("firstDirScan");
1199         firstDirScan.start();
1200         // Time taken : preVisitDirectory + 100 visitFiles
1201         mModern.scanDirectory(mDir, REASON_UNKNOWN);
1202         firstDirScan.stop();
1203         firstDirScan.dumpResults();
1204 
1205         // Time taken : preVisitDirectory
1206         Timer noOpDirScan = new Timer("noOpDirScan1");
1207         for (int i = 0; i < COUNT_REPEAT; i++) {
1208             noOpDirScan.start();
1209             mModern.scanDirectory(mDir, REASON_UNKNOWN);
1210             noOpDirScan.stop();
1211         }
1212         noOpDirScan.dumpResults();
1213         assertThat(noOpDirScan.getMaxDurationMillis()).isLessThan(
1214                 firstDirScan.getMaxDurationMillis());
1215 
1216         // Creating new file in the nomedia dir by a non-M_E_S app should not set nomedia dir dirty.
1217         File file = new File(mDir, "file_" + System.nanoTime());
1218         assertThat(file.createNewFile()).isTrue();
1219 
1220         // The dir should not be dirty and subsequest scans should not scan the entire directory.
1221         // Time taken : preVisitDirectory
1222         noOpDirScan = new Timer("noOpDirScan2");
1223         for (int i = 0; i < COUNT_REPEAT; i++) {
1224             noOpDirScan.start();
1225             mModern.scanDirectory(mDir, REASON_UNKNOWN);
1226             noOpDirScan.stop();
1227         }
1228         noOpDirScan.dumpResults();
1229         assertThat(noOpDirScan.getMaxDurationMillis()).isLessThan(
1230                 firstDirScan.getMaxDurationMillis());
1231     }
1232 
1233     @Test
testScan_TrackNumber()1234     public void testScan_TrackNumber() throws Exception {
1235         final File music = new File(mDir, "Music");
1236         final File audio = new File(music, "audio.mp3");
1237 
1238         music.mkdirs();
1239         stage(R.raw.test_audio, audio);
1240 
1241         mModern.scanFile(audio, REASON_UNKNOWN);
1242 
1243         try (Cursor cursor = mIsolatedResolver
1244                 .query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
1245             assertEquals(1, cursor.getCount());
1246             cursor.moveToFirst();
1247             assertEquals(2, cursor.getInt(cursor.getColumnIndex(AudioColumns.TRACK)));
1248         }
1249 
1250         stage(R.raw.test_audio_empty_track_number, audio);
1251 
1252         mModern.scanFile(audio, REASON_UNKNOWN);
1253 
1254         try (Cursor cursor = mIsolatedResolver
1255                 .query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
1256             assertEquals(1, cursor.getCount());
1257             cursor.moveToFirst();
1258             assertThat(cursor.getString(cursor.getColumnIndex(AudioColumns.TRACK))).isNull();
1259         }
1260     }
1261 }
1262