1 /*
2  * Copyright (C) 2017 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 package android.device.collectors;
17 
18 import android.device.collectors.annotations.MetricOption;
19 import android.device.collectors.annotations.OptionClass;
20 import android.device.collectors.util.SendToInstrumentation;
21 import android.os.Bundle;
22 import android.os.Environment;
23 import android.os.ParcelFileDescriptor;
24 import androidx.annotation.VisibleForTesting;
25 import android.util.Log;
26 
27 import androidx.test.InstrumentationRegistry;
28 import androidx.test.internal.runner.listener.InstrumentationRunListener;
29 
30 import org.junit.runner.Description;
31 import org.junit.runner.Result;
32 import org.junit.runner.notification.Failure;
33 
34 import java.io.ByteArrayOutputStream;
35 import java.io.File;
36 import java.io.IOException;
37 import java.io.InputStream;
38 import java.io.PrintStream;
39 import java.util.ArrayList;
40 import java.util.Arrays;
41 import java.util.HashMap;
42 import java.util.HashSet;
43 import java.util.Map;
44 import java.util.List;
45 import java.util.Set;
46 
47 /**
48  * Base implementation of a device metric listener that will capture and output metrics for each
49  * test run or test cases. Collectors will have access to {@link DataRecord} objects where they
50  * can put results and the base class ensure these results will be send to the instrumentation.
51  *
52  * Any subclass that calls {@link #createAndEmptyDirectory(String)} needs external storage
53  * permission. So to use this class at runtime, your test need to
54  * <a href="{@docRoot}training/basics/data-storage/files.html#GetWritePermission">have storage
55  * permission enabled</a>, and preferably granted at install time (to avoid interrupting the test).
56  * For testing at desk, run adb install -r -g testpackage.apk
57  * "-g" grants all required permission at install time.
58  *
59  * Filtering:
60  * You can annotate any test method (@Test) with {@link MetricOption} and specify an arbitrary
61  * group name that the test will be part of. It is possible to trigger the collection only against
62  * test part of a group using '--include-filter-group [group name]' or to exclude a particular
63  * group using '--exclude-filter-group [group name]'.
64  * Several group name can be passed using a comma separated argument.
65  *
66  */
67 public class BaseMetricListener extends InstrumentationRunListener {
68 
69     public static final int BUFFER_SIZE = 1024;
70     // Default collect iteration interval.
71     private static final int DEFAULT_COLLECT_INTERVAL = 1;
72 
73     // Default skip metric until iteration count.
74     private static final int SKIP_UNTIL_DEFAULT_ITERATION = 0;
75 
76     /** Options keys that the collector can receive. */
77     // Filter groups, comma separated list of group name to be included or excluded
78     public static final String INCLUDE_FILTER_GROUP_KEY = "include-filter-group";
79     public static final String EXCLUDE_FILTER_GROUP_KEY = "exclude-filter-group";
80     // Argument passed to AndroidJUnitRunner to make it log-only, we shouldn't collect on log only.
81     public static final String ARGUMENT_LOG_ONLY = "log";
82     // Collect metric every nth iteration of a test with the same name.
83     public static final String COLLECT_ITERATION_INTERVAL = "collect_iteration_interval";
84 
85     // Skip metric collection until given n iteration. Uses 1 indexing here.
86     // For example if overall iteration is 10 and skip until iteration is set
87     // to 3. Metric will not be collected for 1st,2nd and 3rd iteration.
88     public static final String SKIP_METRIC_UNTIL_ITERATION = "skip_metric_until_iteration";
89 
90     private static final String NAMESPACE_SEPARATOR = ":";
91 
92     private DataRecord mRunData;
93     private DataRecord mTestData;
94 
95     private Bundle mArgsBundle = null;
96     private final List<String> mIncludeFilters;
97     private final List<String> mExcludeFilters;
98     private boolean mLogOnly = false;
99     // Store the method name and invocation count.
100     private Map<String, Integer> mTestIdInvocationCount = new HashMap<>();
101     private int mCollectIterationInterval = 1;
102     private int mSkipMetricUntilIteration = 0;
103 
BaseMetricListener()104     public BaseMetricListener() {
105         mIncludeFilters = new ArrayList<>();
106         mExcludeFilters = new ArrayList<>();
107     }
108 
109     /**
110      * Constructor to simulate receiving the instrumentation arguments. Should not be used except
111      * for testing.
112      */
113     @VisibleForTesting
BaseMetricListener(Bundle argsBundle)114     protected BaseMetricListener(Bundle argsBundle) {
115         this();
116         mArgsBundle = argsBundle;
117     }
118 
119     @Override
testRunStarted(Description description)120     public final void testRunStarted(Description description) throws Exception {
121         setUp();
122         if (!mLogOnly) {
123             try {
124                 mRunData = createDataRecord();
125                 onTestRunStart(mRunData, description);
126             } catch (RuntimeException e) {
127                 // Prevent exception from reporting events.
128                 Log.e(getTag(), "Exception during onTestRunStart.", e);
129             }
130         }
131         super.testRunStarted(description);
132     }
133 
134     @Override
testRunFinished(Result result)135     public final void testRunFinished(Result result) throws Exception {
136         if (!mLogOnly) {
137             try {
138                 onTestRunEnd(mRunData, result);
139             } catch (RuntimeException e) {
140                 // Prevent exception from reporting events.
141                 Log.e(getTag(), "Exception during onTestRunEnd.", e);
142             }
143         }
144         cleanUp();
145         super.testRunFinished(result);
146     }
147 
148     @Override
testStarted(Description description)149     public final void testStarted(Description description) throws Exception {
150 
151         // Update the current invocation before proceeding with metric collection.
152         // mTestIdInvocationCount uses 1 indexing.
153         mTestIdInvocationCount.compute(description.toString(),
154                 (key, value) -> (value == null) ? 1 : value + 1);
155 
156         if (shouldRun(description)) {
157             try {
158                 mTestData = createDataRecord();
159                 onTestStart(mTestData, description);
160             } catch (RuntimeException e) {
161                 // Prevent exception from reporting events.
162                 Log.e(getTag(), "Exception during onTestStart.", e);
163             }
164         }
165         super.testStarted(description);
166     }
167 
168     @Override
testFailure(Failure failure)169     public final void testFailure(Failure failure) throws Exception {
170         Description description = failure.getDescription();
171         if (shouldRun(description)) {
172             try {
173                 onTestFail(mTestData, description, failure);
174             } catch (RuntimeException e) {
175                 // Prevent exception from reporting events.
176                 Log.e(getTag(), "Exception during onTestFail.", e);
177             }
178         }
179         super.testFailure(failure);
180     }
181 
182     @Override
testFinished(Description description)183     public final void testFinished(Description description) throws Exception {
184         if (shouldRun(description)) {
185             try {
186                 onTestEnd(mTestData, description);
187             } catch (RuntimeException e) {
188                 // Prevent exception from reporting events.
189                 Log.e(getTag(), "Exception during onTestEnd.", e);
190             }
191             if (mTestData.hasMetrics()) {
192                 // Only send the status progress if there are metrics
193                 SendToInstrumentation.sendBundle(getInstrumentation(),
194                         mTestData.createBundleFromMetrics());
195             }
196         }
197         super.testFinished(description);
198     }
199 
200     @Override
instrumentationRunFinished( PrintStream streamResult, Bundle resultBundle, Result junitResults)201     public void instrumentationRunFinished(
202             PrintStream streamResult, Bundle resultBundle, Result junitResults) {
203         // Test Run data goes into the INSTRUMENTATION_RESULT
204         if (mRunData != null) {
205             resultBundle.putAll(mRunData.createBundleFromMetrics());
206         }
207     }
208 
209     /**
210      * Set up the metric collector.
211      *
212      * <p>If another class is invoking the metric collector's callbacks directly, it should call
213      * this method to make sure that the metric collector is set up properly.
214      */
setUp()215     public final void setUp() {
216         parseArguments();
217         setupAdditionalArgs();
218         onSetUp();
219     }
220 
221     /**
222      * Clean up the metric collector.
223      *
224      * <p>If another class is invoking the metric collector's callbacks directly, it should call
225      * this method to make sure that the metric collector is cleaned up properly after collection.
226      */
cleanUp()227     public final void cleanUp() {
228         onCleanUp();
229     }
230 
231     /**
232      * Create a {@link DataRecord}. Exposed for testing.
233      */
234     @VisibleForTesting
createDataRecord()235     DataRecord createDataRecord() {
236         return new DataRecord();
237     }
238 
239     // ---------- Interfaces that can be implemented to set up and clean up metric collection.
240 
241     /** Called if custom set-up is needed for this metric collector. */
onSetUp()242     protected void onSetUp() {
243         // Does nothing by default.
244     }
245 
onCleanUp()246     protected void onCleanUp() {
247         // Does nothing by default.
248     }
249 
250     // ---------- Interfaces that can be implemented to take action on each test state.
251 
252     /**
253      * Called when {@link #testRunStarted(Description)} is called.
254      *
255      * @param runData structure where metrics can be put.
256      * @param description the {@link Description} for the run about to start.
257      */
onTestRunStart(DataRecord runData, Description description)258     public void onTestRunStart(DataRecord runData, Description description) {
259         // Does nothing
260     }
261 
262     /**
263      * Called when {@link #testRunFinished(Result result)} is called.
264      *
265      * @param runData structure where metrics can be put.
266      * @param result the {@link Result} for the run coming from the runner.
267      */
onTestRunEnd(DataRecord runData, Result result)268     public void onTestRunEnd(DataRecord runData, Result result) {
269         // Does nothing
270     }
271 
272     /**
273      * Called when {@link #testStarted(Description)} is called.
274      *
275      * @param testData structure where metrics can be put.
276      * @param description the {@link Description} for the test case about to start.
277      */
onTestStart(DataRecord testData, Description description)278     public void onTestStart(DataRecord testData, Description description) {
279         // Does nothing
280     }
281 
282     /**
283      * Called when {@link #testFailure(Failure)} is called.
284      *
285      * @param testData structure where metrics can be put.
286      * @param description the {@link Description} for the test case that just failed.
287      * @param failure the {@link Failure} describing the failure.
288      */
onTestFail(DataRecord testData, Description description, Failure failure)289     public void onTestFail(DataRecord testData, Description description, Failure failure) {
290         // Does nothing
291     }
292 
293     /**
294      * Called when {@link #testFinished(Description)} is called.
295      *
296      * @param testData structure where metrics can be put.
297      * @param description the {@link Description} of the test coming from the runner.
298      */
onTestEnd(DataRecord testData, Description description)299     public void onTestEnd(DataRecord testData, Description description) {
300         // Does nothing
301     }
302 
303     /**
304      * To add listener-specific extra args, implement this method in the sub class and add the
305      * listener specific args.
306      */
setupAdditionalArgs()307     public void setupAdditionalArgs() {
308         // NO-OP by default
309     }
310 
311     /**
312      * Turn executeShellCommand into a blocking operation.
313      *
314      * @param command shell command to be executed.
315      * @return byte array of execution result
316      */
executeCommandBlocking(String command)317     public byte[] executeCommandBlocking(String command) {
318         try (
319                 InputStream is = new ParcelFileDescriptor.AutoCloseInputStream(
320                         getInstrumentation().getUiAutomation().executeShellCommand(command));
321                 ByteArrayOutputStream out = new ByteArrayOutputStream()
322         ) {
323             byte[] buf = new byte[BUFFER_SIZE];
324             int length;
325             while ((length = is.read(buf)) >= 0) {
326                 out.write(buf, 0, length);
327             }
328             return out.toByteArray();
329         } catch (IOException e) {
330             Log.e(getTag(), "Error executing: " + command, e);
331             return null;
332         }
333     }
334 
335     /**
336      * Create a directory inside external storage, and empty it.
337      *
338      * @param dir full path to the dir to be created.
339      * @return directory file created
340      */
createAndEmptyDirectory(String dir)341     public File createAndEmptyDirectory(String dir) {
342         File rootDir = Environment.getExternalStorageDirectory();
343         File destDir = new File(rootDir, dir);
344         executeCommandBlocking("rm -rf " + destDir.getAbsolutePath());
345         if (!destDir.exists() && !destDir.mkdirs()) {
346             Log.e(getTag(), "Unable to create dir: " + destDir.getAbsolutePath());
347             return null;
348         }
349         return destDir;
350     }
351 
352     /**
353      * Delete a directory and all the file inside.
354      *
355      * @param rootDir the {@link File} directory to delete.
356      */
recursiveDelete(File rootDir)357     public void recursiveDelete(File rootDir) {
358         if (rootDir != null) {
359             if (rootDir.isDirectory()) {
360                 File[] childFiles = rootDir.listFiles();
361                 if (childFiles != null) {
362                     for (File child : childFiles) {
363                         recursiveDelete(child);
364                     }
365                 }
366             }
367             rootDir.delete();
368         }
369     }
370 
371     /**
372      * Returns the name of the current class to be used as a logging tag.
373      */
getTag()374     String getTag() {
375         return this.getClass().getName();
376     }
377 
378     /**
379      * Returns the bundle containing the instrumentation arguments.
380      */
getArgsBundle()381     protected final Bundle getArgsBundle() {
382         if (mArgsBundle == null) {
383             mArgsBundle = InstrumentationRegistry.getArguments();
384         }
385         return mArgsBundle;
386     }
387 
parseArguments()388     private void parseArguments() {
389         Bundle args = getArgsBundle();
390         // First filter the arguments with the alias
391         filterAlias(args);
392         // Handle filtering
393         String includeGroup = args.getString(INCLUDE_FILTER_GROUP_KEY);
394         String excludeGroup = args.getString(EXCLUDE_FILTER_GROUP_KEY);
395         if (includeGroup != null) {
396             mIncludeFilters.addAll(Arrays.asList(includeGroup.split(",")));
397         }
398         if (excludeGroup != null) {
399             mExcludeFilters.addAll(Arrays.asList(excludeGroup.split(",")));
400         }
401         mCollectIterationInterval = Integer.parseInt(args.getString(
402                 COLLECT_ITERATION_INTERVAL, String.valueOf(DEFAULT_COLLECT_INTERVAL)));
403         mSkipMetricUntilIteration = Integer.parseInt(args.getString(
404                 SKIP_METRIC_UNTIL_ITERATION, String.valueOf(SKIP_UNTIL_DEFAULT_ITERATION)));
405 
406         if (mCollectIterationInterval < 1) {
407             Log.i(getTag(), "Metric collection iteration interval cannot be less than 1."
408                     + "Switching to collect for all the iterations.");
409             // Reset to collect for all the iterations.
410             mCollectIterationInterval = 1;
411         }
412         String logOnly = args.getString(ARGUMENT_LOG_ONLY);
413         if (logOnly != null) {
414             mLogOnly = Boolean.parseBoolean(logOnly);
415         }
416     }
417 
418     /**
419      * Filter the alias-ed options from the bundle, each implementation of BaseMetricListener will
420      * have its own list of arguments.
421      * TODO: Split the filtering logic outside the collector class in a utility/helper.
422      */
filterAlias(Bundle bundle)423     private void filterAlias(Bundle bundle) {
424         Set<String> keySet = new HashSet<>(bundle.keySet());
425         OptionClass optionClass = this.getClass().getAnnotation(OptionClass.class);
426         if (optionClass == null) {
427             // No @OptionClass was specified, remove all alias-ed options.
428             for (String key : keySet) {
429                 if (key.indexOf(NAMESPACE_SEPARATOR) != -1) {
430                     bundle.remove(key);
431                 }
432             }
433             return;
434         }
435         // Alias is a required field so if OptionClass is set, alias is set.
436         String alias = optionClass.alias();
437         for (String key : keySet) {
438             if (key.indexOf(NAMESPACE_SEPARATOR) == -1) {
439                 continue;
440             }
441             String optionAlias = key.split(NAMESPACE_SEPARATOR)[0];
442             if (alias.equals(optionAlias)) {
443                 // Place the option again, without alias.
444                 String optionName = key.split(NAMESPACE_SEPARATOR)[1];
445                 bundle.putString(optionName, bundle.getString(key));
446                 bundle.remove(key);
447             } else {
448                 // Remove other aliases.
449                 bundle.remove(key);
450             }
451         }
452     }
453 
454     /**
455      * Helper to decide whether the collector should run or not against the test case.
456      *
457      * @param desc The {@link Description} of the method.
458      * @return True if the collector should run.
459      */
shouldRun(Description desc)460     private boolean shouldRun(Description desc) {
461         if (mLogOnly) {
462             return false;
463         }
464 
465         MetricOption annotation = desc.getAnnotation(MetricOption.class);
466         List<String> groups = new ArrayList<>();
467         if (annotation != null) {
468             String group = annotation.group();
469             groups.addAll(Arrays.asList(group.split(",")));
470         }
471         if (!mExcludeFilters.isEmpty()) {
472             for (String group : groups) {
473                 // Exclude filters has priority, if any of the group is excluded, exclude the method
474                 if (mExcludeFilters.contains(group)) {
475                     return false;
476                 }
477             }
478         }
479         // If we have include filters, we can only run what's part of them.
480         if (!mIncludeFilters.isEmpty()) {
481             for (String group : groups) {
482                 if (mIncludeFilters.contains(group)) {
483                     return true;
484                 }
485             }
486             // We have include filter and did not match them.
487             return false;
488         }
489 
490         // Skip metric collection if current iteration is lesser than or equal to
491         // given skip until iteration count.
492         // mTestIdInvocationCount uses 1 indexing.
493         if (mTestIdInvocationCount.containsKey(desc.toString())
494                 && mTestIdInvocationCount.get(desc.toString()) <= mSkipMetricUntilIteration) {
495             Log.i(getTag(), String.format("Skipping metric collection. Current iteration is %d."
496                     + "Requested to skip metric until %d",
497                     mTestIdInvocationCount.get(desc.toString()),
498                     mSkipMetricUntilIteration));
499             return false;
500         }
501 
502         // Check for iteration interval metric collection criteria.
503         if (mTestIdInvocationCount.containsKey(desc.toString())
504                 && (mTestIdInvocationCount.get(desc.toString()) % mCollectIterationInterval != 0)) {
505             return false;
506         }
507         return true;
508     }
509 }
510