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.shouldScanPathAndIsPathHidden;
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.util.FileUtils.isDirectoryHidden;
37 import static com.android.providers.media.util.FileUtils.isFileHidden;
38 
39 import static org.junit.Assert.assertEquals;
40 import static org.junit.Assert.assertFalse;
41 import static org.junit.Assert.assertNotNull;
42 import static org.junit.Assert.assertTrue;
43 import static org.mockito.ArgumentMatchers.eq;
44 import static org.mockito.Mockito.mock;
45 import static org.mockito.Mockito.when;
46 
47 import android.Manifest;
48 import android.content.ContentResolver;
49 import android.content.ContentUris;
50 import android.content.Context;
51 import android.database.Cursor;
52 import android.graphics.Bitmap;
53 import android.media.ExifInterface;
54 import android.media.MediaMetadataRetriever;
55 import android.net.Uri;
56 import android.os.ParcelFileDescriptor;
57 import android.provider.MediaStore;
58 import android.provider.MediaStore.Files.FileColumns;
59 import android.provider.MediaStore.MediaColumns;
60 import android.util.Pair;
61 
62 import androidx.test.InstrumentationRegistry;
63 import androidx.test.runner.AndroidJUnit4;
64 
65 import com.android.providers.media.R;
66 import com.android.providers.media.scan.MediaScannerTest.IsolatedContext;
67 import com.android.providers.media.util.FileUtils;
68 
69 import org.junit.After;
70 import org.junit.Before;
71 import org.junit.Test;
72 import org.junit.runner.RunWith;
73 
74 import java.io.File;
75 import java.io.FileOutputStream;
76 import java.util.Optional;
77 
78 @RunWith(AndroidJUnit4.class)
79 public class ModernMediaScannerTest {
80     // TODO: scan directory-vs-files and confirm identical results
81 
82     private File mDir;
83 
84     private Context mIsolatedContext;
85     private ContentResolver mIsolatedResolver;
86 
87     private ModernMediaScanner mModern;
88 
89     @Before
setUp()90     public void setUp() {
91         final Context context = InstrumentationRegistry.getTargetContext();
92         InstrumentationRegistry.getInstrumentation().getUiAutomation()
93                 .adoptShellPermissionIdentity(Manifest.permission.LOG_COMPAT_CHANGE,
94                         Manifest.permission.READ_COMPAT_CHANGE_CONFIG);
95 
96         mDir = new File(context.getExternalMediaDirs()[0], "test_" + System.nanoTime());
97         mDir.mkdirs();
98         FileUtils.deleteContents(mDir);
99 
100         mIsolatedContext = new IsolatedContext(context, "modern", /*asFuseThread*/ false);
101         mIsolatedResolver = mIsolatedContext.getContentResolver();
102 
103         mModern = new ModernMediaScanner(mIsolatedContext);
104     }
105 
106     @After
tearDown()107     public void tearDown() {
108         FileUtils.deleteContents(mDir);
109         InstrumentationRegistry.getInstrumentation()
110                 .getUiAutomation().dropShellPermissionIdentity();
111     }
112 
113     @Test
testSimple()114     public void testSimple() throws Exception {
115         assertNotNull(mModern.getContext());
116     }
117 
118     @Test
testOverrideMimeType()119     public void testOverrideMimeType() throws Exception {
120         assertFalse(parseOptionalMimeType("image/png", null).isPresent());
121         assertFalse(parseOptionalMimeType("image/png", "image").isPresent());
122         assertFalse(parseOptionalMimeType("image/png", "im/im").isPresent());
123         assertFalse(parseOptionalMimeType("image/png", "audio/x-shiny").isPresent());
124 
125         assertTrue(parseOptionalMimeType("image/png", "image/x-shiny").isPresent());
126         assertEquals("image/x-shiny",
127                 parseOptionalMimeType("image/png", "image/x-shiny").get());
128     }
129 
130     @Test
testOverrideMimeType_148316354()131     public void testOverrideMimeType_148316354() throws Exception {
132         // Radical file type shifting isn't allowed
133         assertEquals(Optional.empty(),
134                 parseOptionalMimeType("video/mp4", "audio/mpeg"));
135 
136         // One specific narrow type of shift (mp4 -> m4a) is allowed
137         assertEquals(Optional.of("audio/mp4"),
138                 parseOptionalMimeType("video/mp4", "audio/mp4"));
139 
140         // The other direction isn't allowed
141         assertEquals(Optional.empty(),
142                 parseOptionalMimeType("audio/mp4", "video/mp4"));
143     }
144 
145     @Test
testParseOptional()146     public void testParseOptional() throws Exception {
147         assertFalse(parseOptional(null).isPresent());
148         assertFalse(parseOptional("").isPresent());
149         assertFalse(parseOptional(" ").isPresent());
150         assertFalse(parseOptional("-1").isPresent());
151 
152         assertFalse(parseOptional(-1).isPresent());
153         assertTrue(parseOptional(0).isPresent());
154         assertTrue(parseOptional(1).isPresent());
155 
156         assertEquals("meow", parseOptional("meow").get());
157         assertEquals(42, (int) parseOptional(42).get());
158     }
159 
160     @Test
testParseOptionalOrZero()161     public void testParseOptionalOrZero() throws Exception {
162         assertFalse(parseOptionalOrZero(-1).isPresent());
163         assertFalse(parseOptionalOrZero(0).isPresent());
164         assertTrue(parseOptionalOrZero(1).isPresent());
165     }
166 
167     @Test
testParseOptionalNumerator()168     public void testParseOptionalNumerator() throws Exception {
169         assertEquals(12, (int) parseOptionalNumerator("12").get());
170         assertEquals(12, (int) parseOptionalNumerator("12/24").get());
171 
172         assertFalse(parseOptionalNumerator("/24").isPresent());
173     }
174 
175     @Test
testParseOptionalOrientation()176     public void testParseOptionalOrientation() throws Exception {
177         assertEquals(0,
178                 (int) parseOptionalOrientation(ExifInterface.ORIENTATION_NORMAL).get());
179         assertEquals(90,
180                 (int) parseOptionalOrientation(ExifInterface.ORIENTATION_ROTATE_90).get());
181         assertEquals(180,
182                 (int) parseOptionalOrientation(ExifInterface.ORIENTATION_ROTATE_180).get());
183         assertEquals(270,
184                 (int) parseOptionalOrientation(ExifInterface.ORIENTATION_ROTATE_270).get());
185 
186         // We can't represent this as an orientation
187         assertFalse(parseOptionalOrientation(ExifInterface.ORIENTATION_TRANSPOSE).isPresent());
188     }
189 
190     @Test
testParseOptionalImageResolution()191     public void testParseOptionalImageResolution() throws Exception {
192         final MediaMetadataRetriever mmr = mock(MediaMetadataRetriever.class);
193         when(mmr.extractMetadata(eq(MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH)))
194                 .thenReturn("640");
195         when(mmr.extractMetadata(eq(MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT)))
196                 .thenReturn("480");
197         assertEquals("640\u00d7480", parseOptionalImageResolution(mmr).get());
198     }
199 
200     @Test
testParseOptionalVideoResolution()201     public void testParseOptionalVideoResolution() throws Exception {
202         final MediaMetadataRetriever mmr = mock(MediaMetadataRetriever.class);
203         when(mmr.extractMetadata(eq(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)))
204                 .thenReturn("640");
205         when(mmr.extractMetadata(eq(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)))
206                 .thenReturn("480");
207         assertEquals("640\u00d7480", parseOptionalVideoResolution(mmr).get());
208     }
209 
210     @Test
testParseOptionalResolution()211     public void testParseOptionalResolution() throws Exception {
212         final ExifInterface exif = mock(ExifInterface.class);
213         when(exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)).thenReturn("640");
214         when(exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)).thenReturn("480");
215         assertEquals("640\u00d7480", parseOptionalResolution(exif).get());
216     }
217 
218     @Test
testParseOptionalDate()219     public void testParseOptionalDate() throws Exception {
220         assertEquals(1577836800000L, (long) parseOptionalDate("20200101T000000").get());
221     }
222 
223     @Test
testParseOptionalTrack()224     public void testParseOptionalTrack() throws Exception {
225         final MediaMetadataRetriever mmr = mock(MediaMetadataRetriever.class);
226         when(mmr.extractMetadata(eq(MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER)))
227                 .thenReturn("1/2");
228         when(mmr.extractMetadata(eq(MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER)))
229                 .thenReturn("4/12");
230         assertEquals(1004, (int) parseOptionalTrack(mmr).get());
231     }
232 
233     @Test
testParseDateTaken_Complete()234     public void testParseDateTaken_Complete() throws Exception {
235         final File file = File.createTempFile("test", ".jpg");
236         final ExifInterface exif = new ExifInterface(file);
237         exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
238 
239         // Offset is recorded, test both zeros
240         exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "-00:00");
241         assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
242         exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "+00:00");
243         assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
244 
245         // Offset is recorded, test both directions
246         exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "-07:00");
247         assertEquals(1453972654000L + 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
248         exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "+07:00");
249         assertEquals(1453972654000L - 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
250     }
251 
252     @Test
testParseDateTaken_Gps()253     public void testParseDateTaken_Gps() 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         // GPS tells us we're in UTC
259         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
260         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:14:00");
261         assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
262         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
263         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:20:00");
264         assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
265 
266         // GPS tells us we're in -7
267         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
268         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "16:14:00");
269         assertEquals(1453972654000L + 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
270         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
271         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "16:20:00");
272         assertEquals(1453972654000L + 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
273 
274         // GPS tells us we're in +7
275         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
276         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "02:14:00");
277         assertEquals(1453972654000L - 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
278         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
279         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "02:20:00");
280         assertEquals(1453972654000L - 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
281 
282         // GPS beyond 24 hours isn't helpful
283         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:27");
284         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:17:34");
285         assertFalse(parseOptionalDateTaken(exif, 0L).isPresent());
286         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:29");
287         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:17:34");
288         assertFalse(parseOptionalDateTaken(exif, 0L).isPresent());
289     }
290 
291     @Test
testParseDateTaken_File()292     public void testParseDateTaken_File() throws Exception {
293         final File file = File.createTempFile("test", ".jpg");
294         final ExifInterface exif = new ExifInterface(file);
295         exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
296 
297         // Modified tells us we're in UTC
298         assertEquals(1453972654000L,
299                 (long) parseOptionalDateTaken(exif, 1453972654000L - 60000L).get());
300         assertEquals(1453972654000L,
301                 (long) parseOptionalDateTaken(exif, 1453972654000L + 60000L).get());
302 
303         // Modified tells us we're in -7
304         assertEquals(1453972654000L + 25200000L,
305                 (long) parseOptionalDateTaken(exif, 1453972654000L + 25200000L - 60000L).get());
306         assertEquals(1453972654000L + 25200000L,
307                 (long) parseOptionalDateTaken(exif, 1453972654000L + 25200000L + 60000L).get());
308 
309         // Modified tells us we're in +7
310         assertEquals(1453972654000L - 25200000L,
311                 (long) parseOptionalDateTaken(exif, 1453972654000L - 25200000L - 60000L).get());
312         assertEquals(1453972654000L - 25200000L,
313                 (long) parseOptionalDateTaken(exif, 1453972654000L - 25200000L + 60000L).get());
314 
315         // Modified beyond 24 hours isn't helpful
316         assertFalse(parseOptionalDateTaken(exif, 1453972654000L - 86400000L).isPresent());
317         assertFalse(parseOptionalDateTaken(exif, 1453972654000L + 86400000L).isPresent());
318     }
319 
320     @Test
testParseDateTaken_Hopeless()321     public void testParseDateTaken_Hopeless() throws Exception {
322         final File file = File.createTempFile("test", ".jpg");
323         final ExifInterface exif = new ExifInterface(file);
324         exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
325 
326         // Offset is completely missing, and no useful GPS or modified time
327         assertFalse(parseOptionalDateTaken(exif, 0L).isPresent());
328     }
329 
330     @Test
testParseYear_Invalid()331     public void testParseYear_Invalid() throws Exception {
332         assertEquals(Optional.empty(), parseOptionalYear(null));
333         assertEquals(Optional.empty(), parseOptionalYear(""));
334         assertEquals(Optional.empty(), parseOptionalYear(" "));
335         assertEquals(Optional.empty(), parseOptionalYear("meow"));
336 
337         assertEquals(Optional.empty(), parseOptionalYear("0"));
338         assertEquals(Optional.empty(), parseOptionalYear("00"));
339         assertEquals(Optional.empty(), parseOptionalYear("000"));
340         assertEquals(Optional.empty(), parseOptionalYear("0000"));
341 
342         assertEquals(Optional.empty(), parseOptionalYear("1"));
343         assertEquals(Optional.empty(), parseOptionalYear("01"));
344         assertEquals(Optional.empty(), parseOptionalYear("001"));
345         assertEquals(Optional.empty(), parseOptionalYear("0001"));
346 
347         // No sane way to determine year from two-digit date formats
348         assertEquals(Optional.empty(), parseOptionalYear("01-01-01"));
349 
350         // Specific example from partner
351         assertEquals(Optional.empty(), parseOptionalYear("000 "));
352     }
353 
354     @Test
testParseYear_Valid()355     public void testParseYear_Valid() throws Exception {
356         assertEquals(Optional.of(1900), parseOptionalYear("1900"));
357         assertEquals(Optional.of(2020), parseOptionalYear("2020"));
358         assertEquals(Optional.of(2020), parseOptionalYear(" 2020 "));
359         assertEquals(Optional.of(2020), parseOptionalYear("01-01-2020"));
360 
361         // Specific examples from partner
362         assertEquals(Optional.of(1984), parseOptionalYear("1984-06-26T07:00:00Z"));
363         assertEquals(Optional.of(2016), parseOptionalYear("Thu, 01 Sep 2016 10:11:12.123456 -0500"));
364     }
365 
assertShouldScanPathAndIsPathHidden(boolean isScannable, boolean isHidden, File dir)366     private static void assertShouldScanPathAndIsPathHidden(boolean isScannable, boolean isHidden,
367         File dir) {
368         assertEquals(Pair.create(isScannable, isHidden), shouldScanPathAndIsPathHidden(dir));
369     }
370 
371     @Test
testShouldScanPathAndIsPathHidden()372     public void testShouldScanPathAndIsPathHidden() {
373         for (String prefix : new String[] {
374                 "/storage/emulated/0",
375                 "/storage/emulated/0/Android/sandbox/com.example",
376                 "/storage/0000-0000",
377                 "/storage/0000-0000/Android/sandbox/com.example",
378         }) {
379             assertShouldScanPathAndIsPathHidden(true, false, new File(prefix));
380             assertShouldScanPathAndIsPathHidden(true, false, new File(prefix + "/meow"));
381             assertShouldScanPathAndIsPathHidden(true, false, new File(prefix + "/Android/meow"));
382             assertShouldScanPathAndIsPathHidden(true, false,
383                     new File(prefix + "/Android/sandbox/meow"));
384 
385             assertShouldScanPathAndIsPathHidden(true, true, new File(prefix + "/.meow/dir"));
386 
387             assertShouldScanPathAndIsPathHidden(false, false,
388                     new File(prefix + "/Android/data/meow"));
389             assertShouldScanPathAndIsPathHidden(false, false,
390                     new File(prefix + "/Android/obb/meow"));
391 
392             // When the path is not scannable, we don't care if it's hidden or not.
393             assertShouldScanPathAndIsPathHidden(false, false,
394                     new File(prefix + "/Pictures/.thumbnails/meow"));
395             assertShouldScanPathAndIsPathHidden(false, false,
396                     new File(prefix + "/Movies/.thumbnails/meow"));
397             assertShouldScanPathAndIsPathHidden(false, false,
398                     new File(prefix + "/Music/.thumbnails/meow"));
399         }
400     }
401 
assertShouldScanDirectory(File file)402     private static void assertShouldScanDirectory(File file) {
403         assertTrue(file.getAbsolutePath(), shouldScanDirectory(file));
404     }
405 
assertShouldntScanDirectory(File file)406     private static void assertShouldntScanDirectory(File file) {
407         assertFalse(file.getAbsolutePath(), shouldScanDirectory(file));
408     }
409 
410     @Test
testShouldScanDirectory()411     public void testShouldScanDirectory() throws Exception {
412         for (String prefix : new String[] {
413                 "/storage/emulated/0",
414                 "/storage/emulated/0/Android/sandbox/com.example",
415                 "/storage/0000-0000",
416                 "/storage/0000-0000/Android/sandbox/com.example",
417         }) {
418             assertShouldScanDirectory(new File(prefix));
419             assertShouldScanDirectory(new File(prefix + "/meow"));
420             assertShouldScanDirectory(new File(prefix + "/Android"));
421             assertShouldScanDirectory(new File(prefix + "/Android/meow"));
422             assertShouldScanDirectory(new File(prefix + "/Android/sandbox"));
423             assertShouldScanDirectory(new File(prefix + "/Android/sandbox/meow"));
424             assertShouldScanDirectory(new File(prefix + "/.meow"));
425 
426             assertShouldntScanDirectory(new File(prefix + "/Android/data"));
427             assertShouldntScanDirectory(new File(prefix + "/Android/obb"));
428 
429             assertShouldntScanDirectory(new File(prefix + "/Pictures/.thumbnails"));
430             assertShouldntScanDirectory(new File(prefix + "/Movies/.thumbnails"));
431             assertShouldntScanDirectory(new File(prefix + "/Music/.thumbnails"));
432 
433             assertShouldScanDirectory(new File(prefix + "/DCIM/.thumbnails"));
434         }
435     }
436 
assertDirectoryHidden(File file)437     private static void assertDirectoryHidden(File file) {
438         assertTrue(file.getAbsolutePath(), isDirectoryHidden(file));
439     }
440 
assertDirectoryNotHidden(File file)441     private static void assertDirectoryNotHidden(File file) {
442         assertFalse(file.getAbsolutePath(), isDirectoryHidden(file));
443     }
444 
445     @Test
testIsDirectoryHidden()446     public void testIsDirectoryHidden() throws Exception {
447         for (String prefix : new String[] {
448                 "/storage/emulated/0",
449                 "/storage/emulated/0/Android/sandbox/com.example",
450                 "/storage/0000-0000",
451                 "/storage/0000-0000/Android/sandbox/com.example",
452         }) {
453             assertDirectoryNotHidden(new File(prefix));
454             assertDirectoryNotHidden(new File(prefix + "/meow"));
455 
456             assertDirectoryHidden(new File(prefix + "/.meow"));
457         }
458 
459 
460         final File nomediaFile = new File("storage/emulated/0/Download/meow", ".nomedia");
461         try {
462             assertTrue(nomediaFile.getParentFile().mkdirs());
463             assertTrue(nomediaFile.createNewFile());
464 
465             assertDirectoryHidden(nomediaFile.getParentFile());
466 
467             assertTrue(nomediaFile.delete());
468 
469             assertDirectoryNotHidden(nomediaFile.getParentFile());
470         } finally {
471             nomediaFile.delete();
472             nomediaFile.getParentFile().delete();
473         }
474     }
475 
476     @Test
testIsFileHidden()477     public void testIsFileHidden() throws Exception {
478         assertFalse(isFileHidden(
479                 new File("/storage/emulated/0/DCIM/IMG1024.JPG")));
480         assertFalse(isFileHidden(
481                 new File("/storage/emulated/0/DCIM/.pending-1577836800-IMG1024.JPG")));
482         assertFalse(isFileHidden(
483                 new File("/storage/emulated/0/DCIM/.trashed-1577836800-IMG1024.JPG")));
484         assertTrue(isFileHidden(
485                 new File("/storage/emulated/0/DCIM/.IMG1024.JPG")));
486     }
487 
488     @Test
testIsZero()489     public void testIsZero() throws Exception {
490         assertFalse(ModernMediaScanner.isZero(""));
491         assertFalse(ModernMediaScanner.isZero("meow"));
492         assertFalse(ModernMediaScanner.isZero("1"));
493         assertFalse(ModernMediaScanner.isZero("01"));
494         assertFalse(ModernMediaScanner.isZero("010"));
495 
496         assertTrue(ModernMediaScanner.isZero("0"));
497         assertTrue(ModernMediaScanner.isZero("00"));
498         assertTrue(ModernMediaScanner.isZero("000"));
499     }
500 
501     @Test
testPlaylistM3u()502     public void testPlaylistM3u() throws Exception {
503         doPlaylist(R.raw.test_m3u, "test.m3u");
504     }
505 
506     @Test
testPlaylistPls()507     public void testPlaylistPls() throws Exception {
508         doPlaylist(R.raw.test_pls, "test.pls");
509     }
510 
511     @Test
testPlaylistWpl()512     public void testPlaylistWpl() throws Exception {
513         doPlaylist(R.raw.test_wpl, "test.wpl");
514     }
515 
516     @Test
testPlaylistXspf()517     public void testPlaylistXspf() throws Exception {
518         doPlaylist(R.raw.test_xspf, "test.xspf");
519     }
520 
doPlaylist(int res, String name)521     private void doPlaylist(int res, String name) throws Exception {
522         final File music = new File(mDir, "Music");
523         music.mkdirs();
524         stage(R.raw.test_audio, new File(music, "001.mp3"));
525         stage(R.raw.test_audio, new File(music, "002.mp3"));
526         stage(R.raw.test_audio, new File(music, "003.mp3"));
527         stage(R.raw.test_audio, new File(music, "004.mp3"));
528         stage(R.raw.test_audio, new File(music, "005.mp3"));
529         stage(res, new File(music, name));
530 
531         mModern.scanDirectory(mDir, REASON_UNKNOWN);
532 
533         // We should see a new playlist with all three items as members
534         final long playlistId;
535         try (Cursor cursor = mIsolatedContext.getContentResolver().query(
536                 MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
537                 new String[] { FileColumns._ID },
538                 FileColumns.MEDIA_TYPE + "=" + FileColumns.MEDIA_TYPE_PLAYLIST, null, null)) {
539             assertTrue(cursor.moveToFirst());
540             playlistId = cursor.getLong(0);
541         }
542 
543         final Uri membersUri = MediaStore.Audio.Playlists.Members
544                 .getContentUri(MediaStore.VOLUME_EXTERNAL, playlistId);
545         try (Cursor cursor = mIsolatedResolver.query(membersUri, new String[] {
546                 MediaColumns.DISPLAY_NAME
547         }, null, null, MediaStore.Audio.Playlists.Members.PLAY_ORDER + " ASC")) {
548             assertEquals(5, cursor.getCount());
549             cursor.moveToNext();
550             assertEquals("001.mp3", cursor.getString(0));
551             cursor.moveToNext();
552             assertEquals("002.mp3", cursor.getString(0));
553             cursor.moveToNext();
554             assertEquals("003.mp3", cursor.getString(0));
555             cursor.moveToNext();
556             assertEquals("004.mp3", cursor.getString(0));
557             cursor.moveToNext();
558             assertEquals("005.mp3", cursor.getString(0));
559         }
560 
561         // Delete one of the media files and rescan
562         new File(music, "002.mp3").delete();
563         new File(music, name).setLastModified(10L);
564         mModern.scanDirectory(mDir, REASON_UNKNOWN);
565 
566         try (Cursor cursor = mIsolatedResolver.query(membersUri, new String[] {
567                 MediaColumns.DISPLAY_NAME
568         }, null, null, MediaStore.Audio.Playlists.Members.PLAY_ORDER + " ASC")) {
569             assertEquals(4, cursor.getCount());
570             cursor.moveToNext();
571             assertEquals("001.mp3", cursor.getString(0));
572             cursor.moveToNext();
573             assertEquals("003.mp3", cursor.getString(0));
574         }
575 
576         // Replace media file in a completely different location, which normally
577         // wouldn't match the exact playlist path, but we're willing to perform
578         // a relaxed search
579         final File soundtracks = new File(mDir, "Soundtracks");
580         soundtracks.mkdirs();
581         stage(R.raw.test_audio, new File(soundtracks, "002.mp3"));
582         stage(res, new File(music, name));
583 
584         mModern.scanDirectory(mDir, REASON_UNKNOWN);
585 
586         try (Cursor cursor = mIsolatedResolver.query(membersUri, new String[] {
587                 MediaColumns.DISPLAY_NAME
588         }, null, null, MediaStore.Audio.Playlists.Members.PLAY_ORDER + " ASC")) {
589             assertEquals(5, cursor.getCount());
590             cursor.moveToNext();
591             assertEquals("001.mp3", cursor.getString(0));
592             cursor.moveToNext();
593             assertEquals("002.mp3", cursor.getString(0));
594             cursor.moveToNext();
595             assertEquals("003.mp3", cursor.getString(0));
596         }
597     }
598 
599     @Test
testFilter()600     public void testFilter() throws Exception {
601         final File music = new File(mDir, "Music");
602         music.mkdirs();
603         stage(R.raw.test_audio, new File(music, "example.mp3"));
604         mModern.scanDirectory(mDir, REASON_UNKNOWN);
605 
606         // Exact matches
607         assertQueryCount(1, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
608                 .buildUpon().appendQueryParameter("filter", "artist").build());
609         assertQueryCount(1, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
610                 .buildUpon().appendQueryParameter("filter", "album").build());
611         assertQueryCount(1, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
612                 .buildUpon().appendQueryParameter("filter", "title").build());
613 
614         // Partial matches mid-string
615         assertQueryCount(1, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
616                 .buildUpon().appendQueryParameter("filter", "ArT").build());
617 
618         // Filter should only apply to narrow collection type
619         assertQueryCount(0, MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI
620                 .buildUpon().appendQueryParameter("filter", "title").build());
621 
622         // Other unrelated search terms
623         assertQueryCount(0, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
624                 .buildUpon().appendQueryParameter("filter", "example").build());
625         assertQueryCount(0, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
626                 .buildUpon().appendQueryParameter("filter", "チ").build());
627     }
628 
629     @Test
testScan_Common()630     public void testScan_Common() throws Exception {
631         final File file = new File(mDir, "red.jpg");
632         stage(R.raw.test_image, file);
633 
634         mModern.scanDirectory(mDir, REASON_UNKNOWN);
635 
636         // Confirm that we found new image and scanned it
637         final Uri uri;
638         try (Cursor cursor = mIsolatedResolver
639                 .query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
640             assertEquals(1, cursor.getCount());
641             cursor.moveToFirst();
642             uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
643                     cursor.getLong(cursor.getColumnIndex(MediaColumns._ID)));
644             assertEquals(1280, cursor.getLong(cursor.getColumnIndex(MediaColumns.WIDTH)));
645             assertEquals(720, cursor.getLong(cursor.getColumnIndex(MediaColumns.HEIGHT)));
646         }
647 
648         // Write a totally different image and confirm that we automatically
649         // rescanned it
650         try (ParcelFileDescriptor pfd = mIsolatedResolver.openFile(uri, "wt", null)) {
651             final Bitmap bitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888);
652             bitmap.compress(Bitmap.CompressFormat.JPEG, 90,
653                     new FileOutputStream(pfd.getFileDescriptor()));
654         }
655 
656         // Make sure out pending scan has finished
657         MediaStore.waitForIdle(mIsolatedResolver);
658 
659         try (Cursor cursor = mIsolatedResolver
660                 .query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
661             assertEquals(1, cursor.getCount());
662             cursor.moveToFirst();
663             assertEquals(32, cursor.getLong(cursor.getColumnIndex(MediaColumns.WIDTH)));
664             assertEquals(32, cursor.getLong(cursor.getColumnIndex(MediaColumns.HEIGHT)));
665         }
666 
667         // Delete raw file and confirm it's cleaned up
668         file.delete();
669         mModern.scanDirectory(mDir, REASON_UNKNOWN);
670         assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
671     }
672 
673     /**
674      * All file formats are thoroughly tested by {@code CtsProviderTestCases},
675      * but to prove code coverage we also need to exercise manually here with a
676      * bare-bones scan operation.
677      */
678     @Test
testScan_Coverage()679     public void testScan_Coverage() throws Exception {
680         stage(R.raw.test_audio, new File(mDir, "audio.mp3"));
681         stage(R.raw.test_video, new File(mDir, "video.mp4"));
682         stage(R.raw.test_image, new File(mDir, "image.jpg"));
683         stage(R.raw.test_m3u, new File(mDir, "playlist.m3u"));
684         stage(R.raw.test_srt, new File(mDir, "subtitle.srt"));
685         stage(R.raw.test_txt, new File(mDir, "document.txt"));
686         stage(R.raw.test_bin, new File(mDir, "random.bin"));
687 
688         mModern.scanDirectory(mDir, REASON_UNKNOWN);
689     }
690 
691     @Test
testScan_Nomedia_Dir()692     public void testScan_Nomedia_Dir() throws Exception {
693         final File red = new File(mDir, "red");
694         final File blue = new File(mDir, "blue");
695         red.mkdirs();
696         blue.mkdirs();
697         stage(R.raw.test_image, new File(red, "red.jpg"));
698         stage(R.raw.test_image, new File(blue, "blue.jpg"));
699 
700         mModern.scanDirectory(mDir, REASON_UNKNOWN);
701 
702         // We should have found both images
703         assertQueryCount(2, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
704 
705         // Hide one directory, rescan, and confirm hidden
706         final File redNomedia = new File(red, ".nomedia");
707         redNomedia.createNewFile();
708         mModern.scanDirectory(mDir, REASON_UNKNOWN);
709         assertQueryCount(1, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
710 
711         // Unhide, rescan, and confirm visible again
712         redNomedia.delete();
713         mModern.scanDirectory(mDir, REASON_UNKNOWN);
714         assertQueryCount(2, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
715     }
716 
717     @Test
testScan_Nomedia_File()718     public void testScan_Nomedia_File() throws Exception {
719         final File image = new File(mDir, "image.jpg");
720         final File nomedia = new File(mDir, ".nomedia");
721         stage(R.raw.test_image, image);
722         nomedia.createNewFile();
723 
724         // Direct scan with nomedia will change media type to MEDIA_TYPE_NONE
725         assertNotNull(mModern.scanFile(image, REASON_UNKNOWN));
726         assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
727 
728         // Direct scan without nomedia means image
729         nomedia.delete();
730         assertNotNull(mModern.scanFile(image, REASON_UNKNOWN));
731         assertQueryCount(1, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
732 
733         // Direct scan again changes the media type to MEDIA_TYPE_NONE
734         nomedia.createNewFile();
735         assertNotNull(mModern.scanFile(image, REASON_UNKNOWN));
736         assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
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     /**
805      * Verify a narrow exception where we allow an {@code mp4} video file on
806      * disk to be indexed as an {@code m4a} audio file.
807      */
808     @Test
testScan_148316354()809     public void testScan_148316354() throws Exception {
810         final File file = new File(mDir, "148316354.mp4");
811         stage(R.raw.test_m4a, file);
812 
813         final Uri uri = mModern.scanFile(file, REASON_UNKNOWN);
814         try (Cursor cursor = mIsolatedResolver
815                 .query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
816             assertEquals(1, cursor.getCount());
817             cursor.moveToFirst();
818             assertEquals("audio/mp4",
819                     cursor.getString(cursor.getColumnIndex(MediaColumns.MIME_TYPE)));
820         }
821     }
822 
823     @Test
testAlbumArtPattern()824     public void testAlbumArtPattern() throws Exception {
825         for (String path : new String[] {
826                 "/storage/emulated/0/._abc",
827                 "/storage/emulated/0/a._abc",
828 
829                 "/storage/emulated/0/AlbumArtSmall.jpg",
830                 "/storage/emulated/0/albumartsmall.jpg",
831 
832                 "/storage/emulated/0/AlbumArt_{}_Small.jpg",
833                 "/storage/emulated/0/albumart_{a}_small.jpg",
834                 "/storage/emulated/0/AlbumArt_{}_Large.jpg",
835                 "/storage/emulated/0/albumart_{a}_large.jpg",
836 
837                 "/storage/emulated/0/Folder.jpg",
838                 "/storage/emulated/0/folder.jpg",
839 
840                 "/storage/emulated/0/AlbumArt.jpg",
841                 "/storage/emulated/0/albumart.jpg",
842                 "/storage/emulated/0/albumart1.jpg",
843         }) {
844             final File file = new File(path);
845             assertEquals(LegacyMediaScannerTest.isNonMediaFile(path), isFileAlbumArt(file));
846         }
847 
848         for (String path : new String[] {
849                 "/storage/emulated/0/AlbumArtLarge.jpg",
850                 "/storage/emulated/0/albumartlarge.jpg",
851         }) {
852             final File file = new File(path);
853             assertTrue(isFileAlbumArt(file));
854         }
855     }
856 }
857