Skip to content

Commit 5544897

Browse files
committed
feat: Implement internal audio/video recording for devices >= Android Q
Signed-off-by: sirmordred <[email protected]>
1 parent 31c62d4 commit 5544897

File tree

7 files changed

+1261
-1
lines changed

7 files changed

+1261
-1
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,36 @@ and add them to the media library if their MIME types are supported. If the
321321
file/folder in _path_ does not exist/is not readable or is not provided then an
322322
error will be returned and the corresponding log message would be written into logs.
323323

324+
## Internal Audio & Video Recording
325+
326+
Required steps to activate recording:
327+
328+
```bash
329+
adb shell pm grant io.appium.settings android.permission.RECORD_AUDIO
330+
adb shell appops set io.appium.settings PROJECT_MEDIA allow
331+
```
332+
333+
Start Recording:
334+
```bash
335+
adb shell am start -n "io.appium.settings/io.appium.settings.Settings" -a io.appium.settings.recording.ACTION_START --es filename abc.mp4 --es priority high --es max_duration_sec 900 --es resolution 1920x1080
336+
```
337+
338+
### Arguments (see above start command as an example for giving arguments)
339+
- filename (Mandatory) - You can type recording video file name as you want, but recording currently supports only "mp4" format so your filename must end with ".mp4"
340+
- priority (Optional) - Default value: "high" which means recording thread priority is maximum however if you face performance drops during testing with recording enabled, you can reduce recording priority to "normal" or "low"
341+
- max_duration_sec (Optional) (in seconds) - Default value: 900 seconds which means maximum allowed duration is 15 minute, you can increase it if your test takes longer than that
342+
- resolution (Optional) - Default value: maximum supported resolution on-device(Detected automatically on app itself), which usually equals to Full HD 1920x1080 on most phones however you can change it to following supported resolutions as well: "1920x1080", "1280x720", "720x480", "320x240", "176x144"
343+
344+
Stop Recording:
345+
```bash
346+
adb shell am start -n "io.appium.settings/io.appium.settings.Settings" -a io.appium.settings.recording.ACTION_STOP
347+
```
348+
349+
Obtain Recording Output File:
350+
```bash
351+
adb pull /storage/emulated/0/Android/data/io.appium.settings/files/abc.mp4 abc.mp4
352+
```
353+
324354

325355
## Notes:
326356

app/src/main/AndroidManifest.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
2929
<uses-permission android:name="android.permission.READ_SMS" />
3030

31+
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
32+
3133
<uses-feature android:name="android.hardware.wifi" />
3234

3335
<application
@@ -66,6 +68,12 @@
6668
android:exported="true">
6769
</service>
6870

71+
<service
72+
android:foregroundServiceType="mediaProjection"
73+
android:name=".recorder.RecorderService"
74+
android:exported="true">
75+
</service>
76+
6977
<service
7078
android:name=".AppiumIME"
7179
android:label="Appium IME"

app/src/main/java/io/appium/settings/Settings.java

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,17 @@
1818

1919
import android.app.Activity;
2020
import android.content.BroadcastReceiver;
21+
import android.content.Context;
22+
import android.content.Intent;
2123
import android.content.IntentFilter;
24+
import android.media.projection.MediaProjectionManager;
2225
import android.os.Build;
2326
import android.os.Bundle;
2427
import android.os.Handler;
2528
import android.util.Log;
2629

30+
import java.io.File;
31+
import java.nio.file.Paths;
2732
import java.util.Arrays;
2833
import java.util.List;
2934

@@ -39,10 +44,34 @@
3944
import io.appium.settings.receivers.SmsReader;
4045
import io.appium.settings.receivers.UnpairBluetoothDevicesReceiver;
4146
import io.appium.settings.receivers.WiFiConnectionSettingReceiver;
47+
import io.appium.settings.recorder.RecorderService;
48+
import io.appium.settings.recorder.RecorderUtil;
49+
50+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_BASE;
51+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_FILENAME;
52+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_MAX_DURATION;
53+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_PRIORITY;
54+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_RESOLUTION;
55+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_RESULT_CODE;
56+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_ROTATION;
57+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_START;
58+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_STOP;
59+
import static io.appium.settings.recorder.RecorderConstant.NO_PATH_SET;
60+
import static io.appium.settings.recorder.RecorderConstant.NO_RESOLUTION_MODE_SET;
61+
import static io.appium.settings.recorder.RecorderConstant.RECORDING_MAX_DURATION_DEFAULT_MS;
62+
import static io.appium.settings.recorder.RecorderConstant.RECORDING_PRIORITY_DEFAULT;
63+
import static io.appium.settings.recorder.RecorderConstant.RECORDING_ROTATION_DEFAULT_DEGREE;
64+
import static io.appium.settings.recorder.RecorderConstant.REQUEST_CODE_SCREEN_CAPTURE;
4265

