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