blob: 1a94ef1f686efea0fad7ce6c3d095211e3387917 [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.client;
import static android.provider.MediaStore.rewriteToLegacy;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import android.app.UiAutomation;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.pm.ProviderInfo;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.os.SystemClock;
import android.os.storage.StorageManager;
import android.provider.BaseColumns;
import android.provider.MediaStore;
import android.provider.MediaStore.Audio.AudioColumns;
import android.provider.MediaStore.DownloadColumns;
import android.provider.MediaStore.Files.FileColumns;
import android.provider.MediaStore.MediaColumns;
import android.provider.MediaStore.Video.VideoColumns;
import android.system.ErrnoException;
import android.system.Os;
import android.util.Log;
import android.webkit.MimeTypeMap;
import androidx.annotation.NonNull;
import androidx.test.filters.FlakyTest;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
import com.google.common.truth.Truth;
import org.junit.Assume;
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.io.InterruptedIOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;
/**
* Verify that we preserve information from the old "legacy" provider from
* before we migrated into a Mainline module.
* <p>
* Specifically, values like {@link BaseColumns#_ID} and user edits like
* {@link MediaColumns#IS_FAVORITE} should be retained.
*/
@RunWith(AndroidJUnit4.class)
@FlakyTest(bugId = 176977253)
public class LegacyProviderMigrationTest {
private static final String TAG = "LegacyTest";
// TODO: expand test to cover secondary storage devices
private String mVolumeName = MediaStore.VOLUME_EXTERNAL_PRIMARY;
private static final long POLLING_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(10);
private static final long POLLING_SLEEP_MILLIS = 100;
/**
* Number of media items to insert for {@link #testLegacy_Extreme()}.
*/
private static final int EXTREME_COUNT = 10_000;
private Uri mExternalAudio;
private Uri mExternalVideo;
private Uri mExternalImages;
private Uri mExternalDownloads;
@Before
public void setUp() throws Exception {
Log.d(TAG, "Using volume " + mVolumeName);
mExternalAudio = MediaStore.Audio.Media.getContentUri(mVolumeName);
mExternalVideo = MediaStore.Video.Media.getContentUri(mVolumeName);
mExternalImages = MediaStore.Images.Media.getContentUri(mVolumeName);
mExternalDownloads = MediaStore.Downloads.getContentUri(mVolumeName);
}
private ContentValues generateValues(int mediaType, String mimeType, String dirName) {
final Context context = InstrumentationRegistry.getContext();
final File dir = context.getSystemService(StorageManager.class)
.getStorageVolume(MediaStore.Files.getContentUri(mVolumeName)).getDirectory();
final File subDir = new File(dir, dirName);
final File file = new File(subDir, "legacy" + System.nanoTime() + "."
+ MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType));
final ContentValues values = new ContentValues();
values.put(FileColumns.MEDIA_TYPE, mediaType);
values.put(MediaColumns.DATA, file.getAbsolutePath());
values.put(MediaColumns.DISPLAY_NAME, file.getName());
values.put(MediaColumns.MIME_TYPE, mimeType);
values.put(MediaColumns.VOLUME_NAME, mVolumeName);
values.put(MediaColumns.DATE_ADDED, String.valueOf(System.currentTimeMillis() / 1_000));
values.put(MediaColumns.OWNER_PACKAGE_NAME,
InstrumentationRegistry.getContext().getPackageName());
return values;
}
@Test
public void testLegacy_Pending() throws Exception {
final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_IMAGE,
"image/png", Environment.DIRECTORY_PICTURES);
values.put(MediaColumns.IS_PENDING, String.valueOf(1));
values.put(MediaColumns.DATE_EXPIRES, String.valueOf(System.currentTimeMillis() / 1_000));
doLegacy(mExternalImages, values);
}
@Test
public void testLegacy_Trashed() throws Exception {
final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_IMAGE,
"image/png", Environment.DIRECTORY_PICTURES);
values.put(MediaColumns.IS_TRASHED, String.valueOf(1));
doLegacy(mExternalImages, values);
}
@Test
public void testLegacy_Favorite() throws Exception {
final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_IMAGE,
"image/png", Environment.DIRECTORY_PICTURES);
values.put(MediaColumns.IS_FAVORITE, String.valueOf(1));
doLegacy(mExternalImages, values);
}
@Test
public void testLegacy_Orphaned() throws Exception {
final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_IMAGE,
"image/png", Environment.DIRECTORY_PICTURES);
values.putNull(MediaColumns.OWNER_PACKAGE_NAME);
doLegacy(mExternalImages, values);
}
@Test
public void testLegacy_Audio() throws Exception {
final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_AUDIO,
"audio/mpeg", Environment.DIRECTORY_MUSIC);
values.put(AudioColumns.BOOKMARK, String.valueOf(42));
doLegacy(mExternalAudio, values);
}
@Test
public void testLegacy_Video() throws Exception {
final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_VIDEO,
"video/mpeg", Environment.DIRECTORY_MOVIES);
values.put(VideoColumns.BOOKMARK, String.valueOf(42));
values.put(VideoColumns.TAGS, "My Tags");
values.put(VideoColumns.CATEGORY, "My Category");
doLegacy(mExternalVideo, values);
}
@Test
public void testLegacy_Image() throws Exception {
final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_IMAGE,
"image/png", Environment.DIRECTORY_PICTURES);
doLegacy(mExternalImages, values);
}
@Test
public void testLegacy_Download() throws Exception {
final ContentValues values = generateValues(FileColumns.MEDIA_TYPE_NONE,
"application/x-iso9660-image", Environment.DIRECTORY_DOWNLOADS);
values.put(DownloadColumns.DOWNLOAD_URI, "http://example.com/download");
values.put(DownloadColumns.REFERER_URI, "http://example.com/referer");
doLegacy(mExternalDownloads, values);
}
/**
* Verify that a legacy database with thousands of media entries can be
* successfully migrated.
*/
@Test
public void testLegacy_Extreme() throws Exception {
final Context context = InstrumentationRegistry.getTargetContext();
final UiAutomation ui = InstrumentationRegistry.getInstrumentation().getUiAutomation();
final ProviderInfo legacyProvider = context.getPackageManager()
.resolveContentProvider(MediaStore.AUTHORITY_LEGACY, 0);
final ProviderInfo modernProvider = context.getPackageManager()
.resolveContentProvider(MediaStore.AUTHORITY, 0);
// Only continue if we have both providers to test against
Assume.assumeNotNull(legacyProvider);
Assume.assumeNotNull(modernProvider);
// Clear data on the legacy provider so that we create a database
waitForMountedAndIdle(context.getContentResolver());
executeShellCommand("sync", ui);
executeShellCommand("pm clear " + legacyProvider.applicationInfo.packageName, ui);
waitForMountedAndIdle(context.getContentResolver());
// Create thousands of items in the legacy provider
try (ContentProviderClient legacy = context.getContentResolver()
.acquireContentProviderClient(MediaStore.AUTHORITY_LEGACY)) {
// We're purposefully "silent" to avoid creating the raw file on
// disk, since otherwise this test would take several minutes
final Uri insertTarget = rewriteToLegacy(
mExternalImages.buildUpon().appendQueryParameter("silent", "true").build());
final ArrayList<ContentProviderOperation> ops = new ArrayList<>();
for (int i = 0; i < EXTREME_COUNT; i++) {
ops.add(ContentProviderOperation.newInsert(insertTarget)
.withValues(generateValues(FileColumns.MEDIA_TYPE_IMAGE, "image/png",
Environment.DIRECTORY_PICTURES))
.build());
if ((ops.size() > 1_000) || (i == (EXTREME_COUNT - 1))) {
Log.v(TAG, "Inserting items...");
legacy.applyBatch(MediaStore.AUTHORITY_LEGACY, ops);
ops.clear();
}
}
}
// Clear data on the modern provider so that the initial scan recovers
// metadata from the legacy provider
waitForMountedAndIdle(context.getContentResolver());
executeShellCommand("sync", ui);
executeShellCommand("pm clear " + modernProvider.applicationInfo.packageName, ui);
waitForMountedAndIdle(context.getContentResolver());
// Confirm that details from legacy provider have migrated
try (ContentProviderClient modern = context.getContentResolver()
.acquireContentProviderClient(MediaStore.AUTHORITY)) {
try (Cursor cursor = modern.query(mExternalImages, null, null, null)) {
Truth.assertThat(cursor.getCount()).isAtLeast(EXTREME_COUNT);
}
}
}
private void doLegacy(Uri collectionUri, ContentValues values) throws Exception {
final Context context = InstrumentationRegistry.getTargetContext();
final UiAutomation ui = InstrumentationRegistry.getInstrumentation().getUiAutomation();
final ProviderInfo legacyProvider = context.getPackageManager()
.resolveContentProvider(MediaStore.AUTHORITY_LEGACY, 0);
final ProviderInfo modernProvider = context.getPackageManager()
.resolveContentProvider(MediaStore.AUTHORITY, 0);
// Only continue if we have both providers to test against
Assume.assumeNotNull(legacyProvider);
Assume.assumeNotNull(modernProvider);
// Clear data on the legacy provider so that we create a database
waitForMountedAndIdle(context.getContentResolver());
executeShellCommand("sync", ui);
executeShellCommand("pm clear " + legacyProvider.applicationInfo.packageName, ui);
waitForMountedAndIdle(context.getContentResolver());
// Create a well-known entry in legacy provider, and write data into
// place to ensure the file is created on disk
final Uri legacyUri;
final File legacyFile;
try (ContentProviderClient legacy = context.getContentResolver()
.acquireContentProviderClient(MediaStore.AUTHORITY_LEGACY)) {
legacyUri = rewriteToLegacy(legacy.insert(rewriteToLegacy(collectionUri), values));
legacyFile = new File(values.getAsString(MediaColumns.DATA));
// Remember our ID to check it later
values.put(MediaColumns._ID, legacyUri.getLastPathSegment());
// Drop media type from the columns we check, since it's implicitly
// verified via the collection Uri
values.remove(FileColumns.MEDIA_TYPE);
// Drop raw path, since we may rename pending or trashed files
values.remove(FileColumns.DATA);
}
// Clear data on the modern provider so that the initial scan recovers
// metadata from the legacy provider
waitForMountedAndIdle(context.getContentResolver());
executeShellCommand("sync", ui);
executeShellCommand("pm clear " + modernProvider.applicationInfo.packageName, ui);
waitForMountedAndIdle(context.getContentResolver());
// And force a scan to confirm upgraded data survives
MediaStore.scanVolume(context.getContentResolver(),
MediaStore.getVolumeName(collectionUri));
// Confirm that details from legacy provider have migrated
try (ContentProviderClient modern = context.getContentResolver()
.acquireContentProviderClient(MediaStore.AUTHORITY)) {
final Bundle extras = new Bundle();
extras.putString(ContentResolver.QUERY_ARG_SQL_SELECTION,
MediaColumns.DISPLAY_NAME + "=?");
extras.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS,
new String[] { legacyFile.getName() });
extras.putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_INCLUDE);
extras.putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE);
extras.putInt(MediaStore.QUERY_ARG_MATCH_FAVORITE, MediaStore.MATCH_INCLUDE);
try (Cursor cursor = modern.query(collectionUri, null, extras, null)) {
assertTrue(cursor.moveToFirst());
final ContentValues actualValues = new ContentValues();
for (String key : values.keySet()) {
actualValues.put(key, cursor.getString(cursor.getColumnIndexOrThrow(key)));
}
assertEquals(values, actualValues);
}
}
}
private static void waitForMountedAndIdle(ContentResolver resolver) {
// We purposefully perform these operations twice in this specific
// order, since clearing the data on a package can asynchronously
// perform a vold reset, which can make us think storage is ready and
// mounted when it's moments away from being torn down.
pollForExternalStorageState();
MediaStore.waitForIdle(resolver);
pollForExternalStorageState();
MediaStore.waitForIdle(resolver);
}
private static void pollForExternalStorageState() {
final File target = Environment.getExternalStorageDirectory();
for (int i = 0; i < POLLING_TIMEOUT_MILLIS / POLLING_SLEEP_MILLIS; i++) {
try {
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState(target))
&& Os.statvfs(target.getAbsolutePath()).f_blocks > 0) {
return;
}
} catch (ErrnoException ignored) {
}
Log.v(TAG, "Waiting for external storage...");
SystemClock.sleep(POLLING_SLEEP_MILLIS);
}
fail("Timed out while waiting for ExternalStorageState to be MEDIA_MOUNTED");
}
public static String executeShellCommand(String command, UiAutomation uiAutomation)
throws IOException {
int attempt = 0;
while (attempt++ < 5) {
try {
return executeShellCommandInternal(command, uiAutomation);
} catch (InterruptedIOException e) {
// Hmm, we had trouble executing the shell command; the best we
// can do is try again a few more times
Log.v(TAG, "Trouble executing " + command + "; trying again", e);
}
}
throw new IOException("Failed to execute " + command);
}
public static String executeShellCommandInternal(String command, UiAutomation uiAutomation)
throws IOException {
Log.v(TAG, "$ " + command);
ParcelFileDescriptor pfd = uiAutomation.executeShellCommand(command.toString());
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();
}
}
}
public static void copyFromCursorToContentValues(@NonNull String column, @NonNull Cursor cursor,
@NonNull ContentValues values) {
final int index = cursor.getColumnIndex(column);
if (index != -1) {
if (cursor.isNull(index)) {
values.putNull(column);
} else {
values.put(column, cursor.getString(index));
}
}
}
}