blob: bd0e7bd18b416176720b416f0b672e85f9d26701 [file] [log] [blame]
/*
* Copyright (C) 2018 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;
import static android.os.Environment.DIRECTORY_MOVIES;
import static android.os.Environment.DIRECTORY_PICTURES;
import static android.os.Environment.buildPath;
import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT;
import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_NONE;
import static android.provider.MediaStore.MediaColumns.DATE_EXPIRES;
import static android.provider.MediaStore.MediaColumns.DISPLAY_NAME;
import static android.provider.MediaStore.MediaColumns.GENERATION_MODIFIED;
import static android.provider.MediaStore.MediaColumns.RELATIVE_PATH;
import static com.google.common.truth.Truth.assertThat;
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 static org.junit.Assert.fail;
import static org.junit.Assume.assumeTrue;
import android.Manifest;
import android.app.Instrumentation;
import android.app.job.JobScheduler;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.CancellationSignal;
import android.os.Environment;
import android.os.NewUserRequest;
import android.os.ParcelFileDescriptor;
import android.os.SystemClock;
import android.os.UserHandle;
import android.os.UserManager;
import android.provider.MediaStore;
import android.text.format.DateUtils;
import android.util.Log;
import androidx.test.InstrumentationRegistry;
import androidx.test.filters.LargeTest;
import androidx.test.filters.SdkSuppress;
import androidx.test.runner.AndroidJUnit4;
import com.android.providers.media.library.RunOnlyOnPostsubmit;
import com.android.providers.media.scan.MediaScannerTest;
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.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
@RunWith(AndroidJUnit4.class)
public class IdleServiceTest {
private static final String TAG = MediaProviderTest.TAG;
private static final int IDLE_JOB_ID = -200;
private File mDir;
@Before
public void setUp() {
final Context context = InstrumentationRegistry.getTargetContext();
InstrumentationRegistry.getInstrumentation().getUiAutomation()
.adoptShellPermissionIdentity(android.Manifest.permission.LOG_COMPAT_CHANGE,
android.Manifest.permission.READ_COMPAT_CHANGE_CONFIG,
android.Manifest.permission.READ_DEVICE_CONFIG,
Manifest.permission.INTERACT_ACROSS_USERS,
Manifest.permission.MANAGE_USERS,
Manifest.permission.WRITE_MEDIA_STORAGE,
android.Manifest.permission.DUMP);
mDir = new File(context.getExternalMediaDirs()[0], "test_" + System.nanoTime());
mDir.mkdirs();
FileUtils.deleteContents(mDir);
}
@After
public void tearDown() {
FileUtils.deleteContents(mDir);
InstrumentationRegistry.getInstrumentation()
.getUiAutomation().dropShellPermissionIdentity();
}
@Test
public void testPruneThumbnails() throws Exception {
final Context context = InstrumentationRegistry.getTargetContext();
final ContentResolver resolver = context.getContentResolver();
// Previous tests (like DatabaseHelperTest) may have left stale
// .database_uuid files, do an idle run first to clean them up.
MediaStore.runIdleMaintenance(resolver);
MediaStore.waitForIdle(resolver);
final File dir = Environment.getExternalStorageDirectory();
final File mediaDir = context.getExternalMediaDirs()[0];
// Insert valid item into database
final File file = MediaScannerTest.stage(R.raw.test_image,
new File(mediaDir, System.nanoTime() + ".jpg"));
final Uri uri = MediaStore.scanFile(resolver, file);
final long id = ContentUris.parseId(uri);
// Let things settle so our thumbnails don't get invalidated
MediaStore.waitForIdle(resolver);
// Touch some thumbnail files
final File a = touch(buildPath(dir, DIRECTORY_PICTURES, ".thumbnails", "1234567.jpg"));
final File b = touch(buildPath(dir, DIRECTORY_MOVIES, ".thumbnails", "7654321.jpg"));
final File c = touch(buildPath(dir, DIRECTORY_PICTURES, ".thumbnails", id + ".jpg"));
final File d = touch(buildPath(dir, DIRECTORY_PICTURES, ".thumbnails", "random.bin"));
final File e = touch(buildPath(dir, DIRECTORY_PICTURES, ".thumbnails", ".nomedia"));
try {
// Idle maintenance pass should clean up unknown files
MediaStore.runIdleMaintenance(resolver);
assertFalse(exists(a));
assertFalse(exists(b));
assertTrue(exists(c));
assertFalse(exists(d));
assertTrue(exists(e));
// And change the UUID, which emulates ejecting and mounting a different
// storage device; all thumbnails should then be invalidated
final File uuidFile = buildPath(dir, Environment.DIRECTORY_PICTURES,
".thumbnails", ".database_uuid");
delete(uuidFile);
touch(uuidFile);
// Idle maintenance pass should clean up all files except .nomedia file
MediaStore.runIdleMaintenance(resolver);
assertFalse("File a should have been deleted", exists(a));
assertFalse("File b should have been deleted", exists(b));
assertFalse("File c should have been deleted", exists(c));
assertFalse("File d should have been deleted", exists(d));
assertTrue("File e should have existed", exists(e));
delete(uuidFile);
} finally {
a.delete();
b.delete();
c.delete();
d.delete();
e.delete();
}
}
/**
* b/199469244. If there are expired items with the same name in the same folder, we need to
* extend the expiration time of them successfully.
*/
@Test
public void testExtendTrashedItemsHaveSameName() throws Exception {
final Context context = InstrumentationRegistry.getTargetContext();
final ContentResolver resolver = context.getContentResolver();
final String displayName = String.valueOf(System.nanoTime());
final long dateExpires1 =
(System.currentTimeMillis() - 10 * DateUtils.DAY_IN_MILLIS) / 1000;
final Uri uri1 = createExpiredTrashedItem(resolver, dateExpires1, displayName);
final long dateExpires2 =
(System.currentTimeMillis() - 11 * DateUtils.DAY_IN_MILLIS) / 1000;
final Uri uri2 = createExpiredTrashedItem(resolver, dateExpires2, displayName);
final long dateExpires3 =
(System.currentTimeMillis() - 12 * DateUtils.DAY_IN_MILLIS) / 1000;
final Uri uri3 = createExpiredTrashedItem(resolver, dateExpires3, displayName);
MediaStore.runIdleMaintenance(resolver);
assertExpiredItemIsExtended(resolver, uri1, dateExpires1);
assertExpiredItemIsExtended(resolver, uri2, dateExpires2);
assertExpiredItemIsExtended(resolver, uri3, dateExpires3);
}
@Test
public void testExtendTrashedItemExpiresOverOneWeek() throws Exception {
final Context context = InstrumentationRegistry.getTargetContext();
final ContentResolver resolver = context.getContentResolver();
final long dateExpires = (System.currentTimeMillis() - 10 * DateUtils.DAY_IN_MILLIS) / 1000;
final Uri uri = createExpiredTrashedItem(resolver, dateExpires);
MediaStore.runIdleMaintenance(resolver);
assertExpiredItemIsExtended(resolver, uri, dateExpires);
}
@Test
public void testDeleteExpiredTrashedItem() throws Exception {
final Context context = InstrumentationRegistry.getTargetContext();
final ContentResolver resolver = context.getContentResolver();
// Create the expired item and scan the file to add it into database
final long dateExpires = (System.currentTimeMillis() - 3 * DateUtils.DAY_IN_MILLIS) / 1000;
final Uri uri = createExpiredTrashedItem(resolver, dateExpires);
MediaStore.runIdleMaintenance(resolver);
final String[] projection = new String[]{DATE_EXPIRES};
final Bundle queryArgs = new Bundle();
queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
try (Cursor cursor = resolver.query(uri, projection, queryArgs,
null /* cancellationSignal */)) {
assertThat(cursor.getCount()).isEqualTo(0);
}
}
@Test
public void testDetectSpecialFormat() throws Exception {
// Require isolated resolver to query hidden column _special_format
final Context context = InstrumentationRegistry.getTargetContext();
final Context isolatedContext = new IsolatedContext(context, "modern",
/*asFuseThread*/ false);
final ContentResolver resolver = isolatedContext.getContentResolver();
// Create file such that it is not scanned
final File dir = Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS);
final String fileName = TAG + System.nanoTime() + ".jpg";
ContentValues values = new ContentValues();
values.put(DISPLAY_NAME, fileName);
values.put(RELATIVE_PATH, dir.getName());
final Uri filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL);
final Uri uri = resolver.insert(filesUri, values);
assertThat(uri).isNotNull();
final File file = new File(dir, fileName);
file.createNewFile();
try {
final String[] projection = new String[]{_SPECIAL_FORMAT, GENERATION_MODIFIED};
final int initialGenerationModified;
try (Cursor cr = resolver.query(uri, projection, null, null, null)) {
assertThat(cr.getCount()).isEqualTo(1);
assertThat(cr.moveToFirst()).isNotNull();
assertThat(cr.isNull(0)).isTrue();
initialGenerationModified = cr.getInt(1);
}
// We are not calling runIdleMaintenance as it also scans volumes.
try (ContentProviderClient cpc = resolver
.acquireContentProviderClient(MediaStore.AUTHORITY)) {
((MediaProvider) cpc.getLocalContentProvider())
.detectSpecialFormat(new CancellationSignal());
}
try (Cursor cr = resolver.query(uri, projection, null, null, null)) {
assertThat(cr.getCount()).isEqualTo(1);
assertThat(cr.moveToFirst()).isNotNull();
assertThat(cr.getInt(0)).isEqualTo(_SPECIAL_FORMAT_NONE);
// Make sure that updating special format column doesn't update GENERATION_MODIFIED
assertThat(cr.getInt(1)).isEqualTo(initialGenerationModified);
}
} finally {
file.delete();
}
}
@Test
public void testJobScheduling() {
try {
final Context context = InstrumentationRegistry.getTargetContext();
final JobScheduler scheduler = InstrumentationRegistry.getTargetContext()
.getSystemService(JobScheduler.class);
cancelJob();
assertNull(scheduler.getPendingJob(IDLE_JOB_ID));
IdleService.scheduleIdlePass(context);
assertNotNull(scheduler.getPendingJob(IDLE_JOB_ID));
} finally {
cancelJob();
}
}
private void cancelJob() {
final JobScheduler scheduler = InstrumentationRegistry.getTargetContext()
.getSystemService(JobScheduler.class);
if (scheduler.getPendingJob(IDLE_JOB_ID) != null) {
scheduler.cancel(IDLE_JOB_ID);
}
}
/**
* Idle maintenance run on non-demo devices should not remove xattr data stored for different
* users on /data/media/0. This is not done due to b/305658663.
*/
@Test
@RunOnlyOnPostsubmit
@LargeTest
@SdkSuppress(minSdkVersion = 33, codeName = "T")
public void test_idle_maintenance_nonDemoDevice() throws IOException {
assumeTrue(UserManager.supportsMultipleUsers());
InstrumentationRegistry.getInstrumentation().getUiAutomation()
.adoptShellPermissionIdentity(
Manifest.permission.INTERACT_ACROSS_USERS,
Manifest.permission.CREATE_USERS,
Manifest.permission.MANAGE_USERS,
Manifest.permission.WRITE_MEDIA_STORAGE,
android.Manifest.permission.DUMP);
SystemClock.sleep(3000);
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
Context context = instrumentation.getContext();
UserManager userManager = context.getSystemService(UserManager.class);
Integer secondaryUser = -1;
boolean secondaryUserPresent = false;
try {
secondaryUser = createNewUser(userManager);
secondaryUserPresent = true;
startUser(secondaryUser);
SystemClock.sleep(3000);
// Verify presence of recovery data
String[] recoveryData = MediaStore.getRecoveryData(context.getContentResolver());
assertThat(getUserIdsForUsersFromRecoveryData(recoveryData)).containsAtLeastElementsIn(
Arrays.asList(UserHandle.SYSTEM.getIdentifier(), secondaryUser));
executeShellCommand(
"content call --uri content://media/external/file --method "
+ "run_idle_maintenance --user "
+ UserHandle.SYSTEM.getIdentifier());
executeShellCommand(
"content call --uri content://media/external/file --method "
+ "run_idle_maintenance --user " + secondaryUser);
// Verify presence of recovery data even after running idle maintenance
recoveryData = MediaStore.getRecoveryData(context.getContentResolver());
assertThat(getUserIdsForUsersFromRecoveryData(recoveryData)).containsAtLeastElementsIn(
Arrays.asList(UserHandle.SYSTEM.getIdentifier(), secondaryUser));
// Remove secondary user
removeUser(secondaryUser);
secondaryUserPresent = false;
SystemClock.sleep(3000);
// Run idle maintenance for user 0
executeShellCommand(
"content call --uri content://media/external/file --method "
+ "run_idle_maintenance --user "
+ UserHandle.SYSTEM.getIdentifier());
// Verify presence of recovery data
recoveryData = MediaStore.getRecoveryData(context.getContentResolver());
assertThat(getUserIdsForUsersFromRecoveryData(recoveryData)).containsAtLeastElementsIn(
Arrays.asList(UserHandle.SYSTEM.getIdentifier(), secondaryUser));
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
if (secondaryUserPresent) {
removeUser(secondaryUser);
}
MediaStore.removeRecoveryData(context.getContentResolver());
}
}
private Set<Integer> getUserIdsForUsersFromRecoveryData(String[] recoveryData) {
Set<Integer> userIdSet = new HashSet<>();
for (String data : recoveryData) {
if (data.startsWith("user.extdbnextrowid")) {
userIdSet.add(Integer.valueOf(data.substring("user.extdbnextrowid".length())));
} else if (data.startsWith("user.extdbsessionid")) {
userIdSet.add(Integer.valueOf(data.substring("user.extdbsessionid".length())));
}
}
return userIdSet;
}
private int createNewUser(UserManager userManager) {
final NewUserRequest newUserRequest = new NewUserRequest.Builder().setName(
"test_user" + System.currentTimeMillis()).setUserType(
UserManager.USER_TYPE_FULL_SECONDARY).build();
final UserHandle newUser = userManager.createUser(newUserRequest).getUser();
if (newUser == null) {
fail("Error while creating a new user");
}
return newUser.getIdentifier();
}
private void startUser(int userId) throws IOException {
Log.i(TAG, "Starting user " + userId);
String output = executeShellCommand("am start-user -w " + userId);
if (output.startsWith("Error")) {
fail(String.format("Failed to start user %d: %s", userId, output));
}
}
private void removeUser(int userId) throws IOException {
final String output = executeShellCommand("cmd package remove-user " + userId);
if (output.startsWith("Error")) {
fail("Error removing the user #" + userId + ": " + output);
}
}
private void assertExpiredItemIsExtended(ContentResolver resolver, Uri uri,
long lastExpiredDate) {
final String[] projection = new String[]{DATE_EXPIRES};
final Bundle queryArgs = new Bundle();
queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
try (Cursor cursor = resolver.query(uri, projection, queryArgs,
null /* cancellationSignal */)) {
assertThat(cursor.getCount()).isEqualTo(1);
cursor.moveToFirst();
final long dateExpiresAfter = cursor.getLong(0);
assertThat(dateExpiresAfter).isGreaterThan(lastExpiredDate);
assertTrue(timeDifferenceInSeconds(
(System.currentTimeMillis() + FileUtils.DEFAULT_DURATION_EXTENDED) / 1000,
dateExpiresAfter) <= 10);
}
}
private long timeDifferenceInSeconds(long timeAfter, long timeBefore) {
return timeAfter - timeBefore;
}
private Uri createExpiredTrashedItem(ContentResolver resolver, long dateExpires)
throws Exception {
return createExpiredTrashedItem(resolver, dateExpires,
/* displayName */ String.valueOf(System.nanoTime()));
}
private Uri createExpiredTrashedItem(ContentResolver resolver, long dateExpires,
String displayName) throws Exception {
// Create the expired item and scan the file to add it into database
final String expiredFileName = String.format(Locale.US, ".%s-%d-%s",
FileUtils.PREFIX_TRASHED, dateExpires, displayName + ".jpg");
final File file = MediaScannerTest.stage(R.raw.test_image, new File(mDir, expiredFileName));
final Uri uri = MediaStore.scanFile(resolver, file);
MediaStore.waitForIdle(resolver);
final String[] projection = new String[]{DATE_EXPIRES};
final Bundle queryArgs = new Bundle();
queryArgs.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
try (Cursor cursor = resolver.query(uri, projection, queryArgs,
null /* cancellationSignal */)) {
assertThat(cursor.getCount()).isEqualTo(1);
}
return uri;
}
public static File delete(File file) throws IOException {
executeShellCommand("rm -rf " + file.getAbsolutePath());
assertFalse(exists(file));
return file;
}
public static File touch(File file) throws IOException {
executeShellCommand("mkdir -p " + file.getParentFile().getAbsolutePath());
executeShellCommand("touch " + file.getAbsolutePath());
assertTrue(exists(file));
return file;
}
public static boolean exists(File file) throws IOException {
final String path = file.getAbsolutePath();
return executeShellCommand("ls -la " + path).contains(path);
}
private static String executeShellCommand(String command) throws IOException {
Log.v(TAG, "$ " + command);
ParcelFileDescriptor pfd = InstrumentationRegistry.getInstrumentation().getUiAutomation()
.executeShellCommand(command);
BufferedReader br = null;
try (InputStream in = new FileInputStream(pfd.getFileDescriptor());) {
br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
String str = null;
StringBuilder out = new StringBuilder();
while ((str = br.readLine()) != null) {
Log.v(TAG, "> " + str);
out.append(str);
}
return out.toString();
} finally {
if (br != null) {
br.close();
}
}
}
}