Skip to content

Commit 501ac72

Browse files
feat: Add mobile wrappers for media-projection-based screen recording (#748)
1 parent e9045d7 commit 501ac72

File tree

5 files changed

+295
-3
lines changed

5 files changed

+295
-3
lines changed

lib/android-helpers.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,15 +571,24 @@ helpers.pushSettingsApp = async function pushSettingsApp (adb, throwError = fals
571571
'will raise an error if you try to use them.');
572572
}
573573

574-
// Reinstall will stop the settings helper process anyway, so
574+
// Reinstall would stop the settings helper process anyway, so
575575
// there is no need to continue if the application is still running
576576
if (await adb.processExists(SETTINGS_HELPER_PKG_ID)) {
577577
logger.debug(`${SETTINGS_HELPER_PKG_ID} is already running. ` +
578578
`There is no need to reset its permissions.`);
579579
return;
580580
}
581581

582-
if (await adb.getApiLevel() <= 23) { // Android 6- devices should have granted permissions
582+
const apiLevel = await adb.getApiLevel();
583+
if (apiLevel >= 29) {
584+
// https://github.com/appium/io.appium.settings#internal-audio--video-recording
585+
try {
586+
await adb.shell(['appops', 'set', SETTINGS_HELPER_PKG_ID, 'PROJECT_MEDIA', 'allow']);
587+
} catch (err) {
588+
logger.debug(err);
589+
}
590+
}
591+
if (apiLevel <= 23) { // Android 6- devices should have granted permissions
583592
// https://github.com/appium/appium/pull/11640#issuecomment-438260477
584593
const perms = ['SET_ANIMATION_SCALE', 'CHANGE_CONFIGURATION', 'ACCESS_FINE_LOCATION'];
585594
logger.info(`Granting permissions ${perms} to '${SETTINGS_HELPER_PKG_ID}'`);

lib/commands/execute.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ extensions.executeMobile = async function executeMobile (mobileCommand, opts = {
6060
unlock: 'mobileUnlock',
6161

6262
refreshGpsCache: 'mobileRefreshGpsCache',
63+
64+
startMediaProjectionRecording: 'mobileStartMediaProjectionRecording',
65+
isMediaProjectionRecordingRunning: 'mobileIsMediaProjectionRecordingRunning',
66+
stopMediaProjectionRecording: 'mobileStopMediaProjectionRecording',
6367
};
6468

6569
if (!_.has(mobileCommandsMapping, mobileCommand)) {

lib/commands/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import appManagementCmds from './app-management';
1919
import intentCmds from './intent';
2020
import systemBarsCmds from './system-bars';
2121
import logCmds from './log';
22+
import mediaProjectionCmds from './media-projection';
2223

2324

2425
let commands = {};
@@ -45,6 +46,7 @@ Object.assign(
4546
appManagementCmds,
4647
fileActionsCmds,
4748
logCmds,
49+
mediaProjectionCmds,
4850
// add other command types here
4951
);
5052

lib/commands/media-projection.js

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import _ from 'lodash';
2+
import { waitForCondition } from 'asyncbox';
3+
import { util, fs, net, tempDir } from 'appium/support';
4+
import path from 'path';
5+
import B from 'bluebird';
6+
import { SETTINGS_HELPER_PKG_ID } from '../android-helpers';
7+
import moment from 'moment';
8+
9+
10+
const commands = {};
11+
12+
// https://github.com/appium/io.appium.settings#internal-audio--video-recording
13+
const DEFAULT_EXT = '.mp4';
14+
const RECORDING_STARTUP_TIMEOUT_MS = 3 * 1000;
15+
const RECORDING_STOP_TIMEOUT_MS = 3 * 1000;
16+
const MIN_API_LEVEL = 29;
17+
const RECORDING_SERVICE_NAME = `${SETTINGS_HELPER_PKG_ID}/.recorder.RecorderService`;
18+
const RECORDING_ACTIVITY_NAME = `${SETTINGS_HELPER_PKG_ID}/io.appium.settings.Settings`;
19+
const RECORDING_ACTION_START = `${SETTINGS_HELPER_PKG_ID}.recording.ACTION_START`;
20+
const RECORDING_ACTION_STOP = `${SETTINGS_HELPER_PKG_ID}.recording.ACTION_STOP`;
21+
const RECORDINGS_ROOT = `/storage/emulated/0/Android/data/${SETTINGS_HELPER_PKG_ID}/files`;
22+
const DEFAULT_FILENAME_FORMAT = 'YYYY-MM-DDTHH-mm-ss';
23+
24+
25+
async function uploadRecordedMedia (localFile, remotePath = null, uploadOptions = {}) {
26+
if (_.isEmpty(remotePath)) {
27+
return (await util.toInMemoryBase64(localFile)).toString();
28+
}
29+
30+
const {user, pass, method, headers, fileFieldName, formFields} = uploadOptions;
31+
const options = {
32+
method: method || 'PUT',
33+
headers,
34+
fileFieldName,
35+
formFields,
36+
};
37+
if (user && pass) {
38+
options.auth = {user, pass};
39+
}
40+
await net.uploadFile(localFile, remotePath, options);
41+
return '';
42+
}
43+
44+
function adjustMediaExtension (name) {
45+
return _.toLower(name).endsWith(DEFAULT_EXT) ? name : `${name}${DEFAULT_EXT}`;
46+
}
47+
48+
async function verifyMediaProjectionRecordingIsSupported (adb) {
49+
const apiLevel = await adb.getApiLevel();
50+
if (apiLevel < MIN_API_LEVEL) {
51+
throw new Error(`Media projection-based recording is not available on API Level ${apiLevel}. ` +
52+
`Minimum required API Level is ${MIN_API_LEVEL}.`);
53+
}
54+
}
55+
56+
57+
class MediaProjectionRecorder {
58+
constructor (adb) {
59+
this.adb = adb;
60+
}
61+
62+
async isRunning () {
63+
const stdout = await this.adb.shell([
64+
'dumpsys', 'activity', 'services', RECORDING_SERVICE_NAME
65+
]);
66+
return stdout.includes(RECORDING_SERVICE_NAME);
67+
}
68+
69+
async start (opts = {}) {
70+
if (await this.isRunning()) {
71+
return false;
72+
}
73+
74+
await this.cleanup();
75+
const {
76+
filename,
77+
maxDurationSec,
78+
priority,
79+
resolution,
80+
} = opts;
81+
const args = [
82+
'am', 'start',
83+
'-n', RECORDING_ACTIVITY_NAME,
84+
'-a', RECORDING_ACTION_START,
85+
];
86+
if (filename) {
87+
args.push('--es', 'filename', filename);
88+
}
89+
if (maxDurationSec) {
90+
args.push('--es', 'max_duration_sec', `${maxDurationSec}`);
91+
}
92+
if (priority) {
93+
args.push('--es', 'priority', priority);
94+
}
95+
if (resolution) {
96+
args.push('--es', 'resolution', resolution);
97+
}
98+
await this.adb.shell(args);
99+
await new B((resolve, reject) => {
100+
setTimeout(async () => {
101+
if (!await this.isRunning()) {
102+
return reject(new Error(
103+
`The media projection recording is not running after ${RECORDING_STARTUP_TIMEOUT_MS}ms. ` +
104+
`Please check the logcat output for more details.`
105+
));
106+
}
107+
resolve();
108+
}, RECORDING_STARTUP_TIMEOUT_MS);
109+
});
110+
return true;
111+
}
112+
113+
async cleanup () {
114+
await this.adb.shell([`rm -f ${RECORDINGS_ROOT}/*`]);
115+
}
116+
117+
async pullRecent () {
118+
const recordings = await this.adb.ls(RECORDINGS_ROOT, ['-tr']);
119+
if (_.isEmpty(recordings)) {
120+
return null;
121+
}
122+
123+
const dstPath = path.join(await tempDir.openDir(), recordings[0]);
124+
await this.adb.pull(`${RECORDINGS_ROOT}/${recordings[0]}`, dstPath);
125+
return dstPath;
126+
}
127+
128+
async stop () {
129+
if (!await this.isRunning()) {
130+
return false;
131+
}
132+
133+
await this.adb.shell([
134+
'am', 'start',
135+
'-n', RECORDING_ACTIVITY_NAME,
136+
'-a', RECORDING_ACTION_STOP,
137+
]);
138+
try {
139+
await waitForCondition(async () => !(await this.isRunning()), {
140+
waitMs: RECORDING_STOP_TIMEOUT_MS,
141+
intervalMs: 500,
142+
});
143+
} catch (e) {
144+
throw new Error(
145+
`The attempt to stop the current media projection recording timed out after ` +
146+
`${RECORDING_STOP_TIMEOUT_MS}ms`
147+
);
148+
}
149+
return true;
150+
}
151+
}
152+
153+
154+
/**
155+
* @typedef {Object} StartRecordingOptions
156+
*
157+
* @property {string?} resolution Maximum supported resolution on-device (Detected
158+
* automatically by the app itself), which usually equals to Full HD 1920x1080 on most
159+
* phones however you can change it to following supported resolutions
160+
* as well: "1920x1080", "1280x720", "720x480", "320x240", "176x144".
161+
* @property {number?} maxDurationSec [900] Default value: 900 seconds which means
162+
* maximum allowed duration is 15 minute, you can increase it if your test takes
163+
* longer than that.
164+
* @property {string?} priority [high] Means recording thread priority is maximum
165+
* however if you face performance drops during testing with recording enabled, you
166+
* can reduce recording priority to "normal" or "low".
167+
* @property {string?} filename You can type recording video file name as you want,
168+
* but recording currently supports only "mp4" format so your filename must end with ".mp4".
169+
* An invalid file name will fail to start the recording.
170+
* If not provided then the current timestamp will be used as file name.
171+
*/
172+
173+
/**
174+
* Record the display of a real devices running Android 10 (API level 29) and higher.
175+
* The screen activity is recorded to a MPEG-4 file. Audio is also recorded by default
176+
* (only for apps that allow it in their manifests).
177+
* If another recording has been already started then the command will exit silently.
178+
* The previously recorded video file is deleted when a new recording session is started.
179+
* Recording continues it is stopped explicitly or until the timeout happens.
180+
*
181+
* @param {?StartRecordingOptions} options Available options.
182+
* @returns {boolean} True if a new recording has successfully started.
183+
* @throws {Error} If recording has failed to start or is not supported on the device under test.
184+
*/
185+
commands.mobileStartMediaProjectionRecording = async function mobileStartMediaProjectionRecording (options = {}) {
186+
await verifyMediaProjectionRecordingIsSupported(this.adb);
187+
188+
const {resolution, priority, maxDurationSec, filename} = options;
189+
const recorder = new MediaProjectionRecorder(this.adb);
190+
const fname = adjustMediaExtension(filename || moment().format(DEFAULT_FILENAME_FORMAT));
191+
const didStart = await recorder.start({
192+
resolution,
193+
priority,
194+
maxDurationSec,
195+
filename: fname,
196+
});
197+
if (didStart) {
198+
this.log.info(`A new media projection recording '${fname}' has been successfully started`);
199+
} else {
200+
this.log.info('Another media projection recording is already in progress. There is nothing to start');
201+
}
202+
return didStart;
203+
};
204+
205+
/**
206+
* Checks if a media projection-based recording is currently running.
207+
*
208+
* @returns {boolean} True if a recording is in progress.
209+
* @throws {Error} If a recording is not supported on the device under test.
210+
*/
211+
commands.mobileIsMediaProjectionRecordingRunning = async function mobileIsMediaProjectionRecordingRunning () {
212+
await verifyMediaProjectionRecordingIsSupported(this.adb);
213+
214+
const recorder = new MediaProjectionRecorder(this.adb);
215+
return await recorder.isRunning();
216+
};
217+
218+
/**
219+
* @typedef {Object} StopRecordingOptions
220+
*
221+
* @property {string?} remotePath The path to the remote location, where the resulting video should be uploaded.
222+
* The following protocols are supported: http/https, ftp.
223+
* Null or empty string value (the default setting) means the content of resulting
224+
* file should be encoded as Base64 and passed as the endpoont response value.
225+
* An exception will be thrown if the generated media file is too big to
226+
* fit into the available process memory.
227+
* @property {string?} user The name of the user for the remote authentication.
228+
* @property {string?} pass The password for the remote authentication.
229+
* @property {string?} method The http multipart upload method name. The 'PUT' one is used by default.
230+
* @property {Object?} headers Additional headers mapping for multipart http(s) uploads
231+
* @property {string?} fileFieldName [file] The name of the form field, where the file content BLOB should be stored for
232+
* http(s) uploads
233+
* @property {Object|Array<Pair>?} formFields Additional form fields for multipart http(s) uploads
234+
*/
235+
236+
/**
237+
* Stop a media projection-based recording.
238+
* If no recording has been started before then an error is thrown.
239+
* If the recording has been already finished before this API has been called
240+
* then the most recent recorded file is returned.
241+
*
242+
* @param {?StopRecordingOptions} options Available options.
243+
* @returns {string} Base64-encoded content of the recorded media file if 'remotePath'
244+
* parameter is falsy or an empty string.
245+
* @throws {Error} If there was an error while stopping a recording,
246+
* fetching the content of the remote media file,
247+
* or if a recording is not supported on the device under test.
248+
*/
249+
commands.mobileStopMediaProjectionRecording = async function mobileStopMediaProjectionRecording (options = {}) {
250+
await verifyMediaProjectionRecordingIsSupported(this.adb);
251+
252+
const recorder = new MediaProjectionRecorder(this.adb);
253+
if (await recorder.stop()) {
254+
this.log.info('Successfully stopped a media projection recording. Pulling the recorded media');
255+
} else {
256+
this.log.info('Media projection recording is not running. There is nothing to stop');
257+
}
258+
const recentRecordingPath = await recorder.pullRecent();
259+
if (!recentRecordingPath) {
260+
throw new Error(`No recent media projection recording have been found. Did you start any?`);
261+
}
262+
263+
const {remotePath} = options;
264+
if (_.isEmpty(remotePath)) {
265+
const {size} = await fs.stat(recentRecordingPath);
266+
this.log.debug(`The size of the resulting media projection recording is ${util.toReadableSizeString(size)}`);
267+
}
268+
try {
269+
return await uploadRecordedMedia(recentRecordingPath, remotePath, options);
270+
} finally {
271+
await fs.rimraf(path.dirname(recentRecordingPath));
272+
}
273+
};
274+
275+
276+
export { commands };
277+
export default commands;

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
"asyncbox": "^2.8.0",
4242
"axios": "^0.x",
4343
"bluebird": "^3.4.7",
44-
"io.appium.settings": "^4.0.0",
44+
"io.appium.settings": "^4.1.0",
4545
"jimp": "^0.x",
4646
"lodash": "^4.17.4",
4747
"lru-cache": "^7.3.0",

0 commit comments

Comments
 (0)