• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2019 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 static org.junit.Assert.assertNotNull;
19 
20 import android.device.collectors.annotations.OptionClass;
21 import android.os.Bundle;
22 import android.os.SystemClock;
23 import android.util.Log;
24 import androidx.annotation.VisibleForTesting;
25 import androidx.test.uiautomator.UiDevice;
26 
27 import java.io.IOException;
28 import java.io.File;
29 import java.nio.file.Paths;
30 import java.util.ArrayList;
31 import java.util.List;
32 import java.util.Map;
33 import java.util.HashMap;
34 
35 import org.junit.runner.Description;
36 
37 /**
38  * A {@link BaseMetricListener} that captures video of the screen.
39  *
40  * <p>This class needs external storage permission. See {@link BaseMetricListener} how to grant
41  * external storage permission, especially at install time.
42  */
43 @OptionClass(alias = "screen-record-collector")
44 public class ScreenRecordCollector extends BaseMetricListener {
45     // Quality is relative to screen resolution.
46     // *  "medium" is 1/2 the resolution.
47     // *  "low" is 1/8 the resolution.
48     // *  Otherwise, use the resolution.
49     @VisibleForTesting static final String QUALITY_ARG = "video-quality";
50     // Option for whether to empty the output directory before collecting. Defaults to true. Setting
51     // to false is useful when multiple test classes need recordings and recordings are pulled at
52     // the end of the test run.
53     @VisibleForTesting static final String EMPTY_OUTPUT_DIR_ARG = "empty-output-dir";
54     // Maximum parts per test (each part is <= 3min).
55     @VisibleForTesting static final int MAX_RECORDING_PARTS = 5;
56     private static final long VIDEO_TAIL_BUFFER = 500;
57 
58     static final String OUTPUT_DIR = "run_listeners/videos";
59 
60     private UiDevice mDevice;
61     private static File mDestDir;
62 
63     private RecordingThread mCurrentThread;
64 
65     private String mVideoDimensions;
66     private boolean mEmptyOutputDir;
67 
68     // Tracks the test iterations to ensure that each failure gets unique filenames.
69     // Key: test description; value: number of iterations.
70     private Map<String, Integer> mTestIterations = new HashMap<String, Integer>();
71 
ScreenRecordCollector()72     public ScreenRecordCollector() {
73         super();
74     }
75 
76     /** Constructors for overriding instrumentation arguments only. */
77     @VisibleForTesting
ScreenRecordCollector(Bundle args)78     ScreenRecordCollector(Bundle args) {
79         super(args);
80     }
81 
82     @Override
onSetUp()83     public void onSetUp() {
84         mDestDir = createDirectory(OUTPUT_DIR, mEmptyOutputDir);
85     }
86 
87     @Override
setupAdditionalArgs()88     public void setupAdditionalArgs() {
89         mEmptyOutputDir =
90                 Boolean.parseBoolean(
91                         getArgsBundle().getString(EMPTY_OUTPUT_DIR_ARG, String.valueOf(true)));
92 
93         try {
94             long scaleDown = 1;
95             switch (getArgsBundle().getString(QUALITY_ARG, "default")) {
96                 case "high":
97                     scaleDown = 1;
98                     break;
99 
100                 case "medium":
101                     scaleDown = 2;
102                     break;
103 
104                 case "low":
105                     scaleDown = 8;
106                     break;
107 
108                 default:
109                     return;
110             }
111 
112             // Display metrics isn't the absolute size, so use "wm size".
113             String[] dims =
114                     getDevice()
115                             .executeShellCommand("wm size")
116                             .substring("Physical size: ".length())
117                             .trim()
118                             .split("x");
119             int width = Integer.parseInt(dims[0]);
120             int height = Integer.parseInt(dims[1]);
121             mVideoDimensions = String.format("%dx%d", width / scaleDown, height / scaleDown);
122             Log.v(getTag(), String.format("Using video dimensions: %s", mVideoDimensions));
123         } catch (Exception e) {
124             Log.e(getTag(), "Failed to query the device dimensions. Using default.", e);
125         }
126     }
127 
128     @Override
onTestStart(DataRecord testData, Description description)129     public void onTestStart(DataRecord testData, Description description) {
130         if (mDestDir == null) {
131             return;
132         }
133 
134         // Track the number of iteration for this test.
135         amendIterations(description);
136         // Start the screen recording operation.
137         mCurrentThread = new RecordingThread("test-screen-record", description);
138         mCurrentThread.start();
139     }
140 
141     @Override
onTestEnd(DataRecord testData, Description description)142     public void onTestEnd(DataRecord testData, Description description) {
143         // Skip if not directory.
144         if (mDestDir == null) {
145             return;
146         }
147 
148         // Add some extra time to the video end.
149         SystemClock.sleep(getTailBuffer());
150         // Ctrl + C all screen record processes.
151         mCurrentThread.cancel();
152         // Wait for the thread to completely die.
153         try {
154             mCurrentThread.join();
155         } catch (InterruptedException ex) {
156             Log.e(getTag(), "Interrupted when joining the recording thread.", ex);
157         }
158 
159         // Add the output files to the data record.
160         for (File recording : mCurrentThread.getRecordings()) {
161             Log.d(getTag(), String.format("Adding video part: #%s", recording.getName()));
162             testData.addFileMetric(
163                     String.format("%s_%s", getTag(), recording.getName()), recording);
164         }
165 
166         // TODO(b/144869954): Delete when tests pass.
167     }
168 
169     /** Updates the number of iterations performed for a given test {@link Description}. */
amendIterations(Description description)170     private void amendIterations(Description description) {
171         String testName = description.getDisplayName();
172         mTestIterations.computeIfPresent(testName, (name, iterations) -> iterations + 1);
173         mTestIterations.computeIfAbsent(testName, name -> 1);
174     }
175 
176     /** Returns the recording's name for part {@code part} of test {@code description}. */
getOutputFile(Description description, int part)177     private File getOutputFile(Description description, int part) {
178         StringBuilder builder = new StringBuilder(description.getClassName());
179         if (description.getMethodName() != null) {
180             builder.append(".");
181             builder.append(description.getMethodName());
182         }
183         int iteration = mTestIterations.get(description.getDisplayName());
184         // Omit the iteration number for the first iteration.
185         if (iteration > 1) {
186             builder.append("-");
187             builder.append(iteration);
188         }
189         builder.append("-video");
190         // Omit the part number for the first part.
191         if (part > 1) {
192             builder.append(part);
193         }
194         builder.append(".mp4");
195         return Paths.get(mDestDir.getAbsolutePath(), builder.toString()).toFile();
196     }
197 
198     /** Returns a buffer duration for the end of the video. */
199     @VisibleForTesting
getTailBuffer()200     public long getTailBuffer() {
201         return VIDEO_TAIL_BUFFER;
202     }
203 
204     /** Returns the currently active {@link UiDevice}. */
getDevice()205     public UiDevice getDevice() {
206         if (mDevice == null) {
207             mDevice = UiDevice.getInstance(getInstrumentation());
208         }
209         return mDevice;
210     }
211 
212     private class RecordingThread extends Thread {
213         private final Description mDescription;
214         private final List<File> mRecordings;
215 
216         private boolean mContinue;
217 
RecordingThread(String name, Description description)218         public RecordingThread(String name, Description description) {
219             super(name);
220 
221             mContinue = true;
222             mRecordings = new ArrayList<>();
223 
224             assertNotNull("No test description provided for recording.", description);
225             mDescription = description;
226         }
227 
228         @Override
run()229         public void run() {
230             try {
231                 // Start at i = 1 to encode parts as X.mp4, X2.mp4, X3.mp4, etc.
232                 for (int i = 1; i <= MAX_RECORDING_PARTS && mContinue; i++) {
233                     File output = getOutputFile(mDescription, i);
234                     Log.d(
235                             getTag(),
236                             String.format("Recording screen to %s", output.getAbsolutePath()));
237                     mRecordings.add(output);
238                     // Make sure not to block on this background command in the main thread so
239                     // that the test continues to run, but block in this thread so it does not
240                     // trigger a new screen recording session before the prior one completes.
241                     String dimensionsOpt =
242                             mVideoDimensions == null
243                                     ? ""
244                                     : String.format("--size=%s", mVideoDimensions);
245                     getDevice()
246                             .executeShellCommand(
247                                     String.format(
248                                             "screenrecord %s %s",
249                                             dimensionsOpt, output.getAbsolutePath()));
250                 }
251             } catch (IOException e) {
252                 throw new RuntimeException("Caught exception while screen recording.");
253             }
254         }
255 
cancel()256         public void cancel() {
257             mContinue = false;
258 
259             // Identify the screenrecord PIDs and send SIGINT 2 (Ctrl + C) to each.
260             try {
261                 String[] pids = getDevice().executeShellCommand("pidof screenrecord").split(" ");
262                 for (String pid : pids) {
263                     // Avoid empty process ids, because of weird splitting behavior.
264                     if (pid.isEmpty()) {
265                         continue;
266                     }
267 
268                     getDevice().executeShellCommand(String.format("kill -2 %s", pid));
269                     Log.d(
270                             getTag(),
271                             String.format("Sent SIGINT 2 to screenrecord process (%s)", pid));
272                 }
273             } catch (IOException e) {
274                 throw new RuntimeException("Failed to kill screen recording process.");
275             }
276         }
277 
getRecordings()278         public List<File> getRecordings() {
279             return mRecordings;
280         }
281 
getTag()282         private String getTag() {
283             return RecordingThread.class.getName();
284         }
285     }
286 }
287