WebSocket streaming (patchset squashed)

This commit is contained in:
Sergey Volkov 2021-10-03 15:08:55 +03:00
parent 228e2c15f4
commit e6c92d55c1
23 changed files with 1738 additions and 326 deletions

View file

@ -139,7 +139,9 @@ page at http://checkstyle.sourceforge.net/config.html -->
<module name="FinalClass" />
<module name="HideUtilityClassConstructor" />
<module name="InterfaceIsType" />
<module name="VisibilityModifier" />
<module name="VisibilityModifier">
<property name="protectedAllowed" value="true"/>
</module>
<!-- Miscellaneous other checks. -->

View file

@ -7,7 +7,7 @@ android {
minSdkVersion 21
targetSdkVersion 30
versionCode 11900
versionName "1.19"
versionName "1.19-ws1"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
@ -20,6 +20,9 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'org.java-websocket:Java-WebSocket:1.4.0'
implementation 'org.slf4j:slf4j-api:1.7.25'
implementation 'uk.uuid.slf4j:slf4j-android:1.7.25-1'
testImplementation 'junit:junit:4.13'
}

View file

@ -0,0 +1,118 @@
package com.genymobile.scrcpy;
import com.genymobile.scrcpy.wrappers.ContentProvider;
import android.os.BatteryManager;
import android.os.Build;
import java.io.IOException;
import java.nio.ByteBuffer;
public abstract class Connection implements Device.RotationListener, Device.ClipboardListener {
public interface StreamInvalidateListener {
void onStreamInvalidate();
}
protected final ControlMessageReader reader = new ControlMessageReader();
protected static final int DEVICE_NAME_FIELD_LENGTH = 64;
protected StreamInvalidateListener streamInvalidateListener;
protected Device device;
protected final VideoSettings videoSettings;
protected final Options options;
protected Controller controller;
protected ScreenEncoder screenEncoder;
abstract void send(ByteBuffer data);
abstract void sendDeviceMessage(DeviceMessage msg) throws IOException;
abstract void close() throws Exception;
abstract boolean hasConnections();
public Connection(Options options, VideoSettings videoSettings) {
Ln.i("Device: " + Build.MANUFACTURER + " " + Build.MODEL + " (Android " + Build.VERSION.RELEASE + ")");
this.videoSettings = videoSettings;
this.options = options;
device = new Device(options, videoSettings);
device.setRotationListener(this);
controller = new Controller(device, this);
startDeviceMessageSender(controller.getSender());
device.setClipboardListener(this);
boolean mustDisableShowTouchesOnCleanUp = false;
int restoreStayOn = -1;
if (options.getShowTouches() || options.getStayAwake()) {
try (ContentProvider settings = Device.createSettingsProvider()) {
if (options.getShowTouches()) {
String oldValue = settings.getAndPutValue(ContentProvider.TABLE_SYSTEM, "show_touches", "1");
// If "show touches" was disabled, it must be disabled back on clean up
mustDisableShowTouchesOnCleanUp = !"1".equals(oldValue);
}
if (options.getStayAwake()) {
int stayOn = BatteryManager.BATTERY_PLUGGED_AC | BatteryManager.BATTERY_PLUGGED_USB | BatteryManager.BATTERY_PLUGGED_WIRELESS;
String oldValue = settings.getAndPutValue(ContentProvider.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(stayOn));
try {
restoreStayOn = Integer.parseInt(oldValue);
if (restoreStayOn == stayOn) {
// No need to restore
restoreStayOn = -1;
}
} catch (NumberFormatException e) {
restoreStayOn = 0;
}
}
}
}
try {
CleanUp.configure(options.getDisplayId(), restoreStayOn, mustDisableShowTouchesOnCleanUp, true, options.getPowerOffScreenOnClose());
} catch (IOException e) {
Ln.w("CleanUp.configure() failed:" + e.getMessage());
}
}
public boolean setVideoSettings(VideoSettings newSettings) {
if (!videoSettings.equals(newSettings)) {
videoSettings.merge(newSettings);
device.applyNewVideoSetting(videoSettings);
if (this.streamInvalidateListener != null) {
streamInvalidateListener.onStreamInvalidate();
}
return true;
}
return false;
}
public void setStreamInvalidateListener(StreamInvalidateListener listener) {
this.streamInvalidateListener = listener;
}
@Override
public void onRotationChanged(int rotation) {
if (this.streamInvalidateListener != null) {
streamInvalidateListener.onStreamInvalidate();
}
}
@Override
public void onClipboardTextChanged(String text) {
controller.getSender().pushClipboardText(text);
}
private static void startDeviceMessageSender(final DeviceMessageSender sender) {
new Thread(new Runnable() {
@Override
public void run() {
try {
sender.loop();
} catch (IOException | InterruptedException e) {
// this is expected on close
Ln.d("Device message sender stopped");
}
}
}).start();
}
}

View file

