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 package com.android.tradefed.device.contentprovider; 17 18 import com.android.tradefed.device.DeviceNotAvailableException; 19 import com.android.tradefed.device.ITestDevice; 20 import com.android.tradefed.log.LogUtil.CLog; 21 import com.android.tradefed.util.CommandResult; 22 import com.android.tradefed.util.CommandStatus; 23 import com.android.tradefed.util.FileUtil; 24 import com.android.tradefed.util.StreamUtil; 25 26 import com.google.common.annotations.VisibleForTesting; 27 import com.google.common.base.Strings; 28 import com.google.common.net.UrlEscapers; 29 30 import java.io.File; 31 import java.io.FileNotFoundException; 32 import java.io.FileOutputStream; 33 import java.io.IOException; 34 import java.io.InputStream; 35 import java.io.OutputStream; 36 import java.io.UnsupportedEncodingException; 37 import java.net.URLEncoder; 38 import java.util.HashMap; 39 import java.util.StringJoiner; 40 import java.util.regex.Matcher; 41 import java.util.regex.Pattern; 42 43 /** 44 * Handler that abstract the content provider interactions and allow to use the device side content 45 * provider for different operations. 46 * 47 * <p>All implementation in this class should be mindful of the user currently running on the 48 * device. 49 */ 50 public class ContentProviderHandler { 51 public static final String COLUMN_NAME = "name"; 52 public static final String COLUMN_ABSOLUTE_PATH = "absolute_path"; 53 public static final String COLUMN_DIRECTORY = "is_directory"; 54 public static final String COLUMN_MIME_TYPE = "mime_type"; 55 public static final String COLUMN_METADATA = "metadata"; 56 public static final String QUERY_INFO_VALUE = "INFO"; 57 public static final String NO_RESULTS_STRING = "No result found."; 58 59 // Has to be kept in sync with columns in ManagedFileContentProvider.java. 60 public static final String[] COLUMNS = 61 new String[] { 62 COLUMN_NAME, 63 COLUMN_ABSOLUTE_PATH, 64 COLUMN_DIRECTORY, 65 COLUMN_MIME_TYPE, 66 COLUMN_METADATA 67 }; 68 69 public static final String PACKAGE_NAME = "android.tradefed.contentprovider"; 70 public static final String CONTENT_PROVIDER_URI = "content://android.tradefed.contentprovider"; 71 private static final String APK_NAME = "TradefedContentProvider.apk"; 72 private static final String CONTENT_PROVIDER_APK_RES = "/apks/contentprovider/" + APK_NAME; 73 private static final String PROPERTY_RESULT = "LEGACY_STORAGE: allow"; 74 private static final String ERROR_MESSAGE_TAG = "[ERROR]"; 75 // Error thrown by device if the content provider is not installed for any reason. 76 private static final String ERROR_PROVIDER_NOT_INSTALLED = 77 "Could not find provider: android.tradefed.contentprovider"; 78 79 private ITestDevice mDevice; 80 private File mContentProviderApk = null; 81 private boolean mReportNotFound = false; 82 83 /** Constructor. */ ContentProviderHandler(ITestDevice device)84 public ContentProviderHandler(ITestDevice device) { 85 mDevice = device; 86 } 87 88 /** 89 * Returns True if one of the operation failed with Content provider not found. Can be cleared 90 * by running {@link #setUp()} successfully again. 91 */ contentProviderNotFound()92 public boolean contentProviderNotFound() { 93 return mReportNotFound; 94 } 95 96 /** 97 * Ensure the content provider helper apk is installed and ready to be used. 98 * 99 * @return True if ready to be used, False otherwise. 100 */ setUp()101 public boolean setUp() throws DeviceNotAvailableException { 102 if (mDevice.isPackageInstalled(PACKAGE_NAME, Integer.toString(mDevice.getCurrentUser()))) { 103 mReportNotFound = false; 104 return true; 105 } 106 if (mContentProviderApk == null || !mContentProviderApk.exists()) { 107 try { 108 mContentProviderApk = extractResourceApk(); 109 } catch (IOException e) { 110 CLog.e(e); 111 return false; 112 } 113 } 114 // Install package for all users 115 String output = 116 mDevice.installPackage( 117 mContentProviderApk, 118 /** reinstall */ 119 true, 120 /** grant permission */ 121 true); 122 if (output != null) { 123 CLog.e("Something went wrong while installing the content provider apk: %s", output); 124 FileUtil.deleteFile(mContentProviderApk); 125 return false; 126 } 127 // Enable appops legacy storage 128 CommandResult setResult = 129 mDevice.executeShellV2Command( 130 String.format( 131 "cmd appops set %s android:legacy_storage allow", PACKAGE_NAME)); 132 if (!CommandStatus.SUCCESS.equals(setResult.getStatus())) { 133 CLog.e( 134 "Failed to set legacy_storage. Stdout: %s\nstderr: %s", 135 setResult.getStdout(), setResult.getStderr()); 136 FileUtil.deleteFile(mContentProviderApk); 137 return false; 138 } 139 // Check that it worked and set on the system 140 CommandResult appOpsResult = 141 mDevice.executeShellV2Command(String.format("cmd appops get %s", PACKAGE_NAME)); 142 if (CommandStatus.SUCCESS.equals(appOpsResult.getStatus()) 143 && appOpsResult.getStdout().contains(PROPERTY_RESULT)) { 144 mReportNotFound = false; 145 return true; 146 } 147 CLog.e( 148 "Failed to get legacy_storage. Stdout: %s\nstderr: %s", 149 appOpsResult.getStdout(), appOpsResult.getStderr()); 150 FileUtil.deleteFile(mContentProviderApk); 151 return false; 152 } 153 154 /** Clean the device from the content provider helper. */ tearDown()155 public void tearDown() throws DeviceNotAvailableException { 156 FileUtil.deleteFile(mContentProviderApk); 157 mDevice.uninstallPackage(PACKAGE_NAME); 158 } 159 160 /** 161 * Content provider callback that delete a file at the URI location. File will be deleted from 162 * the device content. 163 * 164 * @param deviceFilePath The path on the device of the file to delete. 165 * @return True if successful, False otherwise 166 * @throws DeviceNotAvailableException 167 */ deleteFile(String deviceFilePath)168 public boolean deleteFile(String deviceFilePath) throws DeviceNotAvailableException { 169 String contentUri = createEscapedContentUri(deviceFilePath); 170 String deleteCommand = 171 String.format( 172 "content delete --user %d --uri %s", mDevice.getCurrentUser(), contentUri); 173 CommandResult deleteResult = mDevice.executeShellV2Command(deleteCommand); 174 175 if (isSuccessful(deleteResult)) { 176 return true; 177 } 178 CLog.e( 179 "Failed to remove a file at %s using content provider. Error: '%s'", 180 deviceFilePath, deleteResult.getStderr()); 181 return false; 182 } 183 184 /** 185 * Recursively pull directory contents from device using content provider. 186 * 187 * @param deviceFilePath the absolute file path of the remote source 188 * @param localDir the local directory to pull files into 189 * @return <code>true</code> if file was pulled successfully. <code>false</code> otherwise. 190 * @throws DeviceNotAvailableException if connection with device is lost and cannot be 191 * recovered. 192 */ pullDir(String deviceFilePath, File localDir)193 public boolean pullDir(String deviceFilePath, File localDir) 194 throws DeviceNotAvailableException { 195 return pullDirInternal(deviceFilePath, localDir, /* currentUser */ null); 196 } 197 198 /** 199 * Content provider callback that pulls a file from the URI location into a local file. 200 * 201 * @param deviceFilePath The path on the device where to pull the file from. 202 * @param localFile The {@link File} to store the contents in. If non-empty, contents will be 203 * replaced. 204 * @return True if successful, False otherwise 205 * @throws DeviceNotAvailableException 206 */ pullFile(String deviceFilePath, File localFile)207 public boolean pullFile(String deviceFilePath, File localFile) 208 throws DeviceNotAvailableException { 209 return pullFileInternal(deviceFilePath, localFile, /* currentUser */ null); 210 } 211 212 /** 213 * Content provider callback that push a file to the URI location. 214 * 215 * @param fileToPush The {@link File} to be pushed to the device. 216 * @param deviceFilePath The path on the device where to push the file. 217 * @return True if successful, False otherwise 218 * @throws DeviceNotAvailableException 219 * @throws IllegalArgumentException 220 */ pushFile(File fileToPush, String deviceFilePath)221 public boolean pushFile(File fileToPush, String deviceFilePath) 222 throws DeviceNotAvailableException, IllegalArgumentException { 223 if (!fileToPush.exists()) { 224 CLog.w("File '%s' to push does not exist.", fileToPush); 225 return false; 226 } 227 if (fileToPush.isDirectory()) { 228 CLog.w("'%s' is not a file but a directory, can't use #pushFile on it.", fileToPush); 229 return false; 230 } 231 String contentUri = createEscapedContentUri(deviceFilePath); 232 String pushCommand = 233 String.format( 234 "content write --user %d --uri %s", mDevice.getCurrentUser(), contentUri); 235 CommandResult pushResult = mDevice.executeShellV2Command(pushCommand, fileToPush); 236 237 if (isSuccessful(pushResult)) { 238 return true; 239 } 240 241 CLog.e( 242 "Failed to push a file '%s' at %s using content provider. Error: '%s'", 243 fileToPush, deviceFilePath, pushResult.getStderr()); 244 return false; 245 } 246 247 /** Returns true if {@link CommandStatus} is successful and there is no error message. */ isSuccessful(CommandResult result)248 private boolean isSuccessful(CommandResult result) { 249 if (!CommandStatus.SUCCESS.equals(result.getStatus())) { 250 return false; 251 } 252 String stdout = result.getStdout(); 253 if (stdout.contains(ERROR_MESSAGE_TAG)) { 254 return false; 255 } 256 String stderr = result.getStderr(); 257 if (stderr != null && stderr.contains(ERROR_PROVIDER_NOT_INSTALLED)) { 258 mReportNotFound = true; 259 } 260 return Strings.isNullOrEmpty(stderr); 261 } 262 263 /** Helper method to extract the content provider apk. */ extractResourceApk()264 private File extractResourceApk() throws IOException { 265 File apkTempFile = FileUtil.createTempFile(APK_NAME, ".apk"); 266 InputStream apkStream = 267 ContentProviderHandler.class.getResourceAsStream(CONTENT_PROVIDER_APK_RES); 268 FileUtil.writeToFile(apkStream, apkTempFile); 269 return apkTempFile; 270 } 271 272 /** 273 * Returns the full URI string for the given device path, escaped and encoded to avoid non-URL 274 * characters. 275 */ createEscapedContentUri(String deviceFilePath)276 public static String createEscapedContentUri(String deviceFilePath) { 277 String escapedFilePath = deviceFilePath; 278 try { 279 // Encode the path then escape it. This logic must invert the logic in 280 // ManagedFileContentProvider.getFileForUri. That calls to Uri.getPath() and then 281 // URLDecoder.decode(), so this must invert each of those two steps and switch the order 282 String encoded = URLEncoder.encode(deviceFilePath, "UTF-8"); 283 escapedFilePath = UrlEscapers.urlPathSegmentEscaper().escape(encoded); 284 } catch (UnsupportedEncodingException e) { 285 CLog.e(e); 286 } 287 return String.format("\"%s/%s\"", CONTENT_PROVIDER_URI, escapedFilePath); 288 } 289 290 /** 291 * Parses the String output of "adb shell content query" for a single row. 292 * 293 * @param row The entire row representing a single file/directory returned by the "adb shell 294 * content query" command. 295 * @return Key-value map of column name to column value. 296 */ 297 @VisibleForTesting parseQueryResultRow(String row)298 final HashMap<String, String> parseQueryResultRow(String row) { 299 HashMap<String, String> columnValues = new HashMap<>(); 300 301 StringJoiner pattern = new StringJoiner(", "); 302 for (int i = 0; i < COLUMNS.length; i++) { 303 pattern.add(String.format("(%s=.*)", COLUMNS[i])); 304 } 305 306 Pattern p = Pattern.compile(pattern.toString()); 307 Matcher m = p.matcher(row); 308 if (m.find()) { 309 for (int i = 1; i <= m.groupCount(); i++) { 310 String[] keyValue = m.group(i).split("="); 311 if (keyValue.length == 2) { 312 columnValues.put(keyValue[0], keyValue[1]); 313 } 314 } 315 } 316 return columnValues; 317 } 318 319 /** 320 * Internal method to actually do the pull directory but without re-querying the current user 321 * while doing the recursive pull. 322 */ pullDirInternal(String deviceFilePath, File localDir, Integer currentUser)323 private boolean pullDirInternal(String deviceFilePath, File localDir, Integer currentUser) 324 throws DeviceNotAvailableException { 325 if (!localDir.isDirectory()) { 326 CLog.e("Local path %s is not a directory", localDir.getAbsolutePath()); 327 return false; 328 } 329 330 String contentUri = createEscapedContentUri(deviceFilePath); 331 if (currentUser == null) { 332 // Keep track of the user so if we recursively pull dir we don't re-query it. 333 currentUser = mDevice.getCurrentUser(); 334 } 335 String queryContentCommand = 336 String.format("content query --user %d --uri %s", currentUser, contentUri); 337 338 String listCommandResult = mDevice.executeShellCommand(queryContentCommand); 339 340 if (NO_RESULTS_STRING.equals(listCommandResult.trim())) { 341 // Empty directory. 342 return true; 343 } 344 345 CLog.d("Received from content provider:\n%s", listCommandResult); 346 String[] listResult = listCommandResult.split("[\\r\\n]+"); 347 348 for (String row : listResult) { 349 HashMap<String, String> columnValues = parseQueryResultRow(row); 350 boolean isDirectory = Boolean.valueOf(columnValues.get(COLUMN_DIRECTORY)); 351 String name = columnValues.get(COLUMN_NAME); 352 if (name == null) { 353 CLog.w("Output from the content provider doesn't seem well formatted:\n%s", row); 354 return false; 355 } 356 String path = columnValues.get(COLUMN_ABSOLUTE_PATH); 357 358 File localChild = new File(localDir, name); 359 if (isDirectory) { 360 if (!localChild.mkdir()) { 361 CLog.w( 362 "Failed to create sub directory %s, aborting.", 363 localChild.getAbsolutePath()); 364 return false; 365 } 366 367 if (!pullDirInternal(path, localChild, currentUser)) { 368 CLog.w("Failed to pull sub directory %s from device, aborting", path); 369 return false; 370 } 371 } else { 372 // handle regular file 373 if (!pullFileInternal(path, localChild, currentUser)) { 374 CLog.w("Failed to pull file %s from device, aborting", path); 375 return false; 376 } 377 } 378 } 379 return true; 380 } 381 pullFileInternal(String deviceFilePath, File localFile, Integer currentUser)382 private boolean pullFileInternal(String deviceFilePath, File localFile, Integer currentUser) 383 throws DeviceNotAvailableException { 384 String contentUri = createEscapedContentUri(deviceFilePath); 385 if (currentUser == null) { 386 currentUser = mDevice.getCurrentUser(); 387 } 388 String pullCommand = 389 String.format("content read --user %d --uri %s", currentUser, contentUri); 390 391 // Open the output stream to the local file. 392 OutputStream localFileStream; 393 try { 394 localFileStream = new FileOutputStream(localFile); 395 } catch (FileNotFoundException e) { 396 CLog.e("Failed to open OutputStream to the local file. Error: %s", e.getMessage()); 397 return false; 398 } 399 400 try { 401 CommandResult pullResult = mDevice.executeShellV2Command(pullCommand, localFileStream); 402 if (isSuccessful(pullResult)) { 403 return true; 404 } 405 String stderr = pullResult.getStderr(); 406 CLog.e( 407 "Failed to pull a file at '%s' to %s using content provider. Error: '%s'", 408 deviceFilePath, localFile, stderr); 409 if (stderr.contains(ERROR_PROVIDER_NOT_INSTALLED)) { 410 mReportNotFound = true; 411 } 412 return false; 413 } finally { 414 StreamUtil.close(localFileStream); 415 } 416 } 417 } 418