blob: 1bbcb45aad739c41addc541692a31cedaade27ab [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.providers.media.scan;
import static com.android.providers.media.scan.MediaScanner.REASON_UNKNOWN;
import static com.android.providers.media.scan.MediaScannerTest.stage;
import static com.android.providers.media.scan.ModernMediaScanner.isDirectoryHidden;
import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalDateTaken;
import static com.android.providers.media.scan.ModernMediaScanner.parseOptionalMimeType;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.media.ExifInterface;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.MediaStore;
import android.provider.MediaStore.Files.FileColumns;
import android.provider.MediaStore.MediaColumns;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
import com.android.providers.media.R;
import com.android.providers.media.scan.MediaScannerTest.IsolatedContext;
import com.android.providers.media.util.FileUtils;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.io.File;
import java.io.FileOutputStream;
@RunWith(AndroidJUnit4.class)
public class ModernMediaScannerTest {
// TODO: scan directory-vs-files and confirm identical results
private File mDir;
private Context mIsolatedContext;
private ContentResolver mIsolatedResolver;
private ModernMediaScanner mModern;
@Before
public void setUp() {
final Context context = InstrumentationRegistry.getTargetContext();
mDir = new File(context.getExternalMediaDirs()[0], "test_" + System.nanoTime());
mDir.mkdirs();
FileUtils.deleteContents(mDir);
mIsolatedContext = new IsolatedContext(context, "modern");
mIsolatedResolver = mIsolatedContext.getContentResolver();
mModern = new ModernMediaScanner(mIsolatedContext);
}
@After
public void tearDown() {
FileUtils.deleteContents(mDir);
}
@Test
public void testSimple() throws Exception {
assertNotNull(mModern.getContext());
}
@Test
public void testOverrideMimeType() throws Exception {
assertFalse(parseOptionalMimeType("image/png", null).isPresent());
assertFalse(parseOptionalMimeType("image/png", "image").isPresent());
assertFalse(parseOptionalMimeType("image/png", "im/im").isPresent());
assertFalse(parseOptionalMimeType("image/png", "audio/x-shiny").isPresent());
assertTrue(parseOptionalMimeType("image/png", "image/x-shiny").isPresent());
assertEquals("image/x-shiny",
parseOptionalMimeType("image/png", "image/x-shiny").get());
}
@Test
public void testParseDateTaken_Complete() throws Exception {
final File file = File.createTempFile("test", ".jpg");
final ExifInterface exif = new ExifInterface(file);
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
// Offset is recorded, test both zeros
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "-00:00");
assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "+00:00");
assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
// Offset is recorded, test both directions
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "-07:00");
assertEquals(1453972654000L + 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
exif.setAttribute(ExifInterface.TAG_OFFSET_TIME_ORIGINAL, "+07:00");
assertEquals(1453972654000L - 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
}
@Test
public void testParseDateTaken_Gps() throws Exception {
final File file = File.createTempFile("test", ".jpg");
final ExifInterface exif = new ExifInterface(file);
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
// GPS tells us we're in UTC
exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:14:00");
assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:20:00");
assertEquals(1453972654000L, (long) parseOptionalDateTaken(exif, 0L).get());
// GPS tells us we're in -7
exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "16:14:00");
assertEquals(1453972654000L + 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "16:20:00");
assertEquals(1453972654000L + 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
// GPS tells us we're in +7
exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "02:14:00");
assertEquals(1453972654000L - 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:28");
exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "02:20:00");
assertEquals(1453972654000L - 25200000L, (long) parseOptionalDateTaken(exif, 0L).get());
// GPS beyond 24 hours isn't helpful
exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:27");
exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:17:34");
assertFalse(parseOptionalDateTaken(exif, 0L).isPresent());
exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, "2016:01:29");
exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "09:17:34");
assertFalse(parseOptionalDateTaken(exif, 0L).isPresent());
}
@Test
public void testParseDateTaken_File() throws Exception {
final File file = File.createTempFile("test", ".jpg");
final ExifInterface exif = new ExifInterface(file);
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
// Modified tells us we're in UTC
assertEquals(1453972654000L,
(long) parseOptionalDateTaken(exif, 1453972654000L - 60000L).get());
assertEquals(1453972654000L,
(long) parseOptionalDateTaken(exif, 1453972654000L + 60000L).get());
// Modified tells us we're in -7
assertEquals(1453972654000L + 25200000L,
(long) parseOptionalDateTaken(exif, 1453972654000L + 25200000L - 60000L).get());
assertEquals(1453972654000L + 25200000L,
(long) parseOptionalDateTaken(exif, 1453972654000L + 25200000L + 60000L).get());
// Modified tells us we're in +7
assertEquals(1453972654000L - 25200000L,
(long) parseOptionalDateTaken(exif, 1453972654000L - 25200000L - 60000L).get());
assertEquals(1453972654000L - 25200000L,
(long) parseOptionalDateTaken(exif, 1453972654000L - 25200000L + 60000L).get());
// Modified beyond 24 hours isn't helpful
assertFalse(parseOptionalDateTaken(exif, 1453972654000L - 86400000L).isPresent());
assertFalse(parseOptionalDateTaken(exif, 1453972654000L + 86400000L).isPresent());
}
@Test
public void testParseDateTaken_Hopeless() throws Exception {
final File file = File.createTempFile("test", ".jpg");
final ExifInterface exif = new ExifInterface(file);
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, "2016:01:28 09:17:34");
// Offset is completely missing, and no useful GPS or modified time
assertFalse(parseOptionalDateTaken(exif, 0L).isPresent());
}
private static void assertDirectoryHidden(File file) {
assertTrue(file.getAbsolutePath(), isDirectoryHidden(file));
}
private static void assertDirectoryNotHidden(File file) {
assertFalse(file.getAbsolutePath(), isDirectoryHidden(file));
}
@Test
public void testIsDirectoryHidden() throws Exception {
for (String prefix : new String[] {
"/storage/emulated/0",
"/storage/emulated/0/Android/sandbox/com.example",
"/storage/0000-0000",
"/storage/0000-0000/Android/sandbox/com.example",
}) {
assertDirectoryNotHidden(new File(prefix));
assertDirectoryNotHidden(new File(prefix + "/meow"));
assertDirectoryNotHidden(new File(prefix + "/Android"));
assertDirectoryNotHidden(new File(prefix + "/Android/meow"));
assertDirectoryNotHidden(new File(prefix + "/Android/sandbox"));
assertDirectoryNotHidden(new File(prefix + "/Android/sandbox/meow"));
assertDirectoryHidden(new File(prefix + "/.meow"));
assertDirectoryHidden(new File(prefix + "/Android/data"));
assertDirectoryHidden(new File(prefix + "/Android/obb"));
}
}
@Test
public void testIsZero() throws Exception {
assertFalse(ModernMediaScanner.isZero(""));
assertFalse(ModernMediaScanner.isZero("meow"));
assertFalse(ModernMediaScanner.isZero("1"));
assertFalse(ModernMediaScanner.isZero("01"));
assertFalse(ModernMediaScanner.isZero("010"));
assertTrue(ModernMediaScanner.isZero("0"));
assertTrue(ModernMediaScanner.isZero("00"));
assertTrue(ModernMediaScanner.isZero("000"));
}
@Test
public void testPlaylistM3u() throws Exception {
doPlaylist(R.raw.test_m3u, "test.m3u");
}
@Test
public void testPlaylistPls() throws Exception {
doPlaylist(R.raw.test_pls, "test.pls");
}
@Test
public void testPlaylistWpl() throws Exception {
doPlaylist(R.raw.test_wpl, "test.wpl");
}
private void doPlaylist(int res, String name) throws Exception {
final File music = new File(mDir, "Music");
music.mkdirs();
stage(R.raw.test_audio, new File(music, "001.mp3"));
stage(R.raw.test_audio, new File(music, "002.mp3"));
stage(R.raw.test_audio, new File(music, "003.mp3"));
stage(R.raw.test_audio, new File(music, "004.mp3"));
stage(R.raw.test_audio, new File(music, "005.mp3"));
stage(res, new File(music, name));
mModern.scanDirectory(mDir, REASON_UNKNOWN);
// We should see a new playlist with all three items as members
final long playlistId;
try (Cursor cursor = mIsolatedContext.getContentResolver().query(
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
new String[] { FileColumns._ID },
FileColumns.MEDIA_TYPE + "=" + FileColumns.MEDIA_TYPE_PLAYLIST, null, null)) {
assertTrue(cursor.moveToFirst());
playlistId = cursor.getLong(0);
}
final Uri membersUri = MediaStore.Audio.Playlists.Members
.getContentUri(MediaStore.VOLUME_EXTERNAL, playlistId);
try (Cursor cursor = mIsolatedResolver.query(membersUri, new String[] {
MediaColumns.DISPLAY_NAME
}, null, null, MediaStore.Audio.Playlists.Members.PLAY_ORDER + " ASC")) {
assertEquals(5, cursor.getCount());
cursor.moveToNext();
assertEquals("001.mp3", cursor.getString(0));
cursor.moveToNext();
assertEquals("002.mp3", cursor.getString(0));
cursor.moveToNext();
assertEquals("003.mp3", cursor.getString(0));
cursor.moveToNext();
assertEquals("004.mp3", cursor.getString(0));
cursor.moveToNext();
assertEquals("005.mp3", cursor.getString(0));
}
// Delete one of the media files and rescan
new File(music, "002.mp3").delete();
new File(music, name).setLastModified(10L);
mModern.scanDirectory(mDir, REASON_UNKNOWN);
try (Cursor cursor = mIsolatedResolver.query(membersUri, new String[] {
MediaColumns.DISPLAY_NAME
}, null, null, MediaStore.Audio.Playlists.Members.PLAY_ORDER + " ASC")) {
assertEquals(4, cursor.getCount());
cursor.moveToNext();
assertEquals("001.mp3", cursor.getString(0));
cursor.moveToNext();
assertEquals("003.mp3", cursor.getString(0));
}
}
@Test
public void testFilter() throws Exception {
final File music = new File(mDir, "Music");
music.mkdirs();
stage(R.raw.test_audio, new File(music, "example.mp3"));
mModern.scanDirectory(mDir, REASON_UNKNOWN);
// Exact matches
assertQueryCount(1, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
.buildUpon().appendQueryParameter("filter", "artist").build());
assertQueryCount(1, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
.buildUpon().appendQueryParameter("filter", "album").build());
assertQueryCount(1, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
.buildUpon().appendQueryParameter("filter", "title").build());
// Partial matches mid-string
assertQueryCount(1, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
.buildUpon().appendQueryParameter("filter", "ArT").build());
// Filter should only apply to narrow collection type
assertQueryCount(0, MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI
.buildUpon().appendQueryParameter("filter", "title").build());
// Other unrelated search terms
assertQueryCount(0, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
.buildUpon().appendQueryParameter("filter", "example").build());
assertQueryCount(0, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
.buildUpon().appendQueryParameter("filter", "チ").build());
}
@Test
public void testScan_Common() throws Exception {
final File file = new File(mDir, "red.jpg");
stage(R.raw.test_image, file);
mModern.scanDirectory(mDir, REASON_UNKNOWN);
// Confirm that we found new image and scanned it
final Uri uri;
try (Cursor cursor = mIsolatedResolver
.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
assertEquals(1, cursor.getCount());
cursor.moveToFirst();
uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
cursor.getLong(cursor.getColumnIndex(MediaColumns._ID)));
assertEquals(1280, cursor.getLong(cursor.getColumnIndex(MediaColumns.WIDTH)));
assertEquals(720, cursor.getLong(cursor.getColumnIndex(MediaColumns.HEIGHT)));
}
// Write a totally different image and confirm that we automatically
// rescanned it
try (ParcelFileDescriptor pfd = mIsolatedResolver.openFile(uri, "wt", null)) {
final Bitmap bitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888);
bitmap.compress(Bitmap.CompressFormat.JPEG, 90,
new FileOutputStream(pfd.getFileDescriptor()));
}
// Make sure out pending scan has finished
MediaStore.waitForIdle(mIsolatedResolver);
try (Cursor cursor = mIsolatedResolver
.query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, null, null, null, null)) {
assertEquals(1, cursor.getCount());
cursor.moveToFirst();
assertEquals(32, cursor.getLong(cursor.getColumnIndex(MediaColumns.WIDTH)));
assertEquals(32, cursor.getLong(cursor.getColumnIndex(MediaColumns.HEIGHT)));
}
// Delete raw file and confirm it's cleaned up
file.delete();
mModern.scanDirectory(mDir, REASON_UNKNOWN);
assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
}
@Test
public void testScan_Nomedia_Dir() throws Exception {
final File red = new File(mDir, "red");
final File blue = new File(mDir, "blue");
red.mkdirs();
blue.mkdirs();
stage(R.raw.test_image, new File(red, "red.jpg"));
stage(R.raw.test_image, new File(blue, "blue.jpg"));
mModern.scanDirectory(mDir, REASON_UNKNOWN);
// We should have found both images
assertQueryCount(2, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
// Hide one directory, rescan, and confirm hidden
final File redNomedia = new File(red, ".nomedia");
redNomedia.createNewFile();
mModern.scanDirectory(mDir, REASON_UNKNOWN);
assertQueryCount(1, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
// Unhide, rescan, and confirm visible again
redNomedia.delete();
mModern.scanDirectory(mDir, REASON_UNKNOWN);
assertQueryCount(2, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
}
@Test
public void testScan_Nomedia_File() throws Exception {
final File image = new File(mDir, "image.jpg");
final File nomedia = new File(mDir, ".nomedia");
stage(R.raw.test_image, image);
nomedia.createNewFile();
// Direct scan with nomedia means no image
assertNull(mModern.scanFile(image, REASON_UNKNOWN));
assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
// Direct scan without nomedia means image
nomedia.delete();
assertNotNull(mModern.scanFile(image, REASON_UNKNOWN));
assertNotNull(mModern.scanFile(image, REASON_UNKNOWN));
assertQueryCount(1, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
// Direct scan again hides it again
nomedia.createNewFile();
assertNull(mModern.scanFile(image, REASON_UNKNOWN));
assertQueryCount(0, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
}
/**
* Verify fix for obscure bug which would cause us to delete files outside a
* directory that share a common prefix.
*/
@Test
public void testScan_Prefix() throws Exception {
final File dir = new File(mDir, "test");
final File inside = new File(dir, "testfile.jpg");
final File outside = new File(mDir, "testfile.jpg");
dir.mkdirs();
inside.createNewFile();
outside.createNewFile();
// Scanning from top means we get both items
mModern.scanDirectory(mDir, REASON_UNKNOWN);
assertQueryCount(2, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
// Scanning from middle means we still have both items
mModern.scanDirectory(dir, REASON_UNKNOWN);
assertQueryCount(2, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
}
private void assertQueryCount(int expected, Uri actualUri) {
try (Cursor cursor = mIsolatedResolver.query(actualUri, null, null, null, null)) {
assertEquals(expected, cursor.getCount());
}
}
}