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