blob: 3163b52afaedbf8e10eb4dc3fb14293edec1605a [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.tradefed.device.contentprovider;
import com.android.tradefed.device.DeviceNotAvailableException;
import com.android.tradefed.device.ITestDevice;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.util.CommandResult;
import com.android.tradefed.util.CommandStatus;
import com.android.tradefed.util.FileUtil;
import com.android.tradefed.util.StreamUtil;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.net.UrlEscapers;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.StringJoiner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Handler that abstract the content provider interactions and allow to use the device side content
* provider for different operations.
*
* <p>All implementation in this class should be mindful of the user currently running on the
* device.
*/
public class ContentProviderHandler {
public static final String COLUMN_NAME = "name";
public static final String COLUMN_ABSOLUTE_PATH = "absolute_path";
public static final String COLUMN_DIRECTORY = "is_directory";
public static final String COLUMN_MIME_TYPE = "mime_type";
public static final String COLUMN_METADATA = "metadata";
public static final String QUERY_INFO_VALUE = "INFO";
public static final String NO_RESULTS_STRING = "No result found.";
// Has to be kept in sync with columns in ManagedFileContentProvider.java.
public static final String[] COLUMNS =
new String[] {
COLUMN_NAME,
COLUMN_ABSOLUTE_PATH,
COLUMN_DIRECTORY,
COLUMN_MIME_TYPE,
COLUMN_METADATA
};
public static final String PACKAGE_NAME = "android.tradefed.contentprovider";
public static final String CONTENT_PROVIDER_URI = "content://android.tradefed.contentprovider";
private static final String APK_NAME = "TradefedContentProvider.apk";
private static final String CONTENT_PROVIDER_APK_RES = "/apks/contentprovider/" + APK_NAME;
private static final String PROPERTY_RESULT = "LEGACY_STORAGE: allow";
private static final String ERROR_MESSAGE_TAG = "[ERROR]";
// Error thrown by device if the content provider is not installed for any reason.
private static final String ERROR_PROVIDER_NOT_INSTALLED =
"Could not find provider: android.tradefed.contentprovider";
private ITestDevice mDevice;
private File mContentProviderApk = null;
private boolean mReportNotFound = false;
/** Constructor. */
public ContentProviderHandler(ITestDevice device) {
mDevice = device;
}
/**
* Returns True if one of the operation failed with Content provider not found. Can be cleared
* by running {@link #setUp()} successfully again.
*/
public boolean contentProviderNotFound() {
return mReportNotFound;
}
/**
* Ensure the content provider helper apk is installed and ready to be used.
*
* @return True if ready to be used, False otherwise.
*/
public boolean setUp() throws DeviceNotAvailableException {
if (mDevice.isPackageInstalled(PACKAGE_NAME, Integer.toString(mDevice.getCurrentUser()))) {
mReportNotFound = false;
return true;
}
if (mContentProviderApk == null || !mContentProviderApk.exists()) {
try {
mContentProviderApk = extractResourceApk();
} catch (IOException e) {
CLog.e(e);
return false;
}
}
// Install package for all users
String output =
mDevice.installPackage(
mContentProviderApk,
/** reinstall */
true,
/** grant permission */
true);
if (output != null) {
CLog.e("Something went wrong while installing the content provider apk: %s", output);
FileUtil.deleteFile(mContentProviderApk);
return false;
}
// Enable appops legacy storage
CommandResult setResult =
mDevice.executeShellV2Command(
String.format(
"cmd appops set %s android:legacy_storage allow", PACKAGE_NAME));
if (!CommandStatus.SUCCESS.equals(setResult.getStatus())) {
CLog.e(
"Failed to set legacy_storage. Stdout: %s\nstderr: %s",
setResult.getStdout(), setResult.getStderr());
FileUtil.deleteFile(mContentProviderApk);
return false;
}
// Check that it worked and set on the system
CommandResult appOpsResult =
mDevice.executeShellV2Command(String.format("cmd appops get %s", PACKAGE_NAME));
if (CommandStatus.SUCCESS.equals(appOpsResult.getStatus())
&& appOpsResult.getStdout().contains(PROPERTY_RESULT)) {
mReportNotFound = false;
return true;
}
CLog.e(
"Failed to get legacy_storage. Stdout: %s\nstderr: %s",
appOpsResult.getStdout(), appOpsResult.getStderr());
FileUtil.deleteFile(mContentProviderApk);
return false;
}
/** Clean the device from the content provider helper. */
public void tearDown() throws DeviceNotAvailableException {
FileUtil.deleteFile(mContentProviderApk);
mDevice.uninstallPackage(PACKAGE_NAME);
}
/**
* Content provider callback that delete a file at the URI location. File will be deleted from
* the device content.
*
* @param deviceFilePath The path on the device of the file to delete.
* @return True if successful, False otherwise
* @throws DeviceNotAvailableException
*/
public boolean deleteFile(String deviceFilePath) throws DeviceNotAvailableException {
String contentUri = createEscapedContentUri(deviceFilePath);
String deleteCommand =
String.format(
"content delete --user %d --uri %s", mDevice.getCurrentUser(), contentUri);
CommandResult deleteResult = mDevice.executeShellV2Command(deleteCommand);
if (isSuccessful(deleteResult)) {
return true;
}
CLog.e(
"Failed to remove a file at %s using content provider. Error: '%s'",
deviceFilePath, deleteResult.getStderr());
return false;
}
/**
* Recursively pull directory contents from device using content provider.
*
* @param deviceFilePath the absolute file path of the remote source
* @param localDir the local directory to pull files into
* @return <code>true</code> if file was pulled successfully. <code>false</code> otherwise.
* @throws DeviceNotAvailableException if connection with device is lost and cannot be
* recovered.
*/
public boolean pullDir(String deviceFilePath, File localDir)
throws DeviceNotAvailableException {
return pullDirInternal(deviceFilePath, localDir, /* currentUser */ null);
}
/**
* Content provider callback that pulls a file from the URI location into a local file.
*
* @param deviceFilePath The path on the device where to pull the file from.
* @param localFile The {@link File} to store the contents in. If non-empty, contents will be
* replaced.
* @return True if successful, False otherwise
* @throws DeviceNotAvailableException
*/
public boolean pullFile(String deviceFilePath, File localFile)
throws DeviceNotAvailableException {
return pullFileInternal(deviceFilePath, localFile, /* currentUser */ null);
}
/**
* Content provider callback that push a file to the URI location.
*
* @param fileToPush The {@link File} to be pushed to the device.
* @param deviceFilePath The path on the device where to push the file.
* @return True if successful, False otherwise
* @throws DeviceNotAvailableException
* @throws IllegalArgumentException
*/
public boolean pushFile(File fileToPush, String deviceFilePath)
throws DeviceNotAvailableException, IllegalArgumentException {
if (!fileToPush.exists()) {
CLog.w("File '%s' to push does not exist.", fileToPush);
return false;
}
if (fileToPush.isDirectory()) {
CLog.w("'%s' is not a file but a directory, can't use #pushFile on it.", fileToPush);
return false;
}
String contentUri = createEscapedContentUri(deviceFilePath);
String pushCommand =
String.format(
"content write --user %d --uri %s", mDevice.getCurrentUser(), contentUri);
CommandResult pushResult = mDevice.executeShellV2Command(pushCommand, fileToPush);
if (isSuccessful(pushResult)) {
return true;
}
CLog.e(
"Failed to push a file '%s' at %s using content provider. Error: '%s'",
fileToPush, deviceFilePath, pushResult.getStderr());
return false;
}
/**
* Determines if the file or non-empty directory exists on the device.
*
* @param deviceFilePath The absolute file path on device to check for existence.
* @return True if file/directory exists, False otherwise. If directory is empty, it will return
* False as well.
*/
public boolean doesFileExist(String deviceFilePath) throws DeviceNotAvailableException {
String contentUri = createEscapedContentUri(deviceFilePath);
String queryContentCommand =
String.format(
"content query --user %d --uri %s", mDevice.getCurrentUser(), contentUri);
String listCommandResult = mDevice.executeShellCommand(queryContentCommand);
if (NO_RESULTS_STRING.equals(listCommandResult.trim())) {
// No file found.
return false;
}
return true;
}
/** Returns true if {@link CommandStatus} is successful and there is no error message. */
private boolean isSuccessful(CommandResult result) {
if (!CommandStatus.SUCCESS.equals(result.getStatus())) {
return false;
}
String stdout = result.getStdout();
if (stdout.contains(ERROR_MESSAGE_TAG)) {
return false;
}
String stderr = result.getStderr();
if (stderr != null && stderr.contains(ERROR_PROVIDER_NOT_INSTALLED)) {
mReportNotFound = true;
}
return Strings.isNullOrEmpty(stderr);
}
/** Helper method to extract the content provider apk. */
private File extractResourceApk() throws IOException {
File apkTempFile = FileUtil.createTempFile(APK_NAME, ".apk");
InputStream apkStream =
ContentProviderHandler.class.getResourceAsStream(CONTENT_PROVIDER_APK_RES);
FileUtil.writeToFile(apkStream, apkTempFile);
return apkTempFile;
}
/**
* Returns the full URI string for the given device path, escaped and encoded to avoid non-URL
* characters.
*/
public static String createEscapedContentUri(String deviceFilePath) {
String escapedFilePath = deviceFilePath;
try {
// Encode the path then escape it. This logic must invert the logic in
// ManagedFileContentProvider.getFileForUri. That calls to Uri.getPath() and then
// URLDecoder.decode(), so this must invert each of those two steps and switch the order
String encoded = URLEncoder.encode(deviceFilePath, "UTF-8");
escapedFilePath = UrlEscapers.urlPathSegmentEscaper().escape(encoded);
} catch (UnsupportedEncodingException e) {
CLog.e(e);
}
return String.format("\"%s/%s\"", CONTENT_PROVIDER_URI, escapedFilePath);
}
/**
* Parses the String output of "adb shell content query" for a single row.
*
* @param row The entire row representing a single file/directory returned by the "adb shell
* content query" command.
* @return Key-value map of column name to column value.
*/
@VisibleForTesting
final HashMap<String, String> parseQueryResultRow(String row) {
HashMap<String, String> columnValues = new HashMap<>();
StringJoiner pattern = new StringJoiner(", ");
for (int i = 0; i < COLUMNS.length; i++) {
pattern.add(String.format("(%s=.*)", COLUMNS[i]));
}
Pattern p = Pattern.compile(pattern.toString());
Matcher m = p.matcher(row);
if (m.find()) {
for (int i = 1; i <= m.groupCount(); i++) {
String[] keyValue = m.group(i).split("=");
if (keyValue.length == 2) {
columnValues.put(keyValue[0], keyValue[1]);
}
}
}
return columnValues;
}
/**
* Internal method to actually do the pull directory but without re-querying the current user
* while doing the recursive pull.
*/
private boolean pullDirInternal(String deviceFilePath, File localDir, Integer currentUser)
throws DeviceNotAvailableException {
if (!localDir.isDirectory()) {
CLog.e("Local path %s is not a directory", localDir.getAbsolutePath());
return false;
}
String contentUri = createEscapedContentUri(deviceFilePath);
if (currentUser == null) {
// Keep track of the user so if we recursively pull dir we don't re-query it.
currentUser = mDevice.getCurrentUser();
}
String queryContentCommand =
String.format("content query --user %d --uri %s", currentUser, contentUri);
String listCommandResult = mDevice.executeShellCommand(queryContentCommand);
if (NO_RESULTS_STRING.equals(listCommandResult.trim())) {
// Empty directory.
return true;
}
CLog.d("Received from content provider:\n%s", listCommandResult);
String[] listResult = listCommandResult.split("[\\r\\n]+");
for (String row : listResult) {
HashMap<String, String> columnValues = parseQueryResultRow(row);
boolean isDirectory = Boolean.valueOf(columnValues.get(COLUMN_DIRECTORY));
String name = columnValues.get(COLUMN_NAME);
if (name == null) {
CLog.w("Output from the content provider doesn't seem well formatted:\n%s", row);
return false;
}
String path = columnValues.get(COLUMN_ABSOLUTE_PATH);
File localChild = new File(localDir, name);
if (isDirectory) {
if (!localChild.mkdir()) {
CLog.w(
"Failed to create sub directory %s, aborting.",
localChild.getAbsolutePath());
return false;
}
if (!pullDirInternal(path, localChild, currentUser)) {
CLog.w("Failed to pull sub directory %s from device, aborting", path);
return false;
}
} else {
// handle regular file
if (!pullFileInternal(path, localChild, currentUser)) {
CLog.w("Failed to pull file %s from device, aborting", path);
return false;
}
}
}
return true;
}
private boolean pullFileInternal(String deviceFilePath, File localFile, Integer currentUser)
throws DeviceNotAvailableException {
String contentUri = createEscapedContentUri(deviceFilePath);
if (currentUser == null) {
currentUser = mDevice.getCurrentUser();
}
String pullCommand =
String.format("content read --user %d --uri %s", currentUser, contentUri);
// Open the output stream to the local file.
OutputStream localFileStream;
try {
localFileStream = new FileOutputStream(localFile);
} catch (FileNotFoundException e) {
CLog.e("Failed to open OutputStream to the local file. Error: %s", e.getMessage());
return false;
}
try {
CommandResult pullResult = mDevice.executeShellV2Command(pullCommand, localFileStream);
if (isSuccessful(pullResult)) {
return true;
}
String stderr = pullResult.getStderr();
CLog.e(
"Failed to pull a file at '%s' to %s using content provider. Error: '%s'",
deviceFilePath, localFile, stderr);
if (stderr.contains(ERROR_PROVIDER_NOT_INSTALLED)) {
mReportNotFound = true;
}
return false;
} finally {
StreamUtil.close(localFileStream);
}
}
}