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