@ -1,5 +1,8 @@
package com.genymobile.scrcpy;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
/**
* Union of all supported event types, identified by their {@code type}.
*/
@ -17,6 +20,14 @@ public final class ControlMessage {
public static final int TYPE_SET_CLIPBOARD = 9;
public static final int TYPE_SET_SCREEN_POWER_MODE = 10;
public static final int TYPE_ROTATE_DEVICE = 11;
public static final int TYPE_CHANGE_STREAM_PARAMETERS = 101;
public static final int TYPE_PUSH_FILE = 102;
public static final int PUSH_STATE_NEW = 0;
public static final int PUSH_STATE_START = 1;
public static final int PUSH_STATE_APPEND = 2;
public static final int PUSH_STATE_FINISH = 3;
public static final int PUSH_STATE_CANCEL = 4;
private int type;
private String text;
@ -31,6 +42,14 @@ public final class ControlMessage {
private int vScroll;
private boolean paste;
private int repeat;
private byte[] bytes;
private short pushId;
private int pushState;
private byte[] pushChunk;
private int pushChunkSize;
private int fileSize;
private String fileName;
private VideoSettings videoSettings;
private ControlMessage() {
}
@ -97,6 +116,50 @@ public final class ControlMessage {
return msg;
}
public static ControlMessage createChangeSteamParameters(byte[] bytes) {
ControlMessage event = new ControlMessage();
event.type = TYPE_CHANGE_STREAM_PARAMETERS;
event.videoSettings = VideoSettings.fromByteArray(bytes);
return event;
}
public static ControlMessage createFilePush(byte[] bytes) {
ControlMessage event = new ControlMessage();
event.type = TYPE_PUSH_FILE;
ByteBuffer buffer = ByteBuffer.wrap(bytes);
event.pushId = buffer.getShort();
event.pushState = buffer.get();
switch (event.pushState) {
case PUSH_STATE_START:
event.fileSize = buffer.getInt();
short nameLength = buffer.getShort();
byte[] textBuffer = new byte[nameLength];
buffer.get(textBuffer, 0, nameLength);
event.fileName = new String(textBuffer, 0, nameLength, StandardCharsets.UTF_8);
break;
case PUSH_STATE_APPEND:
int chunkSize = buffer.getInt();
byte[] chunk = new byte[chunkSize];
if (buffer.remaining() >= chunkSize) {
buffer.get(chunk, 0, chunkSize);
event.pushChunkSize = chunkSize;
event.pushChunk = chunk;
} else {
event.pushState = PUSH_STATE_CANCEL;
}
break;
case PUSH_STATE_NEW:
case PUSH_STATE_CANCEL:
case PUSH_STATE_FINISH:
break;
// nothing special;
default:
Ln.w("Unknown push event state: " + event.pushState);
return null;
}
return event;
}
public static ControlMessage createEmpty(int type) {
ControlMessage msg = new ControlMessage();
msg.type = type;
@ -154,4 +217,36 @@ public final class ControlMessage {
public int getRepeat() {
return repeat;
}
public byte[] getBytes() {
return bytes;
}
public short getPushId() {
return pushId;
}
public int getPushState() {
return pushState;
}
public byte[] getPushChunk() {
return pushChunk;
}
public int getPushChunkSize() {
return pushChunkSize;
}
public String getFileName() {
return fileName;
}
public int getFileSize() {
return fileSize;
}
public VideoSettings getVideoSettings() {
return videoSettings;
}
}

View file

@ -47,6 +47,10 @@ public class ControlMessageReader {
}
public ControlMessage next() {
return parseEvent(buffer);
}
public ControlMessage parseEvent(ByteBuffer buffer) {
if (!buffer.hasRemaining()) {
return null;
}
@ -56,25 +60,31 @@ public class ControlMessageReader {
ControlMessage msg;
switch (type) {
case ControlMessage.TYPE_INJECT_KEYCODE:
msg = parseInjectKeycode();
msg = parseInjectKeycode(buffer);
break;
case ControlMessage.TYPE_INJECT_TEXT:
msg = parseInjectText();
msg = parseInjectText(buffer);
break;
case ControlMessage.TYPE_INJECT_TOUCH_EVENT:
msg = parseInjectTouchEvent();
msg = parseInjectTouchEvent(buffer);
break;
case ControlMessage.TYPE_INJECT_SCROLL_EVENT:
msg = parseInjectScrollEvent();
msg = parseInjectScrollEvent(buffer);
break;
case ControlMessage.TYPE_BACK_OR_SCREEN_ON:
msg = parseBackOrScreenOnEvent();
msg = parseBackOrScreenOnEvent(buffer);
break;
case ControlMessage.TYPE_SET_CLIPBOARD:
msg = parseSetClipboard();
msg = parseSetClipboard(buffer);
break;
case ControlMessage.TYPE_SET_SCREEN_POWER_MODE:
msg = parseSetScreenPowerMode();
msg = parseSetScreenPowerMode(buffer);
break;
case ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS:
msg = parseChangeStreamParameters(buffer);
break;
case ControlMessage.TYPE_PUSH_FILE:
msg = parsePushFile(buffer);
break;
case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL:
case ControlMessage.TYPE_EXPAND_SETTINGS_PANEL:
@ -96,7 +106,25 @@ public class ControlMessageReader {
return msg;
}
private ControlMessage parseInjectKeycode() {
private ControlMessage parseChangeStreamParameters(ByteBuffer buffer) {
int re = buffer.remaining();
byte[] bytes = new byte[re];
if (re > 0) {
buffer.get(bytes, 0, re);
}
return ControlMessage.createChangeSteamParameters(bytes);
}
private ControlMessage parsePushFile(ByteBuffer buffer) {
int re = buffer.remaining();
byte[] bytes = new byte[re];
if (re > 0) {
buffer.get(bytes, 0, re);
}
return ControlMessage.createFilePush(bytes);
}
private ControlMessage parseInjectKeycode(ByteBuffer buffer) {
if (buffer.remaining() < INJECT_KEYCODE_PAYLOAD_LENGTH) {
return null;
}
@ -107,7 +135,7 @@ public class ControlMessageReader {
return ControlMessage.createInjectKeycode(action, keycode, repeat, metaState);
}
private String parseString() {
private String parseString(ByteBuffer buffer) {
if (buffer.remaining() < 4) {
return null;
}
@ -115,21 +143,19 @@ public class ControlMessageReader {
if (buffer.remaining() < len) {
return null;
}
int position = buffer.position();
// Move the buffer position to consume the text
buffer.position(position + len);
return new String(rawBuffer, position, len, StandardCharsets.UTF_8);
buffer.get(rawBuffer, 0, len);
return new String(rawBuffer, 0, len, StandardCharsets.UTF_8);
}
private ControlMessage parseInjectText() {
String text = parseString();
private ControlMessage parseInjectText(ByteBuffer buffer) {
String text = parseString(buffer);
if (text == null) {
return null;
}
return ControlMessage.createInjectText(text);
}
private ControlMessage parseInjectTouchEvent() {
private ControlMessage parseInjectTouchEvent(ByteBuffer buffer) {
if (buffer.remaining() < INJECT_TOUCH_EVENT_PAYLOAD_LENGTH) {
return null;
}
@ -144,7 +170,7 @@ public class ControlMessageReader {
return ControlMessage.createInjectTouchEvent(action, pointerId, position, pressure, buttons);
}
private ControlMessage parseInjectScrollEvent() {
private ControlMessage parseInjectScrollEvent(ByteBuffer buffer) {
if (buffer.remaining() < INJECT_SCROLL_EVENT_PAYLOAD_LENGTH) {
return null;
}
@ -154,7 +180,7 @@ public class ControlMessageReader {
return ControlMessage.createInjectScrollEvent(position, hScroll, vScroll);
}
private ControlMessage parseBackOrScreenOnEvent() {
private ControlMessage parseBackOrScreenOnEvent(ByteBuffer buffer) {
if (buffer.remaining() < BACK_OR_SCREEN_ON_LENGTH) {
return null;
}
@ -162,19 +188,19 @@ public class ControlMessageReader {
return ControlMessage.createBackOrScreenOn(action);
}
private ControlMessage parseSetClipboard() {
private ControlMessage parseSetClipboard(ByteBuffer buffer) {
if (buffer.remaining() < SET_CLIPBOARD_FIXED_PAYLOAD_LENGTH) {
return null;
}
boolean paste = buffer.get() != 0;
String text = parseString();
String text = parseString(buffer);
if (text == null) {
return null;
}
return ControlMessage.createSetClipboard(text, paste);
}
private ControlMessage parseSetScreenPowerMode() {
private ControlMessage parseSetScreenPowerMode(ByteBuffer buffer) {
if (buffer.remaining() < SET_SCREEN_POWER_MODE_PAYLOAD_LENGTH) {
return null;
}

View file

@ -19,7 +19,7 @@ public class Controller {
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor();
private final Device device;
private final DesktopConnection connection;
private final Connection connection;
private final DeviceMessageSender sender;
private final KeyCharacterMap charMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
@ -31,7 +31,7 @@ public class Controller {
private boolean keepPowerModeOff;
public Controller(Device device, DesktopConnection connection) {
public Controller(Device device, Connection connection) {
this.device = device;
this.connection = connection;
initPointers();
@ -52,32 +52,11 @@ public class Controller {
}
}
public void control() throws IOException {
// on start, power on the device
if (!Device.isScreenOn()) {
device.pressReleaseKeycode(KeyEvent.KEYCODE_POWER);
// dirty hack
// After POWER is injected, the device is powered on asynchronously.
// To turn the device screen off while mirroring, the client will send a message that
// would be handled before the device is actually powered on, so its effect would
// be "canceled" once the device is turned back on.
// Adding this delay prevents to handle the message before the device is actually
// powered on.
SystemClock.sleep(500);
}
while (true) {
handleEvent();
}
}
public DeviceMessageSender getSender() {
return sender;
}
private void handleEvent() throws IOException {
ControlMessage msg = connection.receiveControlMessage();
public void handleEvent(ControlMessage msg) {
switch (msg.getType()) {
case ControlMessage.TYPE_INJECT_KEYCODE:
if (device.supportsInputEvents()) {
@ -116,7 +95,12 @@ public class Controller {
case ControlMessage.TYPE_GET_CLIPBOARD:
String clipboardText = Device.getClipboardText();
if (clipboardText != null) {
sender.pushClipboardText(clipboardText);
DeviceMessage event = DeviceMessage.createClipboard(clipboardText);
try {
connection.sendDeviceMessage(event);
} catch (IOException e) {
Ln.w("");
}
}
break;
case ControlMessage.TYPE_SET_CLIPBOARD:
@ -289,4 +273,8 @@ public class Controller {
return ok;
}
public void turnScreenOn() {
device.pressReleaseKeycode(KeyEvent.KEYCODE_POWER);
}
}

View file

@ -3,17 +3,16 @@ package com.genymobile.scrcpy;
import android.net.LocalServerSocket;
import android.net.LocalSocket;
import android.net.LocalSocketAddress;
import android.os.SystemClock;
import java.io.Closeable;
import java.io.FileDescriptor;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public final class DesktopConnection implements Closeable {
private static final int DEVICE_NAME_FIELD_LENGTH = 64;
public final class DesktopConnection extends Connection {
private static final String SOCKET_NAME = "scrcpy";
@ -24,26 +23,17 @@ public final class DesktopConnection implements Closeable {
private final InputStream controlInputStream;
private final OutputStream controlOutputStream;
private final ControlMessageReader reader = new ControlMessageReader();
private final DeviceMessageWriter writer = new DeviceMessageWriter();
private DesktopConnection(LocalSocket videoSocket, LocalSocket controlSocket) throws IOException {
this.videoSocket = videoSocket;
this.controlSocket = controlSocket;
controlInputStream = controlSocket.getInputStream();
controlOutputStream = controlSocket.getOutputStream();
videoFd = videoSocket.getFileDescriptor();
}
private static LocalSocket connect(String abstractName) throws IOException {
LocalSocket localSocket = new LocalSocket();
localSocket.connect(new LocalSocketAddress(abstractName));
return localSocket;
}
public static DesktopConnection open(Device device, boolean tunnelForward) throws IOException {
LocalSocket videoSocket;
LocalSocket controlSocket;
public DesktopConnection(Options options, VideoSettings videoSettings) throws IOException {
super(options, videoSettings);
boolean tunnelForward = options.isTunnelForward();
if (tunnelForward) {
LocalServerSocket localServerSocket = new LocalServerSocket(SOCKET_NAME);
try {
@ -69,10 +59,18 @@ public final class DesktopConnection implements Closeable {
}
}
DesktopConnection connection = new DesktopConnection(videoSocket, controlSocket);
controlInputStream = controlSocket.getInputStream();
controlOutputStream = controlSocket.getOutputStream();
videoFd = videoSocket.getFileDescriptor();
if (options.getControl()) {
startEventController();
}
Size videoSize = device.getScreenInfo().getVideoSize();
connection.send(Device.getDeviceName(), videoSize.getWidth(), videoSize.getHeight());
return connection;
send(Device.getDeviceName(), videoSize.getWidth(), videoSize.getHeight());
screenEncoder = new ScreenEncoder(videoSettings);
screenEncoder.setDevice(device);
screenEncoder.setConnection(this);
screenEncoder.run();
}
public void close() throws IOException {
@ -99,10 +97,56 @@ public final class DesktopConnection implements Closeable {
IO.writeFully(videoFd, buffer, 0, buffer.length);
}
public void send(ByteBuffer data) {
try {
IO.writeFully(videoFd, data);
} catch (IOException e) {
Ln.e("Failed to send data", e);
}
}
@Override
boolean hasConnections() {
return true;
}
public FileDescriptor getVideoFd() {
return videoFd;
}
private void startEventController() {
new Thread(new Runnable() {
@Override
public void run() {
try {
// on start, power on the device
if (!Device.isScreenOn()) {
controller.turnScreenOn();
// dirty hack
// After POWER is injected, the device is powered on asynchronously.
// To turn the device screen off while mirroring, the client will send a message that
// would be handled before the device is actually powered on, so its effect would
// be "canceled" once the device is turned back on.
// Adding this delay prevents to handle the message before the device is actually
// powered on.
SystemClock.sleep(500);
}
while (true) {
ControlMessage controlEvent = receiveControlMessage();
if (controlEvent != null) {
controller.handleEvent(controlEvent);
}
}
} catch (IOException e) {
// this is expected on close
Ln.d("Event controller stopped");
}
}
}).start();
}
public ControlMessage receiveControlMessage() throws IOException {
ControlMessage msg = reader.next();
while (msg == null) {

View file

@ -55,24 +55,26 @@ public final class Device {
private final boolean supportsInputEvents;
public Device(Options options) {
displayId = options.getDisplayId();
DisplayInfo displayInfo = SERVICE_MANAGER.getDisplayManager().getDisplayInfo(displayId);
private IRotationWatcher rotationWatcher;
private IOnPrimaryClipChangedListener clipChangedListener;
public Device(final Options options, final VideoSettings videoSettings) {
displayId = videoSettings.getDisplayId();
final DisplayInfo displayInfo = Device.getDisplayInfo(displayId);
if (displayInfo == null) {
int[] displayIds = SERVICE_MANAGER.getDisplayManager().getDisplayIds();
throw new InvalidDisplayIdException(displayId, displayIds);
throw new InvalidDisplayIdException(displayId, Device.getDisplayIds());
}
int displayInfoFlags = displayInfo.getFlags();
screenInfo = ScreenInfo.computeScreenInfo(displayInfo, options.getCrop(), options.getMaxSize(), options.getLockedVideoOrientation());
screenInfo = ScreenInfo.computeScreenInfo(displayInfo, videoSettings);
layerStack = displayInfo.getLayerStack();
SERVICE_MANAGER.getWindowManager().registerRotationWatcher(new IRotationWatcher.Stub() {
rotationWatcher = new IRotationWatcher.Stub() {
@Override
public void onRotationChanged(int rotation) {
synchronized (Device.this) {
screenInfo = screenInfo.withDeviceRotation(rotation);
applyNewVideoSetting(videoSettings);
// notify
if (rotationListener != null) {
@ -80,29 +82,32 @@ public final class Device {
}
}
}
}, displayId);
};
SERVICE_MANAGER.getWindowManager().registerRotationWatcher(rotationWatcher, displayId);
if (options.getControl()) {
// If control is enabled, synchronize Android clipboard to the computer automatically
ClipboardManager clipboardManager = SERVICE_MANAGER.getClipboardManager();
if (clipboardManager != null) {
clipboardManager.addPrimaryClipChangedListener(new IOnPrimaryClipChangedListener.Stub() {
@Override
public void dispatchPrimaryClipChanged() {
if (isSettingClipboard.get()) {
// This is a notification for the change we are currently applying, ignore it
return;
}
synchronized (Device.this) {
if (clipboardListener != null) {
String text = getClipboardText();
if (text != null) {
clipboardListener.onClipboardTextChanged(text);
}
clipChangedListener = new IOnPrimaryClipChangedListener.Stub() {
@Override
public void dispatchPrimaryClipChanged() {
if (isSettingClipboard.get()) {
// This is a notification for the change we are currently applying, ignore it
return;
}
synchronized (Device.this) {
if (clipboardListener != null) {
String text = getClipboardText();
if (text != null) {
clipboardListener.onClipboardTextChanged(text);
}
}
}
});
}
};
if (clipboardManager != null) {
clipboardManager.addPrimaryClipChangedListener(clipChangedListener);
} else {
Ln.w("No clipboard manager, copy-paste between device and computer will not work");
}
@ -127,6 +132,14 @@ public final class Device {
return layerStack;
}
public void applyNewVideoSetting(VideoSettings videoSettings) {
this.setScreenInfo(ScreenInfo.computeScreenInfo(Device.getDisplayInfo(displayId), videoSettings));
}
public synchronized void setScreenInfo(ScreenInfo screenInfo) {
this.screenInfo = screenInfo;
}
public Point getPhysicalPoint(Position position) {
// it hides the field on purpose, to read it with a lock
@SuppressWarnings("checkstyle:HiddenField")
@ -256,6 +269,22 @@ public final class Device {
return ok;
}
public void release() {
if (clipChangedListener != null) {
ClipboardManager clipboardManager = SERVICE_MANAGER.getClipboardManager();
if (clipboardManager != null) {
clipboardManager.removePrimaryClipChangedListener(clipChangedListener);
}
this.clipboardListener = null;
}
if (rotationWatcher != null) {
SERVICE_MANAGER.getWindowManager().unregisterRotationWatcher(rotationWatcher);
rotationWatcher = null;
}
this.rotationListener = null;
this.clipboardListener = null;
}
/**
* @param mode one of the {@code POWER_MODE_*} constants
*/
@ -299,4 +328,12 @@ public final class Device {
public static ContentProvider createSettingsProvider() {
return SERVICE_MANAGER.getActivityManager().createSettingsProvider();
}
public static int[] getDisplayIds() {
return SERVICE_MANAGER.getDisplayManager().getDisplayIds();
}
public static DisplayInfo getDisplayInfo(int displayId) {
return SERVICE_MANAGER.getDisplayManager().getDisplayInfo(displayId);
}
}

View file

@ -1,27 +1,83 @@
package com.genymobile.scrcpy;
public final class DeviceMessage {
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public abstract class DeviceMessage {
private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k
public static final int MAX_EVENT_SIZE = 4096;
public static final int TYPE_CLIPBOARD = 0;
public static final int TYPE_PUSH_RESPONSE = 101;
private int type;
private String text;
private DeviceMessage() {
private DeviceMessage(int type) {
this.type = type;
}
private static final class ClipboardMessage extends DeviceMessage {
public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 5; // type: 1 byte; length: 4 bytes
private byte[] raw;
private int len;
private ClipboardMessage(String text) {
super(TYPE_CLIPBOARD);
this.raw = text.getBytes(StandardCharsets.UTF_8);
this.len = StringUtils.getUtf8TruncationIndex(raw, CLIPBOARD_TEXT_MAX_LENGTH);
}
public void writeToByteArray(byte[] array, int offset) {
ByteBuffer buffer = ByteBuffer.wrap(array, offset, array.length - offset);
buffer.put((byte) this.getType());
buffer.putInt(len);
buffer.put(raw, 0, len);
}
public int getLen() {
return 5 + len;
}
}
private static final class FilePushResponseMessage extends DeviceMessage {
private short id;
private int result;
private FilePushResponseMessage(short id, int result) {
super(TYPE_PUSH_RESPONSE);
this.id = id;
this.result = result;
}
@Override
public void writeToByteArray(byte[] array, int offset) {
ByteBuffer buffer = ByteBuffer.wrap(array, offset, array.length - offset);
buffer.put((byte) this.getType());
buffer.putShort(id);
buffer.put((byte) result);
}
@Override
public int getLen() {
return 4;
}
}
public static DeviceMessage createClipboard(String text) {
DeviceMessage event = new DeviceMessage();
event.type = TYPE_CLIPBOARD;
event.text = text;
return event;
return new ClipboardMessage(text);
}
public static DeviceMessage createPushResponse(short id, int result) {
return new FilePushResponseMessage(id, result);
}
public int getType() {
return type;
}
public String getText() {
return text;
public void writeToByteArray(byte[] array) {
writeToByteArray(array, 0);
}
public byte[] writeToByteArray(int offset) {
byte[] temp = new byte[offset + this.getLen()];
writeToByteArray(temp, offset);
return temp;
}
public abstract void writeToByteArray(byte[] array, int offset);
public abstract int getLen();
}

View file

@ -4,11 +4,11 @@ import java.io.IOException;
public final class DeviceMessageSender {
private final DesktopConnection connection;
private final Connection connection;
private String clipboardText;
public DeviceMessageSender(DesktopConnection connection) {
public DeviceMessageSender(Connection connection) {
this.connection = connection;
}

View file

@ -2,32 +2,13 @@ package com.genymobile.scrcpy;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public class DeviceMessageWriter {
private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k
public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 5; // type: 1 byte; length: 4 bytes
private final byte[] rawBuffer = new byte[MESSAGE_MAX_SIZE];
private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer);
private final byte[] rawBuffer = new byte[DeviceMessage.MAX_EVENT_SIZE];
public void writeTo(DeviceMessage msg, OutputStream output) throws IOException {
buffer.clear();
buffer.put((byte) DeviceMessage.TYPE_CLIPBOARD);
switch (msg.getType()) {
case DeviceMessage.TYPE_CLIPBOARD:
String text = msg.getText();
byte[] raw = text.getBytes(StandardCharsets.UTF_8);
int len = StringUtils.getUtf8TruncationIndex(raw, CLIPBOARD_TEXT_MAX_LENGTH);
buffer.putInt(len);
buffer.put(raw, 0, len);
output.write(rawBuffer, 0, buffer.position());
break;
default:
Ln.w("Unknown device message: " + msg.getType());
break;
}
msg.writeToByteArray(rawBuffer);
output.write(rawBuffer, 0, msg.getLen());
}
}

View file

@ -1,5 +1,7 @@
package com.genymobile.scrcpy;
import java.nio.ByteBuffer;
public final class DisplayInfo {
private final int displayId;
private final Size size;
@ -36,5 +38,16 @@ public final class DisplayInfo {
public int getFlags() {
return flags;
}
}
public byte[] toByteArray() {
ByteBuffer temp = ByteBuffer.allocate(24);
temp.putInt(displayId);
temp.putInt(size.getWidth());
temp.putInt(size.getHeight());
temp.putInt(rotation);
temp.putInt(layerStack);
temp.putInt(flags);
temp.rewind();
return temp.array();
}
}

View file

@ -0,0 +1,247 @@
package com.genymobile.scrcpy;
import org.java_websocket.WebSocket;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.HashMap;
public final class FilePushHandler {
private static final int NEW_PUSH_ID = 1;
private static final int NO_ERROR = 0;
private static final int ERROR_INVALID_NAME = -1;
private static final int ERROR_NO_SPACE = -2;
private static final int ERROR_FAILED_TO_DELETE = -3;
private static final int ERROR_FAILED_TO_CREATE = -4;
private static final int ERROR_FILE_NOT_FOUND = -5;
private static final int ERROR_FAILED_TO_WRITE = -6;
private static final int ERROR_FILE_IS_BUSY = -7;
private static final int ERROR_INVALID_STATE = -8;
private static final int ERROR_UNKNOWN_ID = -9;
private static final int ERROR_NO_FREE_ID = -10;
private static final int ERROR_INCORRECT_SIZE = -11;
private static final String PUSH_PATH = "/data/local/tmp";
private FilePushHandler() {
}
private static final class FilePush {
private static final HashMap<String, FilePush> INSTANCES_BY_NAME = new HashMap<>();
private static final HashMap<Short, FilePush> INSTANCES_BY_ID = new HashMap<>();
private static short nextPushId = 0;
private final FileOutputStream stream;
private final WebSocket conn;
private final short pushId;
private final String fileName;
private final long fileSize;
private long processedBytes = 0;
FilePush(short pushId, long fileSize, String fileName, FileOutputStream stream, WebSocket conn) {
this.pushId = pushId;
this.fileSize = fileSize;
this.fileName = fileName;
this.stream = stream;
this.conn = conn;
INSTANCES_BY_ID.put(pushId, this);
INSTANCES_BY_NAME.put(fileName, this);
}
public static FilePush getInstance(String fileName) {
return INSTANCES_BY_NAME.get(fileName);
}
public static FilePush getInstance(short id) {
return INSTANCES_BY_ID.get(id);
}
public static short getNextPushId() {
short current = nextPushId;
while (INSTANCES_BY_ID.containsKey(++nextPushId)) {
if (nextPushId == Short.MAX_VALUE) {
nextPushId = 0;
}
if (nextPushId == current) {
return -1;
}
}
return nextPushId;
}
public static void releaseByConnection(WebSocket conn) {
ArrayList<FilePush> related = new ArrayList<>();
for (FilePush filePush: INSTANCES_BY_ID.values()) {
if (filePush.conn.equals(conn)) {
related.add(filePush);
}
}
for (FilePush filePush: related) {
try {
filePush.release();
} catch (IOException e) {
Ln.w("Failed to release stream for file: \"" + filePush.getFileName() + "\"");
}
}
}
public void write(byte[] chunk, int len) throws IOException {
processedBytes += len;
stream.write(chunk, 0, len);
}
public String getFileName() {
return fileName;
}
public boolean isComplete() {
return processedBytes == fileSize;
}
public void release() throws IOException {
INSTANCES_BY_ID.remove(pushId);
INSTANCES_BY_NAME.remove(fileName);
this.stream.close();
}
}
private static ByteBuffer pushFilePushResponse(short id, int result) {
DeviceMessage msg = DeviceMessage.createPushResponse(id, result);
return WebSocketConnection.deviceMessageToByteBuffer(msg);
}
private static FilePush checkPushId(WebSocket conn, ControlMessage msg) {
short pushId = msg.getPushId();
FilePush filePush = FilePush.getInstance(pushId);
if (filePush == null) {
conn.send(pushFilePushResponse(pushId, ERROR_UNKNOWN_ID));
}
return filePush;
}
private static void handleNew(WebSocket conn) {
short newPushId = FilePush.getNextPushId();
if (newPushId == -1) {
conn.send(FilePushHandler.pushFilePushResponse(newPushId, ERROR_NO_FREE_ID));
} else {
conn.send(FilePushHandler.pushFilePushResponse(newPushId, NEW_PUSH_ID));
}
}
private static void handleStart(WebSocket conn, ControlMessage msg) {
short pushId = msg.getPushId();
String fileName = msg.getFileName();
int fileSize = msg.getFileSize();
if (FilePush.getInstance(fileName) != null) {
conn.send(pushFilePushResponse(pushId, ERROR_FILE_IS_BUSY));
return;
}
if (fileName.contains("/")) {
conn.send(pushFilePushResponse(pushId, ERROR_INVALID_NAME));
return;
}
File file = new File(PUSH_PATH, fileName);
// long freeSpace = file.getFreeSpace();
// if (freeSpace < fileSize) {
// sender.send(pushFilePushResponse(pushId, ERROR_NO_SPACE));
// return;
// }
try {
if (!file.createNewFile()) {
if (!file.delete()) {
conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_DELETE));
return;
}
}
} catch (IOException e) {
conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_CREATE));
return;
}
FileOutputStream stream;
try {
stream = new FileOutputStream(file);
} catch (FileNotFoundException e) {
conn.send(pushFilePushResponse(pushId, ERROR_FILE_NOT_FOUND));
return;
}
new FilePush(pushId, fileSize, fileName, stream, conn);
conn.send(pushFilePushResponse(pushId, NO_ERROR));
}
private static void handleAppend(WebSocket conn, ControlMessage msg) {
FilePush filePush = checkPushId(conn, msg);
if (filePush == null) {
return;
}
short pushId = msg.getPushId();
try {
filePush.write(msg.getPushChunk(), msg.getPushChunkSize());
conn.send(pushFilePushResponse(pushId, NO_ERROR));
} catch (IOException e) {
conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_WRITE));
try {
filePush.release();
} catch (IOException ex) {
Ln.w("Failed to release stream for file: \"" + filePush.getFileName() + "\"");
}
}
}
private static void handleFinish(WebSocket conn, ControlMessage msg) {
FilePush filePush = checkPushId(conn, msg);
if (filePush == null) {
return;
}
short pushId = msg.getPushId();
if (filePush.isComplete()) {
try {
filePush.release();
conn.send(pushFilePushResponse(pushId, NO_ERROR));
} catch (IOException e) {
conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_WRITE));
}
} else {
conn.send(pushFilePushResponse(pushId, ERROR_INCORRECT_SIZE));
}
}
private static void handleCancel(WebSocket conn, ControlMessage msg) {
FilePush filePush = checkPushId(conn, msg);
if (filePush == null) {
return;
}
short pushId = msg.getPushId();
try {
filePush.release();
conn.send(pushFilePushResponse(pushId, NO_ERROR));
} catch (IOException e) {
conn.send(pushFilePushResponse(pushId, ERROR_FAILED_TO_WRITE));
}
}
public static void cancelAllForConnection(WebSocket conn) {
FilePush.releaseByConnection(conn);
}
public static void handlePush(WebSocket conn, ControlMessage msg) {
int state = msg.getPushState();
switch (state) {
case ControlMessage.PUSH_STATE_NEW:
handleNew(conn);
break;
case ControlMessage.PUSH_STATE_APPEND:
handleAppend(conn, msg);
break;
case ControlMessage.PUSH_STATE_START:
handleStart(conn, msg);
break;
case ControlMessage.PUSH_STATE_FINISH:
handleFinish(conn, msg);
break;
case ControlMessage.PUSH_STATE_CANCEL:
handleCancel(conn, msg);
break;
default:
conn.send(pushFilePushResponse(msg.getPushId(), ERROR_INVALID_STATE));
}
}
}

View file

@ -3,21 +3,27 @@ package com.genymobile.scrcpy;
import android.graphics.Rect;
public class Options {
private Ln.Level logLevel;
public static final int TYPE_LOCAL_SOCKET = 1;
public static final int TYPE_WEB_SOCKET = 2;
private Ln.Level logLevel = Ln.Level.ERROR;
private int maxSize;
private int bitRate;
private int maxFps;
private int lockedVideoOrientation;
private boolean tunnelForward;
private boolean tunnelForward = false;
private Rect crop;
private boolean sendFrameMeta; // send PTS so that the client may record properly
private boolean control;
private boolean control = true;
private int displayId;
private boolean showTouches;
private boolean stayAwake;
private boolean showTouches = false;
private boolean stayAwake = false;
private String codecOptions;
private String encoderName;
private boolean powerOffScreenOnClose;
private int serverType = TYPE_LOCAL_SOCKET;
private int portNumber = 8886;
private boolean listenOnAllInterfaces = true;
public Ln.Level getLogLevel() {
return logLevel;
@ -32,7 +38,7 @@ public class Options {
}
public void setMaxSize(int maxSize) {
this.maxSize = maxSize;
this.maxSize = (maxSize / 8) * 8;
}
public int getBitRate() {
@ -138,4 +144,44 @@ public class Options {
public boolean getPowerOffScreenOnClose() {
return this.powerOffScreenOnClose;
}
public int getServerType() {
return serverType;
}
public void setServerType(int type) {
if (type == TYPE_LOCAL_SOCKET || type == TYPE_WEB_SOCKET) {
this.serverType = type;
}
}
public void setPortNumber(int portNumber) {
this.portNumber = portNumber;
}
public int getPortNumber() {
return this.portNumber;
}
public boolean getListenOnAllInterfaces() {
return this.listenOnAllInterfaces;
}
public void setListenOnAllInterfaces(boolean value) {
this.listenOnAllInterfaces = value;
}
@Override
public String toString() {
return "Options{"
+ "maxSize=" + maxSize
+ ", bitRate=" + bitRate
+ ", maxFps=" + maxFps
+ ", tunnelForward=" + tunnelForward
+ ", crop=" + crop
+ ", sendFrameMeta=" + sendFrameMeta
+ ", serverType=" + (serverType == TYPE_LOCAL_SOCKET ? "local" : "web")
+ ", listenOnAllInterfaces=" + (this.listenOnAllInterfaces ? "true" : "false")
+ '}';
}
}

View file

@ -11,7 +11,6 @@ import android.os.Build;
import android.os.IBinder;
import android.view.Surface;
import java.io.FileDescriptor;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
@ -19,7 +18,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
public class ScreenEncoder implements Device.RotationListener {
public class ScreenEncoder implements Connection.StreamInvalidateListener, Runnable {
private static final int DEFAULT_I_FRAME_INTERVAL = 10; // seconds
private static final int REPEAT_FRAME_DELAY_US = 100_000; // repeat after 100ms
@ -27,55 +26,77 @@ public class ScreenEncoder implements Device.RotationListener {
private static final int NO_PTS = -1;
private final AtomicBoolean rotationChanged = new AtomicBoolean();
private final AtomicBoolean streamIsInvalide = new AtomicBoolean();
private final ByteBuffer headerBuffer = ByteBuffer.allocate(12);
private Thread selectorThread;
private String encoderName;
private List<CodecOption> codecOptions;
private int bitRate;
private int maxFps;
private boolean sendFrameMeta;
private long ptsOrigin;
private Device device;
private Connection connection;
private VideoSettings videoSettings;
private MediaFormat format;
private int timeout = -1;
public ScreenEncoder(boolean sendFrameMeta, int bitRate, int maxFps, List<CodecOption> codecOptions, String encoderName) {
this.sendFrameMeta = sendFrameMeta;
this.bitRate = bitRate;
this.maxFps = maxFps;
this.codecOptions = codecOptions;
this.encoderName = encoderName;
public ScreenEncoder(VideoSettings videoSettings) {
this.videoSettings = videoSettings;
updateFormat();
}
private void updateFormat() {
format = createFormat(videoSettings);
int maxFps = videoSettings.getMaxFps();
if (maxFps > 0) {
timeout = 1_000_000 / maxFps;
} else {
timeout = -1;
}
}
public void setConnection(Connection connection) {
this.connection = connection;
}
public void setDevice(Device device) {
this.device = device;
}
@Override
public void onRotationChanged(int rotation) {
rotationChanged.set(true);
public void onStreamInvalidate() {
Ln.d("invalidate stream");
streamIsInvalide.set(true);
updateFormat();
}
public boolean consumeRotationChange() {
return rotationChanged.getAndSet(false);
public boolean consumeStreamInvalidation() {
return streamIsInvalide.getAndSet(false);
}
public void streamScreen(Device device, FileDescriptor fd) throws IOException {
public boolean isAlive() {
return selectorThread != null && selectorThread.isAlive();
}
public void streamScreen() throws IOException {
Workarounds.prepareMainLooper();
try {
internalStreamScreen(device, fd);
internalStreamScreen();
} catch (NullPointerException e) {
// Retry with workarounds enabled:
// <https://github.com/Genymobile/scrcpy/issues/365>
// <https://github.com/Genymobile/scrcpy/issues/940>
Ln.d("Applying workarounds to avoid NullPointerException");
Workarounds.fillAppInfo();
internalStreamScreen(device, fd);
internalStreamScreen();
}
}
private void internalStreamScreen(Device device, FileDescriptor fd) throws IOException {
MediaFormat format = createFormat(bitRate, maxFps, codecOptions);
device.setRotationListener(this);
private void internalStreamScreen() throws IOException {
updateFormat();
connection.setStreamInvalidateListener(this);
boolean alive;
try {
do {
MediaCodec codec = createCodec(encoderName);
MediaCodec codec = createCodec(videoSettings.getEncoderName());
IBinder display = createDisplay();
ScreenInfo screenInfo = device.getScreenInfo();
Rect contentRect = screenInfo.getContentRect();
@ -92,7 +113,7 @@ public class ScreenEncoder implements Device.RotationListener {
setDisplaySurface(display, surface, videoRotation, contentRect, unlockedVideoRect, layerStack);
codec.start();
try {
alive = encode(codec, fd);
alive = encode(codec);
// do not call stop() on exception, it would trigger an IllegalStateException
codec.stop();
} finally {
@ -102,30 +123,30 @@ public class ScreenEncoder implements Device.RotationListener {
}
} while (alive);
} finally {
device.setRotationListener(null);
connection.setStreamInvalidateListener(null);
}
}
private boolean encode(MediaCodec codec, FileDescriptor fd) throws IOException {
private boolean encode(MediaCodec codec) throws IOException {
boolean eof = false;
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
while (!consumeRotationChange() && !eof) {
int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, -1);
while (!consumeStreamInvalidation() && !eof && connection.hasConnections()) {
int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, timeout);
eof = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
try {
if (consumeRotationChange()) {
if (consumeStreamInvalidation()) {
// must restart encoding with new size
break;
}
if (outputBufferId >= 0) {
ByteBuffer codecBuffer = codec.getOutputBuffer(outputBufferId);
if (sendFrameMeta) {
writeFrameMeta(fd, bufferInfo, codecBuffer.remaining());
if (videoSettings.getSendFrameMeta()) {
writeFrameMeta(bufferInfo, codecBuffer.remaining());
}
IO.writeFully(fd, codecBuffer);
connection.send(codecBuffer);
}
} finally {
if (outputBufferId >= 0) {
@ -134,10 +155,10 @@ public class ScreenEncoder implements Device.RotationListener {
}
}
return !eof;
return !eof && connection.hasConnections();
}
private void writeFrameMeta(FileDescriptor fd, MediaCodec.BufferInfo bufferInfo, int packetSize) throws IOException {
private void writeFrameMeta(MediaCodec.BufferInfo bufferInfo, int packetSize) throws IOException {
headerBuffer.clear();
long pts;
@ -153,10 +174,10 @@ public class ScreenEncoder implements Device.RotationListener {
headerBuffer.putLong(pts);
headerBuffer.putInt(packetSize);
headerBuffer.flip();
IO.writeFully(fd, headerBuffer);
connection.send(headerBuffer);
}
private static MediaCodecInfo[] listEncoders() {
public static MediaCodecInfo[] listEncoders() {
List<MediaCodecInfo> result = new ArrayList<>();
MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
for (MediaCodecInfo codecInfo : list.getCodecInfos()) {
@ -199,14 +220,18 @@ public class ScreenEncoder implements Device.RotationListener {
Ln.d("Codec option set: " + key + " (" + value.getClass().getSimpleName() + ") = " + value);
}
private static MediaFormat createFormat(int bitRate, int maxFps, List<CodecOption> codecOptions) {
private static MediaFormat createFormat(VideoSettings videoSettings) {
int bitRate = videoSettings.getBitRate();
int maxFps = videoSettings.getMaxFps();
int iFrameInterval = videoSettings.getIFrameInterval();
List<CodecOption> codecOptions = videoSettings.getCodecOptions();
MediaFormat format = new MediaFormat();
format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_AVC);
format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate);
// must be present to configure the encoder, but does not impact the actual frame rate, which is variable
format.setInteger(MediaFormat.KEY_FRAME_RATE, 60);
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, DEFAULT_I_FRAME_INTERVAL);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval);
// display the very first frame, and recover from bad quality when no new frames
format.setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, REPEAT_FRAME_DELAY_US); // µs
if (maxFps > 0) {
@ -256,4 +281,28 @@ public class ScreenEncoder implements Device.RotationListener {
private static void destroyDisplay(IBinder display) {
SurfaceControl.destroyDisplay(display);
}
@Override
public void run() {
synchronized (this) {
if (selectorThread != null && selectorThread.isAlive()) {
throw new IllegalStateException(getClass().getName() + " can only be started once.");
}
selectorThread = Thread.currentThread();
}
try {
this.streamScreen();
} catch (IOException e) {
Ln.e("Failed to start screen recorder", e);
}
}
public void start(Device device, Connection connection) {
this.device = device;
this.connection = connection;
if (selectorThread != null && selectorThread.isAlive()) {
throw new IllegalStateException(getClass().getName() + " can only be started once.");
}
new Thread(this).start();
}
}

View file

@ -2,6 +2,8 @@ package com.genymobile.scrcpy;
import android.graphics.Rect;
import java.nio.ByteBuffer;
public final class ScreenInfo {
/**
* Device (physical) size, possibly cropped
@ -80,7 +82,9 @@ public final class ScreenInfo {
return new ScreenInfo(newContentRect, newUnlockedVideoSize, newDeviceRotation, lockedVideoOrientation);
}
public static ScreenInfo computeScreenInfo(DisplayInfo displayInfo, Rect crop, int maxSize, int lockedVideoOrientation) {
public static ScreenInfo computeScreenInfo(DisplayInfo displayInfo, VideoSettings videoSettings) {
int lockedVideoOrientation = videoSettings.getLockedVideoOrientation();
Rect crop = videoSettings.getCrop();
int rotation = displayInfo.getRotation();
if (lockedVideoOrientation == Device.LOCK_VIDEO_ORIENTATION_INITIAL) {
@ -102,7 +106,8 @@ public final class ScreenInfo {
}
}
Size videoSize = computeVideoSize(contentRect.width(), contentRect.height(), maxSize);
Size bounds = videoSettings.getBounds();
Size videoSize = computeVideoSize(contentRect.width(), contentRect.height(), bounds);
return new ScreenInfo(contentRect, videoSize, rotation, lockedVideoOrientation);
}
@ -110,31 +115,35 @@ public final class ScreenInfo {
return rect.width() + ":" + rect.height() + ":" + rect.left + ":" + rect.top;
}
private static Size computeVideoSize(int w, int h, int maxSize) {
// Compute the video size and the padding of the content inside this video.
// Principle:
// - scale down the great side of the screen to maxSize (if necessary);
// - scale down the other side so that the aspect ratio is preserved;
// - round this value to the nearest multiple of 8 (H.264 only accepts multiples of 8)
w &= ~7; // in case it's not a multiple of 8
h &= ~7;
if (maxSize > 0) {
if (BuildConfig.DEBUG && maxSize % 8 != 0) {
throw new AssertionError("Max size must be a multiple of 8");
}
boolean portrait = h > w;
int major = portrait ? h : w;
int minor = portrait ? w : h;
if (major > maxSize) {
int minorExact = minor * maxSize / major;
// +4 to round the value to the nearest multiple of 8
minor = (minorExact + 4) & ~7;
major = maxSize;
}
w = portrait ? minor : major;
h = portrait ? major : minor;
private static Size computeVideoSize(int w, int h, Size bounds) {
if (bounds == null) {
w &= ~15; // in case it's not a multiple of 16
h &= ~15;
return new Size(w, h);
}
return new Size(w, h);
int boundsWidth = bounds.getWidth();
int boundsHeight = bounds.getHeight();
int scaledHeight;
int scaledWidth;
if (boundsWidth > w) {
scaledHeight = h;
} else {
scaledHeight = boundsWidth * h / w;
}
if (boundsHeight > scaledHeight) {
boundsHeight = scaledHeight;
}
if (boundsHeight == h) {
scaledWidth = w;
} else {
scaledWidth = boundsHeight * w / h;
}
if (boundsWidth > scaledWidth) {
boundsWidth = scaledWidth;
}
boundsWidth &= ~15;
boundsHeight &= ~15;
return new Size(boundsWidth, boundsHeight);
}
private static Rect flipRect(Rect crop) {
@ -166,4 +175,16 @@ public final class ScreenInfo {
}
return (lockedVideoOrientation + 4 - deviceRotation) % 4;
}
public byte[] toByteArray() {
ByteBuffer temp = ByteBuffer.allocate(6 * 4 + 1);
temp.putInt(contentRect.left);
temp.putInt(contentRect.top);
temp.putInt(contentRect.right);
temp.putInt(contentRect.bottom);
temp.putInt(unlockedVideoSize.getWidth());
temp.putInt(unlockedVideoSize.getHeight());
temp.put((byte) getVideoRotation());
return temp.array();
}
}

View file

@ -1,15 +1,10 @@
package com.genymobile.scrcpy;
import com.genymobile.scrcpy.wrappers.ContentProvider;
import android.graphics.Rect;
import android.media.MediaCodec;
import android.media.MediaCodecInfo;
import android.os.BatteryManager;
import android.os.Build;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
public final class Server {
@ -19,112 +14,7 @@ public final class Server {
// not instantiable
}
private static void scrcpy(Options options) throws IOException {
Ln.i("Device: " + Build.MANUFACTURER + " " + Build.MODEL + " (Android " + Build.VERSION.RELEASE + ")");
final Device device = new Device(options);
List<CodecOption> codecOptions = CodecOption.parse(options.getCodecOptions());
boolean mustDisableShowTouchesOnCleanUp = false;
int restoreStayOn = -1;
if (options.getShowTouches() || options.getStayAwake()) {
try (ContentProvider settings = Device.createSettingsProvider()) {
if (options.getShowTouches()) {
String oldValue = settings.getAndPutValue(ContentProvider.TABLE_SYSTEM, "show_touches", "1");
// If "show touches" was disabled, it must be disabled back on clean up
mustDisableShowTouchesOnCleanUp = !"1".equals(oldValue);
}
if (options.getStayAwake()) {
int stayOn = BatteryManager.BATTERY_PLUGGED_AC | BatteryManager.BATTERY_PLUGGED_USB | BatteryManager.BATTERY_PLUGGED_WIRELESS;
String oldValue = settings.getAndPutValue(ContentProvider.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(stayOn));
try {
restoreStayOn = Integer.parseInt(oldValue);
if (restoreStayOn == stayOn) {
// No need to restore
restoreStayOn = -1;
}
} catch (NumberFormatException e) {
restoreStayOn = 0;
}
}
}
}
CleanUp.configure(options.getDisplayId(), restoreStayOn, mustDisableShowTouchesOnCleanUp, true, options.getPowerOffScreenOnClose());
boolean tunnelForward = options.isTunnelForward();
try (DesktopConnection connection = DesktopConnection.open(device, tunnelForward)) {
ScreenEncoder screenEncoder = new ScreenEncoder(options.getSendFrameMeta(), options.getBitRate(), options.getMaxFps(), codecOptions,
options.getEncoderName());
Thread controllerThread = null;
Thread deviceMessageSenderThread = null;
if (options.getControl()) {
final Controller controller = new Controller(device, connection);
// asynchronous
controllerThread = startController(controller);
deviceMessageSenderThread = startDeviceMessageSender(controller.getSender());
device.setClipboardListener(new Device.ClipboardListener() {
@Override
public void onClipboardTextChanged(String text) {
controller.getSender().pushClipboardText(text);
}
});
}
try {
// synchronous
screenEncoder.streamScreen(device, connection.getVideoFd());
} catch (IOException e) {
// this is expected on close
Ln.d("Screen streaming stopped");
} finally {
if (controllerThread != null) {
controllerThread.interrupt();
}
if (deviceMessageSenderThread != null) {
deviceMessageSenderThread.interrupt();
}
}
}
}
private static Thread startController(final Controller controller) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
controller.control();
} catch (IOException e) {
// this is expected on close
Ln.d("Controller stopped");
}
}
});
thread.start();
return thread;
}
private static Thread startDeviceMessageSender(final DeviceMessageSender sender) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
try {
sender.loop();
} catch (IOException | InterruptedException e) {
// this is expected on close
Ln.d("Device message sender stopped");
}
}
});
thread.start();
return thread;
}
private static Options createOptions(String... args) {
private static void parseArguments(Options options, VideoSettings videoSettings, String... args) {
if (args.length < 1) {
throw new IllegalArgumentException("Missing client version");
}
@ -135,43 +25,60 @@ public final class Server {
"The server version (" + BuildConfig.VERSION_NAME + ") does not match the client " + "(" + clientVersion + ")");
}
if (args[1].toLowerCase().equals("web")) {
options.setServerType(Options.TYPE_WEB_SOCKET);
if (args.length > 2) {
Ln.Level level = Ln.Level.valueOf(args[2].toUpperCase(Locale.ENGLISH));
options.setLogLevel(level);
}
if (args.length > 3) {
int portNumber = Integer.parseInt(args[3]);
options.setPortNumber(portNumber);
}
if (args.length > 4) {
boolean listenOnAllInterfaces = Boolean.parseBoolean(args[4]);
options.setListenOnAllInterfaces(listenOnAllInterfaces);
}
return;
}
final int expectedParameters = 16;
if (args.length != expectedParameters) {
throw new IllegalArgumentException("Expecting " + expectedParameters + " parameters");
}
Options options = new Options();
Ln.Level level = Ln.Level.valueOf(args[1].toUpperCase(Locale.ENGLISH));
options.setLogLevel(level);
int maxSize = Integer.parseInt(args[2]) & ~7; // multiple of 8
options.setMaxSize(maxSize);
int maxSize = Integer.parseInt(args[2]);
if (maxSize != 0) {
videoSettings.setBounds(maxSize, maxSize);
}
int bitRate = Integer.parseInt(args[3]);
options.setBitRate(bitRate);
videoSettings.setBitRate(bitRate);
int maxFps = Integer.parseInt(args[4]);
options.setMaxFps(maxFps);
videoSettings.setMaxFps(maxFps);
int lockedVideoOrientation = Integer.parseInt(args[5]);
options.setLockedVideoOrientation(lockedVideoOrientation);
videoSettings.setLockedVideoOrientation(lockedVideoOrientation);
// use "adb forward" instead of "adb tunnel"? (so the server must listen)
boolean tunnelForward = Boolean.parseBoolean(args[6]);
options.setTunnelForward(tunnelForward);
Rect crop = parseCrop(args[7]);
options.setCrop(crop);
videoSettings.setCrop(crop);
boolean sendFrameMeta = Boolean.parseBoolean(args[8]);
options.setSendFrameMeta(sendFrameMeta);
videoSettings.setSendFrameMeta(sendFrameMeta);
boolean control = Boolean.parseBoolean(args[9]);
options.setControl(control);
int displayId = Integer.parseInt(args[10]);
options.setDisplayId(displayId);
videoSettings.setDisplayId(displayId);
boolean showTouches = Boolean.parseBoolean(args[11]);
options.setShowTouches(showTouches);
@ -181,14 +88,13 @@ public final class Server {
String codecOptions = args[13];
options.setCodecOptions(codecOptions);
videoSettings.setCodecOptions(codecOptions);
String encoderName = "-".equals(args[14]) ? null : args[14];
options.setEncoderName(encoderName);
videoSettings.setEncoderName(encoderName);
boolean powerOffScreenOnClose = Boolean.parseBoolean(args[15]);
options.setPowerOffScreenOnClose(powerOffScreenOnClose);
return options;
}
private static Rect parseCrop(String crop) {
@ -248,10 +154,16 @@ public final class Server {
}
});
Options options = createOptions(args);
Options options = new Options();
VideoSettings videoSettings = new VideoSettings();
parseArguments(options, videoSettings, args);
Ln.initLogLevel(options.getLogLevel());
scrcpy(options);
if (options.getServerType() == Options.TYPE_LOCAL_SOCKET) {
new DesktopConnection(options, videoSettings);
} else if (options.getServerType() == Options.TYPE_WEB_SOCKET) {
WSServer wsServer = new WSServer(options);
wsServer.setReuseAddr(true);
wsServer.run();
}
}
}

View file

@ -0,0 +1,290 @@
package com.genymobile.scrcpy;
import android.graphics.Rect;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
public class VideoSettings {
private static final int DEFAULT_BIT_RATE = 8000000;
private static final byte DEFAULT_MAX_FPS = 60;
private static final byte DEFAULT_I_FRAME_INTERVAL = 10; // seconds
private Size bounds;
private int bitRate = DEFAULT_BIT_RATE;
private int maxFps;
private int lockedVideoOrientation;
private byte iFrameInterval = DEFAULT_I_FRAME_INTERVAL;
private Rect crop;
private boolean sendFrameMeta; // send PTS so that the client may record properly
private int displayId;
private String codecOptionsString;
private List<CodecOption> codecOptions;
private String encoderName;
public int getBitRate() {
return bitRate;
}
public void setBitRate(int bitRate) {
this.bitRate = bitRate;
}
public int getIFrameInterval() {
return iFrameInterval;
}
public void setIFrameInterval(byte iFrameInterval) {
this.iFrameInterval = iFrameInterval;
};
public Rect getCrop() {
return crop;
}
public void setCrop(Rect crop) {
this.crop = crop;
}
public boolean getSendFrameMeta() {
return sendFrameMeta;
}
public void setSendFrameMeta(boolean sendFrameMeta) {
this.sendFrameMeta = sendFrameMeta;
}
public int getDisplayId() {
return displayId;
}
public void setDisplayId(int displayId) {
this.displayId = displayId;
}
public int getMaxFps() {
return maxFps;
}
public void setMaxFps(int maxFps) {
this.maxFps = maxFps;
}
public int getLockedVideoOrientation() {
return lockedVideoOrientation;
}
public void setLockedVideoOrientation(int lockedVideoOrientation) {
this.lockedVideoOrientation = lockedVideoOrientation;
}
public Size getBounds() {
return bounds;
}
public void setBounds(Size bounds) {
this.bounds = bounds;
}
public void setBounds(int width, int height) {
this.bounds = new Size(width & ~15, height & ~15); // multiple of 16
}
public List<CodecOption> getCodecOptions() {
return codecOptions;
}
public void setCodecOptions(String codecOptionsString) {
this.codecOptions = CodecOption.parse(codecOptionsString);
if (codecOptionsString.equals("-")) {
this.codecOptionsString = null;
} else {
this.codecOptionsString = codecOptionsString;
}
}
public String getEncoderName() {
return this.encoderName;
}
public void setEncoderName(String encoderName) {
if (encoderName != null && encoderName.equals("-")) {
this.encoderName = null;
} else {
this.encoderName = encoderName;
}
}
public byte[] toByteArray() {
// 35 bytes without codec options and encoder name
int baseLength = 35;
int additionalLength = 0;
byte[] codeOptionsBytes = new byte[]{};
if (this.codecOptionsString != null) {
codeOptionsBytes = this.codecOptionsString.getBytes(StandardCharsets.UTF_8);
additionalLength += codeOptionsBytes.length;
}
byte[] encoderNameBytes = new byte[]{};
if (this.encoderName != null) {
encoderNameBytes = this.encoderName.getBytes(StandardCharsets.UTF_8);
additionalLength += encoderNameBytes.length;
}
ByteBuffer temp = ByteBuffer.allocate(baseLength + additionalLength);
temp.putInt(bitRate);
temp.putInt(maxFps);
temp.put(iFrameInterval);
int width = 0;
int height = 0;
if (bounds != null) {
width = bounds.getWidth();
height = bounds.getHeight();
}
temp.putShort((short) width);
temp.putShort((short) height);
int left = 0;
int top = 0;
int right = 0;
int bottom = 0;
if (crop != null) {
left = crop.left;
top = crop.top;
right = crop.right;
bottom = crop.bottom;
}
temp.putShort((short) left);
temp.putShort((short) top);
temp.putShort((short) right);
temp.putShort((short) bottom);
temp.put((byte) (sendFrameMeta ? 1 : 0));
temp.put((byte) lockedVideoOrientation);
temp.putInt(displayId);
temp.putInt(codeOptionsBytes.length);
if (codeOptionsBytes.length != 0) {
temp.put(codeOptionsBytes);
}
temp.putInt(encoderNameBytes.length);
if (encoderNameBytes.length != 0) {
temp.put(encoderNameBytes);
}
return temp.array();
}
public void merge(VideoSettings source) {
codecOptions = source.codecOptions;
codecOptionsString = source.codecOptionsString;
encoderName = source.encoderName;
bitRate = source.bitRate;
maxFps = source.maxFps;
iFrameInterval = source.iFrameInterval;
bounds = source.bounds;
crop = source.crop;
sendFrameMeta = source.sendFrameMeta;
lockedVideoOrientation = source.lockedVideoOrientation;
displayId = source.displayId;
}
public static VideoSettings fromByteArray(byte[] bytes) {
VideoSettings videoSettings = new VideoSettings();
mergeFromByteArray(videoSettings, bytes);
return videoSettings;
}
public static void mergeFromByteArray(VideoSettings videoSettings, byte[] bytes) {
ByteBuffer data = ByteBuffer.wrap(bytes);
int bitRate = data.getInt();
int maxFps = data.getInt();
byte iFrameInterval = data.get();
int width = data.getShort();
int height = data.getShort();
int left = data.getShort();
int top = data.getShort();
int right = data.getShort();
int bottom = data.getShort();
boolean sendMetaFrame = data.get() != 0;
int lockedVideoOrientation = data.get();
int displayId = data.getInt();
if (data.remaining() > 0) {
int codecOptionsLength = data.getInt();
if (codecOptionsLength > 0) {
byte[] textBuffer = new byte[codecOptionsLength];
data.get(textBuffer, 0, codecOptionsLength);
String codecOptions = new String(textBuffer, 0, codecOptionsLength, StandardCharsets.UTF_8);
if (!codecOptions.isEmpty()) {
videoSettings.setCodecOptions(codecOptions);
}
}
}
if (data.remaining() > 0) {
int encoderNameLength = data.getInt();
if (encoderNameLength > 0) {
byte[] textBuffer = new byte[encoderNameLength];
data.get(textBuffer, 0, encoderNameLength);
String encoderName = new String(textBuffer, 0, encoderNameLength, StandardCharsets.UTF_8);
if (!encoderName.isEmpty()) {
videoSettings.setEncoderName(encoderName);
}
}
}
videoSettings.setBitRate(bitRate);
videoSettings.setMaxFps(maxFps);
videoSettings.setIFrameInterval(iFrameInterval);
videoSettings.setBounds(width, height);
if (left == 0 && right == 0 && top == 0 && bottom == 0) {
videoSettings.setCrop(null);
} else {
videoSettings.setCrop(new Rect(left, top, right, bottom));
}
videoSettings.setSendFrameMeta(sendMetaFrame);
videoSettings.setLockedVideoOrientation(lockedVideoOrientation);
if (displayId > 0) {
videoSettings.setDisplayId(displayId);
}
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
VideoSettings s = (VideoSettings) o;
if (bitRate != s.bitRate || maxFps != s.maxFps || lockedVideoOrientation != s.lockedVideoOrientation || iFrameInterval != s.iFrameInterval
|| sendFrameMeta != s.sendFrameMeta || displayId != s.displayId) {
return false;
}
if (!Objects.equals(codecOptionsString, s.codecOptionsString) || !Objects.equals(encoderName, s.encoderName)
|| !Objects.equals(bounds, s.bounds) || !Objects.equals(crop, s.crop)) {
return false;
}
return true;
}
@Override
public int hashCode() {
return Objects.hash(bitRate, maxFps, lockedVideoOrientation, iFrameInterval, sendFrameMeta,
displayId, Objects.hashCode(codecOptionsString), Objects.hashCode(encoderName),
Objects.hashCode(bounds), Objects.hashCode(crop));
}
@Override
public String toString() {
return "VideoSettings{"
+ "bitRate=" + bitRate
+ ", maxFps=" + maxFps
+ ", iFrameInterval=" + iFrameInterval
+ ", bounds=" + bounds
+ ", crop=" + crop
+ ", metaFrame=" + sendFrameMeta
+ ", lockedVideoOrientation=" + lockedVideoOrientation
+ ", displayId=" + displayId
+ ", codecOptions=" + (this.codecOptionsString == null ? "-" : this.codecOptionsString)
+ ", encoderName=" + (this.encoderName == null ? "-" : this.encoderName)
+ "}";
}
}

View file

@ -0,0 +1,217 @@
package com.genymobile.scrcpy;
import org.java_websocket.WebSocket;
import org.java_websocket.framing.CloseFrame;
import org.java_websocket.handshake.ClientHandshake;
import org.java_websocket.server.WebSocketServer;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.BindException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
public class WSServer extends WebSocketServer {
private static final String PID_FILE_PATH = "/data/local/tmp/ws_scrcpy.pid";
public static final class SocketInfo {
private static final HashSet<Short> INSTANCES_BY_ID = new HashSet<>();
private final short id;
private WebSocketConnection connection;
SocketInfo(short id) {
this.id = id;
INSTANCES_BY_ID.add(id);
}
public static short getNextClientId() {
short nextClientId = 0;
while (INSTANCES_BY_ID.contains(++nextClientId)) {
if (nextClientId == Short.MAX_VALUE) {
return -1;
}
}
return nextClientId;
}
public short getId() {
return id;
}
public WebSocketConnection getConnection() {
return this.connection;
}
public void setConnection(WebSocketConnection connection) {
this.connection = connection;
}
public void release() {
INSTANCES_BY_ID.remove(id);
}
}
protected final ControlMessageReader reader = new ControlMessageReader();
private final Options options;
private static final HashMap<Integer, WebSocketConnection> STREAM_BY_DISPLAY_ID = new HashMap<>();
public WSServer(Options options) {
super(new InetSocketAddress(options.getListenOnAllInterfaces() ? "0.0.0.0" : "127.0.0.1", options.getPortNumber()));
this.options = options;
unlinkPidFile();
}
@Override
public void onOpen(WebSocket webSocket, ClientHandshake handshake) {
if (webSocket.isOpen()) {
short clientId = SocketInfo.getNextClientId();
if (clientId == -1) {
webSocket.close(CloseFrame.TRY_AGAIN_LATER);
return;
}
SocketInfo info = new SocketInfo(clientId);
webSocket.setAttachment(info);
WebSocketConnection.sendInitialInfo(WebSocketConnection.getInitialInfo(), webSocket, clientId);
Ln.d("Client entered the room!");
}
}
@Override
public void onClose(WebSocket webSocket, int code, String reason, boolean remote) {
Ln.d("Client has left the room!");
FilePushHandler.cancelAllForConnection(webSocket);
SocketInfo socketInfo = webSocket.getAttachment();
if (socketInfo != null) {
WebSocketConnection connection = socketInfo.getConnection();
if (connection != null) {
connection.leave(webSocket);
}
socketInfo.release();
}
}
@Override
public void onMessage(WebSocket webSocket, String message) {
String address = webSocket.getRemoteSocketAddress().getAddress().getHostAddress();
Ln.w("? Client from " + address + " says: \"" + message + "\"");
}
@Override
public void onMessage(WebSocket webSocket, ByteBuffer message) {
SocketInfo socketInfo = webSocket.getAttachment();
if (socketInfo == null) {
Ln.e("No info attached to connection");
return;
}
WebSocketConnection connection = socketInfo.getConnection();
String address = webSocket.getRemoteSocketAddress().getAddress().getHostAddress();
ControlMessage controlMessage = reader.parseEvent(message);
if (controlMessage != null) {
if (controlMessage.getType() == ControlMessage.TYPE_PUSH_FILE) {
FilePushHandler.handlePush(webSocket, controlMessage);
return;
}
if (controlMessage.getType() == ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS) {
VideoSettings videoSettings = controlMessage.getVideoSettings();
int displayId = videoSettings.getDisplayId();
if (connection != null) {
if (connection.getVideoSettings().getDisplayId() != displayId) {
connection.leave(webSocket);
}
}
joinStreamForDisplayId(webSocket, videoSettings, options, displayId, this);
return;
}
if (connection != null) {
Controller controller = connection.getController();
controller.handleEvent(controlMessage);
}
} else {
Ln.w("? Client from " + address + " sends bytes: " + message);
}
}
@Override
public void onError(WebSocket webSocket, Exception ex) {
Ln.e("WebSocket error", ex);
if (webSocket != null) {
// some errors like port binding failed may not be assignable to a specific websocket
FilePushHandler.cancelAllForConnection(webSocket);
}
if (ex instanceof BindException) {
System.exit(1);
}
}
@Override
public void onStart() {
Ln.d("Server started! " + this.getAddress().toString());
this.setConnectionLostTimeout(0);
this.setConnectionLostTimeout(100);
writePidFile();
}
private static void joinStreamForDisplayId(
WebSocket webSocket, VideoSettings videoSettings, Options options, int displayId, WSServer wsServer) {
SocketInfo socketInfo = webSocket.getAttachment();
WebSocketConnection connection = STREAM_BY_DISPLAY_ID.get(displayId);
if (connection == null) {
connection = new WebSocketConnection(options, videoSettings, wsServer);
STREAM_BY_DISPLAY_ID.put(displayId, connection);
}
socketInfo.setConnection(connection);
connection.join(webSocket, videoSettings);
}
private static void unlinkPidFile() {
try {
File pidFile = new File(PID_FILE_PATH);
if (pidFile.exists()) {
if (!pidFile.delete()) {
Ln.e("Failed to delete PID file");
}
}
} catch (Exception e) {
Ln.e("Failed to delete PID file:", e);
}
}
private static void writePidFile() {
File file = new File(PID_FILE_PATH);
FileOutputStream stream;
try {
stream = new FileOutputStream(file, false);
stream.write(Integer.toString(android.os.Process.myPid()).getBytes(StandardCharsets.UTF_8));
stream.close();
} catch (IOException e) {
Ln.e(e.getMessage());
}
}
public static WebSocketConnection getConnectionForDisplay(int displayId) {
return STREAM_BY_DISPLAY_ID.get(displayId);
}
public static void releaseConnectionForDisplay(int displayId) {
STREAM_BY_DISPLAY_ID.remove(displayId);
}
public void sendInitialInfoToAll() {
Collection<WebSocket> webSockets = this.getConnections();
if (webSockets.isEmpty()) {
return;
}
ByteBuffer initialInfo = WebSocketConnection.getInitialInfo();
for (WebSocket webSocket : webSockets) {
SocketInfo socketInfo = webSocket.getAttachment();
if (socketInfo == null) {
continue;
}
WebSocketConnection.sendInitialInfo(initialInfo, webSocket, socketInfo.getId());
}
}
}

View file

@ -0,0 +1,209 @@
package com.genymobile.scrcpy;
import android.media.MediaCodecInfo;
import org.java_websocket.WebSocket;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
public class WebSocketConnection extends Connection {
private static final byte[] MAGIC_BYTES_INITIAL = "scrcpy_initial".getBytes(StandardCharsets.UTF_8);
private static final byte[] MAGIC_BYTES_MESSAGE = "scrcpy_message".getBytes(StandardCharsets.UTF_8);
private static final byte[] DEVICE_NAME_BYTES = Device.getDeviceName().getBytes(StandardCharsets.UTF_8);
private final WSServer wsServer;
private final HashSet<WebSocket> sockets = new HashSet<>();
private ScreenEncoder screenEncoder;
public WebSocketConnection(Options options, VideoSettings videoSettings, WSServer wsServer) {
super(options, videoSettings);
this.wsServer = wsServer;
}
public void join(WebSocket webSocket, VideoSettings videoSettings) {
sockets.add(webSocket);
boolean changed = setVideoSettings(videoSettings);
wsServer.sendInitialInfoToAll();
if (!Device.isScreenOn()) {
controller.turnScreenOn();
}
if (screenEncoder == null || !screenEncoder.isAlive()) {
Ln.d("First connection. Start new encoder.");
device.setRotationListener(this);
screenEncoder = new ScreenEncoder(videoSettings);
screenEncoder.start(device, this);
} else {
if (!changed) {
if (this.streamInvalidateListener != null) {
streamInvalidateListener.onStreamInvalidate();
}
}
}
}
public void leave(WebSocket webSocket) {
sockets.remove(webSocket);
if (sockets.isEmpty()) {
Ln.d("Last client has left");
this.release();
}
wsServer.sendInitialInfoToAll();
}
public static ByteBuffer deviceMessageToByteBuffer(DeviceMessage msg) {
ByteBuffer buffer = ByteBuffer.wrap(msg.writeToByteArray(MAGIC_BYTES_MESSAGE.length));
buffer.put(MAGIC_BYTES_MESSAGE);
buffer.rewind();
return buffer;
}
@Override
void send(ByteBuffer data) {
if (sockets.isEmpty()) {
return;
}
synchronized (sockets) {
for (WebSocket webSocket : sockets) {
WSServer.SocketInfo info = webSocket.getAttachment();
if (!webSocket.isOpen() || info == null) {
continue;
}
webSocket.send(data);
}
}
}
public static void sendInitialInfo(ByteBuffer initialInfo, WebSocket webSocket, int clientId) {
initialInfo.position(initialInfo.capacity() - 4);
initialInfo.putInt(clientId);
initialInfo.rewind();
webSocket.send(initialInfo);
}
public void sendDeviceMessage(DeviceMessage msg) {
ByteBuffer buffer = deviceMessageToByteBuffer(msg);
send(buffer);
}
@Override
public boolean hasConnections() {
return sockets.size() > 0;
}
@Override
public void close() throws Exception {
// wsServer.stop();
}
public VideoSettings getVideoSettings() {
return videoSettings;
}
public Controller getController() {
return controller;
}
public Device getDevice() {
return device;
}
@SuppressWarnings("checkstyle:MagicNumber")
public static ByteBuffer getInitialInfo() {
int baseLength = MAGIC_BYTES_INITIAL.length
+ DEVICE_NAME_FIELD_LENGTH
+ 4 // displays count
+ 4; // client id
int additionalLength = 0;
int[] displayIds = Device.getDisplayIds();
List<DisplayInfo> displayInfoList = new ArrayList<>();
HashMap<Integer, Integer> connectionsCount = new HashMap<>();
HashMap<Integer, byte[]> displayInfoMap = new HashMap<>();
HashMap<Integer, byte[]> videoSettingsBytesMap = new HashMap<>();
HashMap<Integer, byte[]> screenInfoBytesMap = new HashMap<>();
for (int displayId : displayIds) {
DisplayInfo displayInfo = Device.getDisplayInfo(displayId);
displayInfoList.add(displayId, displayInfo);
byte[] displayInfoBytes = displayInfo.toByteArray();
additionalLength += displayInfoBytes.length;
displayInfoMap.put(displayId, displayInfoBytes);
WebSocketConnection connection = WSServer.getConnectionForDisplay(displayId);
additionalLength += 4; // for connection.connections.size()
additionalLength += 4; // for screenInfoBytes.length
additionalLength += 4; // for videoSettingsBytes.length
if (connection != null) {
connectionsCount.put(displayId, connection.sockets.size());
byte[] screenInfoBytes = connection.getDevice().getScreenInfo().toByteArray();
additionalLength += screenInfoBytes.length;
screenInfoBytesMap.put(displayId, screenInfoBytes);
byte[] videoSettingsBytes = connection.getVideoSettings().toByteArray();
additionalLength += videoSettingsBytes.length;
videoSettingsBytesMap.put(displayId, videoSettingsBytes);
}
}
MediaCodecInfo[] encoders = ScreenEncoder.listEncoders();
List<byte[]> encodersNames = new ArrayList<>();
if (encoders != null && encoders.length > 0) {
additionalLength += 4;
for (MediaCodecInfo encoder : encoders) {
byte[] nameBytes = encoder.getName().getBytes(StandardCharsets.UTF_8);
additionalLength += 4 + nameBytes.length;
encodersNames.add(nameBytes);
}
}
byte[] fullBytes = new byte[baseLength + additionalLength];
ByteBuffer initialInfo = ByteBuffer.wrap(fullBytes);
initialInfo.put(MAGIC_BYTES_INITIAL);
initialInfo.put(DEVICE_NAME_BYTES, 0, Math.min(DEVICE_NAME_FIELD_LENGTH - 1, DEVICE_NAME_BYTES.length));
initialInfo.position(MAGIC_BYTES_INITIAL.length + DEVICE_NAME_FIELD_LENGTH);
initialInfo.putInt(displayIds.length);
for (DisplayInfo displayInfo : displayInfoList) {
int displayId = displayInfo.getDisplayId();
if (displayInfoMap.containsKey(displayId)) {
initialInfo.put(displayInfoMap.get(displayId));
}
int count = 0;
if (connectionsCount.containsKey(displayId)) {
count = connectionsCount.get(displayId);
}
initialInfo.putInt(count);
if (screenInfoBytesMap.containsKey(displayId)) {
byte[] screenInfo = screenInfoBytesMap.get(displayId);
initialInfo.putInt(screenInfo.length);
initialInfo.put(screenInfo);
} else {
initialInfo.putInt(0);
}
if (videoSettingsBytesMap.containsKey(displayId)) {
byte[] videoSettings = videoSettingsBytesMap.get(displayId);
initialInfo.putInt(videoSettings.length);
initialInfo.put(videoSettings);
} else {
initialInfo.putInt(0);
}
}
initialInfo.putInt(encodersNames.size());
for (byte[] encoderNameBytes : encodersNames) {
initialInfo.putInt(encoderNameBytes.length);
initialInfo.put(encoderNameBytes);
}
return initialInfo;
}
public void onRotationChanged(int rotation) {
super.onRotationChanged(rotation);
wsServer.sendInitialInfoToAll();
}
private void release() {
WSServer.releaseConnectionForDisplay(this.videoSettings.getDisplayId());
// encoder will stop itself after checking .hasConnections()
}
}

View file

@ -12,6 +12,7 @@ import java.lang.reflect.Field;
import java.lang.reflect.Method;
public final class Workarounds {
private static boolean looperPrepared = false;
private Workarounds() {
// not instantiable
}
@ -26,6 +27,10 @@ public final class Workarounds {
// "Attempt to read from field 'android.os.MessageQueue android.os.Looper.mQueue'
// on a null object reference"
// <https://github.com/Genymobile/scrcpy/issues/921>
if (looperPrepared) {
return;
}
looperPrepared = true;
Looper.prepareMainLooper();
}

View file

@ -15,6 +15,7 @@ public class ClipboardManager {
private Method getPrimaryClipMethod;
private Method setPrimaryClipMethod;
private Method addPrimaryClipChangedListener;
private Method removePrimaryClipChangedListener;
public ClipboardManager(IInterface manager) {
this.manager = manager;
@ -116,4 +117,39 @@ public class ClipboardManager {
return false;
}
}
private static void removePrimaryClipChangedListener(Method method, IInterface manager, IOnPrimaryClipChangedListener listener)
throws InvocationTargetException, IllegalAccessException {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
method.invoke(manager, listener, ServiceManager.PACKAGE_NAME);
} else {
method.invoke(manager, listener, ServiceManager.PACKAGE_NAME, ServiceManager.USER_ID);
}
}
private Method getRemovePrimaryClipChangedListener() throws NoSuchMethodException {
if (removePrimaryClipChangedListener == null) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
removePrimaryClipChangedListener = manager.getClass()
.getMethod("removePrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class);
} else {
removePrimaryClipChangedListener = manager.getClass()
.getMethod("removePrimaryClipChangedListener", IOnPrimaryClipChangedListener.class, String.class, int.class);
}
}
return removePrimaryClipChangedListener;
}
public boolean removePrimaryClipChangedListener(IOnPrimaryClipChangedListener listener) {
try {
Method method = getRemovePrimaryClipChangedListener();
removePrimaryClipChangedListener(method, manager, listener);
return true;
} catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
Ln.e("Could not invoke method", e);
return false;
}
}
}

View file

@ -14,6 +14,7 @@ public final class WindowManager {
private Method freezeRotationMethod;
private Method isRotationFrozenMethod;
private Method thawRotationMethod;
private Method removeRotationWatcherMethod;
public WindowManager(IInterface manager) {
this.manager = manager;
@ -55,6 +56,13 @@ public final class WindowManager {
return thawRotationMethod;
}
private Method getRemoveRotationWatcherMethod() throws NoSuchMethodException {
if (removeRotationWatcherMethod == null) {
removeRotationWatcherMethod = manager.getClass().getMethod("removeRotationWatcher");
}
return removeRotationWatcherMethod;
}
public int getRotation() {
try {
Method method = getGetRotationMethod();
@ -108,4 +116,13 @@ public final class WindowManager {
throw new AssertionError(e);
}
}
public void unregisterRotationWatcher(IRotationWatcher rotationWatcher) {
try {
Method method = getRemoveRotationWatcherMethod();
method.invoke(manager, rotationWatcher);
} catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
Ln.e("Could not invoke method", e);
}
}
}