Skip to content

Commit c7e8a98

Browse files
Merge pull request shazam#198 from shazam/improve-screen-recording
Improve screen recording
2 parents e5ddc2c + c093f26 commit c7e8a98

14 files changed

+517
-151
lines changed

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ ext.targetCompatibility = JavaVersion.VERSION_1_8
1313

1414
ext {
1515
androidPluginVersion = "7.1.3"
16-
ddmlibVersion = "30.1.3"
16+
ddmlibVersion = "30.2.2"
1717
jcommanderVersion = "1.48"
1818
slf4jVersion = "1.7.21"
1919
guavaVersion = "19.0"
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2022 Apple Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5+
* in compliance with the License.
6+
*
7+
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License
10+
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
* express or implied. See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
15+
package com.shazam.fork.device;
16+
17+
import com.android.ddmlib.testrunner.TestIdentifier;
18+
19+
import java.io.File;
20+
21+
public interface ScreenRecorder {
22+
void startScreenRecording(TestIdentifier test);
23+
24+
void stopScreenRecording(TestIdentifier test);
25+
26+
void saveScreenRecording(TestIdentifier test, File output);
27+
28+
void removeScreenRecording(TestIdentifier test);
29+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/*
2+
* Copyright 2022 Apple Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5+
* in compliance with the License.
6+
*
7+
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the License
10+
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
* express or implied. See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
15+
package com.shazam.fork.device;
16+
17+
import com.android.ddmlib.AdbCommandRejectedException;
18+
import com.android.ddmlib.IDevice;
19+
import com.android.ddmlib.NullOutputReceiver;
20+
import com.android.ddmlib.ScreenRecorderOptions;
21+
import com.android.ddmlib.ShellCommandUnresponsiveException;
22+
import com.android.ddmlib.SyncException;
23+
import com.android.ddmlib.TimeoutException;
24+
import com.android.ddmlib.testrunner.TestIdentifier;
25+
import com.shazam.fork.model.Device;
26+
import org.slf4j.Logger;
27+
import org.slf4j.LoggerFactory;
28+
29+
import java.io.File;
30+
import java.io.IOException;
31+
import java.util.Map;
32+
import java.util.concurrent.ConcurrentHashMap;
33+
import java.util.concurrent.CountDownLatch;
34+
import java.util.concurrent.ExecutorService;
35+
36+
import static com.shazam.fork.Utils.namedExecutor;
37+
import static com.shazam.fork.system.io.RemoteFileManager.remoteVideoForTest;
38+
import static com.shazam.fork.system.io.RemoteFileManager.removeRemotePath;
39+
import static com.shazam.fork.utils.Utils.millisSinceNanoTime;
40+
import static java.lang.System.nanoTime;
41+
import static java.util.concurrent.TimeUnit.SECONDS;
42+
43+
public class ScreenRecorderImpl implements ScreenRecorder {
44+
private static final Logger logger = LoggerFactory.getLogger(ScreenRecorderImpl.class);
45+
private static final int DURATION = 60;
46+
private static final int BIT_RATE_MBPS = 1;
47+
private static final ScreenRecorderOptions RECORDER_OPTIONS = new ScreenRecorderOptions.Builder()
48+
.setTimeLimit(DURATION, SECONDS)
49+
.setBitRate(BIT_RATE_MBPS)
50+
.build();
51+
52+
private final Device device;
53+
private final ScreenRecorderStopper screenRecorderStopper;
54+
private final ExecutorService recorderExecutor =
55+
namedExecutor(/* numberOfThreads = */ 1, "RecorderExecutor-%d");
56+
private final ExecutorService fileExecutor =
57+
namedExecutor(/* numberOfThreads = */ 1, "RecorderFileExecutor-%d");
58+
59+
private State state = State.Stopped;
60+
private final Map<TestIdentifier, RecorderTask> recorderTasksProjection =
61+
new ConcurrentHashMap<>();
62+
63+
public ScreenRecorderImpl(Device device) {
64+
this.device = device;
65+
this.screenRecorderStopper = new ScreenRecorderStopper(device);
66+
}
67+
68+
@Override
69+
public void startScreenRecording(TestIdentifier test) {
70+
if (state != State.Stopped) {
71+
logger.warn("ScreenRecorder is {} -> stopping screen recording", state);
72+
stopActiveScreenRecording();
73+
}
74+
75+
state = State.Recording;
76+
RecorderTask recorderTask = new RecorderTask(test, device);
77+
recorderTasksProjection.put(test, recorderTask);
78+
recorderExecutor.submit(recorderTask);
79+
}
80+
81+
@Override
82+
public void stopScreenRecording(TestIdentifier test) {
83+
if (state != State.Recording) {
84+
logger.warn("ScreenRecorder is {} when we tried to stop recording", state);
85+
}
86+
logger.debug("Stopped screen recording");
87+
88+
stopActiveScreenRecording();
89+
}
90+
91+
private void stopActiveScreenRecording() {
92+
screenRecorderStopper.stopScreenRecord();
93+
state = State.Stopped;
94+
}
95+
96+
@Override
97+
public void saveScreenRecording(TestIdentifier test, File output) {
98+
RecorderTask recorderTask = recorderTasksProjection.get(test);
99+
if (recorderTask == null) {
100+
logger.warn("Recording for {} was not found", test);
101+
return;
102+
}
103+
104+
fileExecutor.submit(() -> {
105+
try {
106+
recorderTask.awaitCompletion();
107+
108+
String remoteFilePath = remoteVideoForTest(test);
109+
logger.debug("Save screen recording {} to {}", remoteFilePath, output);
110+
pullTestVideo(remoteFilePath, output);
111+
} catch (Exception e) {
112+
logger.error("Failed to pull a video file", e);
113+
}
114+
});
115+
}
116+
117+
@Override
118+
public void removeScreenRecording(TestIdentifier test) {
119+
RecorderTask recorderTask = recorderTasksProjection.get(test);
120+
if (recorderTask == null) {
121+
logger.warn("Recording for {} was not found", test);
122+
return;
123+
}
124+
125+
fileExecutor.submit(() -> {
126+
try {
127+
recorderTask.awaitCompletion();
128+
129+
String remoteFilePath = remoteVideoForTest(test);
130+
logger.debug("Remove screen recording {}", remoteFilePath);
131+
removeTestVideo(remoteFilePath);
132+
133+
recorderTasksProjection.remove(test);
134+
} catch (Exception e) {
135+
logger.error("Failed to remove a video file", e);
136+
}
137+
});
138+
}
139+
140+
private void pullTestVideo(String remoteFilePath, File output) throws IOException,
141+
AdbCommandRejectedException, TimeoutException, SyncException {
142+
logger.trace("Started pulling file {} to {}", remoteFilePath, output);
143+
long startNanos = nanoTime();
144+
device.getDeviceInterface().pullFile(remoteFilePath, output.toString());
145+
logger.trace("Pulling finished in {}ms {}", millisSinceNanoTime(startNanos), remoteFilePath);
146+
}
147+
148+
private void removeTestVideo(String remoteFilePath) {
149+
logger.trace("Started removing file {}", remoteFilePath);
150+
long startNanos = nanoTime();
151+
removeRemotePath(device.getDeviceInterface(), remoteFilePath);
152+
logger.trace("Removed file in {}ms {}", millisSinceNanoTime(startNanos), remoteFilePath);
153+
}
154+
155+
private static class RecorderTask implements Runnable {
156+
private final TestIdentifier test;
157+
private final IDevice deviceInterface;
158+
private final CountDownLatch latch = new CountDownLatch(1);
159+
160+
public RecorderTask(TestIdentifier test, Device device) {
161+
this.test = test;
162+
this.deviceInterface = device.getDeviceInterface();
163+
}
164+
165+
public void awaitCompletion() throws InterruptedException {
166+
latch.await();
167+
}
168+
169+
@Override
170+
public void run() {
171+
try {
172+
String remoteFilePath = remoteVideoForTest(test);
173+
logger.debug("Started recording video {}", remoteFilePath);
174+
175+
startRecordingTestVideo(remoteFilePath);
176+
177+
latch.countDown();
178+
179+
logger.debug("Video recording finished {}", remoteFilePath);
180+
} catch (TimeoutException e) {
181+
logger.debug("Screen recording was either interrupted or timed out", e);
182+
} catch (Exception e) {
183+
logger.error("Something went wrong while screen recording", e);
184+
}
185+
}
186+
187+
private void startRecordingTestVideo(String remoteFilePath) throws TimeoutException,
188+
AdbCommandRejectedException, IOException, ShellCommandUnresponsiveException {
189+
logger.trace("Started recording video at: {}", remoteFilePath);
190+
long startNanos = nanoTime();
191+
deviceInterface.startScreenRecorder(remoteFilePath, RECORDER_OPTIONS, new NullOutputReceiver());
192+
logger.trace("Recording finished in {}ms {}", millisSinceNanoTime(startNanos), remoteFilePath);
193+
}
194+
}
195+
196+
private enum State {
197+
Recording,
198+
Stopped
199+
}
200+
}

