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.MediaScannerTest.stage;
20 import static com.android.providers.media.scan.ModernMediaScanner.isDirectoryHidden;
21 import static com.android.providers.media.scan.ModernMediaScanner.maybeOverrideMimeType;
22 import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalDateTaken;
23 
24 import static org.junit.Assert.assertEquals;
25 import static org.junit.Assert.assertFalse;
26 import static org.junit.Assert.assertNotNull;
27 import static org.junit.Assert.assertNull;
28 import static org.junit.Assert.assertTrue;
29 
30 import android.content.ContentResolver;
31 import android.content.ContentUris;
32 import android.content.Context;
33 import android.database.Cursor;
34 import android.graphics.Bitmap;
35 import android.media.ExifInterface;
36 import android.net.Uri;
37 import android.os.Environment;
38 import android.os.FileUtils;
39 import android.os.ParcelFileDescriptor;
40 import android.provider.MediaStore;
41 import android.provider.MediaStore.Files.FileColumns;
42 import android.provider.MediaStore.MediaColumns;
43 
44 import androidx.test.InstrumentationRegistry;
45 import androidx.test.runner.AndroidJUnit4;
46 
47 import com.android.internal.os.BackgroundThread;
48 import com.android.providers.media.MediaProvider;
49 import com.android.providers.media.scan.MediaScannerTest.IsolatedContext;
50 import com.android.providers.media.tests.R;
51 
52 import org.junit.After;
53 import org.junit.Assume;
54 import org.junit.Before;
55 import org.junit.Test;
56 import org.junit.runner.RunWith;
57 
58 import java.io.File;
59 import java.io.FileOutputStream;
60 import java.util.concurrent.CountDownLatch;
61 import java.util.concurrent.TimeUnit;
62 
63 @RunWith(AndroidJUnit4.class)
64 public class ModernMediaScannerTest {
65     // TODO: scan directory-vs-files and confirm identical results
66 
67     private File mDir;
68 
69     private Context mIsolatedContext;
70     private ContentResolver mIsolatedResolver;
71 
72     private ModernMediaScanner mModern;
73 
74     @Before
setUp()75     public void setUp() {
76         mDir = new File(Environment.getExternalStorageDirectory(), "test_" + System.nanoTime());
77         mDir.mkdirs();
78         FileUtils.deleteContents(mDir);
79 
80         final Context context = InstrumentationRegistry.getTargetContext();
81         mIsolatedContext = new IsolatedContext(context, "modern");
82         mIsolatedResolver = mIsolatedContext.getContentResolver();
83 
84         mModern = new ModernMediaScanner(mIsolatedContext);
85     }
86 
87     @After
tearDown()88     public void tearDown() {
89         FileUtils.deleteContents(mDir);
90     }
91 
92     @Test
testOverrideMimeType()93     public void testOverrideMimeType() throws Exception {
94         assertEquals("image/png",
95                 maybeOverrideMimeType("image/png", null));
96         assertEquals("image/png",
97                 maybeOverrideMimeType("image/png", "image"));
98         assertEquals("image/png",
99                 maybeOverrideMimeType("image/png", "im/im"));
100         assertEquals("image/png",
101                 maybeOverrideMimeType("image/png", "audio/x-shiny"));
102         assertEquals("image/x-shiny",
103                 maybeOverrideMimeType("image/png", "image/x-shiny"));
104     }
105 
106     @Test
testParseDateTaken_Complete()107     public void testParseDateTaken_Complete() throws Exception {
108         final File file = File.createTempFile("test", ".jpg");
109         final ExifInterface exif = new ExifInterface(file);
110         exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
111 
112         // Offset is recorded, test both zeros
113         exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "-00:00");
114         assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
115         exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "+00:00");
116         assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
117 
118         // Offset is recorded, test both directions
119         exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "-07:00");
120         assertEquals(1453972654000L + 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
121         exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "+07:00");
122         assertEquals(1453972654000L - 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
123     }
124 
125     @Test
testParseDateTaken_Gps()126     public void testParseDateTaken_Gps() throws Exception {
127         final File file = File.createTempFile("test", ".jpg");
128         final ExifInterface exif = new ExifInterface(file);
129         exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
130 
131         // GPS tells us we're in UTC
132         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
133         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:14:00");
134         assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
135         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
136         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:20:00");
137         assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
138 
139         // GPS tells us we're in -7
140         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
141         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "16:14:00");
142         assertEquals(1453972654000L + 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
143         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
144         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "16:20:00");
145         assertEquals(1453972654000L + 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
146 
147         // GPS tells us we're in +7
148         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
149         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "02:14:00");
150         assertEquals(1453972654000L - 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
151         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
152         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "02:20:00");
153         assertEquals(1453972654000L - 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
154 
155         // GPS beyond 24 hours isn't helpful
156         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:27");
157         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:17:34");
158         assertFalse(parseOptionalDateTaken(exif, 0L).isPresent());
159         exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:29");
160         exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:17:34");
161         assertFalse(parseOptionalDateTaken(exif, 0L).isPresent());
162     }
163 
164     @Test
testParseDateTaken_File()165     public void testParseDateTaken_File() throws Exception {
166         final File file = File.createTempFile("test", ".jpg");
167         final ExifInterface exif = new ExifInterface(file);
168         exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
169 
170         // Modified tells us we're in UTC
171         assertEquals(1453972654000L,
172                 (long) parseOptionalDateTaken(exif, 1453972654000L - 60000L).get());
173         assertEquals(1453972654000L,
174                 (long) parseOptionalDateTaken(exif, 1453972654000L + 60000L).get());
175 
176         // Modified tells us we're in -7
177         assertEquals(1453972654000L + 25200000L,
178                 (long) parseOptionalDateTaken(exif, 1453972654000L + 25200000L - 60000L).get());
179         assertEquals(1453972654000L + 25200000L,
180                 (long) parseOptionalDateTaken(exif, 1453972654000L + 25200000L + 60000L).get());
181 
182         // Modified tells us we're in +7
183         assertEquals(1453972654000L - 25200000L,
184                 (long) parseOptionalDateTaken(exif, 1453972654000L - 25200000L - 60000L).get());
185         assertEquals(1453972654000L - 25200000L,
186                 (long) parseOptionalDateTaken(exif, 1453972654000L - 25200000L + 60000L).get());
187 
188         // Modified beyond 24 hours isn't helpful
189         assertFalse(parseOptionalDateTaken(exif, 1453972654000L - 86400000L).isPresent());
190         assertFalse(parseOptionalDateTaken(exif, 1453972654000L + 86400000L).isPresent());
191     }
192 
193     @Test
testParseDateTaken_Hopeless()194     public void testParseDateTaken_Hopeless() throws Exception {
195         final File file = File.createTempFile("test", ".jpg");
196         final ExifInterface exif = new ExifInterface(file);
197         exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
198 
199         // Offset is completely missing, and no useful GPS or modified time
200         assertFalse(parseOptionalDateTaken(exif, 0L).isPresent());
201     }
202 
assertDirectoryHidden(File file)203     private static void assertDirectoryHidden(File file) {
204         assertTrue(file.getAbsolutePath(), isDirectoryHidden(file));
205     }
206 
assertDirectoryNotHidden(File file)207     private static void assertDirectoryNotHidden(File file) {
208         assertFalse(file.getAbsolutePath(), isDirectoryHidden(file));
209     }
210 
211     @Test
testIsDirectoryHidden()212     public void testIsDirectoryHidden() throws Exception {
213         for (String prefix : new String[] {
214                 "/storage/emulated/0",
215                 "/storage/emulated/0/Android/sandbox/com.example",
216                 "/storage/0000-0000",
217                 "/storage/0000-0000/Android/sandbox/com.example",
218         }) {
219             assertDirectoryNotHidden(new File(prefix));
220             assertDirectoryNotHidden(new File(prefix + "/meow"));
221             assertDirectoryNotHidden(new File(prefix + "/Android"));
222             assertDirectoryNotHidden(new File(prefix + "/Android/meow"));
223             assertDirectoryNotHidden(new File(prefix + "/Android/sandbox"));
224             assertDirectoryNotHidden(new File(prefix + "/Android/sandbox/meow"));
225 
226             assertDirectoryHidden(new File(prefix + "/.meow"));
227             assertDirectoryHidden(new File(prefix + "/Android/data"));
228             assertDirectoryHidden(new File(prefix + "/Android/obb"));
229         }
230     }
231 
232     @Test
testPlaylistM3u()233     public void testPlaylistM3u() throws Exception {
234         Assume.assumeTrue(MediaProvider.ENABLE_MODERN_SCANNER);
235         doPlaylist(R.raw.test_m3u, "test.m3u");
236     }
237 
238     @Test
testPlaylistPls()239     public void testPlaylistPls() throws Exception {
240         Assume.assumeTrue(MediaProvider.ENABLE_MODERN_SCANNER);
241         doPlaylist(R.raw.test_pls, "test.pls");
242     }
243 
244     @Test
testPlaylistWpl()245     public void testPlaylistWpl() throws Exception {
246         Assume.assumeTrue(MediaProvider.ENABLE_MODERN_SCANNER);
247         doPlaylist(R.raw.test_wpl, "test.wpl");
248     }
249 
doPlaylist(int res, String name)250     private void doPlaylist(int res, String name) throws Exception {
251         final File music = new File(mDir, "Music");
252         music.mkdirs();
253         stage(R.raw.test_audio, new File(music, "001.mp3"));
254         stage(R.raw.test_audio, new File(music, "002.mp3"));
255         stage(R.raw.test_audio, new File(music, "003.mp3"));
256         stage(res, new File(music, name));
257 
258         mModern.scanDirectory(mDir);
259 
260         // We should see a new playlist with all three items as members
261         final long playlistId;
262         try (Cursor cursor = mIsolatedContext.getContentResolver().query(
263                 MediaStore.Files.EXTERNAL_CONTENT_URI, new String[] { FileColumns._ID },
264                 FileColumns.MEDIA_TYPE + "=" + FileColumns.MEDIA_TYPE_PLAYLIST, null, null)) {
265             assertTrue(cursor.moveToFirst());
266             playlistId = cursor.getLong(0);
267         }
268 
269         final Uri membersUri = MediaStore.Audio.Playlists.Members
270                 .getContentUri(MediaStore.VOLUME_EXTERNAL, playlistId);
271         try (Cursor cursor = mIsolatedResolver.query(membersUri, new String[] {
272                 MediaColumns.DISPLAY_NAME
273         }, null, null, MediaStore.Audio.Playlists.Members.PLAY_ORDER + " ASC")) {
274             assertEquals(3, cursor.getCount());
275             cursor.moveToNext();
276             assertEquals("001.mp3", cursor.getString(0));
277             cursor.moveToNext();
278             assertEquals("002.mp3", cursor.getString(0));
279             cursor.moveToNext();
280             assertEquals("003.mp3", cursor.getString(0));
281         }
282 
283         // Delete one of the media files and rescan
284         new File(music, "002.mp3").delete();
285         new File(music, name).setLastModified(10L);
286         mModern.scanDirectory(mDir);
287 
288         try (Cursor cursor = mIsolatedResolver.query(membersUri, new String[] {
289                 MediaColumns.DISPLAY_NAME
290         }, null, null, MediaStore.Audio.Playlists.Members.PLAY_ORDER + " ASC")) {
291             assertEquals(2, cursor.getCount());
292             cursor.moveToNext();
293             assertEquals("001.mp3", cursor.getString(0));
294             cursor.moveToNext();
295             assertEquals("003.mp3", cursor.getString(0));
296         }
297     }
298 
299     @Test
testScan_Common()300     public void testScan_Common() throws Exception {
301         Assume.assumeTrue(MediaProvider.ENABLE_MODERN_SCANNER);
302 
303         final File file = new File(mDir, "red.jpg");
304         stage(R.raw.test_image, file);
305 
306         mModern.scanDirectory(mDir);
307 
308         // Confirm that we found new image and scanned it
309         final Uri uri;
310         try (Cursor cursor = mIsolatedResolver
311                 .query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
312             assertEquals(1, cursor.getCount());
313             cursor.moveToFirst();
314             uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
315                     cursor.getLong(cursor.getColumnIndex(MediaColumns._ID)));
316             assertEquals(1280, cursor.getLong(cursor.getColumnIndex(MediaColumns.WIDTH)));
317             assertEquals(720, cursor.getLong(cursor.getColumnIndex(MediaColumns.HEIGHT)));
318         }
319 
320         // Write a totally different image and confirm that we automatically
321         // rescanned it
322         try (ParcelFileDescriptor pfd = mIsolatedResolver.openFile(uri, "wt", null)) {
323             final Bitmap bitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888);
324             bitmap.compress(Bitmap.CompressFormat.JPEG, 90,
325                     new FileOutputStream(pfd.getFileDescriptor()));
326         }
327 
328         // Make sure out pending scan has finished
329         final CountDownLatch latch = new CountDownLatch(1);
330         BackgroundThread.getExecutor().execute(() -> {
331             latch.countDown();
332         });
333         latch.await(10, TimeUnit.SECONDS);
334 
335         try (Cursor cursor = mIsolatedResolver
336                 .query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
337             assertEquals(1, cursor.getCount());
338             cursor.moveToFirst();
339             assertEquals(32, cursor.getLong(cursor.getColumnIndex(MediaColumns.WIDTH)));
340             assertEquals(32, cursor.getLong(cursor.getColumnIndex(MediaColumns.HEIGHT)));
341         }
342 
343         // Delete raw file and confirm it's cleaned up
344         file.delete();
345         mModern.scanDirectory(mDir);
346         assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
347     }
348 
349     @Test
testScan_Nomedia_Dir()350     public void testScan_Nomedia_Dir() throws Exception {
351         Assume.assumeTrue(MediaProvider.ENABLE_MODERN_SCANNER);
352 
353         final File red = new File(mDir, "red");
354         final File blue = new File(mDir, "blue");
355         red.mkdirs();
356         blue.mkdirs();
357         stage(R.raw.test_image, new File(red, "red.jpg"));
358         stage(R.raw.test_image, new File(blue, "blue.jpg"));
359 
360         mModern.scanDirectory(mDir);
361 
362         // We should have found both images
363         assertQueryCount(2, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
364 
365         // Hide one directory, rescan, and confirm hidden
366         final File redNomedia = new File(red, ".nomedia");
367         redNomedia.createNewFile();
368         mModern.scanDirectory(mDir);
369         assertQueryCount(1, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
370 
371         // Unhide, rescan, and confirm visible again
372         redNomedia.delete();
373         mModern.scanDirectory(mDir);
374         assertQueryCount(2, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
375     }
376 
377     @Test
testScan_Nomedia_File()378     public void testScan_Nomedia_File() throws Exception {
379         Assume.assumeTrue(MediaProvider.ENABLE_MODERN_SCANNER);
380 
381         final File image = new File(mDir, "image.jpg");
382         final File nomedia = new File(mDir, ".nomedia");
383         stage(R.raw.test_image, image);
384         nomedia.createNewFile();
385 
386         // Direct scan with nomedia means no image
387         assertNull(mModern.scanFile(image));
388         assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
389 
390         // Direct scan without nomedia means image
391         nomedia.delete();
392         assertNotNull(mModern.scanFile(image));
393         assertNotNull(mModern.scanFile(image));
394         assertQueryCount(1, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
395 
396         // Direct scan again hides it again
397         nomedia.createNewFile();
398         assertNull(mModern.scanFile(image));
399         assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
400     }
401 
assertQueryCount(int expected, Uri actualUri)402     private void assertQueryCount(int expected, Uri actualUri) {
403         try (Cursor cursor = mIsolatedResolver.query(actualUri, null, null, null, null)) {
404             assertEquals(expected, cursor.getCount());
405         }
406     }
407 }
408