1 /* 2 * Copyright (C) 2021 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 17 package com.android.csuite.core; 18 19 import android.service.dropbox.DropBoxManagerServiceDumpProto; 20 import android.service.dropbox.DropBoxManagerServiceDumpProto.Entry; 21 22 import com.android.tradefed.device.DeviceNotAvailableException; 23 import com.android.tradefed.device.DeviceRuntimeException; 24 import com.android.tradefed.device.ITestDevice; 25 import com.android.tradefed.log.LogUtil.CLog; 26 import com.android.tradefed.result.error.DeviceErrorIdentifier; 27 import com.android.tradefed.util.CommandResult; 28 import com.android.tradefed.util.CommandStatus; 29 import com.android.tradefed.util.IRunUtil; 30 import com.android.tradefed.util.RunUtil; 31 32 import com.google.common.annotations.VisibleForTesting; 33 import com.google.protobuf.InvalidProtocolBufferException; 34 35 import java.io.File; 36 import java.io.IOException; 37 import java.nio.file.Files; 38 import java.nio.file.Path; 39 import java.time.Instant; 40 import java.time.ZoneId; 41 import java.time.format.DateTimeFormatter; 42 import java.util.ArrayList; 43 import java.util.Arrays; 44 import java.util.Collections; 45 import java.util.Comparator; 46 import java.util.HashMap; 47 import java.util.List; 48 import java.util.ListIterator; 49 import java.util.Random; 50 import java.util.Set; 51 import java.util.concurrent.TimeUnit; 52 import java.util.regex.Matcher; 53 import java.util.regex.Pattern; 54 import java.util.stream.Collectors; 55 56 /** A utility class that contains common methods to interact with the test device. */ 57 public class DeviceUtils { 58 @VisibleForTesting static final String UNKNOWN = "Unknown"; 59 @VisibleForTesting static final String VERSION_CODE_PREFIX = "versionCode="; 60 @VisibleForTesting static final String VERSION_NAME_PREFIX = "versionName="; 61 @VisibleForTesting static final String RESET_PACKAGE_COMMAND_PREFIX = "pm clear "; 62 public static final Set<String> DROPBOX_APP_CRASH_TAGS = 63 Set.of( 64 "SYSTEM_TOMBSTONE", 65 "system_app_anr", 66 "system_app_native_crash", 67 "system_app_crash", 68 "data_app_anr", 69 "data_app_native_crash", 70 "data_app_crash"); 71 72 private static final String VIDEO_PATH_ON_DEVICE_TEMPLATE = "/sdcard/screenrecord_%s.mp4"; 73 private static final DateTimeFormatter DROPBOX_TIME_FORMATTER = 74 DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss_SSS"); 75 // Pattern for finding a package name following one of the tags such as "Process:" or 76 // "Package:". 77 private static final Pattern DROPBOX_PACKAGE_NAME_PATTERN = 78 Pattern.compile( 79 "\\b(Process|Cmdline|Package|Cmd line):(" 80 + " *)([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+)"); 81 82 @VisibleForTesting 83 static final int WAIT_FOR_SCREEN_RECORDING_START_STOP_TIMEOUT_MILLIS = 10 * 1000; 84 85 @VisibleForTesting static final int WAIT_FOR_SCREEN_RECORDING_START_INTERVAL_MILLIS = 500; 86 87 private final ITestDevice mDevice; 88 private final Sleeper mSleeper; 89 private final Clock mClock; 90 private final RunUtilProvider mRunUtilProvider; 91 private final TempFileSupplier mTempFileSupplier; 92 getInstance(ITestDevice device)93 public static DeviceUtils getInstance(ITestDevice device) { 94 return new DeviceUtils( 95 device, 96 duration -> { 97 Thread.sleep(duration); 98 }, 99 () -> System.currentTimeMillis(), 100 () -> RunUtil.getDefault(), 101 () -> Files.createTempFile(TestUtils.class.getName(), ".tmp")); 102 } 103 104 @VisibleForTesting DeviceUtils( ITestDevice device, Sleeper sleeper, Clock clock, RunUtilProvider runUtilProvider, TempFileSupplier tempFileSupplier)105 DeviceUtils( 106 ITestDevice device, 107 Sleeper sleeper, 108 Clock clock, 109 RunUtilProvider runUtilProvider, 110 TempFileSupplier tempFileSupplier) { 111 mDevice = device; 112 mSleeper = sleeper; 113 mClock = clock; 114 mRunUtilProvider = runUtilProvider; 115 mTempFileSupplier = tempFileSupplier; 116 } 117 118 /** 119 * A runnable that throws DeviceNotAvailableException. Use this interface instead of Runnable so 120 * that the DeviceNotAvailableException won't need to be handled inside the run() method. 121 */ 122 public interface RunnableThrowingDeviceNotAvailable { run()123 void run() throws DeviceNotAvailableException; 124 } 125 126 /** Returns the stored ITestDevice instance. */ getITestDevice()127 public ITestDevice getITestDevice() { 128 return mDevice; 129 } 130 131 /** 132 * Grants additional permissions for installed an installed app 133 * 134 * <p>If the package is not installed or the command failed, there will be no error thrown 135 * beyond debug logging. 136 * 137 * @param packageName the package name to grant permission. 138 * @throws DeviceNotAvailableException 139 */ grantExternalStoragePermissions(String packageName)140 public void grantExternalStoragePermissions(String packageName) 141 throws DeviceNotAvailableException { 142 CommandResult cmdResult = 143 mDevice.executeShellV2Command( 144 String.format("appops set %s MANAGE_EXTERNAL_STORAGE allow", packageName)); 145 if (cmdResult.getStatus() != CommandStatus.SUCCESS) { 146 CLog.d( 147 "Granting MANAGE_EXTERNAL_STORAGE permissions for package %s was unsuccessful." 148 + " Reason: %s.", 149 packageName, cmdResult.toString()); 150 } 151 } 152 153 /** 154 * Get the current device timestamp in milliseconds. 155 * 156 * @return The device time 157 * @throws DeviceNotAvailableException When the device is not available. 158 * @throws DeviceRuntimeException When the command to get device time failed or failed to parse 159 * the timestamp. 160 */ currentTimeMillis()161 public DeviceTimestamp currentTimeMillis() 162 throws DeviceNotAvailableException, DeviceRuntimeException { 163 CommandResult result = mDevice.executeShellV2Command("echo ${EPOCHREALTIME:0:14}"); 164 if (result.getStatus() != CommandStatus.SUCCESS) { 165 throw new DeviceRuntimeException( 166 "Failed to get device time: " + result, 167 DeviceErrorIdentifier.DEVICE_UNEXPECTED_RESPONSE); 168 } 169 try { 170 return new DeviceTimestamp(Long.parseLong(result.getStdout().replace(".", "").trim())); 171 } catch (NumberFormatException e) { 172 CLog.e("Cannot parse device time string: " + result.getStdout()); 173 throw new DeviceRuntimeException( 174 "Cannot parse device time string: " + result.getStdout(), 175 DeviceErrorIdentifier.DEVICE_UNEXPECTED_RESPONSE); 176 } 177 } 178 179 /** 180 * Get the device's build sdk level. 181 * 182 * @return The Sdk level, or -1 if failed to get it. 183 * @throws DeviceNotAvailableException When the device is lost during test 184 */ getSdkLevel()185 public int getSdkLevel() throws DeviceNotAvailableException { 186 CommandResult result = mDevice.executeShellV2Command("getprop ro.build.version.sdk"); 187 if (result.getStatus() != CommandStatus.SUCCESS) { 188 CLog.e( 189 "Failed to get device build sdk level: " + result, 190 DeviceErrorIdentifier.DEVICE_UNEXPECTED_RESPONSE); 191 return -1; 192 } 193 try { 194 return Integer.parseInt(result.getStdout().trim()); 195 } catch (NumberFormatException e) { 196 CLog.e("Cannot parse device build sdk level: " + result.getStdout()); 197 return -1; 198 } 199 } 200 201 /** 202 * Record the device screen while running a task. 203 * 204 * <p>This method will not throw exception when the screenrecord command failed unless the 205 * device is unresponsive. 206 * 207 * @param action A runnable job that throws DeviceNotAvailableException. 208 * @param handler A file handler that process the output screen record mp4 file located on the 209 * host. 210 * @throws DeviceNotAvailableException When the device is unresponsive. 211 */ runWithScreenRecording( RunnableThrowingDeviceNotAvailable action, ScreenrecordFileHandler handler)212 public void runWithScreenRecording( 213 RunnableThrowingDeviceNotAvailable action, ScreenrecordFileHandler handler) 214 throws DeviceNotAvailableException { 215 String videoPath = String.format(VIDEO_PATH_ON_DEVICE_TEMPLATE, new Random().nextInt()); 216 mDevice.deleteFile(videoPath); 217 218 // Start screen recording 219 Process recordingProcess = null; 220 DeviceTimestamp recordingDeviceStartTime = null; 221 try { 222 CLog.i("Starting screen recording at %s", videoPath); 223 recordingProcess = 224 mRunUtilProvider 225 .get() 226 .runCmdInBackground( 227 String.format( 228 "adb -s %s shell screenrecord%s %s", 229 mDevice.getSerialNumber(), 230 getSdkLevel() >= 34 ? " --time-limit 600" : "", 231 videoPath) 232 .split("\\s+")); 233 } catch (IOException ioException) { 234 CLog.e("Exception is thrown when starting screen recording process: %s", ioException); 235 } 236 237 try { 238 // Not exact video start time. The exact time can be found in the logcat but for 239 // simplicity we use shell command to get the current time as the approximate of 240 // the video start time. 241 recordingDeviceStartTime = currentTimeMillis(); 242 long hostStartTime = mClock.currentTimeMillis(); 243 // Wait for the recording to start since it may take time for the device to start 244 // recording 245 while (recordingProcess != null) { 246 CommandResult result = mDevice.executeShellV2Command("ls " + videoPath); 247 if (result.getStatus() == CommandStatus.SUCCESS) { 248 break; 249 } 250 251 CLog.d( 252 "Screenrecord not started yet. Waiting %s milliseconds.", 253 WAIT_FOR_SCREEN_RECORDING_START_INTERVAL_MILLIS); 254 255 try { 256 mSleeper.sleep(WAIT_FOR_SCREEN_RECORDING_START_INTERVAL_MILLIS); 257 } catch (InterruptedException e) { 258 throw new RuntimeException(e); 259 } 260 261 if (mClock.currentTimeMillis() - hostStartTime 262 > WAIT_FOR_SCREEN_RECORDING_START_STOP_TIMEOUT_MILLIS) { 263 CLog.e( 264 "Screenrecord did not start within %s milliseconds.", 265 WAIT_FOR_SCREEN_RECORDING_START_STOP_TIMEOUT_MILLIS); 266 break; 267 } 268 } 269 270 action.run(); 271 } finally { 272 if (recordingProcess != null) { 273 mRunUtilProvider 274 .get() 275 .runTimedCmd( 276 WAIT_FOR_SCREEN_RECORDING_START_STOP_TIMEOUT_MILLIS, 277 "kill", 278 "-SIGINT", 279 Long.toString(recordingProcess.pid())); 280 try { 281 recordingProcess.waitFor( 282 WAIT_FOR_SCREEN_RECORDING_START_STOP_TIMEOUT_MILLIS, 283 TimeUnit.MILLISECONDS); 284 } catch (InterruptedException e) { 285 e.printStackTrace(); 286 recordingProcess.destroyForcibly(); 287 } 288 } 289 290 CommandResult sizeResult = mDevice.executeShellV2Command("ls -sh " + videoPath); 291 if (sizeResult != null && sizeResult.getStatus() == CommandStatus.SUCCESS) { 292 CLog.d( 293 "Completed screenrecord %s, video size: %s", 294 videoPath, sizeResult.getStdout()); 295 } 296 CommandResult hashResult = mDevice.executeShellV2Command("md5sum " + videoPath); 297 if (hashResult != null && hashResult.getStatus() == CommandStatus.SUCCESS) { 298 CLog.d("Video file md5 sum: %s", hashResult.getStdout()); 299 } 300 // Try to pull, handle, and delete the video file from the device anyway. 301 handler.handleScreenRecordFile(mDevice.pullFile(videoPath), recordingDeviceStartTime); 302 mDevice.deleteFile(videoPath); 303 } 304 } 305 306 /** A file handler for screen record results. */ 307 public interface ScreenrecordFileHandler { 308 /** 309 * Handles the screen record mp4 file located on the host. 310 * 311 * @param screenRecord The mp4 file located on the host. If screen record failed then the 312 * input could be null. 313 * @param recordingStartTime The device time when the screen record started.. 314 */ handleScreenRecordFile(File screenRecord, DeviceTimestamp recordingStartTime)315 void handleScreenRecordFile(File screenRecord, DeviceTimestamp recordingStartTime); 316 } 317 318 /** 319 * Freeze the screen rotation to the default orientation. 320 * 321 * @return True if succeed; False otherwise. 322 * @throws DeviceNotAvailableException 323 */ freezeRotation()324 public boolean freezeRotation() throws DeviceNotAvailableException { 325 CommandResult result = 326 mDevice.executeShellV2Command( 327 "content insert --uri content://settings/system --bind" 328 + " name:s:accelerometer_rotation --bind value:i:0"); 329 if (result.getStatus() != CommandStatus.SUCCESS || result.getExitCode() != 0) { 330 CLog.e("The command to disable auto screen rotation failed: %s", result); 331 return false; 332 } 333 334 return true; 335 } 336 337 /** 338 * Unfreeze the screen rotation to the default orientation. 339 * 340 * @return True if succeed; False otherwise. 341 * @throws DeviceNotAvailableException 342 */ unfreezeRotation()343 public boolean unfreezeRotation() throws DeviceNotAvailableException { 344 CommandResult result = 345 mDevice.executeShellV2Command( 346 "content insert --uri content://settings/system --bind" 347 + " name:s:accelerometer_rotation --bind value:i:1"); 348 if (result.getStatus() != CommandStatus.SUCCESS || result.getExitCode() != 0) { 349 CLog.e("The command to enable auto screen rotation failed: %s", result); 350 return false; 351 } 352 353 return true; 354 } 355 356 /** 357 * Launches a package on the device. 358 * 359 * @param packageName The package name to launch. 360 * @throws DeviceNotAvailableException When device was lost. 361 * @throws DeviceUtilsException When failed to launch the package. 362 */ launchPackage(String packageName)363 public void launchPackage(String packageName) 364 throws DeviceUtilsException, DeviceNotAvailableException { 365 CommandResult monkeyResult = 366 mDevice.executeShellV2Command( 367 String.format( 368 "monkey -p %s -c android.intent.category.LAUNCHER 1", packageName)); 369 if (monkeyResult.getStatus() == CommandStatus.SUCCESS) { 370 return; 371 } 372 CLog.w( 373 "Continuing to attempt using am command to launch the package %s after the monkey" 374 + " command failed: %s", 375 packageName, monkeyResult); 376 377 CommandResult pmResult = 378 mDevice.executeShellV2Command(String.format("pm dump %s", packageName)); 379 if (pmResult.getStatus() != CommandStatus.SUCCESS || pmResult.getExitCode() != 0) { 380 if (isPackageInstalled(packageName)) { 381 throw new DeviceUtilsException( 382 String.format( 383 "The command to dump package info for %s failed: %s", 384 packageName, pmResult)); 385 } else { 386 throw new DeviceUtilsException( 387 String.format("Package %s is not installed on the device.", packageName)); 388 } 389 } 390 391 String activity = getLaunchActivity(pmResult.getStdout()); 392 393 CommandResult amResult = 394 mDevice.executeShellV2Command(String.format("am start -n %s", activity)); 395 if (amResult.getStatus() != CommandStatus.SUCCESS 396 || amResult.getExitCode() != 0 397 || amResult.getStdout().contains("Error")) { 398 throw new DeviceUtilsException( 399 String.format( 400 "The command to start the package %s with activity %s failed: %s", 401 packageName, activity, amResult)); 402 } 403 } 404 405 /** 406 * Extracts the launch activity from a pm dump output. 407 * 408 * <p>This method parses the package manager dump, extracts the activities and filters them 409 * based on the categories and actions defined in the Android framework. The activities are 410 * sorted based on these attributes, and the first activity that is either the main action or a 411 * launcher category is returned. 412 * 413 * @param pmDump the pm dump output to parse. 414 * @return a activity that can be used to launch the package. 415 * @throws DeviceUtilsException if the launch activity cannot be found in the 416 * dump. @VisibleForTesting 417 */ 418 @VisibleForTesting getLaunchActivity(String pmDump)419 String getLaunchActivity(String pmDump) throws DeviceUtilsException { 420 class Activity { 421 String mName; 422 int mIndex; 423 List<String> mActions = new ArrayList<>(); 424 List<String> mCategories = new ArrayList<>(); 425 } 426 427 Pattern activityNamePattern = 428 Pattern.compile( 429 "([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+" 430 + "\\/([a-zA-Z][a-zA-Z0-9_]*)*(\\.[a-zA-Z][a-zA-Z0-9_]*)+)"); 431 Pattern actionPattern = 432 Pattern.compile( 433 "Action:([^a-zA-Z0-9_\\.]*)([a-zA-Z][a-zA-Z0-9_]*" 434 + "(\\.[a-zA-Z][a-zA-Z0-9_]*)+)"); 435 Pattern categoryPattern = 436 Pattern.compile( 437 "Category:([^a-zA-Z0-9_\\.]*)([a-zA-Z][a-zA-Z0-9_]*" 438 + "(\\.[a-zA-Z][a-zA-Z0-9_]*)+)"); 439 440 Matcher activityNameMatcher = activityNamePattern.matcher(pmDump); 441 442 List<Activity> activities = new ArrayList<>(); 443 while (activityNameMatcher.find()) { 444 Activity activity = new Activity(); 445 activity.mName = activityNameMatcher.group(0); 446 activity.mIndex = activityNameMatcher.start(); 447 activities.add(activity); 448 } 449 450 int endIdx = pmDump.length(); 451 ListIterator<Activity> iterator = activities.listIterator(activities.size()); 452 while (iterator.hasPrevious()) { 453 Activity activity = iterator.previous(); 454 Matcher actionMatcher = 455 actionPattern.matcher(pmDump.substring(activity.mIndex, endIdx)); 456 while (actionMatcher.find()) { 457 activity.mActions.add(actionMatcher.group(2)); 458 } 459 Matcher categoryMatcher = 460 categoryPattern.matcher(pmDump.substring(activity.mIndex, endIdx)); 461 while (categoryMatcher.find()) { 462 activity.mCategories.add(categoryMatcher.group(2)); 463 } 464 endIdx = activity.mIndex; 465 } 466 467 String categoryDefault = "android.intent.category.DEFAULT"; 468 String categoryLauncher = "android.intent.category.LAUNCHER"; 469 String actionMain = "android.intent.action.MAIN"; 470 471 class AndroidActivityComparator implements Comparator<Activity> { 472 @Override 473 public int compare(Activity a1, Activity a2) { 474 if (a1.mCategories.contains(categoryLauncher) 475 && !a2.mCategories.contains(categoryLauncher)) { 476 return -1; 477 } 478 if (!a1.mCategories.contains(categoryLauncher) 479 && a2.mCategories.contains(categoryLauncher)) { 480 return 1; 481 } 482 if (a1.mActions.contains(actionMain) && !a2.mActions.contains(actionMain)) { 483 return -1; 484 } 485 if (!a1.mActions.contains(actionMain) && a2.mActions.contains(actionMain)) { 486 return 1; 487 } 488 if (a1.mCategories.contains(categoryDefault) 489 && !a2.mCategories.contains(categoryDefault)) { 490 return -1; 491 } 492 if (!a1.mCategories.contains(categoryDefault) 493 && a2.mCategories.contains(categoryDefault)) { 494 return 1; 495 } 496 return Integer.compare(a2.mCategories.size(), a1.mCategories.size()); 497 } 498 } 499 500 Collections.sort(activities, new AndroidActivityComparator()); 501 List<Activity> filteredActivities = 502 activities.stream() 503 .filter( 504 activity -> 505 activity.mActions.contains(actionMain) 506 || activity.mCategories.contains(categoryLauncher)) 507 .collect(Collectors.toList()); 508 if (filteredActivities.isEmpty()) { 509 throw new DeviceUtilsException( 510 String.format( 511 "Cannot find an activity to launch the package. Number of activities" 512 + " parsed: %s", 513 activities.size())); 514 } 515 516 Activity res = filteredActivities.get(0); 517 518 if (!res.mCategories.contains(categoryLauncher)) { 519 CLog.d("Activity %s is not specified with a LAUNCHER category.", res.mName); 520 } 521 if (!res.mActions.contains(actionMain)) { 522 CLog.d("Activity %s is not specified with a MAIN action.", res.mName); 523 } 524 525 return res.mName; 526 } 527 528 /** 529 * Gets the version name of a package installed on the device. 530 * 531 * @param packageName The full package name to query 532 * @return The package version name, or 'Unknown' if the package doesn't exist or the adb 533 * command failed. 534 * @throws DeviceNotAvailableException 535 */ getPackageVersionName(String packageName)536 public String getPackageVersionName(String packageName) throws DeviceNotAvailableException { 537 CommandResult cmdResult = 538 mDevice.executeShellV2Command( 539 String.format("dumpsys package %s | grep versionName", packageName)); 540 541 if (cmdResult.getStatus() != CommandStatus.SUCCESS 542 || !cmdResult.getStdout().trim().startsWith(VERSION_NAME_PREFIX)) { 543 return UNKNOWN; 544 } 545 546 return cmdResult.getStdout().trim().substring(VERSION_NAME_PREFIX.length()); 547 } 548 549 /** 550 * Gets the version code of a package installed on the device. 551 * 552 * @param packageName The full package name to query 553 * @return The package version code, or 'Unknown' if the package doesn't exist or the adb 554 * command failed. 555 * @throws DeviceNotAvailableException 556 */ getPackageVersionCode(String packageName)557 public String getPackageVersionCode(String packageName) throws DeviceNotAvailableException { 558 CommandResult cmdResult = 559 mDevice.executeShellV2Command( 560 String.format("dumpsys package %s | grep versionCode", packageName)); 561 562 if (cmdResult.getStatus() != CommandStatus.SUCCESS 563 || !cmdResult.getStdout().trim().startsWith(VERSION_CODE_PREFIX)) { 564 return UNKNOWN; 565 } 566 567 return cmdResult.getStdout().trim().split(" ")[0].substring(VERSION_CODE_PREFIX.length()); 568 } 569 570 /** 571 * Stops a running package on the device. 572 * 573 * @param packageName 574 * @throws DeviceNotAvailableException 575 */ stopPackage(String packageName)576 public void stopPackage(String packageName) throws DeviceNotAvailableException { 577 mDevice.executeShellV2Command("am force-stop " + packageName); 578 } 579 580 /** 581 * Resets a package's data storage on the device. 582 * 583 * @param packageName The package name of an app to reset. 584 * @return True if the package exists and its data was reset; False otherwise. 585 * @throws DeviceNotAvailableException If the device was lost. 586 */ resetPackage(String packageName)587 public boolean resetPackage(String packageName) throws DeviceNotAvailableException { 588 return mDevice.executeShellV2Command(RESET_PACKAGE_COMMAND_PREFIX + packageName).getStatus() 589 == CommandStatus.SUCCESS; 590 } 591 592 /** 593 * Checks whether a package is installed on the device. 594 * 595 * @param packageName The name of the package to check 596 * @return True if the package is installed on the device; false otherwise. 597 * @throws DeviceUtilsException If the adb shell command failed. 598 * @throws DeviceNotAvailableException If the device was lost. 599 */ isPackageInstalled(String packageName)600 public boolean isPackageInstalled(String packageName) 601 throws DeviceUtilsException, DeviceNotAvailableException { 602 CommandResult commandResult = 603 executeShellCommandOrThrow( 604 String.format("pm list packages %s", packageName), 605 "Failed to execute pm command"); 606 607 if (commandResult.getStdout() == null) { 608 throw new DeviceUtilsException( 609 String.format( 610 "Failed to get pm command output: %s", commandResult.getStdout())); 611 } 612 613 return Arrays.asList(commandResult.getStdout().split("\\r?\\n")) 614 .contains(String.format("package:%s", packageName)); 615 } 616 executeShellCommandOrThrow(String command, String failureMessage)617 private CommandResult executeShellCommandOrThrow(String command, String failureMessage) 618 throws DeviceUtilsException, DeviceNotAvailableException { 619 CommandResult commandResult = mDevice.executeShellV2Command(command); 620 621 if (commandResult.getStatus() != CommandStatus.SUCCESS) { 622 throw new DeviceUtilsException( 623 String.format("%s; Command result: %s", failureMessage, commandResult)); 624 } 625 626 return commandResult; 627 } 628 629 /** 630 * Gets dropbox entries from the device filtered by the provided tags. 631 * 632 * @param tags Dropbox tags to query. 633 * @return A list of dropbox entries. 634 * @throws IOException when failed to dump or read the dropbox protos. 635 */ getDropboxEntries(Set<String> tags)636 public List<DropboxEntry> getDropboxEntries(Set<String> tags) throws IOException { 637 CommandResult resHelp = 638 mRunUtilProvider 639 .get() 640 .runTimedCmd( 641 1L * 60 * 1000, 642 "sh", 643 "-c", 644 String.format( 645 "adb -s %s shell dumpsys dropbox --help", 646 mDevice.getSerialNumber())); 647 if (resHelp.getStatus() != CommandStatus.SUCCESS) { 648 throw new IOException("Dropbox dump help command failed: " + resHelp); 649 } 650 if (!resHelp.getStdout().contains("--proto")) { 651 // If dumping proto format is not supported such as in Android 10, the command will 652 // still succeed with exit code 0 and output strings instead of protobuf bytes, 653 // causing parse error. In this case we fallback to dumping dropbox --print option. 654 return getDropboxEntriesFromStdout(tags); 655 } 656 657 List<DropboxEntry> entries = new ArrayList<>(); 658 659 for (String tag : tags) { 660 Path dumpFile = mTempFileSupplier.get(); 661 662 CommandResult res = 663 mRunUtilProvider 664 .get() 665 .runTimedCmd( 666 4L * 60 * 1000, 667 "sh", 668 "-c", 669 String.format( 670 "adb -s %s shell dumpsys dropbox --proto %s > %s", 671 mDevice.getSerialNumber(), tag, dumpFile)); 672 if (res.getStatus() != CommandStatus.SUCCESS) { 673 throw new IOException("Dropbox dump command failed: " + res); 674 } 675 676 if (Files.size(dumpFile) == 0) { 677 CLog.d("Skipping empty proto " + dumpFile); 678 continue; 679 } 680 681 CLog.d("Parsing proto for tag %s. Size: %s", tag, Files.size(dumpFile)); 682 DropBoxManagerServiceDumpProto proto; 683 try { 684 proto = DropBoxManagerServiceDumpProto.parseFrom(Files.readAllBytes(dumpFile)); 685 } catch (InvalidProtocolBufferException e) { 686 CLog.e( 687 "Falling back to stdout dropbox dump due to unexpected proto parse error:" 688 + " %s", 689 e); 690 return getDropboxEntriesFromStdout(tags); 691 } 692 Files.delete(dumpFile); 693 694 for (Entry entry : proto.getEntriesList()) { 695 entries.add( 696 new DropboxEntry(entry.getTimeMs(), tag, entry.getData().toStringUtf8())); 697 } 698 } 699 return entries.stream() 700 .sorted(Comparator.comparing(DropboxEntry::getTime)) 701 .collect(Collectors.toList()); 702 } 703 704 /** 705 * Gets dropbox entries from the device filtered by the provided tags. 706 * 707 * @param tags Dropbox tags to query. 708 * @param packageName package name for filtering the entries. Can be null. 709 * @param startTime entry start timestamp to filter the results. Can be null. 710 * @param endTime entry end timestamp to filter the results. Can be null. 711 * @return A list of dropbox entries. 712 * @throws IOException when failed to dump or read the dropbox protos. 713 */ getDropboxEntries( Set<String> tags, String packageName, DeviceTimestamp startTime, DeviceTimestamp endTime)714 public List<DropboxEntry> getDropboxEntries( 715 Set<String> tags, 716 String packageName, 717 DeviceTimestamp startTime, 718 DeviceTimestamp endTime) 719 throws IOException { 720 return getDropboxEntries(tags).stream() 721 .filter( 722 entry -> 723 ((startTime == null || entry.getTime() >= startTime.get()) 724 && (endTime == null || entry.getTime() < endTime.get()))) 725 .filter( 726 entry -> 727 packageName == null 728 || isDropboxEntryFromPackageProcess( 729 entry.getData(), packageName)) 730 .collect(Collectors.toList()); 731 } 732 733 /* Checks whether a dropbox entry is logged from the given package name. */ 734 @VisibleForTesting isDropboxEntryFromPackageProcess(String entryData, String packageName)735 boolean isDropboxEntryFromPackageProcess(String entryData, String packageName) { 736 Matcher m = DROPBOX_PACKAGE_NAME_PATTERN.matcher(entryData); 737 738 boolean matched = false; 739 while (m.find()) { 740 matched = true; 741 if (m.group(3).equals(packageName)) { 742 return true; 743 } 744 } 745 746 // Package/process name is identified but not equal to the packageName provided 747 if (matched) { 748 return false; 749 } 750 751 // If the process name is not identified, fall back to checking if the package name is 752 // present in the entry. This is because the process name detection logic above does not 753 // guarantee to identify the process name. 754 return Pattern.compile( 755 String.format( 756 // Pattern for checking whether a given package name exists. 757 "(.*(?:[^a-zA-Z0-9_\\.]+)|^)%s((?:[^a-zA-Z0-9_\\.]+).*|$)", 758 packageName.replaceAll("\\.", "\\\\."))) 759 .matcher(entryData) 760 .find(); 761 } 762 763 @VisibleForTesting getDropboxEntriesFromStdout(Set<String> tags)764 List<DropboxEntry> getDropboxEntriesFromStdout(Set<String> tags) throws IOException { 765 HashMap<String, DropboxEntry> entries = new HashMap<>(); 766 767 // The first step is to read the entry names and timestamps from the --file dump option 768 // output because the --print dump option does not contain timestamps. 769 CommandResult res; 770 Path fileDumpFile = mTempFileSupplier.get(); 771 res = 772 mRunUtilProvider 773 .get() 774 .runTimedCmd( 775 4L * 60 * 1000, 776 "sh", 777 "-c", 778 String.format( 779 "adb -s %s shell dumpsys dropbox --file > %s", 780 mDevice.getSerialNumber(), fileDumpFile)); 781 if (res.getStatus() != CommandStatus.SUCCESS) { 782 throw new IOException("Dropbox dump command failed: " + res); 783 } 784 785 String lastEntryName = null; 786 for (String line : Files.readAllLines(fileDumpFile)) { 787 if (DropboxEntry.isDropboxEntryName(line)) { 788 lastEntryName = line.trim(); 789 entries.put(lastEntryName, DropboxEntry.fromEntryName(line)); 790 } else if (DropboxEntry.isDropboxFilePath(line) && lastEntryName != null) { 791 entries.get(lastEntryName).parseTimeFromFilePath(line); 792 } 793 } 794 Files.delete(fileDumpFile); 795 796 // Then we get the entry data from the --print dump output. Entry names parsed from the 797 // --print dump output are verified against the entry names from the --file dump output to 798 // ensure correctness. 799 Path printDumpFile = mTempFileSupplier.get(); 800 res = 801 mRunUtilProvider 802 .get() 803 .runTimedCmd( 804 4L * 60 * 1000, 805 "sh", 806 "-c", 807 String.format( 808 "adb -s %s shell dumpsys dropbox --print > %s", 809 mDevice.getSerialNumber(), printDumpFile)); 810 if (res.getStatus() != CommandStatus.SUCCESS) { 811 throw new IOException("Dropbox dump command failed: " + res); 812 } 813 814 lastEntryName = null; 815 for (String line : Files.readAllLines(printDumpFile)) { 816 if (DropboxEntry.isDropboxEntryName(line)) { 817 lastEntryName = line.trim(); 818 } 819 820 if (lastEntryName != null && entries.containsKey(lastEntryName)) { 821 entries.get(lastEntryName).addData(line); 822 entries.get(lastEntryName).addData("\n"); 823 } 824 } 825 Files.delete(printDumpFile); 826 827 return entries.values().stream() 828 .filter(entry -> tags.contains(entry.getTag())) 829 .collect(Collectors.toList()); 830 } 831 832 /** A class that stores the information of a dropbox entry. */ 833 public static final class DropboxEntry { 834 private long mTime; 835 private String mTag; 836 private final StringBuilder mData = new StringBuilder(); 837 private static final Pattern ENTRY_NAME_PATTERN = 838 Pattern.compile( 839 "\\d{4}\\-\\d{2}\\-\\d{2} \\d{2}:\\d{2}:\\d{2} .+ \\(.+, [0-9]+ .+\\)"); 840 private static final Pattern DATE_PATTERN = 841 Pattern.compile("\\d{4}\\-\\d{2}\\-\\d{2} \\d{2}:\\d{2}:\\d{2}"); 842 private static final Pattern FILE_NAME_PATTERN = Pattern.compile(" +/.+@[0-9]+\\..+"); 843 844 /** Returns the entrt's time stamp on device. */ getTime()845 public long getTime() { 846 return mTime; 847 } 848 addData(String data)849 private void addData(String data) { 850 mData.append(data); 851 } 852 parseTimeFromFilePath(String input)853 private void parseTimeFromFilePath(String input) { 854 mTime = Long.parseLong(input.substring(input.indexOf('@') + 1, input.indexOf('.'))); 855 } 856 857 /** Returns the entrt's tag. */ getTag()858 public String getTag() { 859 return mTag; 860 } 861 862 /** Returns the entrt's data. */ getData()863 public String getData() { 864 return mData.toString(); 865 } 866 867 @Override toString()868 public String toString() { 869 long time = getTime(); 870 String formattedTime = 871 DROPBOX_TIME_FORMATTER.format( 872 Instant.ofEpochMilli(time).atZone(ZoneId.systemDefault())); 873 return String.format( 874 "Dropbox entry tag: %s\n" 875 + "Dropbox entry timestamp: %s\n" 876 + "Dropbox entry time: %s\n%s", 877 getTag(), time, formattedTime, getData()); 878 } 879 880 @VisibleForTesting DropboxEntry(long time, String tag, String data)881 DropboxEntry(long time, String tag, String data) { 882 mTime = time; 883 mTag = tag; 884 addData(data); 885 } 886 DropboxEntry()887 private DropboxEntry() { 888 // Intentionally left blank; 889 } 890 fromEntryName(String name)891 private static DropboxEntry fromEntryName(String name) { 892 DropboxEntry entry = new DropboxEntry(); 893 Matcher matcher = DATE_PATTERN.matcher(name); 894 if (!matcher.find()) { 895 throw new RuntimeException("Unexpected entry name: " + name); 896 } 897 entry.mTag = name.trim().substring(matcher.group().length()).trim().split(" ")[0]; 898 return entry; 899 } 900 isDropboxEntryName(String input)901 private static boolean isDropboxEntryName(String input) { 902 return ENTRY_NAME_PATTERN.matcher(input).find(); 903 } 904 isDropboxFilePath(String input)905 private static boolean isDropboxFilePath(String input) { 906 return FILE_NAME_PATTERN.matcher(input).find(); 907 } 908 } 909 910 /** A general exception class representing failed device utility operations. */ 911 public static final class DeviceUtilsException extends Exception { 912 /** 913 * Constructs a new {@link DeviceUtilsException} with a meaningful error message. 914 * 915 * @param message A error message describing the cause of the error. 916 */ DeviceUtilsException(String message)917 private DeviceUtilsException(String message) { 918 super(message); 919 } 920 921 /** 922 * Constructs a new {@link DeviceUtilsException} with a meaningful error message, and a 923 * cause. 924 * 925 * @param message A detailed error message. 926 * @param cause A {@link Throwable} capturing the original cause of the {@link 927 * DeviceUtilsException}. 928 */ DeviceUtilsException(String message, Throwable cause)929 private DeviceUtilsException(String message, Throwable cause) { 930 super(message, cause); 931 } 932 933 /** 934 * Constructs a new {@link DeviceUtilsException} with a cause. 935 * 936 * @param cause A {@link Throwable} capturing the original cause of the {@link 937 * DeviceUtilsException}. 938 */ DeviceUtilsException(Throwable cause)939 private DeviceUtilsException(Throwable cause) { 940 super(cause); 941 } 942 } 943 944 /** 945 * A class to contain a device timestamp. 946 * 947 * <p>Use this class instead of long to pass device timestamps so that they are less likely to 948 * be confused with host timestamps. 949 */ 950 public static class DeviceTimestamp { 951 private final long mTimestamp; 952 DeviceTimestamp(long timestamp)953 public DeviceTimestamp(long timestamp) { 954 mTimestamp = timestamp; 955 } 956 957 /** Gets the time stamp on a device. */ get()958 public long get() { 959 return mTimestamp; 960 } 961 962 /** 963 * Gets the time stamp in formatted string 964 * 965 * @param format date format 966 * @return A formatted string representing the device time stamp 967 */ getFormatted(String format)968 public String getFormatted(String format) { 969 return DateTimeFormatter.ofPattern(format) 970 .format(Instant.ofEpochMilli(get()).atZone(ZoneId.systemDefault())); 971 } 972 } 973 974 @VisibleForTesting 975 interface Sleeper { sleep(long milliseconds)976 void sleep(long milliseconds) throws InterruptedException; 977 } 978 979 @VisibleForTesting 980 interface Clock { currentTimeMillis()981 long currentTimeMillis(); 982 } 983 984 @VisibleForTesting 985 interface RunUtilProvider { get()986 IRunUtil get(); 987 } 988 989 @VisibleForTesting 990 interface TempFileSupplier { get()991 Path get() throws IOException; 992 } 993 } 994