io.appium.settings 4.0.9 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/apks/settings_apk-debug.apk +0 -0
- package/app/build.gradle +4 -3
- package/app/src/main/AndroidManifest.xml +8 -0
- package/app/src/main/java/io/appium/settings/Settings.java +171 -2
- package/app/src/main/java/io/appium/settings/receivers/LocaleSettingReceiver.java +33 -28
- package/app/src/main/java/io/appium/settings/recorder/RecorderConstant.java +94 -0
- package/app/src/main/java/io/appium/settings/recorder/RecorderService.java +202 -0
- package/app/src/main/java/io/appium/settings/recorder/RecorderThread.java +459 -0
- package/app/src/main/java/io/appium/settings/recorder/RecorderUtil.java +298 -0
- package/build.gradle +1 -1
- package/gradle/wrapper/gradle-wrapper.properties +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -321,6 +321,36 @@ and add them to the media library if their MIME types are supported. If the
|
|
|
321
321
|
file/folder in _path_ does not exist/is not readable or is not provided then an
|
|
322
322
|
error will be returned and the corresponding log message would be written into logs.
|
|
323
323
|
|
|
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
|
+
|
|
324
354
|
|
|
325
355
|
## Notes:
|
|
326
356
|
|
|
Binary file
|
package/app/build.gradle
CHANGED
|
@@ -6,8 +6,8 @@ android {
|
|
|
6
6
|
defaultConfig {
|
|
7
7
|
minSdkVersion 18
|
|
8
8
|
targetSdkVersion 30
|
|
9
|
-
versionCode
|
|
10
|
-
versionName "4.
|
|
9
|
+
versionCode 45
|
|
10
|
+
versionName "4.1.1"
|
|
11
11
|
applicationId "io.appium.settings"
|
|
12
12
|
}
|
|
13
13
|
|
|
@@ -27,7 +27,8 @@ android {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
dependencies {
|
|
30
|
-
implementation 'com.google.android.gms:play-services-location:19.0.
|
|
30
|
+
implementation 'com.google.android.gms:play-services-location:19.0.1'
|
|
31
|
+
implementation 'org.apache.commons:commons-lang3:3.12.0'
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
static def renameAPK(variant) {
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
|
29
29
|
<uses-permission android:name="android.permission.READ_SMS" />
|
|
30
30
|
|
|
31
|
+
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
|
32
|
+
|
|
31
33
|
<uses-feature android:name="android.hardware.wifi" />
|
|
32
34
|
|
|
33
35
|
<application
|
|
@@ -66,6 +68,12 @@
|
|
|
66
68
|
android:exported="true">
|
|
67
69
|
</service>
|
|
68
70
|
|
|
71
|
+
<service
|
|
72
|
+
android:foregroundServiceType="mediaProjection"
|
|
73
|
+
android:name=".recorder.RecorderService"
|
|
74
|
+
android:exported="true">
|
|
75
|
+
</service>
|
|
76
|
+
|
|
69
77
|
<service
|
|
70
78
|
android:name=".AppiumIME"
|
|
71
79
|
android:label="Appium IME"
|
|
@@ -18,12 +18,17 @@ package io.appium.settings;
|
|
|
18
18
|
|
|
19
19
|
import android.app.Activity;
|
|
20
20
|
import android.content.BroadcastReceiver;
|
|
21
|
+
import android.content.Context;
|
|
22
|
+
import android.content.Intent;
|
|
21
23
|
import android.content.IntentFilter;
|
|
24
|
+
import android.media.projection.MediaProjectionManager;
|
|
22
25
|
import android.os.Build;
|
|
23
26
|
import android.os.Bundle;
|
|
24
27
|
import android.os.Handler;
|
|
25
28
|
import android.util.Log;
|
|
26
29
|
|
|
30
|
+
import java.io.File;
|
|
31
|
+
import java.nio.file.Paths;
|
|
27
32
|
import java.util.Arrays;
|
|
28
33
|
import java.util.List;
|
|
29
34
|
|
|
@@ -39,10 +44,34 @@ import io.appium.settings.receivers.NotificationsReceiver;
|
|
|
39
44
|
import io.appium.settings.receivers.SmsReader;
|
|
40
45
|
import io.appium.settings.receivers.UnpairBluetoothDevicesReceiver;
|
|
41
46
|
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;
|
|
42
65
|
|
|
43
66
|
public class Settings extends Activity {
|
|
44
67
|
private static final String TAG = "APPIUM SETTINGS";
|
|
45
68
|
|
|
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
|
+
|
|
46
75
|
@Override
|
|
47
76
|
public void onCreate(Bundle savedInstanceState) {
|
|
48
77
|
super.onCreate(savedInstanceState);
|
|
@@ -70,12 +99,152 @@ public class Settings extends Activity {
|
|
|
70
99
|
LocationTracker.getInstance().start(this);
|
|
71
100
|
}
|
|
72
101
|
|
|
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() {
|
|
73
207
|
Log.d(TAG, "Closing the app");
|
|
74
208
|
Handler handler = new Handler();
|
|
75
|
-
handler.postDelayed(Settings.this::finish,
|
|
209
|
+
handler.postDelayed(Settings.this::finish, 0);
|
|
210
|
+
}
|
|
211
|
+
|
|
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();
|
|
76
244
|
}
|
|
77
245
|
|
|
78
|
-
private void registerSettingsReceivers(List<Class<? extends BroadcastReceiver>> receiverClasses)
|
|
246
|
+
private void registerSettingsReceivers(List<Class<? extends BroadcastReceiver>> receiverClasses)
|
|
247
|
+
{
|
|
79
248
|
for (Class<? extends BroadcastReceiver> receiverClass: receiverClasses) {
|
|
80
249
|
try {
|
|
81
250
|
final BroadcastReceiver receiver = receiverClass.newInstance();
|
|
@@ -16,11 +16,16 @@
|
|
|
16
16
|
|
|
17
17
|
package io.appium.settings.receivers;
|
|
18
18
|
|
|
19
|
+
import static org.apache.commons.lang3.StringUtils.isBlank;
|
|
20
|
+
|
|
21
|
+
import android.app.Activity;
|
|
19
22
|
import android.content.BroadcastReceiver;
|
|
20
23
|
import android.content.Context;
|
|
21
24
|
import android.content.Intent;
|
|
22
25
|
import android.util.Log;
|
|
23
26
|
|
|
27
|
+
import org.apache.commons.lang3.LocaleUtils;
|
|
28
|
+
|
|
24
29
|
import java.util.Locale;
|
|
25
30
|
|
|
26
31
|
import io.appium.settings.handlers.LocaleSettingHandler;
|
|
@@ -37,40 +42,40 @@ public class LocaleSettingReceiver extends BroadcastReceiver implements HasActio
|
|
|
37
42
|
// am broadcast -a io.appium.settings.locale --es lang ja --es country JP
|
|
38
43
|
@Override
|
|
39
44
|
public void onReceive(Context context, Intent intent) {
|
|
40
|
-
if(!hasExtraLocale(intent)) {
|
|
41
|
-
Log.e(TAG, "Don't forget to set lang and country like: am broadcast -a io.appium.settings.locale --es lang ja --es country JP");
|
|
42
|
-
Log.e(TAG, "Set en-US by default.");
|
|
43
|
-
|
|
44
|
-
intent.putExtra(LANG, "en");
|
|
45
|
-
intent.putExtra(COUNTRY, "US");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
45
|
String language = intent.getStringExtra(LANG);
|
|
49
46
|
String country = intent.getStringExtra(COUNTRY);
|
|
50
|
-
|
|
47
|
+
if (language == null || country == null) {
|
|
48
|
+
Log.w(TAG, "It is required to provide both language and country, for example: " +
|
|
49
|
+
"am broadcast -a io.appium.settings.locale --es lang ja --es country JP");
|
|
50
|
+
Log.i(TAG, "Set en-US by default.");
|
|
51
|
+
language = "en";
|
|
52
|
+
country= "US";
|
|
53
|
+
}
|
|
51
54
|
// Expect https://developer.android.com/reference/java/util/Locale.html#Locale(java.lang.String,%20java.lang.String) format.
|
|
52
55
|
Locale locale = new Locale(language, country);
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
56
|
+
String script = intent.getStringExtra(SCRIPT);
|
|
57
|
+
if (!isBlank(script)) {
|
|
58
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
|
|
59
|
+
locale = new Locale.Builder().setLocale(locale).setScript(script).build();
|
|
60
|
+
} else {
|
|
61
|
+
Log.w(TAG, String.format("Script value '%s' is ignored as " +
|
|
62
|
+
"setting of it is not supported by the current Android version", script));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!LocaleUtils.isAvailableLocale(locale)) {
|
|
66
|
+
Log.e(TAG, String.format(
|
|
67
|
+
"The locale %s is not known. Only the following locales are available: %s",
|
|
68
|
+
locale, LocaleUtils.availableLocaleList())
|
|
69
|
+
);
|
|
70
|
+
setResultCode(Activity.RESULT_CANCELED);
|
|
71
|
+
setResultData(String.format("The locale %s is not known", locale));
|
|
72
|
+
return;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
private boolean hasExtraLocale(Intent intent) {
|
|
73
|
-
return intent.hasExtra(LANG) && intent.hasExtra(COUNTRY);
|
|
75
|
+
new LocaleSettingHandler(context).setLocale(locale);
|
|
76
|
+
Log.i(TAG, String.format("Set locale: %s", locale));
|
|
77
|
+
setResultCode(Activity.RESULT_OK);
|
|
78
|
+
setResultData(locale.toString());
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
@Override
|
|
@@ -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,0 +1,202 @@
|
|
|
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.app.Service;
|
|
20
|
+
import android.content.Context;
|
|
21
|
+
import android.content.Intent;
|
|
22
|
+
import android.media.projection.MediaProjection;
|
|
23
|
+
import android.media.projection.MediaProjectionManager;
|
|
24
|
+
import android.os.Build;
|
|
25
|
+
import android.os.IBinder;
|
|
26
|
+
import android.util.DisplayMetrics;
|
|
27
|
+
import android.util.Log;
|
|
28
|
+
import android.util.Size;
|
|
29
|
+
|
|
30
|
+
import androidx.annotation.Nullable;
|
|
31
|
+
import androidx.annotation.RequiresApi;
|
|
32
|
+
import io.appium.settings.helpers.NotificationHelpers;
|
|
33
|
+
|
|
34
|
+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_FILENAME;
|
|
35
|
+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_MAX_DURATION;
|
|
36
|
+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_PRIORITY;
|
|
37
|
+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_RESOLUTION;
|
|
38
|
+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_RESULT_CODE;
|
|
39
|
+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_ROTATION;
|
|
40
|
+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_START;
|
|
41
|
+
import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_STOP;
|
|
42
|
+
import static io.appium.settings.recorder.RecorderConstant.RECORDING_MAX_DURATION_DEFAULT_MS;
|
|
43
|
+
import static io.appium.settings.recorder.RecorderConstant.RECORDING_PRIORITY_DEFAULT;
|
|
44
|
+
import static io.appium.settings.recorder.RecorderConstant.RECORDING_ROTATION_DEFAULT_DEGREE;
|
|
45
|
+
|
|
46
|
+
public class RecorderService extends Service {
|
|
47
|
+
private static final String TAG = "RecorderService";
|
|
48
|
+
|
|
49
|
+
private static RecorderThread recorderThread;
|
|
50
|
+
|
|
51
|
+
public RecorderService() {
|
|
52
|
+
super();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Override
|
|
56
|
+
public void onDestroy() {
|
|
57
|
+
Log.v(TAG, "onDestroy called: Stopping recorder");
|
|
58
|
+
if (recorderThread != null && recorderThread.isRecordingRunning()) {
|
|
59
|
+
recorderThread.stopRecording();
|
|
60
|
+
}
|
|
61
|
+
super.onDestroy();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Nullable
|
|
65
|
+
@Override
|
|
66
|
+
public IBinder onBind(final Intent intent) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@RequiresApi(api = Build.VERSION_CODES.Q)
|
|
71
|
+
@Override
|
|
72
|
+
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
|
73
|
+
if (intent == null) {
|
|
74
|
+
Log.e(TAG, "onStartCommand: Unable to retrieve recording intent");
|
|
75
|
+
return START_NOT_STICKY;
|
|
76
|
+
}
|
|
77
|
+
final String action = intent.getAction();
|
|
78
|
+
if (action == null) {
|
|
79
|
+
Log.e(TAG, "onStartCommand: Unable to retrieve recording intent:action");
|
|
80
|
+
return START_NOT_STICKY;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
int result = START_STICKY;
|
|
84
|
+
if (ACTION_RECORDING_START.equals(action)) {
|
|
85
|
+
showNotification(); // TODO is this really necessary
|
|
86
|
+
|
|
87
|
+
MediaProjectionManager mMediaProjectionManager =
|
|
88
|
+
(MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE);
|
|
89
|
+
|
|
90
|
+
if (mMediaProjectionManager != null) {
|
|
91
|
+
startRecord(mMediaProjectionManager, intent);
|
|
92
|
+
} else {
|
|
93
|
+
Log.e(TAG, "onStartCommand: " +
|
|
94
|
+
"Unable to retrieve MediaProjectionManager instance");
|
|
95
|
+
result = START_NOT_STICKY;
|
|
96
|
+
}
|
|
97
|
+
} else if (ACTION_RECORDING_STOP.equals(action)) {
|
|
98
|
+
Log.v(TAG, "onStartCommand: Received recording stop intent, stopping recording");
|
|
99
|
+
stopRecord();
|
|
100
|
+
result = START_NOT_STICKY;
|
|
101
|
+
} else {
|
|
102
|
+
Log.v(TAG, "onStartCommand: Received unknown recording intent with action: "
|
|
103
|
+
+ action);
|
|
104
|
+
result = START_NOT_STICKY;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* start recording
|
|
112
|
+
*/
|
|
113
|
+
@RequiresApi(api = Build.VERSION_CODES.Q)
|
|
114
|
+
private void startRecord(MediaProjectionManager mediaProjectionManager,
|
|
115
|
+
final Intent intent) {
|
|
116
|
+
if (recorderThread != null) {
|
|
117
|
+
if (recorderThread.isRecordingRunning()) {
|
|
118
|
+
Log.v(TAG, "Recording is already continuing, exiting");
|
|
119
|
+
return;
|
|
120
|
+
} else {
|
|
121
|
+
Log.w(TAG, "Recording is stopped, " +
|
|
122
|
+
"but recording instance is still alive, starting recording");
|
|
123
|
+
recorderThread = null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
int resultCode = intent.getIntExtra(ACTION_RECORDING_RESULT_CODE, 0);
|
|
128
|
+
// get MediaProjection
|
|
129
|
+
final MediaProjection projection = mediaProjectionManager.getMediaProjection(resultCode,
|
|
130
|
+
intent);
|
|
131
|
+
if (projection == null) {
|
|
132
|
+
Log.e(TAG, "Recording is stopped, Unable to retrieve MediaProjection instance");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
String outputFilePath = intent.getStringExtra(ACTION_RECORDING_FILENAME);
|
|
137
|
+
if (outputFilePath == null) {
|
|
138
|
+
Log.e(TAG, "Recording is stopped, Unable to retrieve outputFilePath instance");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* TODO we need to rotate frames that comes from virtual screen before writing to file via muxer,
|
|
143
|
+
* for handling landscape mode properly, we need to find a way to rotate images somehow fast and reliable
|
|
144
|
+
*/
|
|
145
|
+
int recordingRotationDegree = intent.getIntExtra(ACTION_RECORDING_ROTATION,
|
|
146
|
+
RECORDING_ROTATION_DEFAULT_DEGREE);
|
|
147
|
+
|
|
148
|
+
DisplayMetrics metrics = getResources().getDisplayMetrics();
|
|
149
|
+
int rawWidth = metrics.widthPixels;
|
|
150
|
+
int rawHeight = metrics.heightPixels;
|
|
151
|
+
int rawDpi = metrics.densityDpi;
|
|
152
|
+
|
|
153
|
+
String recordingResolutionMode = intent.getStringExtra(ACTION_RECORDING_RESOLUTION);
|
|
154
|
+
|
|
155
|
+
Size recordingResolution = RecorderUtil.
|
|
156
|
+
getRecordingResolution(recordingResolutionMode);
|
|
157
|
+
|
|
158
|
+
int resolutionWidth = recordingResolution.getWidth();
|
|
159
|
+
int resolutionHeight = recordingResolution.getHeight();
|
|
160
|
+
|
|
161
|
+
/*
|
|
162
|
+
MediaCodec's tested supported resolutions (as per CTS tests) are for landscape mode as default (1920x1080, 1280x720 etc.)
|
|
163
|
+
but if phone or tablet is in portrait mode (usually it is),
|
|
164
|
+
we need to flip width/height to match it
|
|
165
|
+
*/
|
|
166
|
+
if (rawWidth < rawHeight) {
|
|
167
|
+
resolutionWidth = recordingResolution.getHeight();
|
|
168
|
+
resolutionHeight = recordingResolution.getWidth();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
Log.v(TAG, String.format("Starting recording with resolution(widthxheight): (%dx%d)",
|
|
172
|
+
resolutionWidth, resolutionHeight));
|
|
173
|
+
|
|
174
|
+
int recordingPriority = intent.getIntExtra(ACTION_RECORDING_PRIORITY,
|
|
175
|
+
RECORDING_PRIORITY_DEFAULT);
|
|
176
|
+
|
|
177
|
+
int recordingMaxDuration = intent.getIntExtra(ACTION_RECORDING_MAX_DURATION,
|
|
178
|
+
RECORDING_MAX_DURATION_DEFAULT_MS);
|
|
179
|
+
|
|
180
|
+
recorderThread = new RecorderThread(projection, outputFilePath,
|
|
181
|
+
resolutionWidth, resolutionHeight, rawDpi,
|
|
182
|
+
recordingRotationDegree, recordingPriority, recordingMaxDuration);
|
|
183
|
+
recorderThread.startRecording();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* stop recording
|
|
188
|
+
*/
|
|
189
|
+
private void stopRecord() {
|
|
190
|
+
if (recorderThread != null) {
|
|
191
|
+
recorderThread.stopRecording();
|
|
192
|
+
recorderThread = null;
|
|
193
|
+
}
|
|
194
|
+
stopSelf();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private void showNotification() {
|
|
198
|
+
// Set the info for the views that show in the notification panel.
|
|
199
|
+
startForeground(NotificationHelpers.APPIUM_NOTIFICATION_IDENTIFIER,
|
|
200
|
+
NotificationHelpers.getNotification(this));
|
|
201
|
+
}
|
|
202
|
+
}
|