4366
public class Settings extends Activity {
4467
private static final String TAG = "APPIUM SETTINGS";
4568

69+
private String recordingOutputPath = NO_PATH_SET;
70+
private int recordingRotation = RECORDING_ROTATION_DEFAULT_DEGREE;
71+
private int recordingPriority = RECORDING_PRIORITY_DEFAULT;
72+
private int recordingMaxDuration = RECORDING_MAX_DURATION_DEFAULT_MS;
73+
private String recordingResolutionMode = NO_RESOLUTION_MODE_SET;
74+
4675
@Override
4776
public void onCreate(Bundle savedInstanceState) {
4877
super.onCreate(savedInstanceState);
@@ -70,12 +99,152 @@ public void onCreate(Bundle savedInstanceState) {
7099
LocationTracker.getInstance().start(this);
71100
}
72101

102+
handleRecording(getIntent());
103+
}
104+
105+
private void handleRecording(Intent intent) {
106+
if (intent == null) {
107+
Log.e(TAG, "handleRecording: Unable to retrieve intent instance");
108+
finishActivity();
109+
return;
110+
}
111+
112+
String recordingAction = intent.getAction();
113+
if (recordingAction == null) {
114+
Log.e(TAG, "handleRecording: Unable to retrieve intent.action instance");
115+
finishActivity();
116+
return;
117+
}
118+
119+
if (!recordingAction.startsWith(ACTION_RECORDING_BASE)) {
120+
Log.i(TAG, "handleRecording: Received different intent with action: "
121+
+ recordingAction);
122+
finishActivity();
123+
return;
124+
}
125+
126+
if (RecorderUtil.isLowerThanQ()) {
127+
Log.e(TAG, "handleRecording: Current Android OS Version is lower than Q");
128+
finishActivity();
129+
return;
130+
}
131+
132+
if (!RecorderUtil.areRecordingPermissionsGranted(getApplicationContext())) {
133+
Log.e(TAG, "handleRecording: Required permissions are not granted");
134+
finishActivity();
135+
return;
136+
}
137+
138+
if (recordingAction.equals(ACTION_RECORDING_START)) {
139+
String recordingFilename = intent.getStringExtra(ACTION_RECORDING_FILENAME);
140+
if (!RecorderUtil.isValidFileName(recordingFilename)) {
141+
Log.e(TAG, "handleRecording: Invalid filename passed by user: "
142+
+ recordingFilename);
143+
finishActivity();
144+
return;
145+
}
146+
147+
/*
148+
External Storage File Directory for app
149+
(i.e /storage/emulated/0/Android/data/io.appium.settings/files) may not be created
150+
so we need to call getExternalFilesDir() method twice
151+
source:https://www.androidbugfix.com/2021/10/getexternalfilesdirnull-returns-null-in.html
152+
*/
153+
File externalStorageFile = getExternalFilesDir(null);
154+
if (externalStorageFile == null) {
155+
externalStorageFile = getExternalFilesDir(null);
156+
}
157+
// if path is still null despite calling method twice, early exit
158+
if (externalStorageFile == null) {
159+
Log.e(TAG, "handleRecording: Unable to retrieve external storage file path");
160+
finishActivity();
161+
return;
162+
}
163+
164+
recordingOutputPath = Paths
165+
.get(externalStorageFile.getAbsolutePath(), recordingFilename)
166+
.toAbsolutePath()
167+
.toString();
168+
169+
recordingRotation = RecorderUtil.getDeviceRotationInDegree(getApplicationContext());
170+
171+
recordingPriority = RecorderUtil.getRecordingPriority(intent);
172+
173+
recordingMaxDuration = RecorderUtil.getRecordingMaxDuration(intent);
174+
175+
recordingResolutionMode = RecorderUtil.getRecordingResolutionMode(intent);
176+
177+
// start record
178+
final MediaProjectionManager manager
179+
= (MediaProjectionManager) getSystemService(
180+
Context.MEDIA_PROJECTION_SERVICE);
181+
182+
if (manager == null) {
183+
Log.e(TAG, "handleRecording: " +
184+
"Unable to retrieve MediaProjectionManager instance");
185+
finishActivity();
186+
return;
187+
}
188+
189+
final Intent permissionIntent = manager.createScreenCaptureIntent();
190+
191+
startActivityForResult(permissionIntent, REQUEST_CODE_SCREEN_CAPTURE);
192+
} else if (recordingAction.equals(ACTION_RECORDING_STOP)) {
193+
// stop record
194+
final Intent recorderIntent = new Intent(this, RecorderService.class);
195+
recorderIntent.setAction(ACTION_RECORDING_STOP);
196+
startService(recorderIntent);
197+
198+
finishActivity();
199+
} else {
200+
Log.e(TAG, "handleRecording: Unknown recording intent with action:"
201+
+ recordingAction);
202+
finishActivity();
203+
}
204+
}
205+
206+
private void finishActivity() {
73207
Log.d(TAG, "Closing the app");
74208
Handler handler = new Handler();
75209
handler.postDelayed(Settings.this::finish, 1000);
76210
}
77211

78-
private void registerSettingsReceivers(List<Class<? extends BroadcastReceiver>> receiverClasses) {
212+
@Override
213+
protected void onActivityResult(final int requestCode, final int resultCode, final Intent data)
214+
{
215+
super.onActivityResult(requestCode, resultCode, data);
216+
if (REQUEST_CODE_SCREEN_CAPTURE != requestCode) {
217+
Log.e(TAG, "handleRecording: onActivityResult: " +
218+
"Received unknown request with code: " + requestCode);
219+
finishActivity();
220+
return;
221+
}
222+
223+
if (resultCode != Activity.RESULT_OK) {
224+
Log.e(TAG, "handleRecording: onActivityResult: " +
225+
"MediaProjection permission is not granted, " +
226+
"Did you apply appops adb command?");
227+
finishActivity();
228+
return;
229+
}
230+
231+
final Intent intent = new Intent(this, RecorderService.class);
232+
intent.setAction(ACTION_RECORDING_START);
233+
intent.putExtra(ACTION_RECORDING_RESULT_CODE, resultCode);
234+
intent.putExtra(ACTION_RECORDING_FILENAME, recordingOutputPath);
235+
intent.putExtra(ACTION_RECORDING_ROTATION, recordingRotation);
236+
intent.putExtra(ACTION_RECORDING_PRIORITY, recordingPriority);
237+
intent.putExtra(ACTION_RECORDING_MAX_DURATION, recordingMaxDuration);
238+
intent.putExtra(ACTION_RECORDING_RESOLUTION, recordingResolutionMode);
239+
intent.putExtras(data);
240+
241+
startService(intent);
242+
243+
finishActivity();
244+
}
245+
246+
private void registerSettingsReceivers(List<Class<? extends BroadcastReceiver>> receiverClasses)
247+
{
79248
for (Class<? extends BroadcastReceiver> receiverClass: receiverClasses) {
80249
try {
81250
final BroadcastReceiver receiver = receiverClass.newInstance();
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
Copyright 2012-present Appium Committers
3+
<p>
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+
<p>
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
<p>
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+
17+
package io.appium.settings.recorder;
18+
19+
import android.media.MediaFormat;
20+
import android.os.Build;
21+
import android.util.Size;
22+
23+
import java.util.Arrays;
24+
import java.util.List;
25+
26+
import androidx.annotation.RequiresApi;
27+
import io.appium.settings.BuildConfig;
28+
29+
public class RecorderConstant {
30+
public static final int REQUEST_CODE_SCREEN_CAPTURE = 123;
31+
public static final String ACTION_RECORDING_BASE = BuildConfig.APPLICATION_ID + ".recording";
32+
public static final String ACTION_RECORDING_START = ACTION_RECORDING_BASE + ".ACTION_START";
33+
public static final String ACTION_RECORDING_STOP = ACTION_RECORDING_BASE + ".ACTION_STOP";
34+
public static final String ACTION_RECORDING_RESULT_CODE = "result_code";
35+
public static final String ACTION_RECORDING_ROTATION = "recording_rotation";
36+
public static final String ACTION_RECORDING_FILENAME = "filename";
37+
public static final String ACTION_RECORDING_PRIORITY = "priority";
38+
public static final String ACTION_RECORDING_MAX_DURATION = "max_duration_sec";
39+
public static final String ACTION_RECORDING_RESOLUTION = "resolution";
40+
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
41+
public static final String RECORDING_DEFAULT_VIDEO_MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_AVC;
42+
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
43+
public static final Size RECORDING_RESOLUTION_FULL_HD = new Size(1920, 1080);
44+
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
45+
public static final Size RECORDING_RESOLUTION_HD = new Size(1280, 720);
46+
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
47+
public static final Size RECORDING_RESOLUTION_480P = new Size(720, 480);
48+
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
49+
public static final Size RECORDING_RESOLUTION_QVGA = new Size(320, 240);
50+
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
51+
public static final Size RECORDING_RESOLUTION_QCIF = new Size(176, 144);
52+
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
53+
public static final Size RECORDING_RESOLUTION_DEFAULT = new Size(1920, 1080);
54+
public static final float BITRATE_MULTIPLIER = 0.25f;
55+
public static final int AUDIO_CODEC_SAMPLE_RATE_HZ = 44100;
56+
public static final int AUDIO_CODEC_CHANNEL_COUNT = 1;
57+
public static final int AUDIO_CODEC_REPEAT_PREV_FRAME_AFTER_MS = 1000000;
58+
public static final int AUDIO_CODEC_I_FRAME_INTERVAL_MS = 5;
59+
public static final int AUDIO_CODEC_DEFAULT_BITRATE = 64000;
60+
public static final int VIDEO_CODEC_DEFAULT_FRAME_RATE = 30;
61+
public static final long MEDIA_QUEUE_BUFFERING_DEFAULT_TIMEOUT_MS = 10000;
62+
public static final long NANOSECONDS_IN_MICROSECOND = 1000;
63+
public static final String NO_PATH_SET = "";
64+
public static final long NO_TIMESTAMP_SET = -1;
65+
// Assume 0 degree == portrait as default
66+
public static final int RECORDING_ROTATION_DEFAULT_DEGREE = 0;
67+
public static final int NO_TRACK_INDEX_SET = -1;
68+
public static final String NO_RESOLUTION_MODE_SET = "";
69+
public static final String RECORDING_PRIORITY_MAX = "high";
70+
public static final String RECORDING_PRIORITY_NORM = "normal";
71+
public static final String RECORDING_PRIORITY_MIN = "low";
72+
public static final int RECORDING_PRIORITY_DEFAULT = Thread.MAX_PRIORITY;
73+
public static final int RECORDING_MAX_DURATION_DEFAULT_MS = 15 * 60 * 1000; // 15 Minutes, in milliseconds
74+
/*
75+
* Note: Reason we limit recording to following resolution list is that
76+
* android's AVC/H264 video encoder capabilities varies device-to-device (OEM modifications)
77+
* and with values larger than 1920x1080, isSizeSupported(width, height) method (see https://developer.android.com/reference/android/media/MediaCodecInfo.VideoCapabilities#isSizeSupported(int,%20int))
78+
* returns false on tested devices and also with arbitrary values between supported range,
79+
* sometimes MediaEncoder.configure() method crashes with an exception on some phones
80+
* also default supported resolutions as per CTS tests are limited to following values (values between 176x144 and 1920x1080)
81+
* see https://android.googlesource.com/platform/cts/+/refs/heads/android12-qpr1-release/tests/tests/media/src/android/media/cts/VideoEncoderTest.java#1766
82+
* because of these reasons, to support wide variety of devices, we pre-limit resolution modes to the following values
83+
*/
84+
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
85+
public static final List<Size> RECORDING_RESOLUTION_LIST =
86+
Arrays.asList(
87+
new Size(1920, 1080),
88+
new Size(1280, 720),
89+
new Size(720, 480),
90+
new Size(320, 240),
91+
new Size(176, 144));
92+
// 1048576 Bps == 1 Mbps (1024*1024)
93+
public static final float BPS_IN_MBPS = 1048576f;
94+
}

0 commit comments

Comments
 (0)