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