fork-runner/src/main/java/com/shazam/fork/runner/listeners/ScreenRecorderStopper.java renamed to fork-runner/src/main/java/com/shazam/fork/device/ScreenRecorderStopper.java

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@
88
* 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.
99
*/
1010

11-
package com.shazam.fork.runner.listeners;
11+
package com.shazam.fork.device;
1212

1313
import com.android.ddmlib.IDevice;
1414
import com.android.ddmlib.NullOutputReceiver;
15+
import com.shazam.fork.model.Device;
1516
import com.shazam.fork.system.adb.CollectingShellOutputReceiver;
16-
1717
import org.slf4j.Logger;
1818
import org.slf4j.LoggerFactory;
1919

@@ -27,34 +27,29 @@ class ScreenRecorderStopper {
2727
private static final int SCREENRECORD_KILL_ATTEMPTS = 5;
2828
private static final int PAUSE_BETWEEN_RECORDER_PROCESS_KILL = 300;
2929
private final NullOutputReceiver nullOutputReceiver = new NullOutputReceiver();
30-
private final IDevice deviceInterface;
31-
private boolean hasFailed;
30+
private final Device device;
3231

33-
ScreenRecorderStopper(IDevice deviceInterface) {
34-
this.deviceInterface = deviceInterface;
32+
ScreenRecorderStopper(Device device) {
33+
this.device = device;
3534
}
3635

3736
/**
3837
* Stops all running screenrecord processes.
3938
*/
40-
public void stopScreenRecord(boolean hasFailed) {
41-
this.hasFailed = hasFailed;
42-
boolean hasKilledScreenRecord = true;
39+
void stopScreenRecord() {
40+
boolean hasKilledScreenRecord = false;
4341
int tries = 0;
44-
while (hasKilledScreenRecord && tries++ < SCREENRECORD_KILL_ATTEMPTS) {
42+
while (!hasKilledScreenRecord && tries++ < SCREENRECORD_KILL_ATTEMPTS) {
4543
hasKilledScreenRecord = attemptToGracefullyKillScreenRecord();
4644
pauseBetweenProcessKill();
4745
}
4846
}
4947

50-
public boolean hasFailed() {
51-
return hasFailed;
52-
}
53-
5448
private boolean attemptToGracefullyKillScreenRecord() {
5549
CollectingShellOutputReceiver receiver = new CollectingShellOutputReceiver();
5650
try {
57-
deviceInterface.executeShellCommand("ps |grep screenrecord", receiver);
51+
IDevice deviceInterface = device.getDeviceInterface();
52+
deviceInterface.executeShellCommand("ps -A | grep screenrecord", receiver);
5853
String pid = extractPidOfScreenrecordProcess(receiver);
5954
if (isNotBlank(pid)) {
6055
logger.trace("Killing PID {} on {}", pid, deviceInterface.getSerialNumber());

fork-runner/src/main/java/com/shazam/fork/runner/DeviceTestRunner.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import com.android.ddmlib.*;
1616
import com.shazam.fork.model.Device;
1717
import com.shazam.fork.model.*;
18+
import com.shazam.fork.device.ScreenRecorder;
1819
import com.shazam.fork.system.adb.Installer;
1920

2021
import org.slf4j.Logger;
@@ -35,6 +36,7 @@ public class DeviceTestRunner implements Runnable {
3536
private final Queue<TestCaseEvent> queueOfTestsInPool;
3637
private final CountDownLatch deviceCountDownLatch;
3738
private final ProgressReporter progressReporter;
39+
private final ScreenRecorder screenRecorder;
3840
private final TestRunFactory testRunFactory;
3941

4042
public DeviceTestRunner(Installer installer,
@@ -43,13 +45,15 @@ public DeviceTestRunner(Installer installer,
4345
Queue<TestCaseEvent> queueOfTestsInPool,
4446
CountDownLatch deviceCountDownLatch,
4547
ProgressReporter progressReporter,
48+
ScreenRecorder screenRecorder,
4649
TestRunFactory testRunFactory) {
4750
this.installer = installer;
4851
this.pool = pool;
4952
this.device = device;
5053
this.queueOfTestsInPool = queueOfTestsInPool;
5154
this.deviceCountDownLatch = deviceCountDownLatch;
5255
this.progressReporter = progressReporter;
56+
this.screenRecorder = screenRecorder;
5357
this.testRunFactory = testRunFactory;
5458
}
5559

@@ -71,6 +75,7 @@ public void run() {
7175
device,
7276
pool,
7377
progressReporter,
78+
screenRecorder,
7479
queueOfTestsInPool);
7580
testRun.execute();
7681
}

fork-runner/src/main/java/com/shazam/fork/runner/DeviceTestRunnerFactory.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010

1111
package com.shazam.fork.runner;
1212

13-
import com.shazam.fork.model.*;
13+
import com.shazam.fork.device.ScreenRecorderImpl;
14+
import com.shazam.fork.model.Device;
15+
import com.shazam.fork.model.Pool;
16+
import com.shazam.fork.model.TestCaseEvent;
1417
import com.shazam.fork.system.adb.Installer;
1518

1619
import java.util.Queue;
@@ -31,14 +34,15 @@ public Runnable createDeviceTestRunner(Pool pool,
3134
CountDownLatch deviceInPoolCountDownLatch,
3235
Device device,
3336
ProgressReporter progressReporter
34-
) {
37+
) {
3538
return new DeviceTestRunner(
3639
installer,
3740
pool,
3841
device,
3942
testClassQueue,
4043
deviceInPoolCountDownLatch,
4144
progressReporter,
45+
new ScreenRecorderImpl(device),
4246
testRunFactory);
4347
}
4448
}

fork-runner/src/main/java/com/shazam/fork/runner/TestRunFactory.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515
import com.shazam.fork.model.Device;
1616
import com.shazam.fork.model.Pool;
1717
import com.shazam.fork.model.TestCaseEvent;
18+
import com.shazam.fork.device.ScreenRecorder;
1819
import com.shazam.fork.runner.listeners.TestRunListenersFactory;
1920

21+
import javax.annotation.Nonnull;
2022
import java.util.List;
2123
import java.util.Queue;
2224

23-
import javax.annotation.Nonnull;
24-
2525
import static com.shazam.fork.runner.TestRunParameters.Builder.testRunParameters;
2626
import static com.shazam.fork.system.PermissionGrantingManager.permissionGrantingManager;
2727

@@ -39,6 +39,7 @@ public TestRun createTestRun(@Nonnull TestCaseEvent testCase,
3939
Device device,
4040
Pool pool,
4141
ProgressReporter progressReporter,
42+
ScreenRecorder screenRecorder,
4243
Queue<TestCaseEvent> queueOfTestsInPool) {
4344
TestRunParameters testRunParameters = testRunParameters()
4445
.withDeviceInterface(device.getDeviceInterface())
@@ -57,6 +58,7 @@ public TestRun createTestRun(@Nonnull TestCaseEvent testCase,
5758
device,
5859
pool,
5960
progressReporter,
61+
screenRecorder,
6062
queueOfTestsInPool);
6163

6264
return new TestRun(

0 commit comments

Comments
 (0)