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