>
final BroadcastReceiver receiver = receiverClass.newInstance();
IntentFilter filter = new IntentFilter(((HasAction) receiver).getAction());
getApplicationContext().registerReceiver(receiver, filter);
- } catch (IllegalAccessException e) {
- e.printStackTrace();
- } catch (InstantiationException e) {
+ } catch (IllegalAccessException | InstantiationException e) {
e.printStackTrace();
}
}
diff --git a/app/src/main/java/io/appium/settings/recorder/RecorderConstant.java b/app/src/main/java/io/appium/settings/helpers/RecorderConstant.java
similarity index 91%
rename from app/src/main/java/io/appium/settings/recorder/RecorderConstant.java
rename to app/src/main/java/io/appium/settings/helpers/RecorderConstant.java
index 17a91a56..74008372 100644
--- a/app/src/main/java/io/appium/settings/recorder/RecorderConstant.java
+++ b/app/src/main/java/io/appium/settings/helpers/RecorderConstant.java
@@ -14,7 +14,7 @@
limitations under the License.
*/
-package io.appium.settings.recorder;
+package io.appium.settings.helpers;
import android.media.MediaFormat;
import android.util.Size;
@@ -26,12 +26,17 @@
public class RecorderConstant {
public static final int REQUEST_CODE_SCREEN_CAPTURE = 123;
+ public static final int REQUEST_CODE_SCREEN_STREAM = 124;
public static final String ACTION_RECORDING_BASE = BuildConfig.APPLICATION_ID + ".recording";
+ public static final String ACTION_STREAMING_BASE = BuildConfig.APPLICATION_ID + ".streaming";
public static final String ACTION_RECORDING_START = ACTION_RECORDING_BASE + ".ACTION_START";
public static final String ACTION_RECORDING_STOP = ACTION_RECORDING_BASE + ".ACTION_STOP";
+ public static final String ACTION_STREAMING_START = ACTION_STREAMING_BASE + ".ACTION_START";
+ public static final String ACTION_STREAMING_STOP = ACTION_STREAMING_BASE + ".ACTION_STOP";
public static final String ACTION_RECORDING_RESULT_CODE = "result_code";
public static final String ACTION_RECORDING_ROTATION = "recording_rotation";
public static final String ACTION_RECORDING_FILENAME = "filename";
+ public static final String ACTION_STREAMING_PORT = "port";
public static final String ACTION_RECORDING_PRIORITY = "priority";
public static final String ACTION_RECORDING_MAX_DURATION = "max_duration_sec";
public static final String ACTION_RECORDING_RESOLUTION = "resolution";
diff --git a/app/src/main/java/io/appium/settings/recorder/RecorderUtil.java b/app/src/main/java/io/appium/settings/helpers/RecorderUtil.java
similarity index 87%
rename from app/src/main/java/io/appium/settings/recorder/RecorderUtil.java
rename to app/src/main/java/io/appium/settings/helpers/RecorderUtil.java
index f802995d..bd26f54f 100644
--- a/app/src/main/java/io/appium/settings/recorder/RecorderUtil.java
+++ b/app/src/main/java/io/appium/settings/helpers/RecorderUtil.java
@@ -14,7 +14,7 @@
limitations under the License.
*/
-package io.appium.settings.recorder;
+package io.appium.settings.helpers;
import android.Manifest;
import android.app.AppOpsManager;
@@ -38,24 +38,24 @@
import androidx.core.app.ActivityCompat;
import static android.content.Context.WINDOW_SERVICE;
-import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_MAX_DURATION;
-import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_PRIORITY;
-import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_RESOLUTION;
-import static io.appium.settings.recorder.RecorderConstant.NO_RESOLUTION_MODE_SET;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_DEFAULT_VIDEO_MIME_TYPE;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_MAX_DURATION_DEFAULT_MS;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_PRIORITY_DEFAULT;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_PRIORITY_MAX;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_PRIORITY_MIN;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_PRIORITY_NORM;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_RESOLUTION_480P;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_RESOLUTION_DEFAULT;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_RESOLUTION_FULL_HD;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_RESOLUTION_HD;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_RESOLUTION_LIST;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_RESOLUTION_QCIF;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_RESOLUTION_QVGA;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_ROTATION_DEFAULT_DEGREE;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_MAX_DURATION;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_PRIORITY;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_RESOLUTION;
+import static io.appium.settings.helpers.RecorderConstant.NO_RESOLUTION_MODE_SET;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_DEFAULT_VIDEO_MIME_TYPE;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_MAX_DURATION_DEFAULT_MS;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_PRIORITY_DEFAULT;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_PRIORITY_MAX;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_PRIORITY_MIN;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_PRIORITY_NORM;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_RESOLUTION_480P;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_RESOLUTION_DEFAULT;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_RESOLUTION_FULL_HD;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_RESOLUTION_HD;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_RESOLUTION_LIST;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_RESOLUTION_QCIF;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_RESOLUTION_QVGA;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_ROTATION_DEFAULT_DEGREE;
public class RecorderUtil {
private static final String TAG = "RecorderUtil";
diff --git a/app/src/main/java/io/appium/settings/recorder/RecorderService.java b/app/src/main/java/io/appium/settings/recorder/RecorderService.java
index 6e64c69e..746c5363 100644
--- a/app/src/main/java/io/appium/settings/recorder/RecorderService.java
+++ b/app/src/main/java/io/appium/settings/recorder/RecorderService.java
@@ -30,18 +30,19 @@
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import io.appium.settings.helpers.NotificationHelpers;
-
-import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_FILENAME;
-import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_MAX_DURATION;
-import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_PRIORITY;
-import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_RESOLUTION;
-import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_RESULT_CODE;
-import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_ROTATION;
-import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_START;
-import static io.appium.settings.recorder.RecorderConstant.ACTION_RECORDING_STOP;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_MAX_DURATION_DEFAULT_MS;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_PRIORITY_DEFAULT;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_ROTATION_DEFAULT_DEGREE;
+import io.appium.settings.helpers.RecorderUtil;
+
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_FILENAME;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_MAX_DURATION;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_PRIORITY;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_RESOLUTION;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_RESULT_CODE;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_ROTATION;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_START;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_STOP;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_MAX_DURATION_DEFAULT_MS;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_PRIORITY_DEFAULT;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_ROTATION_DEFAULT_DEGREE;
public class RecorderService extends Service {
private static final String TAG = "RecorderService";
diff --git a/app/src/main/java/io/appium/settings/recorder/RecorderThread.java b/app/src/main/java/io/appium/settings/recorder/RecorderThread.java
index 2478a8f7..be84fb76 100644
--- a/app/src/main/java/io/appium/settings/recorder/RecorderThread.java
+++ b/app/src/main/java/io/appium/settings/recorder/RecorderThread.java
@@ -39,12 +39,14 @@
import androidx.annotation.RequiresApi;
-import static io.appium.settings.recorder.RecorderConstant.BPS_IN_MBPS;
-import static io.appium.settings.recorder.RecorderConstant.NANOSECONDS_IN_MICROSECOND;
-import static io.appium.settings.recorder.RecorderConstant.NO_TIMESTAMP_SET;
-import static io.appium.settings.recorder.RecorderConstant.NO_TRACK_INDEX_SET;
-import static io.appium.settings.recorder.RecorderConstant.RECORDING_DEFAULT_VIDEO_MIME_TYPE;
-import static io.appium.settings.recorder.RecorderConstant.VIDEO_CODEC_DEFAULT_FRAME_RATE;
+import static io.appium.settings.helpers.RecorderConstant.BPS_IN_MBPS;
+import static io.appium.settings.helpers.RecorderConstant.NANOSECONDS_IN_MICROSECOND;
+import static io.appium.settings.helpers.RecorderConstant.NO_TIMESTAMP_SET;
+import static io.appium.settings.helpers.RecorderConstant.NO_TRACK_INDEX_SET;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_DEFAULT_VIDEO_MIME_TYPE;
+import static io.appium.settings.helpers.RecorderConstant.VIDEO_CODEC_DEFAULT_FRAME_RATE;
+
+import io.appium.settings.helpers.RecorderConstant;
public class RecorderThread implements Runnable {
diff --git a/app/src/main/java/io/appium/settings/streaming/EventEmitter.java b/app/src/main/java/io/appium/settings/streaming/EventEmitter.java
new file mode 100644
index 00000000..2d11f623
--- /dev/null
+++ b/app/src/main/java/io/appium/settings/streaming/EventEmitter.java
@@ -0,0 +1,37 @@
+/*
+ Copyright 2012-present Appium Committers
+
+ 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 io.appium.settings.streaming;
+
+import java.util.concurrent.CopyOnWriteArrayList;
+
+public class EventEmitter {
+ private final CopyOnWriteArrayList> subscribers = new CopyOnWriteArrayList<>();
+
+ public void emit(String name, T payload) {
+ for (IEventHandler subscriber: subscribers) {
+ subscriber.onEvent(name, payload);
+ }
+ }
+
+ public void subscribe(IEventHandler subscriber) {
+ subscribers.addIfAbsent(subscriber);
+ }
+
+ public void unsubscribe(IEventHandler subscriber) {
+ subscribers.remove(subscriber);
+ }
+}
diff --git a/app/src/main/java/io/appium/settings/streaming/IEventHandler.java b/app/src/main/java/io/appium/settings/streaming/IEventHandler.java
new file mode 100644
index 00000000..0b7225ec
--- /dev/null
+++ b/app/src/main/java/io/appium/settings/streaming/IEventHandler.java
@@ -0,0 +1,21 @@
+/*
+ Copyright 2012-present Appium Committers
+
+ 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 io.appium.settings.streaming;
+
+public interface IEventHandler {
+ void onEvent(String name, T payload);
+}
diff --git a/app/src/main/java/io/appium/settings/streaming/IvfWriter.java b/app/src/main/java/io/appium/settings/streaming/IvfWriter.java
new file mode 100644
index 00000000..afe61299
--- /dev/null
+++ b/app/src/main/java/io/appium/settings/streaming/IvfWriter.java
@@ -0,0 +1,109 @@
+package io.appium.settings.streaming;
+
+/*
+ * Copyright (C) 2013 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.
+ */
+
+/**
+ * Writes an IVF file.
+ *
+ * IVF format is a simple container format for VP8 encoded frames.
+ */
+public class IvfWriter {
+ /**
+ * Makes a 32 byte file header for IVF format.
+ *
+ * Timebase fraction is in format scale/rate, e.g. 1/1000
+ *
+ * @param frameCount total number of frames file contains
+ * @param width frame width
+ * @param height frame height
+ * @param scale timebase scale (or numerator of the timebase fraction)
+ * @param rate timebase rate (or denominator of the timebase fraction)
+ */
+ public static byte[] makeIvfHeader(int frameCount, int width, int height, int scale, int rate){
+ byte[] ivfHeader = new byte[32];
+ ivfHeader[0] = 'D';
+ ivfHeader[1] = 'K';
+ ivfHeader[2] = 'I';
+ ivfHeader[3] = 'F';
+ lay16Bits(ivfHeader, 4, 0); // version
+ lay16Bits(ivfHeader, 6, 32); // header size
+ ivfHeader[8] = 'V'; // fourcc
+ ivfHeader[9] = 'P';
+ ivfHeader[10] = '8';
+ ivfHeader[11] = '0';
+ lay16Bits(ivfHeader, 12, width);
+ lay16Bits(ivfHeader, 14, height);
+ lay32Bits(ivfHeader, 16, rate); // scale/rate
+ lay32Bits(ivfHeader, 20, scale);
+ lay32Bits(ivfHeader, 24, frameCount);
+ lay32Bits(ivfHeader, 28, 0); // unused
+ return ivfHeader;
+ }
+ /**
+ * Makes a 12 byte header for an encoded frame.
+ *
+ * @param size frame size
+ * @param timestamp presentation timestamp of the frame
+ */
+ public static byte[] makeIvfFrameHeader(int size, long timestamp){
+ byte[] frameHeader = new byte[12];
+ lay32Bits(frameHeader, 0, size);
+ lay64bits(frameHeader, 4, timestamp);
+ return frameHeader;
+ }
+ /**
+ * Lays least significant 16 bits of an int into 2 items of a byte array.
+ *
+ * Note that ordering is little-endian.
+ *
+ * @param array the array to be modified
+ * @param index index of the array to start laying down
+ * @param value the integer to use least significant 16 bits
+ */
+ private static void lay16Bits(byte[] array, int index, int value){
+ array[index] = (byte) (value);
+ array[index + 1] = (byte) (value >> 8);
+ }
+ /**
+ * Lays an int into 4 items of a byte array.
+ *
+ * Note that ordering is little-endian.
+ *
+ * @param array the array to be modified
+ * @param index index of the array to start laying down
+ * @param value the integer to use
+ */
+ private static void lay32Bits(byte[] array, int index, int value){
+ for (int i = 0; i < 4; i++){
+ array[index + i] = (byte) (value >> (i * 8));
+ }
+ }
+ /**
+ * Lays a long int into 8 items of a byte array.
+ *
+ * Note that ordering is little-endian.
+ *
+ * @param array the array to be modified
+ * @param index index of the array to start laying down
+ * @param value the integer to use
+ */
+ private static void lay64bits(byte[] array, int index, long value){
+ for (int i = 0; i < 8; i++){
+ array[index + i] = (byte) (value >> (i * 8));
+ }
+ }
+}
diff --git a/app/src/main/java/io/appium/settings/streaming/StreamingServer.java b/app/src/main/java/io/appium/settings/streaming/StreamingServer.java
new file mode 100644
index 00000000..e765b1e9
--- /dev/null
+++ b/app/src/main/java/io/appium/settings/streaming/StreamingServer.java
@@ -0,0 +1,62 @@
+/*
+ Copyright 2012-present Appium Committers
+
+ 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 io.appium.settings.streaming;
+
+import org.java_websocket.WebSocket;
+import org.java_websocket.handshake.ClientHandshake;
+import org.java_websocket.server.WebSocketServer;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+
+public class StreamingServer extends WebSocketServer {
+ public static final String ON_CONNECT = "connect";
+ public static final String ON_CLOSE = "close";
+ public static final String ON_ERROR = "error";
+ public static final String ON_START = "start";
+ private final EventEmitter eventEmitter;
+
+ public StreamingServer(int port, EventEmitter eventEmitter) throws IOException {
+ super(new InetSocketAddress(port));
+ this.eventEmitter = eventEmitter;
+ }
+
+ @Override
+ public void onOpen(WebSocket conn, ClientHandshake handshake) {
+ eventEmitter.emit(ON_CONNECT, conn);
+ }
+
+ @Override
+ public void onClose(WebSocket conn, int code, String reason, boolean remote) {
+ eventEmitter.emit(ON_CLOSE, conn);
+ }
+
+ @Override
+ public void onMessage(WebSocket conn, String message) {
+
+ }
+
+ @Override
+ public void onError(WebSocket conn, Exception ex) {
+ eventEmitter.emit(ON_ERROR, conn);
+ }
+
+ @Override
+ public void onStart() {
+ eventEmitter.emit(ON_START, null);
+ }
+}
diff --git a/app/src/main/java/io/appium/settings/streaming/StreamingService.java b/app/src/main/java/io/appium/settings/streaming/StreamingService.java
new file mode 100644
index 00000000..1c85ff2c
--- /dev/null
+++ b/app/src/main/java/io/appium/settings/streaming/StreamingService.java
@@ -0,0 +1,214 @@
+/*
+ Copyright 2012-present Appium Committers
+
+ 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 io.appium.settings.streaming;
+
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_MAX_DURATION;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_STREAMING_PORT;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_RESOLUTION;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_RESULT_CODE;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_START;
+import static io.appium.settings.helpers.RecorderConstant.ACTION_RECORDING_STOP;
+import static io.appium.settings.helpers.RecorderConstant.RECORDING_MAX_DURATION_DEFAULT_MS;
+
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.media.projection.MediaProjection;
+import android.media.projection.MediaProjectionManager;
+import android.os.Build;
+import android.os.IBinder;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.util.Size;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+import org.java_websocket.WebSocket;
+
+import java.io.IOException;
+
+import io.appium.settings.helpers.NotificationHelpers;
+import io.appium.settings.helpers.RecorderUtil;
+
+public class StreamingService extends Service {
+ private static final String TAG = "StreamingService";
+
+ private static StreamingThread streamingThread;
+ private static StreamingServer streamingServer;
+
+ public StreamingService() {
+ super();
+ }
+
+ @Override
+ public void onDestroy() {
+ Log.v(TAG, "onDestroy called: Stopping recorder");
+ if (streamingThread != null && streamingThread.isStreaming()) {
+ streamingThread.stop();
+ streamingThread = null;
+ }
+ if (streamingServer != null) {
+ try {
+ streamingServer.stop();
+ } catch (InterruptedException e) {
+ // ignore
+ }
+ streamingServer = null;
+ }
+ super.onDestroy();
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(final Intent intent) {
+ return null;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.Q)
+ @Override
+ public int onStartCommand(final Intent intent, final int flags, final int startId) {
+ if (intent == null) {
+ Log.e(TAG, "onStartCommand: Unable to retrieve recording intent");
+ return START_NOT_STICKY;
+ }
+ final String action = intent.getAction();
+ if (action == null) {
+ Log.e(TAG, "onStartCommand: Unable to retrieve recording intent:action");
+ return START_NOT_STICKY;
+ }
+
+ int result = START_STICKY;
+ if (ACTION_RECORDING_START.equals(action)) {
+ showNotification(); // TODO is this really necessary
+
+ MediaProjectionManager mMediaProjectionManager =
+ (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE);
+
+ if (mMediaProjectionManager != null) {
+ startStreaming(mMediaProjectionManager, intent);
+ } else {
+ Log.e(TAG, "onStartCommand: " +
+ "Unable to retrieve MediaProjectionManager instance");
+ result = START_NOT_STICKY;
+ }
+ } else if (ACTION_RECORDING_STOP.equals(action)) {
+ Log.v(TAG, "onStartCommand: Received recording stop intent, stopping recording");
+ stopRecord();
+ result = START_NOT_STICKY;
+ } else {
+ Log.v(TAG, "onStartCommand: Received unknown recording intent with action: "
+ + action);
+ result = START_NOT_STICKY;
+ }
+
+ return result;
+ }
+
+ /**
+ * start recording
+ */
+ @RequiresApi(api = Build.VERSION_CODES.Q)
+ private void startStreaming(MediaProjectionManager mediaProjectionManager,
+ final Intent intent) {
+ if (streamingThread != null) {
+ if (streamingThread.isStreaming()) {
+ Log.v(TAG, "Recording is already continuing, exiting");
+ return;
+ } else {
+ Log.w(TAG, "Recording is stopped, " +
+ "but recording instance is still alive, starting recording");
+ streamingThread = null;
+ }
+ }
+
+ int resultCode = intent.getIntExtra(ACTION_RECORDING_RESULT_CODE, 0);
+ // get MediaProjection
+ final MediaProjection projection = mediaProjectionManager.getMediaProjection(resultCode,
+ intent);
+ if (projection == null) {
+ Log.e(TAG, "Recording is stopped, Unable to retrieve MediaProjection instance");
+ return;
+ }
+
+ int port = intent.getIntExtra(ACTION_STREAMING_PORT, 0);
+ if (port == 0) {
+ Log.e(TAG, "Recording is stopped, Unable to retrieve the port number");
+ return;
+ }
+
+ final EventEmitter connectionHandler = new EventEmitter<>();
+ try {
+ streamingServer = new StreamingServer(port, connectionHandler);
+ streamingServer.start();
+ } catch (IOException e) {
+ Log.e(TAG, "Cannot start the streaming server on port " + port, e);
+ return;
+ }
+
+ DisplayMetrics metrics = getResources().getDisplayMetrics();
+ int rawWidth = metrics.widthPixels;
+ int rawHeight = metrics.heightPixels;
+ int rawDpi = metrics.densityDpi;
+
+ String recordingResolutionMode = intent.getStringExtra(ACTION_RECORDING_RESOLUTION);
+
+ Size recordingResolution = RecorderUtil.
+ getRecordingResolution(recordingResolutionMode);
+
+ int resolutionWidth = recordingResolution.getWidth();
+ int resolutionHeight = recordingResolution.getHeight();
+
+ /*
+ MediaCodec's tested supported resolutions (as per CTS tests) are for landscape mode as default (1920x1080, 1280x720 etc.)
+ but if phone or tablet is in portrait mode (usually it is),
+ we need to flip width/height to match it
+ */
+ if (rawWidth < rawHeight) {
+ resolutionWidth = recordingResolution.getHeight();
+ resolutionHeight = recordingResolution.getWidth();
+ }
+
+ Log.v(TAG, String.format("Starting recording with resolution(widthxheight): (%dx%d)",
+ resolutionWidth, resolutionHeight));
+
+ int recordingMaxDuration = intent.getIntExtra(ACTION_RECORDING_MAX_DURATION,
+ RECORDING_MAX_DURATION_DEFAULT_MS);
+
+ streamingThread = new StreamingThread(
+ projection, connectionHandler, resolutionWidth, resolutionHeight, rawDpi, recordingMaxDuration
+ );
+ streamingThread.start();
+ }
+
+ /**
+ * stop recording
+ */
+ private void stopRecord() {
+ if (streamingThread != null) {
+ streamingThread.stop();
+ streamingThread = null;
+ }
+ stopSelf();
+ }
+
+ private void showNotification() {
+ // Set the info for the views that show in the notification panel.
+ startForeground(NotificationHelpers.APPIUM_NOTIFICATION_IDENTIFIER,
+ NotificationHelpers.getNotification(this));
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/appium/settings/streaming/StreamingThread.java b/app/src/main/java/io/appium/settings/streaming/StreamingThread.java
new file mode 100644
index 00000000..dd0ed1d7
--- /dev/null
+++ b/app/src/main/java/io/appium/settings/streaming/StreamingThread.java
@@ -0,0 +1,313 @@
+/*
+ Copyright 2012-present Appium Committers
+
+ 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 io.appium.settings.streaming;
+
+import static io.appium.settings.helpers.RecorderConstant.BPS_IN_MBPS;
+import static io.appium.settings.helpers.RecorderConstant.VIDEO_CODEC_DEFAULT_FRAME_RATE;
+import static io.appium.settings.streaming.StreamingServer.ON_CLOSE;
+import static io.appium.settings.streaming.StreamingServer.ON_CONNECT;
+
+import android.hardware.display.DisplayManager;
+import android.hardware.display.VirtualDisplay;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaFormat;
+import android.media.projection.MediaProjection;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.view.Surface;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+import org.java_websocket.WebSocket;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+import io.appium.settings.helpers.RecorderConstant;
+
+public class StreamingThread implements Runnable, IEventHandler {
+
+ private static final String TAG = "RecorderThread";
+
+ private final MediaProjection mediaProjection;
+ private final int videoWidth;
+ private final int videoHeight;
+ private final int videoDpi;
+ private final int recordingMaxDuration;
+ private byte[] ivfHeader;
+ private final EventEmitter connectionHandler;
+ private final List connectedClients = new ArrayList<>();
+
+ private volatile Semaphore stopped;
+
+ private final VirtualDisplay.Callback displayCallback = new VirtualDisplay.Callback() {
+ @Override
+ public void onPaused() {
+ super.onPaused();
+ Log.v(TAG, "VirtualDisplay callback: Display streaming paused");
+ }
+
+ @Override
+ public void onStopped() {
+ super.onStopped();
+ if (stopped != null) {
+ stopped.release();
+ }
+ }
+ };
+
+ public StreamingThread(
+ MediaProjection mediaProjection, EventEmitter connectionHandler,
+ int videoWidth, int videoHeight, int videoDpi, int recordingMaxDuration
+ ) {
+ this.mediaProjection = mediaProjection;
+ this.connectionHandler = connectionHandler;
+ connectionHandler.subscribe(this);
+ this.videoWidth = videoWidth;
+ this.videoHeight = videoHeight;
+ this.videoDpi = videoDpi;
+ this.recordingMaxDuration = recordingMaxDuration;
+ }
+
+ public void start() {
+ if (stopped != null) {
+ return;
+ }
+ stopped = new Semaphore(1);
+ try {
+ stopped.acquire();
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ new Thread(this).start();
+ }
+
+ public void stop() {
+ if (stopped == null) {
+ return;
+ }
+ stopped.release();
+ connectionHandler.unsubscribe(this);
+ }
+
+ public boolean isStreaming() {
+ return stopped != null && stopped.hasQueuedThreads();
+ }
+
+ private MediaFormat initVideoEncoderFormat(String videoMime, int videoWidth,
+ int videoHeight, int videoBitrate,
+ int videoFrameRate) {
+ MediaFormat encoderFormat = MediaFormat.createVideoFormat(videoMime, videoWidth,
+ videoHeight);
+ encoderFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT,
+ MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible);
+ encoderFormat.setInteger(MediaFormat.KEY_BIT_RATE, videoBitrate);
+ encoderFormat.setInteger(MediaFormat.KEY_FRAME_RATE, videoFrameRate);
+ encoderFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, 0);
+ encoderFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
+ return encoderFormat;
+ }
+
+ private VirtualDisplay initVirtualDisplay(MediaProjection mediaProjection,
+ Surface surface, Handler handler,
+ int videoWidth, int videoHeight, int videoDpi) {
+ return mediaProjection.createVirtualDisplay("Appium Screen Streamer",
+ videoWidth, videoHeight, videoDpi,
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
+ surface, displayCallback, handler);
+ }
+
+ private int calculateBitRate(int width, int height, int frameRate) {
+ return (int) (RecorderConstant.BITRATE_MULTIPLIER *
+ frameRate * width * height);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.Q)
+ @Override
+ public void run() {
+ VirtualDisplay virtualDisplay = null;
+ MediaCodec videoEncoder = null;
+ Surface surface = null;
+ try {
+ videoEncoder = MediaCodec.createEncoderByType("OMX.google.vp8.encoder");
+
+ MediaCodecInfo.VideoCapabilities videoEncoderCapabilities = videoEncoder
+ .getCodecInfo().getCapabilitiesForType(MediaFormat.MIMETYPE_VIDEO_VP8)
+ .getVideoCapabilities();
+
+ int videoFrameRate = Math.min(VIDEO_CODEC_DEFAULT_FRAME_RATE,
+ videoEncoderCapabilities.getSupportedFrameRates().getUpper());
+
+ int videoBitrate = videoEncoderCapabilities.getBitrateRange()
+ .clamp(calculateBitRate(this.videoWidth, this.videoHeight, videoFrameRate));
+
+ Log.i(TAG, String.format("Recording starting with frame rate = %d FPS " +
+ "and bitrate = %5.2f Mbps",
+ videoFrameRate, videoBitrate / BPS_IN_MBPS));
+
+ MediaFormat videoEncoderFormat =
+ initVideoEncoderFormat(MediaFormat.MIMETYPE_VIDEO_VP8,
+ this.videoWidth, this.videoHeight, videoBitrate, videoFrameRate);
+
+ this.ivfHeader = IvfWriter.makeIvfHeader(
+ 0, this.videoWidth, this.videoHeight, 1, videoBitrate
+ );
+ videoEncoder.setCallback(new MediaCodec.Callback() {
+ @Override
+ public void onInputBufferAvailable(@NonNull MediaCodec codec, int inputBufIndex) {
+ }
+
+ @Override
+ public void onOutputBufferAvailable(
+ @NonNull MediaCodec codec, int outputBufferId, @NonNull MediaCodec.BufferInfo info
+ ) {
+ ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
+ if (info.size > 0 && outputBuffer != null) {
+ outputBuffer.position(info.offset);
+ outputBuffer.limit(info.offset + info.size);
+
+ byte[] header = IvfWriter.makeIvfFrameHeader(outputBuffer.remaining(), info.presentationTimeUs);
+ byte[] b = new byte[outputBuffer.remaining()];
+ outputBuffer.get(b);
+
+ sendFrameToConnectedClients(header, b);
+ }
+ codec.releaseOutputBuffer(outputBufferId, false);
+ }
+
+ @Override
+ public void onError(@NonNull MediaCodec codec, @NonNull MediaCodec.CodecException e) {
+ e.printStackTrace();
+ }
+
+ @Override
+ public void onOutputFormatChanged(@NonNull MediaCodec codec, @NonNull MediaFormat format) {
+ Log.i(TAG, "onOutputFormatChanged. CodecInfo:" + codec.getCodecInfo() + " MediaFormat:" + format.toString());
+ }
+ });
+ videoEncoder.configure(
+ videoEncoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE
+ );
+ surface = videoEncoder.createInputSurface();
+ videoEncoder.start();
+
+ Handler handler = new Handler(Looper.getMainLooper());
+ virtualDisplay = initVirtualDisplay(this.mediaProjection, surface, handler,
+ this.videoWidth, this.videoHeight, this.videoDpi);
+
+ if (stopped.tryAcquire(this.recordingMaxDuration, TimeUnit.MILLISECONDS)) {
+ Log.v(TAG, "Streaming has been stopped");
+ }
+ } catch (Exception mainException) {
+ Log.e(TAG, "run: Exception occurred during recording", mainException);
+ } finally {
+ disconnectAllClients();
+
+ if (virtualDisplay != null) {
+ virtualDisplay.release();
+ }
+
+ if (surface != null) {
+ surface.release();
+ }
+
+ if (videoEncoder != null) {
+ videoEncoder.stop();
+ videoEncoder.release();
+ }
+
+ mediaProjection.stop();
+ }
+ }
+
+ @Override
+ public void onEvent(String name, WebSocket socket) {
+ switch (name) {
+ case ON_CONNECT:
+ synchronized (connectedClients) {
+ connectedClients.add(new Client(socket, false));
+ }
+ break;
+ case ON_CLOSE:
+ synchronized (connectedClients) {
+ int i = 0;
+ while (i < connectedClients.size()) {
+ Client client = connectedClients.get(i);
+ if (client.socket.isOpen()) {
+ i++;
+ } else {
+ client.socket.close();
+ connectedClients.remove(client);
+ }
+ }
+ }
+ break;
+ }
+ }
+
+ private void disconnectAllClients() {
+ synchronized (connectedClients) {
+ int i = 0;
+ while (i < connectedClients.size()) {
+ Client client = connectedClients.get(i);
+ if (client.socket.isOpen()) {
+ client.socket.close();
+ }
+ connectedClients.remove(client);
+ }
+ }
+ }
+
+ private void sendFrameToConnectedClients(byte[] header, byte[] data) {
+ byte[] payload = new byte[header.length + data.length];
+ System.arraycopy(header, 0, payload, 0, header.length);
+ System.arraycopy(data, 0, payload, header.length, data.length);
+ int i = 0;
+ synchronized (connectedClients) {
+ while (i < connectedClients.size()) {
+ Client client = connectedClients.get(i);
+ if (!client.socket.isOpen()) {
+ connectedClients.remove(i);
+ continue;
+ }
+ if (!client.didReceiveIvfHeader) {
+ client.socket.send(ivfHeader);
+ client.didReceiveIvfHeader = true;
+ }
+ client.socket.send(payload);
+ i++;
+ }
+ }
+ }
+
+ private static class Client {
+ public final WebSocket socket;
+ public boolean didReceiveIvfHeader;
+
+ public Client(WebSocket socket, boolean didReceiveIvfHeader) {
+ this.socket = socket;
+ this.didReceiveIvfHeader = didReceiveIvfHeader;
+ }
+ }
+}
\ No newline at end of file