• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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