diff --git a/app/build.gradle b/app/build.gradle index b0e590cf..79717af1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -35,6 +35,7 @@ android { dependencies { implementation 'com.google.android.gms:play-services-location:21.3.0' implementation 'org.apache.commons:commons-lang3:3.17.0' + implementation 'org.java-websocket:Java-WebSocket:1.5.7' } static def renameAPK(variant) { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 336b7cdf..4871b9c6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,6 +63,13 @@ android:permission="android.permission.RECORD_AUDIO"> + + + = Build.VERSION_CODES.O) { + recordingOutputPath = Paths + .get(externalStorageFile.getAbsolutePath(), recordingFilename) + .toAbsolutePath() + .toString(); } - - recordingOutputPath = Paths - .get(externalStorageFile.getAbsolutePath(), recordingFilename) - .toAbsolutePath() - .toString(); - recordingRotation = RecorderUtil.getDeviceRotationInDegree(getApplicationContext()); - recordingPriority = RecorderUtil.getRecordingPriority(intent); - recordingMaxDuration = RecorderUtil.getRecordingMaxDuration(intent); - recordingResolutionMode = RecorderUtil.getRecordingResolutionMode(intent); // start record - final MediaProjectionManager manager - = (MediaProjectionManager) getSystemService( - Context.MEDIA_PROJECTION_SERVICE); - + final MediaProjectionManager manager = (MediaProjectionManager) getSystemService( + Context.MEDIA_PROJECTION_SERVICE + ); if (manager == null) { Log.e(TAG, "handleRecording: " + "Unable to retrieve MediaProjectionManager instance"); - finishActivity(); - return; + return false; } - final Intent permissionIntent = manager.createScreenCaptureIntent(); - startActivityForResult(permissionIntent, REQUEST_CODE_SCREEN_CAPTURE); + return true; } else if (recordingAction.equals(ACTION_RECORDING_STOP)) { // stop record final Intent recorderIntent = new Intent(this, RecorderService.class); recorderIntent.setAction(ACTION_RECORDING_STOP); startService(recorderIntent); - - finishActivity(); } else { Log.e(TAG, "handleRecording: Unknown recording intent with action:" + recordingAction); - finishActivity(); } + return false; + } + + private boolean handleScreenStreamingIfRequested(Intent intent) { + String streamingAction = checkIntent(intent, ACTION_STREAMING_BASE); + if (streamingAction == null) { + return false; + } + if (streamingAction.equals(ACTION_STREAMING_START)) { + streamingPort = intent.getIntExtra(ACTION_STREAMING_PORT, 0); + if (streamingPort <= 0) { + Log.e(TAG, "handleStreaming: Invalid port provided by user: " + streamingPort); + return false; + } + recordingMaxDuration = RecorderUtil.getRecordingMaxDuration(intent); + recordingResolutionMode = RecorderUtil.getRecordingResolutionMode(intent); + + // start record + final MediaProjectionManager manager = (MediaProjectionManager) getSystemService( + Context.MEDIA_PROJECTION_SERVICE + ); + if (manager == null) { + Log.e(TAG, "handleStreaming: " + + "Unable to retrieve MediaProjectionManager instance"); + return false; + } + final Intent permissionIntent = manager.createScreenCaptureIntent(); + startActivityForResult(permissionIntent, REQUEST_CODE_SCREEN_STREAM); + return true; + } else if (streamingAction.equals(ACTION_STREAMING_STOP)) { + // stop record + final Intent streamingIntent = new Intent(this, StreamingService.class); + streamingIntent.setAction(ACTION_STREAMING_STOP); + startService(streamingIntent); + } else { + Log.e(TAG, "handleStreaming: Unknown recording intent with action:" + + streamingAction); + } + return false; } private void finishActivity() { @@ -211,25 +224,61 @@ private void finishActivity() { handler.postDelayed(Settings.this::finish, 0); } + private @Nullable String checkIntent(Intent intent, String action) { + if (intent == null) { + Log.e(TAG, "Unable to retrieve intent instance"); + return null; + } + String result = intent.getAction(); + if (result == null) { + Log.e(TAG, "Unable to retrieve intent.action instance"); + return null; + } + if (!result.startsWith(action)) { + Log.i(TAG, "Received different intent with action: " + + result); + return null; + } + if (RecorderUtil.isLowerThanQ()) { + Log.e(TAG, "Current Android OS Version is lower than Q"); + return null; + } + if (!RecorderUtil.areRecordingPermissionsGranted(getApplicationContext())) { + Log.e(TAG, "Required permissions are not granted"); + return null; + } + return result; + } + @Override protected void onActivityResult(final int requestCode, final int resultCode, final Intent data) { super.onActivityResult(requestCode, resultCode, data); - if (REQUEST_CODE_SCREEN_CAPTURE != requestCode) { - Log.e(TAG, "handleRecording: onActivityResult: " + - "Received unknown request with code: " + requestCode); + + if (REQUEST_CODE_SCREEN_CAPTURE != requestCode && REQUEST_CODE_SCREEN_STREAM != requestCode) { + Log.e(TAG, "onActivityResult: Received unknown request with code: " + requestCode); finishActivity(); return; } if (resultCode != Activity.RESULT_OK) { - Log.e(TAG, "handleRecording: onActivityResult: " + + Log.e(TAG, "onActivityResult: " + "MediaProjection permission is not granted, " + "Did you apply appops adb command?"); finishActivity(); return; } + Intent intent = REQUEST_CODE_SCREEN_CAPTURE == requestCode + ? createScreenRecordingIntent(resultCode) + : createScreenStreamingIntent(resultCode); + intent.putExtras(data); + startService(intent); + + finishActivity(); + } + + private @NonNull Intent createScreenRecordingIntent(int resultCode) { final Intent intent = new Intent(this, RecorderService.class); intent.setAction(ACTION_RECORDING_START); intent.putExtra(ACTION_RECORDING_RESULT_CODE, resultCode); @@ -238,11 +287,17 @@ protected void onActivityResult(final int requestCode, final int resultCode, fin intent.putExtra(ACTION_RECORDING_PRIORITY, recordingPriority); intent.putExtra(ACTION_RECORDING_MAX_DURATION, recordingMaxDuration); intent.putExtra(ACTION_RECORDING_RESOLUTION, recordingResolutionMode); - intent.putExtras(data); - - startService(intent); + return intent; + } - finishActivity(); + private @NonNull Intent createScreenStreamingIntent(int resultCode) { + final Intent intent = new Intent(this, StreamingService.class); + intent.setAction(ACTION_STREAMING_START); + intent.putExtra(ACTION_RECORDING_RESULT_CODE, resultCode); + intent.putExtra(ACTION_STREAMING_PORT, streamingPort); + intent.putExtra(ACTION_RECORDING_MAX_DURATION, recordingMaxDuration); + intent.putExtra(ACTION_RECORDING_RESOLUTION, recordingResolutionMode); + return intent; } private void registerSettingsReceivers(List> receiverClasses) @@ -252,9 +307,7 @@ private void registerSettingsReceivers(List> 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