1 /*
2  * Copyright (C) 2022 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.helpers;
18 
19 import android.app.UiAutomation;
20 import android.os.ParcelFileDescriptor;
21 import android.util.Log;
22 
23 import androidx.annotation.VisibleForTesting;
24 import androidx.test.platform.app.InstrumentationRegistry;
25 import androidx.test.uiautomator.UiDevice;
26 
27 import java.io.BufferedReader;
28 import java.io.IOException;
29 import java.io.InputStream;
30 import java.io.InputStreamReader;
31 import java.nio.file.Path;
32 import java.nio.file.Paths;
33 import java.util.ArrayList;
34 import java.util.Collections;
35 import java.util.HashMap;
36 import java.util.HashSet;
37 import java.util.Map;
38 import java.util.regex.Matcher;
39 import java.util.regex.Pattern;
40 
41 /**
42  * BugReportDurationHelper is used to collect the durations of a bug report's component sections
43  * during a test.
44  */
45 public class BugReportDurationHelper implements ICollectorHelper<Double> {
46 
47     private static final String TAG = BugReportDurationHelper.class.getSimpleName();
48 
49     // Commands that will run on the test device.
50     private static final String LS_CMD = "ls %s";
51     private static final String UNZIP_EXTRACT_CMD = "unzip -p %s %s";
52     private static final String UNZIP_CONTENTS_CMD = "unzip -l %s";
53 
54     // Filters for selecting or omitting lines from the raw bug report.
55     private static final String DUMPSTATE_DURATION_FILTER = "was the duration of \'";
56     private static final String DUMPSYS_DURATION_FILTER = "was the duration of dumpsys";
57     private static final String DUMPSTATE_TIMEOUT_FILTER = "timed out after";
58     private static final String DUMPSTATE_LOG_TAG_FILTER = "dumpstate:";
59     private static final String SHOWMAP_FILTER = "SHOW MAP";
60 
61     // Filters for selecting lines from dumpstate_board.txt. Unlike raw bug reports, dumpstate_board
62     // sections and durations are contained on separate lines.
63     private static final String DUMPSTATE_BOARD_SECTION_FILTER = "------ Section end:";
64     private static final String DUMPSTATE_BOARD_DURATION_FILTER = "Elapsed msec:";
65 
66     // This pattern will match a group of characters representing a number with a decimal point.
67     private Pattern decimalDurationPattern = Pattern.compile("[0-9]+\\.[0-9]+");
68     // This pattern will match a group of characters representing a number without a decimal point.
69     private Pattern integerDurationPattern = Pattern.compile("[0-9]+");
70 
71     // This pattern will match a group of characters enclosed by \'.
72     private Pattern dumpstateKeyPattern = Pattern.compile("'(.+)'");
73     // This pattern will match a group of characters surrounded by "dumpsys " and ','.
74     private Pattern dumpsysKeyPattern = Pattern.compile("dumpsys (.+),");
75     // This pattern will match a group of characters surrounded by ": " and " -".
76     private Pattern dumpstateBoardKeyPattern = Pattern.compile(": (.+) -");
77 
78     private String bugReportDir;
79 
80     private UiDevice device;
81 
BugReportDurationHelper(String dir)82     public BugReportDurationHelper(String dir) {
83         super();
84         // The helper methods assume that the directory path is terminated by '/'.
85         if (dir.charAt(dir.length() - 1) != '/') {
86             dir += '/';
87         }
88         bugReportDir = dir;
89     }
90 
91     @Override
startCollecting()92     public boolean startCollecting() {
93         device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
94         Log.d(TAG, "Started collecting for BugReportDuration.");
95         return true;
96     }
97 
98     /** Convenience class for returning separate lists of lines from the raw bug report. */
99     @VisibleForTesting
100     public class BugReportDurationLines {
101         public ArrayList<String> dumpstateLines;
102         public ArrayList<String> dumpsysLines;
103         public ArrayList<String> timeoutLines;
104 
BugReportDurationLines()105         public BugReportDurationLines() {
106             dumpstateLines = new ArrayList<>();
107             dumpsysLines = new ArrayList<>();
108             timeoutLines = new ArrayList<>();
109         }
110 
isEmpty()111         public boolean isEmpty() {
112             return dumpstateLines.isEmpty() && dumpsysLines.isEmpty() && timeoutLines.isEmpty();
113         }
114 
115         // Only used in testing.
contains(String s)116         public boolean contains(String s) {
117             return dumpstateLines.contains(s)
118                     || dumpsysLines.contains(s)
119                     || timeoutLines.contains(s);
120         }
121     }
122 
123     /** Convenience class for returning section name/duration lines from dumpstate_board.txt. */
124     @VisibleForTesting
125     public class DumpstateBoardLines {
126         public ArrayList<String> sectionNameLines;
127         public ArrayList<String> durationLines;
128 
DumpstateBoardLines()129         public DumpstateBoardLines() {
130             sectionNameLines = new ArrayList<>();
131             durationLines = new ArrayList<>();
132         }
133 
isValid()134         public boolean isValid() {
135             return sectionNameLines.size() == durationLines.size();
136         }
137 
isEmpty()138         public boolean isEmpty() {
139             return isValid() && sectionNameLines.isEmpty();
140         }
141 
size()142         public int size() {
143             return sectionNameLines.size();
144         }
145 
146         // Only used in testing.
contains(String s)147         public boolean contains(String s) {
148             return sectionNameLines.contains(s) || durationLines.contains(s);
149         }
150     }
151 
152     @Override
getMetrics()153     public Map<String, Double> getMetrics() {
154         Log.d(TAG, "Grabbing metrics for BugReportDuration.");
155         Map<String, Double> metrics = new HashMap<>();
156         String archive = getLatestBugReport();
157         // No bug report was found, so there are no metrics to return.
158         if (archive == null) {
159             Log.w(TAG, "No bug report was found in directory: " + bugReportDir);
160             return metrics;
161         }
162 
163         BugReportDurationLines bugReportDurationLines = extractAndFilterBugReport(archive);
164         // No lines relevant to bug report durations were found, so there are no metrics to return.
165         if (bugReportDurationLines == null || bugReportDurationLines.isEmpty()) {
166             Log.w(TAG, "No lines relevant to bug report durations were found.");
167             return metrics;
168         }
169 
170         /*
171          * Some examples of section duration-relevant lines are:
172          *     ------ 44.619s was the duration of 'dumpstate_board()' ------
173          *     ------ 21.397s was the duration of 'DUMPSYS' ------
174          */
175         for (String line : bugReportDurationLines.dumpstateLines) {
176             String dumpstateSection = parseDumpstateSection(line);
177             double duration = parseDecimalDuration(line);
178             // The line doesn't contain the expected dumpstate section name or duration value.
179             if (dumpstateSection == null || duration == -1) {
180                 Log.e(TAG, "Dumpstate section name or duration could not be parsed from: " + line);
181                 continue;
182             }
183             String key = convertDumpstateSectionToKey(dumpstateSection);
184             // Some sections are collected multiple times (e.g. trusty version, system log).
185             metrics.put(key, duration + metrics.getOrDefault(key, 0.0));
186         }
187 
188         /*
189          * Some examples of dumpsys duration-relevant lines are:
190          * --------- 20.865s was the duration of dumpsys meminfo, ending at: 2023-04-24 19:53:46
191          * --------- 0.316s was the duration of dumpsys gfxinfo, ending at: 2023-04-24 19:54:06
192          */
193         for (String line : bugReportDurationLines.dumpsysLines) {
194             String dumpsysSection = parseDumpsysSection(line);
195             double duration = parseDecimalDuration(line);
196             // The line doesn't contain the expected dumpsys section name or duration value.
197             if (dumpsysSection == null || duration == -1) {
198                 Log.e(TAG, "Dumpstate section name or duration could not be parsed from: " + line);
199                 continue;
200             }
201             metrics.put(convertDumpsysSectionToKey(dumpsysSection), duration);
202         }
203 
204         metrics.put(
205                 "dumpstate-section-timeout-count",
206                 (double) bugReportDurationLines.timeoutLines.size());
207 
208         DumpstateBoardLines dumpstateBoardLines = extractAndFilterDumpstateBoard(archive);
209         // No lines relevant to dumpstate board durations were found, but we should return the
210         // previously-inserted metrics anyways.
211         if (dumpstateBoardLines == null || dumpstateBoardLines.isEmpty()) {
212             Log.w(TAG, "No lines relevant to dumpstate board durations were found.");
213             return metrics;
214         } else if (!dumpstateBoardLines.isValid()) {
215             Log.w(TAG, "Mismatch in the number of dumpstate_board section names and durations.");
216             return metrics;
217         }
218 
219         /*
220          * Some examples of dumpstate_board section lines are:
221          *     ------ Section end: dump_display ------
222          *     ------ Section end: dump_modem.sh ------
223          *     ------ Section end: dump_modemlog ------
224          *
225          * Some examples of dumpstate_board duration lines are:
226          *     Elapsed msec: 103
227          *     Elapsed msec: 89
228          *     Elapsed msec: 12532
229          */
230         for (int i = 0; i < dumpstateBoardLines.size(); i++) {
231             String dumpstateBoardSection =
232                     parseDumpstateBoardSection(dumpstateBoardLines.sectionNameLines.get(i));
233             double duration = parseIntegerDuration(dumpstateBoardLines.durationLines.get(i));
234             if (dumpstateBoardSection == null || duration == -1) {
235                 Log.e(
236                         TAG,
237                         String.format(
238                                 "Section name or duration could not be parsed from (%s, %s)",
239                                 dumpstateBoardLines.sectionNameLines.get(i),
240                                 dumpstateBoardLines.durationLines.get(i)));
241                 continue;
242             }
243             metrics.put(convertDumpstateBoardSectionToKey(dumpstateBoardSection), duration);
244         }
245         return metrics;
246     }
247 
248     @Override
stopCollecting()249     public boolean stopCollecting() {
250         Log.d(TAG, "Stopped collecting for BugReportDuration.");
251         return true;
252     }
253 
254     // Returns the name of the most recent bug report .zip in bugReportDir.
255     @VisibleForTesting
getLatestBugReport()256     public String getLatestBugReport() {
257         try {
258             // executeShellCommand will return files (separated by '\n') in a single String.
259             String[] files =
260                     device.executeShellCommand(String.format(LS_CMD, bugReportDir)).split("\n");
261             HashSet<String> bugreports = new HashSet<>();
262             for (String file : files) {
263                 if (file.contains("bugreport") && file.endsWith("zip")) {
264                     // We don't want to keep track of wifi or telephony bug reports because they
265                     // break the assumption that lexicographically-greater bug reports are also
266                     // more-recent.
267                     if (file.contains("wifi") || file.contains("telephony")) {
268                         Log.w(TAG, "Wifi or telephony bug report found and skipped: " + file);
269                     } else {
270                         bugreports.add(file);
271                     }
272                 }
273             }
274             if (bugreports.size() == 0) {
275                 Log.e(TAG, "Failed to find a bug report in " + bugReportDir);
276                 return null;
277             } else if (bugreports.size() > 1) {
278                 Log.w(TAG, "There are multiple bug reports in " + bugReportDir + ":");
279                 for (String bugreport : bugreports) {
280                     Log.w(TAG, "    " + bugreport);
281                 }
282             }
283             // Returns the newest bug report. Bug report names contain a timestamp, so the
284             // lexicographically-greatest name will correspond to the most recent bug report.
285             return Collections.max(bugreports);
286         } catch (IOException e) {
287             Log.e(TAG, "Failed to find a bug report in  " + bugReportDir + ": " + e.getMessage());
288             return null;
289         }
290     }
291 
292     // Extracts a bug report .txt to stdout and returns a BugReportDurationLines object containing
293     // lines with dumpstate/dumpsys sections and durations.
294     @VisibleForTesting
extractAndFilterBugReport(String archive)295     public BugReportDurationLines extractAndFilterBugReport(String archive) {
296         Path archivePath = Paths.get(bugReportDir, archive);
297         String entry = archive.replace("zip", "txt");
298         UiAutomation automation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
299         String cmd = String.format(UNZIP_EXTRACT_CMD, archivePath.toString(), entry);
300         Log.d(TAG, "The unzip command that will be run is: " + cmd);
301 
302         // We keep track of whether the buffered reader was empty because this probably indicates an
303         // issue in unzipping (e.g. a mismatch in the bugreport's .zip name and .txt entry name).
304         boolean bufferedReaderNotEmpty = false;
305         BugReportDurationLines bugReportDurationLines = new BugReportDurationLines();
306         try (InputStream is =
307                         new ParcelFileDescriptor.AutoCloseInputStream(
308                                 automation.executeShellCommand(cmd));
309                 BufferedReader br = new BufferedReader(new InputStreamReader(is)); ) {
310             String line;
311             while ((line = br.readLine()) != null) {
312                 bufferedReaderNotEmpty = true;
313                 if (line.contains(DUMPSTATE_DURATION_FILTER) && !line.contains(SHOWMAP_FILTER)) {
314                     bugReportDurationLines.dumpstateLines.add(line);
315                 } else if (line.contains(DUMPSYS_DURATION_FILTER)) {
316                     bugReportDurationLines.dumpsysLines.add(line);
317                 } else if (line.contains(DUMPSTATE_LOG_TAG_FILTER)
318                         && line.contains(DUMPSTATE_TIMEOUT_FILTER)) {
319                     bugReportDurationLines.timeoutLines.add(line);
320                 }
321             }
322         } catch (IOException e) {
323             Log.e(TAG, "Failed to extract and parse the raw bug report: " + e.getMessage());
324             return null;
325         }
326         if (!bufferedReaderNotEmpty) {
327             Log.e(
328                     TAG,
329                     String.format(
330                             "The buffered reader for file %s in archive %s was empty.",
331                             entry, archivePath.toString()));
332             dumpBugReportEntries(archivePath);
333         }
334         return bugReportDurationLines;
335     }
336 
337     // Extracts a dumpstate_board.txt file to stdout and returns a DumpstateBoardLines object
338     // containing lines with dumpstate_board sections and lines with durations.
339     @VisibleForTesting
extractAndFilterDumpstateBoard(String archive)340     public DumpstateBoardLines extractAndFilterDumpstateBoard(String archive) {
341         Path archivePath = Paths.get(bugReportDir, archive);
342         String entry = "dumpstate_board.txt";
343         UiAutomation automation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
344         String cmd = String.format(UNZIP_EXTRACT_CMD, archivePath.toString(), entry);
345         Log.d(TAG, "The unzip command that will be run is: " + cmd);
346 
347         // We keep track of whether the buffered reader was empty because this may indicate an issue
348         // in unzipping (e.g. dumpstate_board.txt doesn't exist for some reason).
349         boolean bufferedReaderNotEmpty = false;
350         DumpstateBoardLines dumpstateBoardLines = new DumpstateBoardLines();
351         try (InputStream is =
352                         new ParcelFileDescriptor.AutoCloseInputStream(
353                                 automation.executeShellCommand(cmd));
354                 BufferedReader br = new BufferedReader(new InputStreamReader(is)); ) {
355             String line;
356             while ((line = br.readLine()) != null) {
357                 bufferedReaderNotEmpty = true;
358                 if (line.contains(DUMPSTATE_BOARD_SECTION_FILTER)) {
359                     dumpstateBoardLines.sectionNameLines.add(line);
360                 } else if (line.contains(DUMPSTATE_BOARD_DURATION_FILTER)) {
361                     dumpstateBoardLines.durationLines.add(line);
362                 }
363             }
364         } catch (IOException e) {
365             Log.e(TAG, "Failed to extract and parse dumpstate_board.txt: " + e.getMessage());
366             return null;
367         }
368         if (!bufferedReaderNotEmpty) {
369             Log.e(
370                     TAG,
371                     String.format(
372                             "The buffered reader for file %s in archive %s was empty.",
373                             entry, archivePath.toString()));
374             dumpBugReportEntries(archivePath);
375         }
376         return dumpstateBoardLines;
377     }
378 
379     // Prints out every entry contained in the zip archive at archivePath.
dumpBugReportEntries(Path archivePath)380     private void dumpBugReportEntries(Path archivePath) {
381         UiAutomation automation = InstrumentationRegistry.getInstrumentation().getUiAutomation();
382         String cmd = String.format(UNZIP_CONTENTS_CMD, archivePath.toString());
383         Log.d(TAG, "The list-contents command that will be run is: " + cmd);
384         try (InputStream is =
385                         new ParcelFileDescriptor.AutoCloseInputStream(
386                                 automation.executeShellCommand(cmd));
387                 BufferedReader br = new BufferedReader(new InputStreamReader(is)); ) {
388             String line;
389             Log.d(TAG, "Dumping list of entries in " + archivePath);
390             while ((line = br.readLine()) != null) {
391                 Log.d(TAG, "    " + line);
392             }
393         } catch (IOException e) {
394             Log.e(TAG, "Failed to list the contents of the bug report: " + e.getMessage());
395         }
396     }
397 
398     // Parses a decimal duration from the input duration-relevant log line. This should only be
399     // called for dumpstate/dumpsys lines.
400     @VisibleForTesting
parseDecimalDuration(String line)401     public double parseDecimalDuration(String line) {
402         Matcher m = decimalDurationPattern.matcher(line);
403         if (m.find()) {
404             return Double.parseDouble(m.group());
405         } else {
406             return -1;
407         }
408     }
409 
410     // Parses an integer duration from the input duration-relevant line. This should only be called
411     // for dumpstate_board duration-specific lines.
412     @VisibleForTesting
parseIntegerDuration(String line)413     public double parseIntegerDuration(String line) {
414         Matcher m = integerDurationPattern.matcher(line);
415         if (m.find()) {
416             return Double.parseDouble(m.group());
417         } else {
418             return -1;
419         }
420     }
421 
parseSection(String line, Pattern p)422     private String parseSection(String line, Pattern p) {
423         Matcher m = p.matcher(line);
424         if (m.find()) {
425             // m.group(0) corresponds to the entire match; m.group(1) is the substring within the
426             // first set of parentheses.
427             return m.group(1);
428         } else {
429             return null;
430         }
431     }
432 
433     @VisibleForTesting
parseDumpstateSection(String line)434     public String parseDumpstateSection(String line) {
435         return parseSection(line, dumpstateKeyPattern);
436     }
437 
438     @VisibleForTesting
parseDumpsysSection(String line)439     public String parseDumpsysSection(String line) {
440         return parseSection(line, dumpsysKeyPattern);
441     }
442 
443     @VisibleForTesting
parseDumpstateBoardSection(String line)444     public String parseDumpstateBoardSection(String line) {
445         return parseSection(line, dumpstateBoardKeyPattern);
446     }
447 
448     // Converts a dumpstate section to a key by replacing spaces with '-', lowercasing, and
449     // prepending "bugreport-duration-".
450     @VisibleForTesting
convertDumpstateSectionToKey(String dumpstateSection)451     public String convertDumpstateSectionToKey(String dumpstateSection) {
452         return "bugreport-duration-" + dumpstateSection.replace(" ", "-").toLowerCase();
453     }
454 
455     // Converts a dumpsys section to a key by prepending "bugreport-dumpsys-duration-".
456     //
457     // Spaces aren't replaced because dumpsys sections shouldn't contain spaces. Lowercasing is not
458     // done either because dumpsys section names are case-sensitive, and can be run on-device as-is
459     // (e.g. "dumpsys SurfaceFlinger").
460     @VisibleForTesting
convertDumpsysSectionToKey(String dumpsysSection)461     public String convertDumpsysSectionToKey(String dumpsysSection) {
462         return "bugreport-dumpsys-duration-" + dumpsysSection;
463     }
464 
465     // Converts a dumpstate_board section name to a key by prepending
466     // "bugreport-dumpstateboard-duration-".
467     //
468     // Spaces aren't replaced and lowercasing is not done because dumpstate_board section names are
469     // binary/script names.
470     @VisibleForTesting
convertDumpstateBoardSectionToKey(String dumpstateBoardSection)471     public String convertDumpstateBoardSectionToKey(String dumpstateBoardSection) {
472         return "bugreport-dumpstate_board-duration-" + dumpstateBoardSection;
473     }
474 }
475