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.SystemClock;
22 import android.util.Log;
23 import androidx.annotation.VisibleForTesting;
24 import androidx.test.uiautomator.UiDevice;
25 
26 import java.io.IOException;
27 import java.io.File;
28 import java.nio.file.Paths;
29 import java.util.ArrayList;
30 import java.util.List;
31 import java.util.Map;
32 import java.util.HashMap;
33 
34 import org.junit.runner.Description;
35 
36 /**
37  * A {@link BaseMetricListener} that captures video of the screen.
38  *
39  * <p>This class needs external storage permission. See {@link BaseMetricListener} how to grant
40  * external storage permission, especially at install time.
41  */
42 @OptionClass(alias = "screen-record-collector")
43 public class ScreenRecordCollector extends BaseMetricListener {
44     @VisibleForTesting static final int MAX_RECORDING_PARTS = 5;
45     private static final long VIDEO_TAIL_BUFFER = 500;
46 
47     static final String OUTPUT_DIR = "run_listeners/videos";
48 
49     private UiDevice mDevice;
50     private static File mDestDir;
51 
52     private RecordingThread mCurrentThread;
53 
54     // Tracks the test iterations to ensure that each failure gets unique filenames.
55     // Key: test description; value: number of iterations.
56     private Map<String, Integer> mTestIterations = new HashMap<String, Integer>();
57 
58     @Override
onTestRunStart(DataRecord runData, Description description)59     public void onTestRunStart(DataRecord runData, Description description) {
60         mDestDir = createAndEmptyDirectory(OUTPUT_DIR);
61     }
62 
63     @Override
onTestStart(DataRecord testData, Description description)64     public void onTestStart(DataRecord testData, Description description) {
65         if (mDestDir == null) {
66             return;
67         }
68 
69         // Track the number of iteration for this test.
70         amendIterations(description);
71         // Start the screen recording operation.
72         mCurrentThread = new RecordingThread("test-screen-record", description);
73         mCurrentThread.start();
74     }
75 
76     @Override
onTestEnd(DataRecord testData, Description description)77     public void onTestEnd(DataRecord testData, Description description) {
78         // Skip if not directory.
79         if (mDestDir == null) {
80             return;
81         }
82 
83         // Add some extra time to the video end.
84         SystemClock.sleep(getTailBuffer());
85         // Ctrl + C all screen record processes.
86         mCurrentThread.cancel();
87         // Wait for the thread to completely die.
88         try {
89             mCurrentThread.join();
90         } catch (InterruptedException ex) {
91             Log.e(getTag(), "Interrupted when joining the recording thread.", ex);
92         }
93 
94         // Add the output files to the data record.
95         for (File recording : mCurrentThread.getRecordings()) {
96             Log.d(getTag(), String.format("Adding video part: #%s", recording.getName()));
97             testData.addFileMetric(
98                     String.format("%s_%s", getTag(), recording.getName()), recording);
99         }
100 
101         // TODO(b/144869954): Delete when tests pass.
102     }
103 
104     /** Updates the number of iterations performed for a given test {@link Description}. */
amendIterations(Description description)105     private void amendIterations(Description description) {
106         String testName = description.getDisplayName();
107         mTestIterations.computeIfPresent(testName, (name, iterations) -> iterations + 1);
108         mTestIterations.computeIfAbsent(testName, name -> 1);
109     }
110 
111     /** Returns the recording's name for part {@code part} of test {@code description}. */
getOutputFile(Description description, int part)112     private File getOutputFile(Description description, int part) {
113         final String baseName =
114                 String.format("%s.%s", description.getClassName(), description.getMethodName());
115         // Omit the iteration number for the first iteration.
116         int iteration = mTestIterations.get(description.getDisplayName());
117         final String fileName =
118                 String.format(
119                         "%s-video%s.mp4",
120                         iteration == 1
121                                 ? baseName
122                                 : String.join("-", baseName, String.valueOf(iteration)),
123                         part == 1 ? "" : part);
124         return Paths.get(mDestDir.getAbsolutePath(), fileName).toFile();
125     }
126 
127     /** Returns a buffer duration for the end of the video. */
128     @VisibleForTesting
getTailBuffer()129     public long getTailBuffer() {
130         return VIDEO_TAIL_BUFFER;
131     }
132 
133     /** Returns the currently active {@link UiDevice}. */
getDevice()134     public UiDevice getDevice() {
135         if (mDevice == null) {
136             mDevice = UiDevice.getInstance(getInstrumentation());
137         }
138         return mDevice;
139     }
140 
141     private class RecordingThread extends Thread {
142         private final Description mDescription;
143         private final List<File> mRecordings;
144 
145         private boolean mContinue;
146 
RecordingThread(String name, Description description)147         public RecordingThread(String name, Description description) {
148             super(name);
149 
150             mContinue = true;
151             mRecordings = new ArrayList<>();
152 
153             assertNotNull("No test description provided for recording.", description);
154             mDescription = description;
155         }
156 
157         @Override
run()158         public void run() {
159             try {
160                 // Start at i = 1 to encode parts as X.mp4, X2.mp4, X3.mp4, etc.
161                 for (int i = 1; i <= MAX_RECORDING_PARTS && mContinue; i++) {
162                     File output = getOutputFile(mDescription, i);
163                     Log.d(
164                             getTag(),
165                             String.format("Recording screen to %s", output.getAbsolutePath()));
166                     mRecordings.add(output);
167                     // Make sure not to block on this background command in the main thread so
168                     // that the test continues to run, but block in this thread so it does not
169                     // trigger a new screen recording session before the prior one completes.
170                     getDevice()
171                             .executeShellCommand(
172                                     String.format("screenrecord %s", output.getAbsolutePath()));
173                 }
174             } catch (IOException e) {
175                 throw new RuntimeException("Caught exception while screen recording.");
176             }
177         }
178 
cancel()179         public void cancel() {
180             mContinue = false;
181 
182             // Identify the screenrecord PIDs and send SIGINT 2 (Ctrl + C) to each.
183             try {
184                 String[] pids = getDevice().executeShellCommand("pidof screenrecord").split(" ");
185                 for (String pid : pids) {
186                     // Avoid empty process ids, because of weird splitting behavior.
187                     if (pid.isEmpty()) {
188                         continue;
189                     }
190 
191                     getDevice().executeShellCommand(String.format("kill -2 %s", pid));
192                     Log.d(
193                             getTag(),
194                             String.format("Sent SIGINT 2 to screenrecord process (%s)", pid));
195                 }
196             } catch (IOException e) {
197                 throw new RuntimeException("Failed to kill screen recording process.");
198             }
199         }
200 
getRecordings()201         public List<File> getRecordings() {
202             return mRecordings;
203         }
204 
getTag()205         private String getTag() {
206             return RecordingThread.class.getName();
207         }
208     }
209 }
210