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