Add a screen record collector and tests.

Bug: 144698400
Test: included
Change-Id: I691eb138cca9470187dcf9e849e5065c3c6e8ca9
(cherry picked from commit b34f56c18a19bc4c3b356bbfda0a1a90aed5bafe)
diff --git a/libraries/device-collectors/src/main/java/android/device/collectors/ScreenRecordCollector.java b/libraries/device-collectors/src/main/java/android/device/collectors/ScreenRecordCollector.java
new file mode 100644
index 0000000..86abe33
--- /dev/null
+++ b/libraries/device-collectors/src/main/java/android/device/collectors/ScreenRecordCollector.java
@@ -0,0 +1,156 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.device.collectors;
+
+import android.device.collectors.annotations.OptionClass;
+import android.os.SystemClock;
+import android.support.test.uiautomator.UiDevice;
+import android.util.Log;
+import androidx.annotation.VisibleForTesting;
+
+import java.io.IOException;
+import java.io.File;
+import java.nio.file.Paths;
+import java.util.Map;
+import java.util.HashMap;
+
+import org.junit.runner.Description;
+
+/**
+ * A {@link BaseMetricListener} that captures video of the screen.
+ *
+ * <p>This class needs external storage permission. See {@link BaseMetricListener} how to grant
+ * external storage permission, especially at install time.
+ */
+@OptionClass(alias = "screen-record-collector")
+public class ScreenRecordCollector extends BaseMetricListener {
+    private static final long VIDEO_TAIL_BUFFER = 2000;
+
+    static final String OUTPUT_DIR = "run_listeners/videos";
+
+    private UiDevice mDevice;
+    private File mDestDir;
+
+    // Tracks the test iterations to ensure that each failure gets unique filenames.
+    // Key: test description; value: number of iterations.
+    private Map<String, Integer> mTestIterations = new HashMap<String, Integer>();
+
+    @Override
+    public void onTestRunStart(DataRecord runData, Description description) {
+        mDestDir = createAndEmptyDirectory(OUTPUT_DIR);
+    }
+
+    @Override
+    public void onTestStart(DataRecord testData, Description description) {
+        if (mDestDir == null) {
+            return;
+        }
+
+        // Track the number of iteration for this test.
+        amendIterations(description);
+        // Start the screen recording operation.
+        startScreenRecordThread(getOutputFile(description).getAbsolutePath());
+    }
+
+    @Override
+    public void onTestEnd(DataRecord testData, Description description) {
+        // Skip if not directory.
+        if (mDestDir == null) {
+            return;
+        }
+
+        // Add some extra time to the video end.
+        SystemClock.sleep(getTailBuffer());
+        // Ctrl + C all screen record processes.
+        killScreenRecordProcesses();
+
+        // Add the output file to the data record.
+        File output = getOutputFile(description);
+        testData.addFileMetric(String.format("%s_%s", getTag(), output.getName()), output);
+
+        // TODO(b/144869954): Delete when tests pass.
+    }
+
+    /** Updates the number of iterations performed for a given test {@link Description}. */
+    private void amendIterations(Description description) {
+        String testName = description.getDisplayName();
+        mTestIterations.computeIfPresent(testName, (name, iterations) -> iterations + 1);
+        mTestIterations.computeIfAbsent(testName, name -> 1);
+    }
+
+    private File getOutputFile(Description description) {
+        final String baseName =
+                String.format("%s.%s", description.getClassName(), description.getMethodName());
+        // Omit the iteration number for the first iteration.
+        int iteration = mTestIterations.get(description.getDisplayName());
+        final String fileName =
+                String.format(
+                        "%s-video.mp4",
+                        iteration == 1
+                                ? baseName
+                                : String.join("-", baseName, String.valueOf(iteration)));
+        return Paths.get(mDestDir.getAbsolutePath(), fileName).toFile();
+    }
+
+    /** Spawns a thread to start screen recording that will save to the provided {@code path}. */
+    public void startScreenRecordThread(String path) {
+        Log.d(getTag(), String.format("Recording screen to %s", path));
+        new Thread("test-screenrecord-thread") {
+            @Override
+            public void run() {
+                try {
+                    // Make sure not to block on this background command.
+                    getDevice().executeShellCommand(String.format("screenrecord %s", path));
+                } catch (IOException e) {
+                    throw new RuntimeException("Failed to start screen recording.");
+                }
+            }
+        }.start();
+    }
+
+    /** Kills all screen recording processes that are actively running on the device. */
+    public void killScreenRecordProcesses() {
+        try {
+            // Identify the screenrecord PIDs and send SIGINT 2 (Ctrl + C) to each.
+            String[] pids = getDevice().executeShellCommand("pidof screenrecord").split(" ");
+            for (String pid : pids) {
+                // Avoid empty process ids, because of weird splitting behavior.
+                if (pid.isEmpty()) {
+                    continue;
+                }
+
+                getDevice().executeShellCommand(String.format("kill -2 %s", pid));
+                Log.d(getTag(), String.format("Sent SIGINT 2 to screenrecord process (%s)", pid));
+            }
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to kill screen recording process.");
+        }
+    }
+
+    /** Returns a buffer duration for the end of the video. */
+    @VisibleForTesting
+    public long getTailBuffer() {
+        return VIDEO_TAIL_BUFFER;
+    }
+
+    /** Returns the currently active {@link UiDevice}. */
+    public UiDevice getDevice() {
+        if (mDevice == null) {
+            mDevice = UiDevice.getInstance(getInstrumentation());
+        }
+        return mDevice;
+    }
+}
diff --git a/libraries/device-collectors/src/test/java/android/device/collectors/ScreenRecordCollectorTest.java b/libraries/device-collectors/src/test/java/android/device/collectors/ScreenRecordCollectorTest.java
new file mode 100644
index 0000000..d7198c1
--- /dev/null
+++ b/libraries/device-collectors/src/test/java/android/device/collectors/ScreenRecordCollectorTest.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.device.collectors;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.AdditionalMatchers.not;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.endsWith;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.matches;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.app.Instrumentation;
+import android.device.collectors.util.SendToInstrumentation;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.support.test.uiautomator.UiDevice;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.Description;
+import org.junit.runner.notification.Failure;
+import org.junit.runner.Result;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Android Unit tests for {@link ScreenRecordCollector}.
+ *
+ * <p>To run: atest CollectorDeviceLibTest:android.device.collectors.ScreenRecordCollectorTest
+ */
+@RunWith(AndroidJUnit4.class)
+public class ScreenRecordCollectorTest {
+
+    private static final int NUM_TEST_CASE = 10;
+
+    private File mLogDir;
+    private Description mRunDesc;
+    private Description mTestDesc;
+    private ScreenRecordCollector mListener;
+
+    @Mock private Instrumentation mInstrumentation;
+
+    @Mock private UiDevice mDevice;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mLogDir = new File("tmp/");
+        mRunDesc = Description.createSuiteDescription("run");
+        mTestDesc = Description.createTestDescription("run", "test");
+    }
+
+    @After
+    public void tearDown() {
+        if (mLogDir != null) {
+            mLogDir.delete();
+        }
+    }
+
+    private ScreenRecordCollector initListener() throws IOException {
+        ScreenRecordCollector listener = spy(new ScreenRecordCollector());
+        listener.setInstrumentation(mInstrumentation);
+        doReturn(mLogDir).when(listener).createAndEmptyDirectory(anyString());
+        doReturn(0L).when(listener).getTailBuffer();
+        doReturn(mDevice).when(listener).getDevice();
+        doReturn("1234").when(mDevice).executeShellCommand(eq("pidof screenrecord"));
+        doReturn("").when(mDevice).executeShellCommand(not(eq("pidof screenrecord")));
+        return listener;
+    }
+
+    /**
+     * Test that screen recording is properly started and ended for each test over the course of a
+     * test run.
+     */
+    @Test
+    public void testScreenRecord() throws Exception {
+        mListener = initListener();
+
+        // Verify output directories are created on test run start.
+        mListener.testRunStarted(mRunDesc);
+        verify(mListener).createAndEmptyDirectory(ScreenRecordCollector.OUTPUT_DIR);
+
+        // Walk through a number of test cases to simulate behavior.
+        for (int i = 1; i <= NUM_TEST_CASE; i++) {
+            // Verify a thread is started when the test starts.
+            mListener.testStarted(mTestDesc);
+            verify(mListener, times(i)).startScreenRecordThread(anyString());
+            // Delay verification by 100 ms to ensure the thread was started.
+            SystemClock.sleep(100);
+            verify(mDevice, times(i)).executeShellCommand(matches("screenrecord .*"));
+
+            // Alternate between pass and fail for variety.
+            if (i % 2 == 0) {
+                mListener.testFailure(new Failure(mTestDesc, new RuntimeException("I failed")));
+            }
+
+            // Verify all processes are killed when the test ends.
+            mListener.testFinished(mTestDesc);
+            verify(mListener, times(i)).killScreenRecordProcesses();
+            verify(mDevice, times(i)).executeShellCommand(eq("pidof screenrecord"));
+            verify(mDevice, times(i)).executeShellCommand(matches("kill -2 1234"));
+        }
+
+        // Verify files are reported
+        mListener.testRunFinished(new Result());
+
+        Bundle resultBundle = new Bundle();
+        mListener.instrumentationRunFinished(System.out, resultBundle, new Result());
+
+        ArgumentCaptor<Bundle> capture = ArgumentCaptor.forClass(Bundle.class);
+        Mockito.verify(mInstrumentation, times(NUM_TEST_CASE))
+                .sendStatus(
+                        Mockito.eq(SendToInstrumentation.INST_STATUS_IN_PROGRESS),
+                        capture.capture());
+        List<Bundle> capturedBundle = capture.getAllValues();
+        assertEquals(NUM_TEST_CASE, capturedBundle.size());
+
+        int videoCount = 0;
+        for (Bundle bundle : capturedBundle) {
+            for (String key : bundle.keySet()) {
+                if (key.contains("mp4")) videoCount++;
+            }
+        }
+        assertEquals(NUM_TEST_CASE, videoCount);
+    }
+
+    /** Test that screen recording is properly done for multiple tests and labels iterations. */
+    @Test
+    public void testMultipleScreenRecords() throws Exception {
+        mListener = initListener();
+
+        // Run through a sequence of `NUM_TEST_CASE` failing tests.
+        mListener.testRunStarted(mRunDesc);
+        verify(mListener).createAndEmptyDirectory(ScreenRecordCollector.OUTPUT_DIR);
+
+        // Walk through a number of test cases to simulate behavior.
+        for (int i = 1; i <= NUM_TEST_CASE; i++) {
+            mListener.testStarted(mTestDesc);
+            mListener.testFinished(mTestDesc);
+        }
+        mListener.testRunFinished(new Result());
+
+        // Verify that videos are saved with iterations.
+        InOrder videoVerifier = inOrder(mListener);
+        // The first video should not have an iteration number.
+        videoVerifier.verify(mListener).startScreenRecordThread(matches("^.*[^1].mp4$"));
+        // The subsequent videos should have an iteration number.
+        for (int i = 1; i < NUM_TEST_CASE; i++) {
+            videoVerifier
+                    .verify(mListener)
+                    .startScreenRecordThread(endsWith(String.format("%d-video.mp4", i + 1)));
+        }
+    }
+}